目录

前面说了,计算机是使用二进制保存所有数据的,那我们写代码的时候不可能全部用 0101 这样的方式去写,这样多费劲啊。所以,我们就在二进制的基础上,衍生出其他人能看懂的数据类型,然后用人能看懂的语言来操作这些数据类型,这种语言就叫做高级语言。相应的,那些由 0101 组成的语言就叫做机器语言

那么,由二进制衍生出的数据类型有哪些呢?

我们可以将这些数据类型分为两大类,值类型引用类型

值类型

首先要说的,就是值类型,我们按照表示的范围可以划分为如下几种类型。

  • 比特类型(byte):8 位二进制。
  • 短整型(short int):16 位二进制。
  • 整型(int):32 位二进制。
  • 长整型(long):64 位二进制。
  • 布尔类型(boolean):默认是使用 int 表示的,也就是 32 位二进制。
  • 字符类型(char):16 位二进制。
  • 单精度浮点(float):32 位二进制。
  • 双精度浮点(double)64 位二进制。

其中,我们将 8 位二进制称作一个字节,也就是 1byte,也就是: 1byte = 8bit,也就是 8 个二进制位;因为最高位要用来表示符号位,所以,1 个 byte 的大小范围就是 [-2^7 ~ 2^7 - 1]。

在 Java 语言中,boolean 比较特殊,当它单独使用时,就是一个 int 的大小,也就是 4 个字节;当它用作数组元素使用,就只占用 1 个字节,这也是为什么很多服务器在定义数据时,将 boolean 定义为 1 和 0 的原因。

我们将上述描述整理下,如表格所示:

类型 大小(字节) 位数 默认值 范围
byte 1 8 0B 27-2^7~2712^7-1
short 2 16 0S 215-2^{15}~21512^{15}-1
int 4 32 0 231-2^{31}~23112^{31}-1
long 8 64 0L 263-2^{63}~26312^{63}-1
boolean 4(1) 32(8) false false/true
char 2 16 null 见 ASCII 码表
float 4 32 0.0f +/-(1.4E-45~3.4E+38)
double 8 64 0.0 +/-(4.9E-324~1.7E+308)

这属于理解性知识,我们不用死记硬背,会查表即可。

不过有同学可能会有疑问了,一个 byte 明明是 8 位,最大应该是:1111 1111,应该是 2^8-1,就像 10 进制中,8 位数最大就是 9999 9999 一样啊,也就是 10^8-1。

不是这样的,因为你那个 9999 9999,并不是 8 位数,而是 9 位数,你还有个符号位呢,也就是 +9999 9999,而我们上一节说到,二进制中所有数据都用 0 和 1 表示,所以 8 位二进制最高位是符号位,也就是实际只有 7 位表示数据,所以最大就是 2^7-1。

那这也不对啊,最大是 2^7-1,最小不应该是 -2^7-1 吗,怎么会是 -2^7 呢?这样不就等于负数比正数多一个吗?

不,我们仔细想一下,负数范围为:-2^7 ~ -1,也就是共有 2^7 个负数;正数范围为:1 ~ 2^7-1,共有 2^7-1 个,确实少一个。唉,0 呢?0 的符号位是 0,也算是正数,所以正数应该是:0 ~ 2^7-1,也是 2^7 个,跟负数总数是一样的。

上述表格中的浮点数,采用的是规格化计数法,比如单精度浮点型 float 的范围: 1.4E-45 ~ 3.4E+38,其中 E 表示以 10 为底的指数,E 后面为 + 就表示正指数,- 就表示负指数;这个范围就是:最小是 1.4 乘以 10 的 -45 次方,最大是 3.4 乘以 10 的 38 次方。

对于浮点数,还有个精度失真问题,比如:

float a = 0.11f;
float b = 0.22f;
println((a+b)==0.33);// 输出false

结果是 0.11+0.22 并不等于 0.33;这么奇葩吗?这是因为计算机是使用二进制存放数据的。根本无法获取精确的小数,比如:0.1 用二进制怎么表示呢?1/2​ 是 0.5,大了;1/4​ 是 0.25,大了;1/8​ 是 0.125,大了;1/16​ 是 0.0625,小了,需要再加点更小的;以此类推,就像十进制永远表述不出来 1/3​ 一样,只能无限接近,就是不能精确等于。

所以,我们做数据敏感的项目时,要使用其他的 api,比如 BigDecimal。

引用类型

引用类型有三大类:字符串(string)、数组(array)和对象。准确地说,字符串和数组也是对象,只不过比较特殊,这里单独拎出来照顾照顾。

引用和值最大的不同在于:值类型是实实在在的一个数值,而引用类型则指向内存中的一个地址,修改一个引用类型可能带来其他风险,所以我们要慎之又慎,为了彻底解决这个问题,很多高级语言中直接就在函数调用的时候,按值传递,也就是把引用类型变量拷贝一份作为参数传递,而不是直接传递原有的引用。

// 值类型demo
int a = 10;
function(a);
void function(int num) {
   num = 11;
}
println(a); // a还是10,没变化

// 引用类型demo
int[] nums = {1,2,3,4,5};
function(nums);
void function(int[] nums) {
    nums[0] = 100;
}
println(nums[0]); // nums[0]不是1了,而是100了。

上述例子简单明了地讲解了值类型和引用类型的区别,所以我们要尽量小心地使用引用类型,其实只要你尽量少地修改参数即可避免这类问题

这里还有个 tips:能声明为局部变量的就不要声明为成员变量,能声明为成员变量的尽量不要声明为全局变量。因为变量的范围越大,越有可能被修改,风险就越高,所以我们要坚持 小范围优先原则

字符串

字符串是引用类型,但是编译器对其做了特殊处理,使其使用起来就像值类型,那么这是为什么呢?

很简单,就是为了不可变。

字符串是代码中使用最多的类型,如果所有的数据类型只能保留一个,那么这个类型肯定是字符串,看看 json 就知道了。

我们一定要像保护我方水晶一样保护我方字符串。

所以,字符串这么重要,肯定要保证它是安全的,如果它是引用类型,那么就很容易被修改,所以编译器就把它特殊处理了下,使其使用起来就像一个值类型一样。你看 java 的 String 就是 final 的,不但不让你改,也不让你继承。

有人说这不对啊,String 是为了不可变,那么字符串执行自加运算是怎么执行的呢? 比如:

String a = "abc";
a+="def"; // 结果a就是abcdef了。

这不就变了吗,是变了,但不是字符串对象变了,是 a 指向了另一个地址。 说白了就是:

String a = "abc"; // a执行内存地址001,001放的是"abc";
a+="def"; // a指向内存地址002,002放的是"abcdef"; 001放的还是"abc"; 

也就是说,a 指向了 “abcdef” 的内存地址而已,而原来存放 “abc” 的那个地址的值还是 “abc”,并没有变化。

也就是说: 字符串每次相加都会创建新字符串对象

而我们知道,创建对象本来就是个高代价操作,所以要尽量避免频繁执行字符串的相加操作,比如:

String name = "";
for(int i = 0; i < names.length; i++){
    name+=names[i];
}

写这种代码的人,先拉出去批斗一顿再说。

那么,怎么办呢?也不能不加啊,可以用字符串提供的 API,比如 Java 中的 StringBuilder、StringBuffer (名字起得真好,简写就是国粹)。

数组

数组是比较特殊的引用类型,数组指向的是第一个元素在内存中的地址,而且数组是占用一块连续的内存空间,这样一来,我们只要知道第一个元素的位置,就可以根据数组存储的变量类型的大小,计算出数组中每个元素在内存中的地址了。很多高级语言就是利用这个特点,衍生出了顺序存储的可变长度的线性集合。

数组这么设计有个特点:快!非常快!

比如: int[] nums = {1,2,3,4,5};,nums 就指向元素 1 在内存中的地址,假如 1 的地址是 0x0001,那么因为一个 int 占用 4 个 byte,元素 2 的地址就是 0x0005,元素 3 的地址就是 0x0009,依次类推。

这样就导致我们只需要知道数组的地址,也就是第一个元素的地址,就能快速定位到第 n 个元素的地址,这也是数组可以根据下标访问元素的原因。

那要这么说的话,我全部用数组不就行了吗?

那可不行!因为数组这个特点必须要连续的内存空间,如果现在我的内存没有那么大一块连续的可咋整呢?那就不申请了吗?就像你要 100 亩连续的地皮,但是我在朝阳区有 30 亩,海淀区有 30 亩,昌平区有 40 亩;我怎么给你整呢?

这就导致一个问题,数组对内存的要求太苛刻了,一旦存满了想扩容,就有可能扩容失败,所以它的缺点就是:内存要求高,扩展性差

所以,当你对随机访问要求不高的时候,尽量不选择数组,可以选择我们后面要讲的链表。

对象

普天之下,莫非类;率土之滨,莫非对象。

世间万物皆是对象,自从我干了程序员之后,我发现我做任何事都要分好几步。

比如吃年夜饭的时候,我就说:第一步拿碗筷;第二步:盛饭;第三步:端桌子上;第四步:吃饭;第五步:收拾碗筷;第六步:端走;第七步:洗碗;第八步:收碗筷。并且一定要确保步骤是偶数的,因为这样才左右对称,才有完整的生命周期。

在这 8 个步骤之中,碗筷和饭都是对象,其中碗筷属于长生命周期的对象,随着我家的存在而存在,随着我家的消失而消失;而饭则属于短生命周期的对象,随着出锅而出生,随着被吃下去而消失。

所以,任何对象都有它的生命周期;局部变量的生命周期,随着函数的调用出生,随着函数调用结束死亡。比如:

fun userName() {
    let user = new User(); // 对象出生
    println(user.name);
    return user.name; // 对象死亡
}

fun test(){
    let name = userName();
}

这个很好理解,因为调用一个函数的时候,会将函数的数据读入内存,而调用完了,也就会把读入的数据清空。

所以,我们给每个变量都定义了叫做作用域的东西,上述例子说白了就是:局部变量的作用域仅限于它所在的函数。一旦函数调用完毕,作用域结束,生命周期也走完了,就等于死亡了。

还有一种可能,即使函数没走完,对象也可能死掉,比如:

void test() {
    Object obj = new User(); // 创建了一个User对象,并用obj这个引用指向它
    obj = new String(); // 创建了一个String对象,并用obj这个引用指向它,此时User对象已经没有引用指向了。
}

在上述代码中,当执行完obj = new String();之后,User 对象已经没有引用指向了,如果此时进行垃圾回收,那么就可以被回收掉,也就是死亡了。那么,这又是为啥呢?

其实我们全部可以概括为一点:一个对象是否可以被回收,取决于执行垃圾回收时它有没有被引用

第一个例子中因为函数调用完了,引用也不存在了,所以 User 没有被引用;第二个例子中虽然函数没执行完,但是引用指向了另一个对象,所以也导致 User 没有被引用。所以它们都会被回收,也就是被干掉。

有人说,我还记得有个置为 null 的,这跟上述的有什么区别?

没区别!因为 null 也是个对象,只不过是个名字叫做 null 的里面啥都没有的对象而已。你可以这么理解:

Object null = new Object(); // 创建一个空对象
User user = new User(); // 创建一个User对象,并用user这个引用指向它
user = null; // 将user指向null,原来的User对象没人指向了,就可以回收了。

这样是不是就很好理解了?

总结

本章我们重点讲了值类型和引用类型,值类型存放的是具体数值,而引用类型存放的是数据的地址;并且,我们讲解了字符串、数组和对象这三大引用类型的特点。

  • 字符串:是引用类型,用起来却是值类型;为了安全设置为不可变的。
  • 数组:数组指向的地址就是第一个元素的地址,可以快速地访问其他元素。
  • 对象:对象的生命周期跟着作用域走,如果没有引用指向它,它就可能提前死亡。

另外,我们一定要注意浮点数的精度问题,下一节,我们就来看一下这些数据类型该如何选择。