从指令集角度分析自动拆/装箱

250 阅读5分钟

1. 前言

Java是一种强类型的语言,这意味着必须为每一个变量声明一种类型。 在Java中,一共有8种基本数据类型,且每个基本数据类型都含有对应的包装类型,对应关系如下表:

基本类型包装类型
byteByte
shortShort
intInteger
longLong
floatFloat
doubleDouble
booleanBoolean
charCharacter

基本类型和包装类型看似功能重合了,有点多余。实则不然,它俩有各自的优缺点和应用场景。 ​

1.1 基本类型

【优点】

  1. 节省内存,不用在堆中开辟额外的对象空间。
  2. 访问高效,不用额外寻址。
  3. 操作效率更高。

【缺点】

  1. 不允许为NULL,必须有值。
  2. 不符合面向对象的编程特征。
  3. 不支持泛型。
  4. 不允许作为「锁」对象(没有对象头)。

1.2 包装类型

【优点】

  1. 符合面向对象的编程特征。
  2. 支持泛型。
  3. 允许为NULL。
  4. 丰富了基本类型的操作。
  5. 支持泛型。

【缺点】

  1. 更耗内存,是一个完整的对象。
  2. 访问需要根据Reference引用寻址。

综上所述,可以发现,基本类型和包装类型的优缺点是互补的,这也说明了它俩的存在并不矛盾,在不同的应用场景下选择合适的类型会更好。 ​

2. 自动拆/装箱

基本类型和包装类型各有优点,但是为了提高开发者编码的效率和代码的可读性,Java自带的「自动拆箱/装箱」特性,弱化了基本类型和包装类型的区别。大多数时候,你可以混用这两种类型。 ​

什么是「自动拆箱/装箱」? 简单来说,就是你可以直接把包装对象赋值给基本类型变量,也可以直接把基本类型赋值给包装类型变量,甚至可以让基本类型和包装类型直接进行运算,如下示例:

void function() {
    Integer a = new Integer(0);
    int b = a;
    Integer c = b;
    int d = a + b;
}

Java会在基本类型和包装类型间自动做转换,是不是感觉很神奇?怎么做到的呢?我们待会再说。 ​

如下示例,演示了如果没有「自动拆箱/装箱」,代码会有多冗杂。

// 不支持自动拆箱/装箱
public Integer add(Integer a, Integer b) {
    int sum = a.intValue() + b.intValue();
    return Integer.valueOf(sum);
}

// 支持自动拆箱/装箱
public Integer add(Integer a, Integer b) {
    return a + b;
}

自动拆箱的坑 自动拆/装箱虽然用的很爽,但是开发者如果缺乏经验,稍有不慎就会踩坑。如下代码示例:

public static void main(String[] args) {
    print(null);
}

public static void print(Boolean isPrint) {
    if (isPrint) {
        System.out.println("输出一段话...");
    }
}

这段代码在编译时没有任何问题,一旦运行,就会报NullPointerException异常。print()形参为Boolean类型,允许接收null,但是它并没有判空,直接进行了布尔判断,此时“聪明”的Java会自动拆箱,将Boolean转换成boolean,导致空指针。 因此,在进行拆箱时,为了避免空指针,一定要先判空。 ​

3. 解密自动拆/装箱

了解了「自动拆箱/装箱」到底是怎么一回事之后,现在来详细分析一下。 ​

我们编写的.java文件为代码源文件,人类能看得懂,但是机器看不懂。为了可以让机器看懂并执行,还需要经过javac程序进行编译,编译后的.class文件JVM可以读懂并翻译为本地机器码并执行,这是后话。 ​

「自动拆箱/装箱」这一步,是在javac程序编译时实现的。 ​

我们以下面这段程序为例,看看编译后的文件到底是什么样子的,机器到底是如何执行的。

public class Demo {
	public Integer add(Integer a, Integer b) {
		return a + b;
	}
}

javac Demo.java编译成字节码文件,再javap -verbose Demo反汇编,得到实际的JVM指令。 ​

我这里只把add()方法的指令集贴出来,如下所示:

public java.lang.Integer add(java.lang.Integer, java.lang.Integer);
    descriptor: (Ljava/lang/Integer;Ljava/lang/Integer;)Ljava/lang/Integer;
    flags: ACC_PUBLIC
    Code:
      stack=2, locals=3, args_size=3
         0: aload_1
         1: invokevirtual #2                  // Method java/lang/Integer.intValue:()I
         4: aload_2
         5: invokevirtual #2                  // Method java/lang/Integer.intValue:()I
         8: iadd
         9: invokestatic  #3                  // Method java/lang/Integer.valueOf:(I)Ljava/lang/Integer;
        12: areturn
      LineNumberTable:
        line 11: 0

看不懂?去Google「JVM 指令集」就能看懂了,我这里解释一下每一步指令在做哪些事情。

指令说明
aload_1将第二个引用类型本地变量推送至栈顶,即将变量a推送至栈顶。
invokevirtual执行实例方法:Integer.intValue(),即调用了a.intValue()
aload_2将第三个引用类型本地变量推送至栈顶,即将变量b推送至栈顶。
invokevirtual执行实例方法:Integer.intValue(),即调用了b.intValue()
iadd将栈顶两int型数值相加并将结果压入栈顶,即执行了a+b运算。
invokestatic调用静态方法:Integer.valueOf(),即将相加结果封装为包装类型。
areturn从当前方法返回对象引用,即返回包装类型结果。

是不是很清晰了?我们写的源代码里虽然没有做类型转换,但是编译后的程序,自动帮我们做了处理哦。 所以,看似很神奇的「自动拆箱/装箱」也没什么大不了的嘛,无非就是编译器帮我们把类型转换的工作给做掉了。 ​

例如,int和Integer互转,就是调用了Integer.valueOf()Integer.intValue()方法。 ​

因此,上面那段代码,等同于下面这段代码:

public class Demo {
	public Integer add(Integer a, Integer b) {
		return Integer.valueOf(a.intValue() + b.intValue());
	}
}

再次编译,指令集是一模一样的,我就不贴了,大家可以自己试试哈~ ​

4. 总结

基本类型和包装类型的存在并不矛盾,它俩各有优点,且缺点互补。为了代码编写起来更简单,可读性更好,Java「自动拆箱/装箱」的特性弱化了它俩的区别,使用更方便了。 看似神奇的「自动拆箱/装箱」功能,其实也没多高级,就是在javac编译时帮我们加上了valueOf()xxxValue()方法完成了类型转换。 ​

最后再提醒一下,NULL对象拆箱时调用xxxValue()方法会导致空指针异常哦~