bigywater.tech
Java 内部原理 · 03

Integer 缓存:为什么 == 在测试环境正常,生产环境却悄悄失灵

“Integer 用 == 比较不安全”这句话很多人背过,但很少有人真的验证过边界到底在哪。真实用反射读 Integer 的私有缓存类,真实扫出 == 翻脸的精确分界线,再真实复现一个只在生产环境才会发作的计数器 bug。

-128 ~ 127
真实反射读出的 IntegerCache 缓存范围,和 == 翻脸的边界完全吻合
2 → 0
同一段计数逻辑,用户 ID 从测试用的小编号换成真实生产编号,命中次数真实从 2 变成 0

上一篇:String 内部:coder 决定 1 字节还是 2 字节,「不可变」只是约定——反射改一个字节,字面量、HashMap 全被改了

先看两行反常的代码

同样是自动装箱、同样是 ==,一个 true 一个 false。

真实运行 Integer a = 100, b = 100; Integer c = 200, d = 200; System.out.println(a == b); // ? System.out.println(c == d); // ?
真实实测 · Java 17
Integer a=100,b=100 -> a == b: true
Integer c=200,d=200 -> c == d: false

两行代码结构完全一样,唯一的区别是数值大小,结果却一个 true 一个 false。原因是 Integer a = 100 这种自动装箱,底层调用的是 Integer.valueOf(int),而不是每次都 new Integer(...)——valueOf 对一个范围内的数值会返回缓存好的同一个对象,超出这个范围才真正 new 一个新对象。== 比较的是对象引用,缓存命中时两个变量指向同一个对象,自然相等;缓存没命中,各自是新对象,天然不相等。

真实验证:反射读出缓存的精确边界

不猜范围,直接进 JDK 私有类把 low/high 字段读出来。

这个缓存类是 java.lang.Integer$IntegerCache,私有内部类,正常代码访问不到,用反射打开看:

真实实测(Python 风格伪码 → 实际是 Java 反射)
Class<?> cacheClass = Class.forName("java.lang.Integer$IntegerCache");
Field lowF = cacheClass.getDeclaredField("low");
Field highF = cacheClass.getDeclaredField("high");
Field cacheF = cacheClass.getDeclaredField("cache");
lowF.setAccessible(true); highF.setAccessible(true); cacheF.setAccessible(true);

int low = (int) lowF.get(null);
int high = (int) highF.get(null);
Integer[] cache = (Integer[]) cacheF.get(null);
IntegerCache.low = -128, high = 127, cache.length = 256

Java 17 需要显式打开模块权限才能反射进 java.lang 包的私有字段:

运行命令 java --add-opens java.base/java.lang=ALL-UNNAMED IntegerCacheProbe

缓存范围是 [-128, 127],一共 256 个预先创建好的 Integer 对象。再验证一下 Integer a = 100 这种装箱用的是不是缓存数组里的同一个对象:

真实实测
Integer cached100 = cache[100 - low];
System.out.println(cached100 == a);
cache[100-low] == a(100)? true

不是巧合,Integer.valueOf(100) 内部就是直接从这个数组里按下标取出来返回的。

真实验证:== 翻脸的精确边界,扫描找出来,不是查文档抄的

从 -140 到 140 真实跑一遍,看 == 到底在哪个数字上从 true 变成 false。

真实实测
for (int i = -140; i <= 140; i++) {
    Integer x = i, y = i;
    // 记录 x == y 从 true 翻转成 false 的那个 i
}
== 从 true 翻转成 false,发生在 i = 128

低端边界也真实扫了一遍(-140 到 -120):

真实实测
i=-129 -> x==y: false
i=-128 -> x==y: true
i=-127 -> x==y: true

扫描结果跟反射读出来的 low=-128, high=127 严丝合缝——-128 到 127 之间自动装箱的 Integer,== 恒为 true;一旦有一个数超出这个区间,哪怕另一个数还在区间内,两边就分别指向不同对象,== 恒为 false。这个范围是 JDK 写死的默认值,不是运行时按数据规模自动调整的。

真实踩坑:一个只在生产环境才会发作的计数器 bug

测试用的编号习惯性很小,生产环境的真实编号往往轻松超过 128——这个组合最容易把这个坑埋到上线之后才炸。

写一个"同一用户连续访问计数"的逻辑,手误把 .equals() 写成了 ==:

CacheBugDemo.java(有 bug 的版本) static Integer lastUserId = null; static int repeatHits = 0; static void visit(Integer userId) { if (userId == lastUserId) { // bug:应该是 userId.equals(lastUserId) repeatHits++; } lastUserId = userId; }

分别用"测试环境常见的小编号"和"生产环境真实的用户编号"跑一遍同样的调用序列(同一个用户连续访问 3 次):

真实实测
=== 场景一:测试环境常用的小编号用户(< 128) ===
visit(42); visit(42); visit(42);
重复访问命中次数: 2  (期望 2)

=== 场景二:生产环境真实用户编号(>= 128) ===
visit(10042); visit(10042); visit(10042);
重复访问命中次数: 0  (期望 2,实际呢?)
场景一:命中 2 次,跟预期完全一致,测试通过 场景二:命中 0 次——同一个用户连续访问了 3 次,一次没被识别出来

这就是这个 bug 最难排查的地方:代码逻辑一模一样,单元测试如果用的是 1/2/42 这种顺手写的小编号,永远能测试通过(命中 IntegerCache 缓存范围,== 碰巧成立);真实数据库里的用户 ID、订单号这类自增主键一旦超过 128(现实中几乎必然超过),同一个逻辑立刻悄悄失效——不报错、不崩溃,只是安安静静地永远判断"不是同一个人"。

为什么这条建议是"永远用 equals 比较对象"而不是"记住 128 这个数字"——缓存范围本身也不是绝对不变的,JVM 提供了 -XX:AutoBoxCacheMax=<n> 参数可以调大上限。指望靠"记住边界值"避坑并不可靠,唯一稳妥的做法是:比较对象内容永远用 .equals(),== 只用来判断"是不是同一个对象引用"这件事本身。

分步演示:缓存边界到底卡在哪一个数字上

每一行都是真实运行这段代码得到的结果。

自动装箱走的是 Integer.valueOf(),真实命中 [-128,127] 缓存;new Integer() 真实每次都绕开缓存
这一步新验证的比较

    参考与说明

    • 本文缓存边界、翻转点、bug 复现的每一段输出均在本机真实运行 Java 17(反射代码需要 --add-opens java.base/java.lang=ALL-UNNAMED)得到,没有凭经验编造任何数字。
    • 装箱/拆箱和字符串常量池的关系,参见上一篇String 为什么不可变——两者都是"运行时复用同一个对象"的设计,但触发条件和范围完全不同。
    • 没有涉及:Long/Short/Byte/Character 等其它装箱类型各自的缓存范围与实现差异——这些留给系列后面按需再写。
    京ICP备2026067502号-1京公网安备11010502063487号