1.1 Integer 缓存与自动装箱拆箱事故 本节摘要:一个只在订单号超过 127 后才复现的接口 Bug,牵出 Java 自动装箱的核心机制—— 缓存池。本节讲清 与 在包装类型上的行为差异、缓存池的区间与可调性、自动拆箱的 NPE 风险,以及高频场景下装箱的性能账单。 事故现场:只在部分数据上复现的比较 Bug 某营销系统的优惠券去重逻辑上线三个月安然无恙,直到一天运营反馈:同一张券偶尔被核销两次。代码长得人畜无害: 测试环境全量回归通过,生产上小面额券(状态码 0 和 1)从没出过事,出事的全部是某种新券种——状态码 200。最后定位到根因只花了一行实验: 同样的代码,100 时相等,200 时不等。这不是玄学,是缓存池。 缓存池的机理 Java 语言规范允许装箱操作复用缓存对象。
本节摘要:一个只在订单号超过 127 后才复现的接口 Bug,牵出 Java 自动装箱的核心机制——
Integer缓存池。本节讲清==与equals在包装类型上的行为差异、缓存池的区间与可调性、自动拆箱的 NPE 风险,以及高频场景下装箱的性能账单。
某营销系统的优惠券去重逻辑上线三个月安然无恙,直到一天运营反馈:同一张券偶尔被核销两次。代码长得人畜无害:
public boolean isRedeemed(Integer couponStatus) { // 0 未使用 1 已使用 Integer redeemed = getStatusFromCache(couponStatus); if (redeemed == couponStatus) { return true; } return false; }
测试环境全量回归通过,生产上小面额券(状态码 0 和 1)从没出过事,出事的全部是某种新券种——状态码 200。最后定位到根因只花了一行实验:
Integer a = 100, b = 100; System.out.println(a == b); // true Integer c = 200, d = 200; System.out.println(c == d); // false
同样的代码,100 时相等,200 时不等。这不是玄学,是缓存池。
Java 语言规范允许装箱操作复用缓存对象。Integer 的默认缓存区间是 -128 到 127,在这个区间内,Integer.valueOf 返回的是 IntegerCache 内预先建好的同一个对象,所以 == 比较地址恰好为真;超出区间则每次 new 一个新对象,地址不同,== 为假。
更微妙的是,这个上限可以改:启动参数 -XX:AutoBoxCacheMax=1000 会把缓存扩到 1000。也就是说,同一段代码在不同 JVM 参数下行为不同——这在排查时是很大的干扰项,"我这台机器上是好的"在这里有了字面意义。
不止 Integer,各包装类型的缓存区间一览:
| 类型 | 缓存区间 | 可调 |
|---|---|---|
| Integer | -128 ~ 127 | 可,-XX:AutoBoxCacheMax |
| Short | -128 ~ 127 | 否 |
| Byte | -128 ~ 127(全集) | 否 |
| Long | -128 ~ 127 | 否 |
| Character | 0 ~ 127 | 否 |
| Boolean | true / false 两个单例 | 否 |
| Float / Double | 无缓存 | — |

修复很简单,把 == 换成 equals,或者先做 null 判断再拆箱比较。但值得追问的是:为什么测试没拦住?因为测试数据的状态码全在 127 以内。边界数据不在测试集里,缓存池的坑就永远不会在测试阶段暴露。
装箱有坑,拆箱更凶。看一段看似等价的代码:
Map<String, Integer> stats = new HashMap<>(); int count = stats.get("pv"); // get 返回 null,拆箱直接 NPE
stats.get 的返回类型是 Integer,赋给 int 触发自动拆箱,调用 null.intValue(),抛 NullPointerException。三元运算符还有一个变种坑:
Integer result = condition ? 1 : nullableInteger;
两个分支一个是 int 一个是 Integer,编译器按类型提升规则把整体提升为 int,于是 nullableInteger 被隐式拆箱——condition 为假且值为 null 时爆炸。阿里巴巴 Java 开发手册把这条列为强制规约,不是没有道理。
语义之外还有性能。把一个 int 计数器放进 Map<Integer, Integer> 里高频更新,每次都会经历装箱:分配对象、写缓存/哈希、增加 GC 压力。压测对比大概是这样的量级感受:纯 int[] 累加一亿次几乎无感,换成 Integer 装箱版本耗时翻几倍、GC 次数显著上升。这正是后续 LongAdder、专门化集合(如 Eclipse Collections 的原始类型 Map)存在的理由。
⚠️ 常见坑:把
==用于任何包装类型的比较。哪怕是 Boolean,也应该用equals或直接Boolean.TRUE.equals(x)这种 null 安全写法。
💡 关键直觉:缓存池是性能优化,不是语义承诺。凡是依赖"两个对象恰好是同一个"的写法,都是把代码的正确性押在 JVM 的优化策略上。
equals,代码评审见到包装类型 == 直接打回Map 装箱计数,改数组、LongAdder 或原始类型集合valueOf 源码三层说全,比背结论值钱-XX:AutoBoxCacheMax 可扩缓存,同代码在不同参数下 == 结果可不同equals;拆箱前判 null再把事故复盘的流程视角补全。这类"数据相关、偶发、不报错"的 Bug 之所以能存活三个月,是因为它同时躲开了三道防线:单测的造数全落在缓存区间内,代码评审时没人对包装类型的 == 保持敏感,线上也没有任何监控能观察到"去重率异常"这种业务语义层面的漂移。修复之后团队加了三条针对性措施:单测造数规范强制覆盖 127 与 128 这对边界值;静态扫描把包装类型 == 列为阻断级告警;去重率接入业务指标大盘。回头看,Integer 缓存本身并不是设计失误——它是语言规范明确允许的实现优化;真正的失误是把"恰好为真"当成了"保证为真"。分清这两者,比记住缓存区间的数字更重要,也是你在评审别人的比较逻辑时最值得带走的一句话。
下一节我们看另一个"精确性被默认行为出卖"的事故:浮点数算钱。