一个权限校验的判断,本地跑得好好的,到同事机器上偶发不生效:
java
Integer userLevel = map.get("level");
if (userLevel == 3) {
// 允许操作
}
同一个包、同一份配置、同一个 JDK 大版本,他那边一直进得去,我这边有时候进不去。
问题在两处叠加:== 比较的是对象引用,而 Integer 的引用有时相同有时不同,取决于值落在不在缓存区间。
javadoc 到底承诺了什么?
Integer.valueOf(int) 的说明里有这句:
This method will always cache values in the range -128 to 127, inclusive, and may cache other values outside of this range.
「总是缓存 -128 到 127(含端点),可能缓存这个范围之外的其他值。」
前半个承诺是硬的,后半个是软的。于是有了那个著名现象:
java
Integer a = 100, b = 100;
System.out.println(a == b); // true 都在缓存里,同一个对象
Integer c = 1000, d = 1000;
System.out.println(c == d); // false 超出缓存,各自新建
100 命中缓存,两次装箱拿到同一个实例,引用相等成立。1000 不在保证范围内,两次装箱各建一个对象,引用不相等。
| 写法 | 是否走缓存 | 结果 |
|---|---|---|
Integer a = 100 |
是,自动装箱调 valueOf | 引用相同 |
Integer c = 1000 |
否,超出保证范围 | 引用不同 |
new Integer(100) |
不走 | 一定是新对象 |
Integer.valueOf(100) |
是 | 引用相同 |
int x = 1000; Integer y = x |
走 valueOf | 引用不同 |
第三行值得单独说:用构造函数创建包装类型已经标记为待移除,javadoc 明确建议改用 valueOf。它一定不返回缓存对象,所以「用构造函数更可靠」这种直觉是反的。
为什么 127 是那条线?
一个字节能表示的范围。用 byte 存「小整数」是 JVM 早期的优化,缓存一个字节范围内的 Integer 能覆盖绝大多数计数器、状态码和索引。
这个区间不是随便定的,但也不是可移植的约定。javadoc 写的是「总是缓存」,意思是实现必须至少缓存这么多,可以更多。
区间上下对称,是因为负数也要缓存。业务里负数用得少,但规范不为此开特例。
为什么本地和同事机器表现不同?
缓存命中与否取决于值的来源。从 Map 里取出的对象可能是别处缓存的,也可能是新建的,两条路径拼在一起,结果就依赖运行时的数据分布。
同一个数值走不同路径,引用就可能不同。所以「我这里是好的」不能说明代码是对的。
所以同一段代码在不同实现、不同启动参数下可能表现不同,而它不会报错。
这不是 bug,是什么?
是装箱的代价。
Integer 是对象,int 是基本类型。a == b 比较两个引用是不是指向同一个对象,a.equals(b) 比较值。只有基本类型之间的 == 才是数值比较。
自动装箱把 int 变成 Integer 时调的是 valueOf,而 valueOf 恰好查缓存。所以「有时相等」不是随机,是缓存命中与否。
根源是「同一个符号,两套语义」,而不是某个实现有缺陷。换一个 JVM 也一样。
这类「同一运算符、不同语义」的条目,我整理成了一张对照表,底稿是用 AI墨衍 把 javadoc 条目按「类型 / 运算符的语义 / 该用什么」三列对齐的,几个项目共用一份。
正确的写法有哪三种?
第一种,比较值:
java
if (userLevel != null && userLevel == 3) { } // 拆箱后比较
if (Integer.valueOf(3).equals(userLevel)) { } // 显式值比较,null 安全
第一种依赖拆箱:包装类型与基本类型比较时,包装类型会被拆箱,变成数值比较。前提是不为空,否则抛空指针。
第二种用 equals,实参可以为空而不抛异常,适合从 Map 里取值这种「可能为空」的场景。
第三种,干脆别用包装类型:
java
int userLevel = Optional.ofNullable(map.get("level")).orElse(0);
把空的可能性在入口处理掉,后面全部用 int,== 就永远是对的。
如果这个值要参与算术,用 int 最省事;如果必须保留空值语义,就用包装类型配 equals。两种都比等号可靠。
缓存区间可以调大吗?
JVM 有个参数能把上界放大。有人用它来「顺手修掉」这个问题,这是危险的做法。
它依赖启动参数,换个环境就失效。上界可调而下界固定,区间不对称。更麻烦的是,它让代码的正确性依赖运行参数,评审时看不出来。
真正该做的是把 == 换成 equals 或拆箱比较。缓存参数只用于性能调优,不该用于修 bug。
如果线上已经依赖这个参数,等于把代码的正确性押在了启动脚本上。
同类问题还在哪里出现?
Long 也有缓存,保证范围同样是 -128 到 127,Long.valueOf 的 javadoc 用的是同样的措辞。Short、Byte、Character 也都有。
所以「Long 值小的时候 == 为 true」这个现象同样成立,而且更容易被忽略,因为大家默认 Long 是「大数类型」,不会想到它有缓存。
String 的坑是另一个版本:字面量在常量池里,构造函数创建的不在,intern() 又回到池里。判断逻辑一样:== 比引用,equals 比值。
判断方法可以统一:先看这个类型是不是引用类型,再看它有没有缓存或常量池。两个条件都成立,等号就不可靠。
代码里怎么拦住?
| 检查项 | 检测手段 | 说明 |
|---|---|---|
| 两个包装类型用等号比较 | IDE 检查或静态分析 | 最直接的一条 |
| 用构造函数创建包装类型 | 编译告警(已标记待移除) | 一律换 valueOf |
| 从 Map 取值后直接比较 | 代码评审 | 优先 equals 或先拆箱判空 |
| 用等号比较 Long | 静态分析同类规则 | 常被漏掉 |
前两行用静态分析就能覆盖,加进 CI 之后这类问题基本绝迹。
静态分析报出来的是形状,不是语义:两个包装类型之间出现等号,一律可疑。这条规则不需要理解业务,误报也低。
小结
一条判断管住全部:== 在 Java 里比的是引用,不是值,而包装类型恰好横跨两个世界。
基本类型比「哪一个」,引用类型比「是不是同一个」,而 Integer 会自动装箱、会查缓存、还会在不该拆箱的时候拆箱。
缓存让这件事变得「时真时假」,也让 bug 更难复现。真正要记的不是 127 这个数字,而是「包装类型不要用等号比值」。