同事发来一段代码,问为什么 127 和 128 结果不一样
万圣节前一天,隔壁工位刚转岗过来的同事在群里贴了这段代码:
Integer a = 127;
Integer b = 127;
System.out.println(a == b);
Integer c = 128;
Integer d = 128;
System.out.println(c == d);
他问:"这两个不应该都是 true 吗?"我脱口而出"第一个 true 第二个 false",然后被追问为什么。我发现自己只能说出"有个缓存"三个字,讲不清楚细节,就翻了一遍源码。
先确认现象
true
false
把 128 换成 -129 也是 false,换成 -128 又是 true。边界很整齐。
关键在装箱用的是哪个方法
Integer a = 127 这行代码,编译之后其实调用的是 Integer.valueOf(127)。我用 javap -c 确认了一下:
$ javap -c IntegerDemo.class
0: bipush 127
2: invokestatic #2 // Method java/lang/Integer.valueOf:(I)Ljava/lang/Integer;
5: astore_1
这是 JDK 5 引入的自动装箱语法糖,编译器帮我们插了 valueOf 调用。而 valueOf 的实现是这样的(JDK 8):
public static Integer valueOf(int i) {
if (i >= IntegerCache.low && i <= IntegerCache.high)
return IntegerCache.cache[i + (-IntegerCache.low)];
return new Integer(i);
}
看到了:落在缓存范围内就返回缓存数组里的同一个对象,超出范围就 new 一个新的。所以 a 和 b 指向同一个对象,== 比较引用自然相等;c 和 d 是两个不同的对象,== 就是 false。
IntegerCache 的源码
这个类是 Integer 的私有静态内部类:
private static class IntegerCache {
static final int low = -128;
static final int high;
static final Integer cache[];
static {
int h = 127;
// 从 JVM 参数读上限
String integerCacheHighPropValue =
sun.misc.VM.getSavedProperty("java.lang.Integer.IntegerCache.high");
if (integerCacheHighPropValue != null) {
try {
int i = parseInt(integerCacheHighPropValue);
i = Math.max(i, 127); // 不能低于 127
h = Math.min(i, Integer.MAX_VALUE - (-low) - 1); // 防止数组太大 OOM
} catch( NumberFormatException nfe) {
// 参数格式不对就忽略,用默认 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++); // 一次性填满
assert IntegerCache.high >= 127;
}
private IntegerCache() {}
}
几个细节我以前不知道:
- 下限 -128 是写死的,改不了;上限 127 是默认值,可以通过 JVM 参数调大。
- 缓存是在静态初始化块里一次性创建好的,类加载时就填满了 256 个对象,不是懒加载。
- JLS(Java 语言规范)只要求缓存 -128 到 127,这是规范强制的范围,具体实现可以更大。
验证一下改上限的参数:
$ java -Djava.lang.Integer.IntegerCache.high=500 IntegerDemo
true
true
设成 500 之后,128 也在缓存里了,两个都是 true。这个参数要用 -XX:AutoBoxCacheMax=<size> 也行,它是 HotSpot 提供的另一种写法。不过我不建议在生产环境调它,改了之后代码行为依赖 JVM 参数,写出来就是隐患。
valueOf 和 new Integer 的区别
Integer x = Integer.valueOf(100);
Integer y = Integer.valueOf(100);
Integer m = new Integer(100);
Integer n = new Integer(100);
System.out.println(x == y); // true,都来自缓存
System.out.println(m == n); // false,new 出来的一定是新对象
System.out.println(x == m); // false
new Integer() 绕过了缓存,无条件创建新对象。JDK 9 开始这个构造器被标记为 @Deprecated,JDk 8 里虽然没废弃,但 IDE 会给警告。阿里巴巴的 Java 开发手册里明确要求:所有整型包装类对象之间的比较,全部用 equals 方法。
我把上面那段改成 equals 再跑,全是 true:
System.out.println(c.equals(d)); // true
拆箱时的另一个陷阱
比 == 更隐蔽的是混合类型比较。我见过同事写这样的代码:
Integer count = null;
if (count == 0) { // NullPointerException
// ...
}
当一个包装类型和一个基本类型用 == 比较时,包装类型会自动拆箱成基本类型再比较。也就是说 count == 0 等价于 count.intValue() == 0,count 是 null 就直接 NPE。
我们项目里的这段代码出过线上事故:
// Mapper 返回 null 时(比如查不到记录),这里会 NPE
Integer stock = goodsMapper.selectStock(skuId);
if (stock < buyCount) { // stock 为 null → NPE
return "库存不足";
}
还有一个更迷惑的情况:
Integer a = 1000;
Integer b = 1000;
Long c = 1000L;
System.out.println(a.equals(b)); // true,同类型比较值
System.out.println(a.equals(c)); // false!equals 里做了类型判断
System.out.println(a == c.intValue()); // true,都转成基本类型
Integer.equals 的源码第一行就是类型检查:
public boolean equals(Object obj) {
if (obj instanceof Integer) {
return value == ((Integer)obj).intValue();
}
return false;
}
传进来的不是 Integer 就直接 false,不会帮你做类型转换。
三目运算符里的隐式拆箱
这个坑我是在 code review 时发现的,非常隐蔽:
Integer total = 100;
Integer discount = null;
Integer result = true ? total : discount; // 看着没问题
System.out.println(result);
编译能通过,但运行会 NPE。原因是三目运算符的两个分支类型不一致时,编译器会找它们的公共类型。一个是 Integer,一个是 null,编译器按"两个操作数都是包装类型"处理还算好的;更常见的是这种:
boolean flag = false;
Integer a = 200;
int b = 100;
Integer result = flag ? a : b; // 结果类型是 int,a 被拆箱
按 JLS 的规则,如果第二和第三操作数一个是基本类型一个是包装类型,整个表达式的类型会被推断为基本类型,包装类型要拆箱。所以 flag ? a : 0 这种写法,a 为 null 时就炸。
我们的代码里正是在 ? a : 0 这种"给个默认值"的常见写法上出的问题,而且还是偶发的——只有 flag 为 true 且 a 为 null 时才触发,测试环境从没覆盖到。改成 a == null ? 0 : a 就好了。
其他包装类的缓存
不只是 Integer 有缓存,我把 JDK 8 里的情况整理了一遍:
| 类型 | 缓存范围 | 是否可配置 | 缓存实现 |
|---|---|---|---|
| Boolean | true / false 全部 | 否 | 静态常量 TRUE / FALSE |
| Byte | -128 ~ 127 全部 | 否 | ByteCache,固定 256 个 |
| Short | -128 ~ 127 | 否 | ShortCache |
| Character | 0 ~ 127(ASCII) | 否 | CharacterCache |
| Integer | -128 ~ 127 | 是,上限可调 | IntegerCache |
| Long | -128 ~ 127 | 否 | LongCache |
| Float / Double | 无缓存 | - | 每次 new |
浮点数没做缓存,因为在一个区间里浮点数是不可数的,没法像整数那样枚举。所以:
Double d1 = 1.0;
Double d2 = 1.0;
System.out.println(d1 == d2); // false
顺带一个相关的坑,跟缓存无关但经常被放在一起讲:
System.out.println(0.1 + 0.2 == 0.3); // false
System.out.println(0.1 + 0.2); // 0.30000000000000004
金额计算一定要用 BigDecimal,而且要用 String 构造器而不是 double 构造器:
new BigDecimal("0.1"); // 精确表示 0.1
new BigDecimal(0.1); // 值是 0.1000000000000000055511151231257827...
我给自己定的规矩
- 包装类型之间比较值,一律用
equals,或者先转成基本类型再==; - 数据库查询返回的包装类型字段,用到之前先判 null,别指望 MyBatis 帮你兜;
- 三目运算符的两个分支保持同类型,避免隐式拆箱;
- 不要在循环里做无意义的装箱。我测过,1000 万次
sum += integer比sum += int慢大约 6 倍(187ms vs 31ms),因为每次都要拆箱再装箱。
回到同事那个问题,我把这篇文章的内容给他讲了一遍,他的总结是"就是 -128 到 127 共用对象,超出就 new"。虽然粗糙,但够用了。真正需要记住的其实是后面那条:别用 == 比较包装类型。