You cannot select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.

160 lines
12 KiB
Markdown

2 years ago
# 02 | Java的基本类型
如果你了解面向对象语言的发展史那你可能听说过Smalltalk这门语言。它的影响力之大以至于之后诞生的面向对象语言或多或少都借鉴了它的设计和实现。
在Smalltalk中所有的值都是对象。因此许多人认为它是一门纯粹的面向对象语言。
Java则不同它引进了八个基本类型来支持数值计算。Java这么做的原因主要是工程上的考虑因为使用基本类型能够在执行效率以及内存使用两方面提升软件性能。
今天我们就来了解一下基本类型在Java虚拟机中的实现。
```
public class Foo {
public static void main(String[] args) {
boolean 吃过饭没 = 2; // 直接编译的话javac会报错
if (吃过饭没) System.out.println("吃了");
if (true == 吃过饭没) System.out.println("真吃了");
}
}
```
在上一篇结尾的小作业里我构造了这么一段代码它将一个boolean类型的局部变量赋值为2。为了方便记忆我们给这个变量起个名字就叫“吃过饭没”。
赋值语句后边我设置了两个看似一样的if语句。第一个if语句也就是直接判断“吃过饭没”在它成立的情况下代码会打印“吃了”。
第二个if语句也就是判断“吃过饭没”和true是否相等在它成立的情况下代码会打印“真吃了”。
当然直接编译这段代码编译器是会报错的。所以我迂回了一下采用一个Java字节码的汇编工具直接对字节码进行更改。
那么问题就来了当一个boolean变量的值是2时它究竟是true还是false呢
如果你跑过这段代码,你会发现,问虚拟机“吃过饭没”,它会回答“吃了”,而问虚拟机“真(==)吃过饭没”,虚拟机则不会回答“真吃了”。
那么虚拟机到底吃过没,下面我们来一起分析一下这背后的细节。
## Java虚拟机的boolean类型
首先我们来看看Java语言规范以及Java虚拟机规范是怎么定义boolean类型的。
在Java语言规范中boolean类型的值只有两种可能它们分别用符号“true”和“false”来表示。显然这两个符号是不能被虚拟机直接使用的。
在Java虚拟机规范中boolean类型则被映射成int类型。具体来说“true”被映射为整数1而“false”被映射为整数0。这个编码规则约束了Java字节码的具体实现。
举个例子对于存储boolean数组的字节码Java虚拟机需保证实际存入的值是整数1或者0。
Java虚拟机规范同时也要求Java编译器遵守这个编码规则并且用整数相关的字节码来实现逻辑运算以及基于boolean类型的条件跳转。这样一来在编译而成的class文件中除了字段和传入参数外基本看不出boolean类型的痕迹了。
```
# Foo.main编译后的字节码
0: iconst_2 // 我们用AsmTools更改了这一指令
1: istore_1
2: iload_1
3: ifeq 14 // 第一个if语句即操作数栈上数值为0时跳转
6: getstatic java.lang.System.out
9: ldc "吃了"
11: invokevirtual java.io.PrintStream.println
14: iload_1
15: iconst_1
16: if_icmpne 27 // 第二个if语句即操作数栈上两个数值不相同时跳转
19: getstatic java.lang.System.out
22: ldc "真吃了"
24: invokevirtual java.io.PrintStream.println
27: return
```
在前面的例子中第一个if语句会被编译成条件跳转字节码ifeq翻译成人话就是说如果局部变量“吃过饭没”的值为0那么跳过打印“吃了”的语句。
而第二个if语句则会被编译成条件跳转字节码if\_icmpne也就是说如果局部变量的值和整数1不相等那么跳过打印“真吃了”的语句。
可以看到Java编译器的确遵守了相同的编码规则。当然这个约束很容易绕开。除了我们小作业中用到的汇编工具AsmTools外还有许多可以修改字节码的Java库比如说ASM [\[1\]](https://asm.ow2.io/) 等。
对于Java虚拟机来说它看到的boolean类型早已被映射为整数类型。因此将原本声明为boolean类型的局部变量赋值为除了0、1之外的整数值在Java虚拟机看来是“合法”的。
在我们的例子中经过编译器编译之后Java虚拟机看到的不是在问“吃过饭没”而是在问“吃过几碗饭”。也就是说第一个if语句变成你不会一碗饭都没吃吧。第二个if语句则变成你吃过一碗饭了吗。
如果我们约定俗成每人每顿只吃一碗那么第二个if语句还是有意义的。但如果我们打破常规吃了两碗那么较真的Java虚拟机就会将第二个if语句判定为假了。
## Java的基本类型
除了上面提到的boolean类型外Java的基本类型还包括整数类型byte、short、char、int和long以及浮点类型float和double。
![](https://static001.geekbang.org/resource/image/77/45/77dfb788a8ad5877e77fc28ed2d51745.png)
Java的基本类型都有对应的值域和默认值。可以看到byte、short、int、long、float以及double的值域依次扩大而且前面的值域被后面的值域所包含。因此从前面的基本类型转换至后面的基本类型无需强制转换。另外一点值得注意的是尽管他们的默认值看起来不一样但在内存中都是0。
在这些基本类型中boolean和char是唯二的无符号类型。在不考虑违反规范的情况下boolean类型的取值范围是0或者1。char类型的取值范围则是\[0, 65535\]。通常我们可以认定char类型的值为非负数。这种特性十分有用比如说作为数组索引等。
在前面的例子中我们能够将整数2存储到一个声明为boolean类型的局部变量中。那么声明为byte、char以及short的局部变量是否也能够存储超出它们取值范围的数值呢
答案是可以的。而且这些超出取值范围的数值同样会带来一些麻烦。比如说声明为char类型的局部变量实际上有可能为负数。当然在正常使用Java编译器的情况下生成的字节码会遵守Java虚拟机规范对编译器的约束因此你无须过分担心局部变量会超出它们的取值范围。
Java的浮点类型采用IEEE 754浮点数格式。以float为例浮点类型通常有两个0+0.0F以及-0.0F。
前者在Java里是0后者是符号位为1、其他位均为0的浮点数在内存中等同于十六进制整数0x8000000即-0.0F可通过Float.intBitsToFloat(0x8000000)求得。尽管它们的内存数值不同但是在Java中+0.0F == -0.0F会返回真。
在有了+0.0F和-0.0F这两个定义后,我们便可以定义浮点数中的正无穷及负无穷。正无穷就是任意正浮点数(不包括+0.0F)除以+0.0F得到的值,而负无穷是任意正浮点数除以-0.0F得到的值。在Java中正无穷和负无穷是有确切的值在内存中分别等同于十六进制整数0x7F800000和0xFF800000。
你也许会好奇既然整数0x7F800000等同于正无穷那么0x7F800001又对应什么浮点数呢
这个数字对应的浮点数是NaNNot-a-Number
不仅如此,\[0x7F800001, 0x7FFFFFFF\]和\[0xFF800001, 0xFFFFFFFF\]对应的都是NaN。当然一般我们计算得出的NaN比如说通过+0.0F/+0.0F在内存中应为0x7FC00000。这个数值我们称之为标准的NaN而其他的我们称之为不标准的NaN。
NaN有一个有趣的特性除了“!=”始终返回true之外所有其他比较结果都会返回false。
举例来说“NaN<1.0FfalseNaN>=1.0F”同样返回false。对于任意浮点数f不管它是0还是NaN“f!=NaN”始终会返回true而“f==NaN”始终会返回false。
因此,我们在程序里做浮点数比较的时候,需要考虑上述特性。在本专栏的第二部分,我会介绍这个特性给向量化比较带来什么麻烦。
## Java基本类型的大小
在第一篇中我曾经提到Java虚拟机每调用一个Java方法便会创建一个栈帧。为了方便理解这里我只讨论供解释器使用的解释栈帧interpreted frame
这种栈帧有两个主要的组成部分分别是局部变量区以及字节码的操作数栈。这里的局部变量是广义的除了普遍意义下的局部变量之外它还包含实例方法的“this指针”以及方法所接收的参数。
在Java虚拟机规范中局部变量区等价于一个数组并且可以用正整数来索引。除了long、double值需要用两个数组单元来存储之外其他基本类型以及引用类型的值均占用一个数组单元。
也就是说boolean、byte、char、short这四种类型在栈上占用的空间和int是一样的和引用类型也是一样的。因此在32位的HotSpot中这些类型在栈上将占用4个字节而在64位的HotSpot中他们将占8个字节。
当然这种情况仅存在于局部变量而并不会出现在存储于堆中的字段或者数组元素上。对于byte、char以及short这三种类型的字段或者数组单元它们在堆上占用的空间分别为一字节、两字节以及两字节也就是说跟这些类型的值域相吻合。
因此当我们将一个int类型的值存储到这些类型的字段或数组时相当于做了一次隐式的掩码操作。举例来说当我们把0xFFFFFFFF-1存储到一个声明为char类型的字段里时由于该字段仅占两字节所以高两位的字节便会被截取掉最终存入“\\uFFFF”。
boolean字段和boolean数组则比较特殊。在HotSpot中boolean字段占用一字节而boolean数组则直接用byte数组来实现。为了保证堆中的boolean值是合法的HotSpot在存储时显式地进行掩码操作也就是说只取最后一位的值存入boolean字段或数组中。
讲完了存储现在我来讲讲加载。Java虚拟机的算数运算几乎全部依赖于操作数栈。也就是说我们需要将堆中的boolean、byte、char以及short加载到操作数栈上而后将栈上的值当成int类型来运算。
对于boolean、char这两个无符号类型来说加载伴随着零扩展。举个例子char的大小为两个字节。在加载时char的值会被复制到int类型的低二字节而高二字节则会用0来填充。
对于byte、short这两个类型来说加载伴随着符号扩展。举个例子short的大小为两个字节。在加载时short的值同样会被复制到int类型的低二字节。如果该short值为非负数即最高位为0那么该int类型的值的高二字节会用0来填充否则用1来填充。
## 总结与实践
今天我介绍了Java里的基本类型。
其中boolean类型在Java虚拟机中被映射为整数类型“true”被映射为1而“false”被映射为0。Java代码中的逻辑运算以及条件跳转都是用整数相关的字节码来实现的。
除boolean类型之外Java还有另外7个基本类型。它们拥有不同的值域但默认值在内存中均为0。这些基本类型之中浮点类型比较特殊。基于它的运算或比较需要考虑+0.0F、-0.0F以及NaN的情况。
除long和double外其他基本类型与引用类型在解释执行的方法栈帧中占用的大小是一致的但它们在堆中占用的大小确不同。在将boolean、byte、char以及short的值存入字段或者数组单元时Java虚拟机会进行掩码操作。在读取时Java虚拟机则会将其扩展为int类型。
今天的动手环节你可以观测一下将boolean类型的值存入字段中时Java虚拟机所做的掩码操作。
你可以将下面代码中boolValue = true里的true换为2或者3看看打印结果与你的猜测是否相符合。
熟悉Unsafe的同学可以使用Unsafe.putBoolean和Unsafe.putByte方法看看还会不会做掩码操作。
```
public class Foo {
static boolean boolValue;
public static void main(String[] args) {
boolValue = true; // 将这个true替换为2或者3再看看打印结果
if (boolValue) System.out.println("Hello, Java!");
if (boolValue == true) System.out.println("Hello, JVM!");
}
}
```