深入理解Java基本数据类型

最近好久没有写文章了,一方面是因为懒,另一方面是因为最近确实没有研究出什么东西来。前一段时间被问到『Java 的 boolean 型数据占多大内存』的时候,有点懵,发现之前在学习这些基础时,只了解了大概,并不知道背后的原理。虽然做 Java 属于半路出身,但毕竟也做了两三年了,这种基本问题没有回答上来确实很丢人,遂仔细研究了一下 Java 和 JVM 的规范,这里做一个 Java 基本数据类型的总结,其中会穿插其相对应包装类型的总结和相关使用技巧。

Java 规定的基本数据类型有 种:byte, char, short, int, long, float, double 和 boolean,制作成表格如下:

类型 占用空间(字节) 包装类型
整数 byte 1 Byte
char 2 Character
short 2 Short
int 4 Integer
long 8 Long
浮点数 float 4 Float
double 8 Double
boolean ? Boolean

接下来对其中的每一个类型,进行详细的总结。

整数

Java 中整数根据占据内存空间大小划分,分为 byte, char, short, int 和 long,和 C/C++ 不同,Java 里没有无符号和有符号之分,所有数字都是有符号的,这里有几个问题了:

  • 为什么要设置占据不同内存空间大小的数据类型?
  • 不同大小的数据在内存中相邻如何存储?
  • char 和 short 都占据两个字节的内存空间,可表示数字均为 65536 个,二者有何不同?

带着这三个问题,下面来分别深入研究一下这五种基本整数类型。

byte

byte 如字面义所示,表示一个字节的数据,其取值范围为 -27 ~ 27-1,即 -128 ~ 127。

其对应的包装类型为 Byte,其中使用 Fly-Weight 模式预先构造了一个能表示所有 byte 类型数据的对象缓存,当使用 valueOf(byte) 进行装箱时,会使用缓存的对象,但其构造方法 Byte(byte)/Byte(String) 不会使用缓存的对象,即存在:

1
2
3
Byte.valueOf((byte) 1) == Byte.valueOf("1"); // true
new Byte((byte) 1) == new Byte("1"); // false
Byte.decode("1") == Byte.decode("0x1"); // true,内部调用 valueOf(byte)

char

char 用来表示字符,Java 中所有字符内部均使用 Unicode 编码存储(这里需要区分存储编码和表示编码,乱码的根源来自于存储和表示两套系统编码的不一致),取值范围为 0 ~ 216-1,即 0 ~ 65535。

好了,问题又来了,Emoji 的编码超过了 65535,例如『🙄』的 Unicode 为 U+1F644, char 如何表示呢?

答案是 Java 无法用 char 表示 Unicode 大于 65535 的字符:

Emoji

我们知道,String 内部有一个 char[] 数组,用来表示字符串内的字符数据,我们可以定义 String emojiStr = "🙄🙄🙄🙄";,那么,Java 内部是如何存储这种字符的呢?

答案是 Java 内部仍然使用 char[] 存储这些字符,但此时一个字符并不一定由一个 char 定义,有可能由两个 char 来确定,因此,Java 引入 Code Point 来表示字符串中的一个字符,例如 "Emoji🙄".codePointAt(5) 得到的是『🙄』这个字符的 Unicode,而 "Emoji🙄".charAt(5) 得到的则不是:

Emoji String

另外需要注意的是,String.length() 得到的并不是字符的个数,而是 char[] 的长度,正确获得字符串中字符数量的方法是使用 String.codePointCount(int, int),并传入 0 和 String.length() 作为参数。

char 对应的包装类型为 Character,内部有一个 128 个元素的 Character 对象缓存,构造方法 Character(char) 不会使用缓存,而是会创建一个新的对象,使用 valueOf(char) 则会用到缓存,但如果字符的 Unicode 超过了 127,则需要创建新的 Character 对象。

short

short 用来表示整数,其取值范围为 -215 ~ 215-1,即 -32768 ~ 32767。

我们注意到 short 和 char 都用两个字节存储,但其取值范围不同,对于 char 来说,其不存在负数。

Short 是 short 类型的包装类型,其内部同样也有一个缓存,缓存了从 -128 ~ 127 之间的所有 Short 对象,当调用 valueOf(short) 时,若入参在范围内,则直接返回缓存对象,否则返回新对象。

int

int 用来表示整数,取值范围为 -231 ~ 231-1,即 -2147483648 ~ 2147483647。

Integer 为 int 类型的包装类型,内部缓存机制同 Short 类型,此处略过。

long

long 用来表示长整数,取值范围为 -263 ~ 263-1。

内部也存在缓存机制,同 Integer,此处略过。

对于非 volatile 的 long 型数据的赋值,JVM 会分为两步操作来执行。

浮点数

Java 的浮点数采用 IEEE754 标准,即数字分为 符号位,指数位和尾数位:

1
2
3
4
5
6
7
float:
s e f
1 - 11111111 - 11111111111111111111111
double:
s e f
1 - 11111111111 - 11111111111111111111111111111111111111111111111111111

以下为转十进制的公式,其中 S 和上述 s 相同,用于控制符号,M 和 E 的取值需要根据 e 的取值来确定。

$$N = (-1)^S × M × 2^E$$

根据 e,浮点数分为:规范化值,非规范化值和特殊值三种:

规范化值

当 e 既不是全 0,也不是全 1,此时,$$M = 1 + f,E = e - (2^{k-1} - 1)$$,k 为 e 的位数。

非规范化值

当 e 为全 0 时,此时,$$M = f, E = 1 - (2^{k-1} - 1)$$,k 为 e 的位数。

特殊值

当 e 为全 1,f 为全 0 时,表示无穷大;当 e 为全 1,f 不全为 0 时,表示 NaN。

布尔数

boolean 类型用于实现诸如 A and BA or B 等布尔逻辑运算,其值只有两种:true/false,因此,在空间占用上,其实只需要 1bit 即可表示。

但实际上,JVM 内部没有 boolean 类型专用的指令,也没有 boolean 的存储类型,所有的 boolean 数据均转换成 int 类型进行存储和计算。

唯一的例外是 boolean[],其中每一个元素使用 byte 类型存储和运算。

运算

这里的运算只说明如何做类型提升,以及 Java 运算类型对齐的一般规律。

整型运算

整型运算在 JVM 中只有两套,一套用于 int 类型数据,另一套用于 long 型数据,所以,所有的 byte/char/short/int 类型数据在运算时,均使用操作 int 类型数据的指令。

如果操作数中没有 long 型,则需要将所有的操作数提升为 int 类型进行运算(JVM 中体现为所有的指令均为 int 类型指令,运算之前不会有提升的指令,直接采用 int 指令操作),运算的结果也是 int 类型的:

整型运算

如果需要窄化,则需要进行强制类型转换。

如果操作数中有 long 型,则需要将所有的操作数提升为 long 类型进行运算(JVM 中体现为所有指令均为 long 类型指令,但运算之前需要使用 i2l 指令将 int 类型数据提升为 long 型数据),运算结果为 long 型。

浮点型运算

浮点数分为 float 和 double 两种类型,相应的运算指令也有两套,当操作符两端的数据均为 float 型,时,使用 float 对应的运算指令,若操作符两端的数据有 double 型,则需要插入 f2d 指令将 float 型数据提升为 double 型:

1
2
3
4
5
float a = 0.1f, b = 0.2f;
double c = 1.2;
double d = a * b * c; // 产生的指令相当于 ((double) (a * b)) * c
double e = c * a * b; // 产生的指令相当于 c * ((double) a) * ((double) b)

混合运算

所谓混合运算指的是当运算式中混合有整型和浮点型数据时,JVM 如何进行运算。

Java 编译器在选择运算指令时,按照运算顺序和运算符两边的操作数进行选择,如果运算符两端操作数均为整型,则使用整型运算指令,否则选择相应的浮点运算指令。

例如:

1
2
3
4
5
int a = 1, b = 2;
double c = 2.3;
double d = a * b * c; // 产生的指令相当于 ((double) (a * b)) * c
double e = c * a * b; // 产生的指令相当于 c * ((double) a) * ((double) b)

类型提升和窄化

因为 JVM 运算指令针对的操作数必须是同一个类型,因此,在运算时,需要进行相应的类型提升,具体提升的方向为:

  • INTEGRAL -> long, INTEGRAL -> float, INTEGRAL -> double
  • long -> float, long -> double
  • float -> double

这里使用了整型,因为对于 boolean/byte/char/short/int 类型的数据,从内存中载入到 ALU 时,均视作 int 类型数据(这里主要考虑到运算器的位宽为 32 位),因此,两个 byte 类型的数据相加产生的指令和两个 int 类型数据相加产生的指令是类似的(均为 iload; iload; iadd 结构)。

在运算完之后存储到结果变量时,如果结果变量的类型和运算结果类型不同,则需要进行相应的提升和窄化,例如:

1
2
3
4
5
float a = 1.2f, b = 2.34f;
double c = a * b; // a 和 b 都是 float 型,JVM 使用 float 型指令运算,然后通过 f2d 指令提升存储
double d = 2.3, e = 1.4;
float f = (float) (d * e); // d 和 e 的运算结果是 double 型,需要通过 d2f 指令窄化后存储

这里进行类型窄化时,比较有意思的是如果运算结果类型是浮点型,但是结果变量类型是除 int 外的整型,则需要两次窄化:

1
2
float a = 1.2f, b = 2.3f;
short c = (short) (a * b);

这里 a 和 b 运算的结果是 float 型,需要先通过 f2i 转成 int 型,再通过 i2s 转成 short 型。