一道看似简单的题目
今天看到一个题目,挺有意思的,题目很简单,看看你会不会?
下面一段java代码里的func()输出结果是什么?
public class Autoboxing {
public void func() {
Integer a = 2021;
Integer b = 2021;
System.out.println(a == b);
}
}
运行结果是false,可能有点出乎意料。我们改改,把a和b的值变成1:
public class Autoboxing {
public void func() {
Integer a = 1;
Integer b = 1;
System.out.println(a == b);
}
}
结果是true!更出乎意料了吧?如果你感到迷惑甚至惊讶,不妨花几分钟来看看本文。
Java的装箱、拆箱行为
Java对基本类型都有一个相应的包装类,如Integer/int、Float/float、Boolean/boolean、Byte/byte等。Java 1.5开始自动装箱、拆箱,在恰当的时候包装类会被转为基本类型,基本类型也能够被自动转换成包装类参与代码的执行。举个例子:
Integer integer = 10; // 装箱,整数10会被自动包装成Integer对象
int num = integer; // 拆箱,Integer对象integer会被转换成整数赋值给num
其他类型Boolean、Character、Byte、Short、Long、Float、Double也都有类似的行为。有兴趣的朋友可以试试。
深入探索——实现、答案和实战
看了上面装箱、拆箱的例子,貌似很简单啊。不过,这还是没有解释开头那段代码诡异的输出结果是怎么来的。接下来我们抽丝剥茧,从3个方面逐步深入了解其中的原因:
1. 装箱、拆箱的实现
我们先来看看上面一段代码部分java字节码。其中a、b赋值前都触发了装箱操作,通过Integer.valueOf(int)返回Integer对象。自动装箱是通过调用各包装类的valueOf()来实现的。
public void func();
Code:
0: sipush 2021
3: invokestatic #15 // 装箱,转化成Integer对象。Method java/lang/Integer.valueOf:(I)Ljava/lang/Integer;
6: astore_1
7: sipush 2021
10: invokestatic #15 // 装箱,转化成Integer对象。Method java/lang/Integer.valueOf:(I)Ljava/lang/Integer;
13: astore_2
14: getstatic #21 // Field
我们对func()稍作修改,做个加法。可以看到,拆箱的过程是调用了Integer.intValue()。其他包装类的实现也类似,都是调用相应的xxxValue()来实现的。
原始代码:
public void func() {
Integer a = 2021;
Integer b = 2021;
int c = a + b;
}
字节码:
public void func();
Code:
0: sipush 2021
3: invokestatic #15 // 装箱,Method java/lang/Integer.valueOf:(I)Ljava/lang/Integer;
6: astore_1
7: sipush 2021
10: invokestatic #15 // 装箱,Method java/lang/Integer.valueOf:(I)Ljava/lang/Integer;
13: astore_2
14: aload_1
15: invokevirtual #21 // 拆箱,Method java/lang/Integer.intValue:()I
18: aload_2
19: invokevirtual #21 // 拆箱,Method java/lang/Integer.intValue:()I
22: iadd
23: istore_3
24: return
2. 谜题的解开
实现方式也是简单明了,但前面的问题还是没有答案。别急,这个问题还是稍微有点复杂的。表达式(a == b)是在比较2个对象是否为同一个对象,并不会调用拆箱的过程。那么a和b为什么有时相同有时又不同呢?让我们以Integer为例来具体看看装箱是怎么操作的。
public static Integer valueOf(int i) {
if (i >= IntegerCache.low && i <= IntegerCache.high) // 默认IntegerCache.cache里缓存了256个Integer对象[-128,127]
return IntegerCache.cache[i + (-IntegerCache.low)];
return new Integer(i);
}
// 以下是IntegerCache
private static class IntegerCache {
static final int low = -128; // 下限
static final int high; // 上限
static final Integer cache[]; // 缓存的Integer对象,大小为(high - low -1)
static {
// 缓存上限可以通过property配置
// 为了清除看明白核心逻辑,我删除了读取、使用property的部分代码
int h = 127;
...
high = h;
cache = new Integer[(high - low) + 1];
int j = low;
for(int k = 0; k < cache.length; k++) // 初始化缓存中的对象
cache[k] = new Integer(j++);
// range [-128, 127] must be interned (JLS7 5.1.7)
assert IntegerCache.high >= 127;
}
private IntegerCache() {}
}
Integer内部缓存了部分Integer对象(默认是-128到127),如果参数i的值落在这个区间里,那么就返回同一个对象。所以,a、b都是1的情况下,返回的是同一个Integer对象;2021的情况下,则调用new Integer(i)创建了2个新的对象。为什么会有这样的缓存呢?原来是java语言规范(JLS)的要求,Oracle网站上原文是这样的:
If the value
p
being boxed istrue
,false
, abyte
, or achar
in the range\u0000
to\u007f
, or anint
orshort
number between-128
and127
(inclusive), then letr1
andr2
be the results of any two boxing conversions ofp
. It is always the case thatr1
==
r2
.
大致意思是说,如果一个基本类型p(比如100)的值处于某取值区间(如-128到127),该值p装箱后的对象分别为r1和r2。那么,表达式r1==r2永远为true。问题来了,那为JLS是处于什么考虑制定了这样的规范呢?
This ensures that in most common cases, the behavior will be the desired one, without imposing an undue performance penalty, especially on small devices. Less memory-limited implementations might, for example, cache all
char
andshort
values, as well asint
andlong
values in the range of -32K to +32K.
原来是出于性能方面的考虑。估计是因为java最初名为green,是为了机顶盒等资源有限的设备设计的,目前也广泛应用在一些嵌入式设备上。这样的设计可以在一定程度上提升执行效率。而且JLS鼓励实现者内存充裕的情况下缓存更大范围的装箱对象,可以缓存所有char、short值,甚至将int、long的缓存范围增加到-32K到32K。
不过请注意,Double、Float是没有这样的缓存机制的。原因很简单,[-128,127]区间内的浮点数太多了,理论上是无限的,自然没法全部缓存;缓存一部分又意义不大。
3. 装箱、拆箱的实战
那么什么时候会触发装箱、拆箱呢?我们先来看一段代码,你能说出它的运行结果吗?
public void func() {
Integer a = 1;
Integer b = 2;
Integer c = 3;
Long d = 3L;
System.out.println(c == (a + b));
System.out.println(c.equals(a + b));
System.out.println(d == (a + b));
System.out.println(c.equals(a + b));
System.out.println(d.equals(a + b));
}
第一行,a+b包含算术运算,触发拆箱,调用Integer.intValue()获取int值,做加法得到结果3。然后,“==”触发拆箱,Integer.intValue(c)的值为3,表达值为true。
第二行,前面步骤与第一行相同,只是调用了Integer.equals(Object obj)。传入参数是Object,触发装箱,然后比较数值。这里要看一下equals的实现。该方法先是判断了传入对象的类型,然后再考虑比较值。看到这里,最后2行的输出结果也就不难理解了。
public boolean equals(Object obj) { // 传入参数是Object,对于基本类型会触发自动装箱
if (obj instanceof Integer) { // 先检测类型,如果类型不同,则直接返回false
return value == ((Integer)obj).intValue();
}
return false;
}
第三行,d是Long类型,a+b的结果是int,做比较的时候会触发拆箱,并将a+b的结果与d的值进行比较。
第四行,结果比较好理解。这个主要是用来跟第五行对比的。
第五行,d是Long,a+b结果是int。a+b先被装箱成为Integer,Long.equals()也先会检查传入参数的类型是不是Long,发现不是,则直接返回false,没有去比较数值。这与Integer的源码是类似的。
总结一下:算术运算会触发拆箱;不同类型equals()返回false;一段范围内的数字装箱后返回的包装类有缓存。
异常处理
有一个小问题需要注意,触发拆箱的时候会调用xxxValue()。如果此时包装类对象是null,会产生NPE。所以,利用这个机制要小心,还是少用为好。
Integer a = null;
System.out.println(a == 100); // 触发拆箱,但a是null,会抛出NPE
为什么要有自动装箱呢?
泛型与容器。
要存储一个对象,要么存放一个该对象的引用;要么为它开辟一块内存,存放一份拷贝。前者可以用reference、指针实现,存储的数据是定长的;后者则需要知道该对象的大小才可以。对于java来说,容器中保存的不是对象本身,而是指向对象的引用,每个引用的大小是相同的。而基本类型(如int、short、double)所占内存却不一样,因而基本类型没法放到容器中。通过包装类把基本类型包装成“对象”,就可以方便地使用各种容器了。容器在运行期其实操作的都是Object,即便通过泛型技术指定了类型,如:ArrayList<String> list = new ArrayList<>(),ArrayList内部也不会创建String数组的,这就是所谓的泛型擦除。有兴趣的同学可以看看ArrayList的实现。
好了,关于自动装箱、拆箱,今天就聊到这里。欢迎大家留言交流:)