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] 等。

对于Java虚拟机来说,它看到的boolean类型,早已被映射为整数类型。因此,将原本声明为boolean类型的局部变量,赋值为除了0、1之外的整数值,在Java虚拟机看来是“合法”的。

在我们的例子中,经过编译器编译之后,Java虚拟机看到的不是在问“吃过饭没”,而是在问“吃过几碗饭”。也就是说,第一个if语句变成:你不会一碗饭都没吃吧。第二个if语句则变成:你吃过一碗饭了吗。

如果我们约定俗成,每人每顿只吃一碗,那么第二个if语句还是有意义的。但如果我们打破常规,吃了两碗,那么较真的Java虚拟机就会将第二个if语句判定为假了。

Java的基本类型

除了上面提到的boolean类型外,Java的基本类型还包括整数类型byte、short、char、int和long,以及浮点类型float和double。

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又对应什么浮点数呢?

这个数字对应的浮点数是NaN(Not-a-Number)。

不仅如此,[0x7F800001, 0x7FFFFFFF]和[0xFF800001, 0xFFFFFFFF]对应的都是NaN。当然,一般我们计算得出的NaN,比如说通过+0.0F/+0.0F,在内存中应为0x7FC00000。这个数值,我们称之为标准的NaN,而其他的我们称之为不标准的NaN。

NaN有一个有趣的特性:除了“!=”始终返回true之外,所有其他比较结果都会返回false。

举例来说,“NaN<1.0F”返回false,而“NaN>=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!");
  }
}

精选留言

  • 丨落灬小莫

    2018-07-23 16:41:35

    当替换为2的时候无输出
    当替换为3的时候打印HelloJava及HelloJVM
    猜测是因为将boolean 保存在静态域中,指定了其类型为'Z',当修改为2时取低位最后一位为0,当修改为3时取低位最后一位为1
    则说明boolean的掩码处理是取低位的最后一位
    作者回复

    对的!

    2018-07-24 15:17:03

  • 追梦

    2018-10-21 22:47:30

    有个地方初看不易看懂,我来解释下:
    作者一开始放的“吃没吃饭”的例子中boolean变量是局部变量,存放在Java方法栈的栈帧中的局部变量区,占据一个数据单元,无需做掩码;最后的例子中boolean变量是成员变量,存储在堆中的对象实例里,占有一个字节,且需要对最后一位做掩码
  • Kyle

    2018-07-23 09:23:24

    老师,文中看你说到:
    “也就是说,boolean、byte、char、short 这四种类型,在栈上占用的空间和 int 是一样的,和引用类型也是一样的。因此,在 32 位的 HotSpot 中,这些类型在栈上将占用 4 个字节;而在 64 位的 HotSpot 中,他们将占 8 个字节。”

    但是我记得boolean在内存中占1字节,char占2字节,这里是什么个意思?
    作者回复

    你说的是在堆里的情况。在解释器栈上是不一样的。至于原因吗,主要是变长数组不好控制,所以就选择浪费一些空间,以便访问时直接通过下标来计算地址。

    2018-07-24 00:40:53

  • LC

    2018-07-23 07:47:25

    老师可以讲下ASM、Unsafe和CAS的底层原理吗?这块儿一直是个拦路虎,谢谢!
    作者回复

    ASM你指的是那个字节码工程包吗?是的话那就是一个提供了字节码抽象的工具,允许用Java代码来生成或者更改字节码。JDK里也会用到ASM,用来生成一些适配器什么的。我印象中代码覆盖工具JaCoCo也是用ASM来实现的。

    Unsafe就是一些不被虚拟机控制的内存操作的合集。具体想要了解哪个API?

    CAS可以理解为原子性的写操作,这个概念来自于底层CPU指令。Unsafe提供了一些cas的Java接口,在即时编译器中我们会将对这些接口的调用替换成具体的CPU指令。

    2018-07-24 01:52:27

  • 曲东方

    2018-07-23 14:31:55

    Unsafe.putBoolean和Unsafe.puByte是native实现

    putBoolean和putByte也是通过宏SET_FIELD模板出的函数

    #define SET_FIELD(obj, offset, type_name, x) \
    oop p = JNIHandles::resolve(obj); \
    *(type_name*)index_oop_from_field_offset_long(p, offset) = truncate_##type_name(x)

    unsafe.cpp中定义宏做truncate
    #define truncate_jboolean(x) ((x) & 1)
    #define truncate_jbyte(x) (x)
    #define truncate_jshort(x) (x)
    #define truncate_jchar(x) (x)
    #define truncate_jint(x) (x)
    #define truncate_jlong(x) (x)
    #define truncate_jfloat(x) (x)
    #define truncate_jdouble(x) (x)

    综上:unsafe.Put*不会对值做修改
    ------------------------------------------------------------------------------------
    getBoolean和getByte也是通过宏GET_FIELD模板出的函数

    #define GET_FIELD(obj, offset, type_name, v) \
    oop p = JNIHandles::resolve(obj); \
    type_name v = *(type_name*)index_oop_from_field_offset_long(p, offset)

    综上,unsafe.Get*不会对值做修改
    ------------------------------------------------------------------------------------
    验证:
    unsafe.putByte(foo, addr, (byte)2); // 设置为: 2
    System.out.println(unsafe.getByte(foo, addr)); // 打印getByte: 2
    System.out.println(unsafe.getBoolean(foo, addr)); // 打印getBoolean: true

    unsafe.putByte(foo, addr, (byte)1); // 设置为: 1
    System.out.println(unsafe.getByte(foo, addr)); // 打印getByte: 1
    System.out.println(unsafe.getBoolean(foo, addr)); // 打印getBoolean: true
    ------------------------------------------------------------------------------------
    疑问:
    if(foo.flag)判断,使用getfield Field flag:"Z",执行逻辑等于:0 != flag
    if(foo.getFlag())判断,使用invokevirtual Method getFlag:"()Z",执行逻辑等于: 0 != ((flag) & 1)

    求大神帮忙解答

    --------------------------
    附getFlag jasm码:
    public Method getFlag:"()Z"
    stack 1 locals 1
    {
    aload_0;
    getfield Field flag:"Z";
    ireturn;
    }



    https://gist.github.com/qudongfang/49635d86882c03e49cff2b0f7d833805
    作者回复

    研究得非常深入!

    Unsafe.putBoolean会做掩码,另外方法返回也会对boolean byte char short进行掩码

    2018-07-24 00:29:54

  • 匿名小板凳

    2018-09-12 17:30:25

    这节看的很吃力,对什么掩码,子码,反码,补码都换给大学老师了。
  • Geek_dde3ac

    2018-07-23 13:25:52

    你好,在内存中都是0,那么是如何区别是哪种类型数据的呢?
    作者回复

    内存中是不做区分的。Java程序想要把它解读成什么类型,它就是什么类型。

    2018-07-24 15:19:50

  • 落叶飞逝的恋

    2018-07-24 12:53:10

    其实那个boolean的true虚拟机里面为1,也就是if(true==吃了没)其实可以替换成if(1==2)这样理解吧
    作者回复

    对的!

    2018-07-24 15:05:09

  • dong

    2018-07-27 16:48:08

    感觉"吃饭了"例子,弄得有点饶了。也有些地方语句的起承转合不是很通顺,个人理解。
    作者回复

    谢谢建议

    2018-07-27 23:27:04

  • 别处

    2018-07-24 09:35:44

    以下两个引至本文内容:
    1、在 Java 虚拟机规范中,boolean 类型则被映射成 int 类型。
    2、在 HotSpot 中,boolean 字段占用一字节,

    问题:一个是int类型,一个是一个字节(32位系统的话就是byte类型),是没讲透还是错误?
    作者回复

    多谢指出。严格来说,前者指的是计算的时候被映射成int类型,后者指的是存储到堆中是一个字节。

    2018-07-24 15:07:06

  • Invincible、

    2018-11-29 21:28:10

    为什么我不能让boolvalue=2或者3……
    作者回复

    因为javac不支持这种操作,它把boolean是用int实现的这种虚拟机的实现细节给隐藏起来了,从而使得在语言层面没有这种会引起歧义的值。

    2018-12-04 00:14:05

  • life is short, enjoy more.

    2018-09-14 09:56:18

    老师你好,我刚来订阅,所以才开始看。
    有一个疑问,您的原文“因此,在 32 位的 HotSpot 中,这些类型在栈上将占用 4 个字节;而在 64 位的 HotSpot 中,他们将占 8 个字节。”。但是有一句话,java一次编译,到处运行。计算机位数不一样的话,导致一样类型的size不一样,还可以到处运行吗?这里指的到处运行,是不是需要同位啊?比如32位的编译只能在32位的机器上运行,64只能在64的上运行。能互相兼容运行嘛?
  • andy

    2018-09-13 16:52:20

    我替换成2和3,都只能打印出一个Hello Java为什么呢?下面是AsmTools反编译代码
    super public class Foo
    version 52:0
    {

    static Field boolValue:Z;

    public Method "<init>":"()V"
    stack 1 locals 1
    {
    aload_0;
    invokespecial Method java/lang/Object."<init>":"()V";
    return;
    }

    public static Method main:"([Ljava/lang/String;)V"
    stack 2 locals 1
    {
    iconst_2;
    putstatic Field boolValue:"Z";
    getstatic Field boolValue:"Z";
    ifeq L18;
    getstatic Field java/lang/System.out:"Ljava/io/PrintStream;";
    ldc String "Hello, Java!";
    invokevirtual Method java/io/PrintStream.println:"(Ljava/lang/String;)V";
    L18: stack_frame_type same;
    getstatic Field boolValue:"Z";
    iconst_1;
    if_icmpne L33;
    getstatic Field java/lang/System.out:"Ljava/io/PrintStream;";
    ldc String "Hello, JVM!";
    invokevirtual Method java/io/PrintStream.println:"(Ljava/lang/String;)V";
    L33: stack_frame_type same;
    return;
    }

    } // end Class Foo
  • 迷上尼古丁的味道

    2019-02-22 10:41:09

    下面这段中0x8000000是不是少写了一个0呢

    前者在 Java 里是 0,后者是符号位为 1、其他位均为 0 的浮点数,在内存中等同于十六进制整数 0x8000000(即 -0.0F 可通过 Float.intBitsToFloat(0x8000000) 求得)。
  • 杨春鹏

    2018-07-26 18:27:46

    局部变量中基本数据类型存储在栈中,变量的变量名(引用符号)和变量值(字面量)都存储在栈中。
    局部变量中引用数据类型的引用地址存储在栈中,对象的实例数据存储在堆中,类型数据存储在方法区
    全局变量的基本数据类型和引用数据类型,都存储在堆中。
    不知理解的是否正确
  • 随心而至

    2019-09-25 20:10:12

    代码验证是可以的,3等奇数值最低位为1,就都打印;2等偶数值最低位为0,就都不打印,表明是掩码处理,取最低位。
    public class Foo {
    private boolean flag;

    public boolean getFlag() {
    return flag;
    }
    public static void main(String[] args) throws NoSuchFieldException, IllegalAccessException {
    Unsafe unsafe = getUnsafe();
    Foo foo = new Foo();
    Field field = foo.getClass().getDeclaredField("flag");
    long offset = unsafe.objectFieldOffset(field);
    unsafe.putInt(foo, offset, 3);
    if (foo.getFlag()) System.out.println("Hello,Java");
    if (foo.getFlag() == true) System.out.println("Hello, JVM");
    }

    public static Unsafe getUnsafe() throws NoSuchFieldException, IllegalAccessException {
    Field f = Unsafe.class.getDeclaredField("theUnsafe");
    f.setAccessible(true);
    return (Unsafe) f.get(null);
    }
    }
  • neohope

    2019-02-14 12:05:07

    测试了3个JDK版本,发现boolValue = true 里的 true 换为 2 或 3的例子,对JRE的版本是有一定要求的。同一个修改后的class文件(JDK8编译),在不同的环境中运行,输出分别为:
    windows+jdk1.8,2 输出第一个,3输出第一个;
    Linux+openjdk1.10,2 不输出,3两个都输出;
    windows+jdk1.11,2 不输出,3两个都输出;
    建议还是用JDK10以上的版本。
  • 星星

    2018-09-25 11:12:00

    long,double,float三种类型存入操作数栈有做转化操作吗?还是做浮点运算会有特殊处理,本文没有提及呀。
    作者回复

    同样会加载至操作数栈上,但都当成各自类型来计算,而不是像其他几种,被当成int来计算

    2018-09-28 14:35:41

  • 志远

    2018-07-23 21:38:16

    NaN 有一个有趣的特性:除了“!= 始终返回 true”之外,所有其他比较结果都会返回 false。这句话好拗口啊,双引号的标点符号有问题吧
    作者回复

    多谢指出!应该是 “!=“

    2018-07-24 00:31:10

  • Arvin

    2018-10-18 14:03:07

    当改为2或者3时则出现编译错误是则么回事!!!!