“Integer 用 == 比较不安全”这句话很多人背过,但很少有人真的验证过边界到底在哪。真实用反射读 Integer 的私有缓存类,真实扫出 == 翻脸的精确分界线,再真实复现一个只在生产环境才会发作的计数器 bug。
上一篇: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); // ?
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,私有内部类,正常代码访问不到,用反射打开看:
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 写死的默认值,不是运行时按数据规模自动调整的。
测试用的编号习惯性很小,生产环境的真实编号往往轻松超过 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(现实中几乎必然超过),同一个逻辑立刻悄悄失效——不报错、不崩溃,只是安安静静地永远判断"不是同一个人"。
每一行都是真实运行这段代码得到的结果。