Administrator
发布于 2018-10-31 / 3627 阅读
65

为什么 127 == 127 为真,128 == 128 却为假?Integer 缓存池踩坑

同事发来一段代码,问为什么 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 一个新的。所以 ab 指向同一个对象,== 比较引用自然相等;cd 是两个不同的对象,== 就是 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() == 0count 是 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 里的情况整理了一遍:

类型缓存范围是否可配置缓存实现
Booleantrue / false 全部静态常量 TRUE / FALSE
Byte-128 ~ 127 全部ByteCache,固定 256 个
Short-128 ~ 127ShortCache
Character0 ~ 127(ASCII)CharacterCache
Integer-128 ~ 127是,上限可调IntegerCache
Long-128 ~ 127LongCache
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...

我给自己定的规矩

  1. 包装类型之间比较值,一律用 equals,或者先转成基本类型再 ==
  2. 数据库查询返回的包装类型字段,用到之前先判 null,别指望 MyBatis 帮你兜;
  3. 三目运算符的两个分支保持同类型,避免隐式拆箱;
  4. 不要在循环里做无意义的装箱。我测过,1000 万次 sum += integersum += int 慢大约 6 倍(187ms vs 31ms),因为每次都要拆箱再装箱。

回到同事那个问题,我把这篇文章的内容给他讲了一遍,他的总结是"就是 -128 到 127 共用对象,超出就 new"。虽然粗糙,但够用了。真正需要记住的其实是后面那条:别用 == 比较包装类型。

参考