1. 前言
java 是一种强类型的语言,这意味着必须为每一个变量声明一种类型。
在Java中,一共有8种基本数据类型,且每个基本数据类型都含有对应的包装类型,对应关系如下表:
基本类型 | 包装类型 |
Byte | Byte |
short | Short |
int | Integer |
long | Long |
float | Float |
double | Double |
Boolean | Boolean |
char | Character |
基本类型和包装类型看似功能重合了,有点多余。实则不然,它俩有各自的优缺点和应用场景。
1.1 基本类型
【优点】
- 节省内存,不用在堆中开辟额外的对象空间。
- 访问高效,不用额外寻址。
- 操作效率更高。
【缺点】
- 不允许为NULL,必须有值。
- 不符合面向对象的编程特征。
- 不支持 泛型 。
- 不允许作为「锁」对象(没有对象头)。
1.2 包装类型
【优点】
- 符合面向对象的编程特征。
- 支持泛型。
- 允许为NULL。
- 丰富了基本类型的操作。
- 支持泛型。
【缺点】
- 更耗内存,是一个完整的对象。
- 访问需要根据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()方法会导致空指针异常哦~