以前很长一段时间,我以为“Integer 和 int 用 == 比较”只是新手面试题,实际代码里没人会这么写。直到有一次我们小组接了一个数据清理的老项目,里面有一段状态判断代码,从 1 到 12 跑得好好的,忽然客户反馈某个功能在状态切换到 13 时失效。查了半天,最后定位到一个 Integer 对象的 == 比较上。那天的排查过程让我对 Integer a = 100, b = 100; a == b 这种八股问题产生了全新认识——它不只是考试题,它背后藏着 Java 自动装箱、对象缓存、JVM 参数、反序列化行为、代码评审习惯这一整条链路。这篇文章我打算把这条链路完完整整拆开讲一遍,从现象到源码再到实战,尽量让看过的人以后再遇到类似的比较问题时,能直接做出正确判断。
1. 先从那个经典的“100 为 true、200 为 false”现象说起
先把这个已经被讨论无数遍的代码摆出来:
java复制Integer a = 100;
Integer b = 100;
System.out.println(a == b); // true
Integer c = 200;
Integer d = 200;
System.out.println(c == d); // false
不管你之前有没有运行过,我建议你先亲手跑一遍。很多人在第一次看到这个结果时,第一反应是“编译器是不是对不同常量做了特殊处理”,第二反应是“Integer 不是对象吗,对象用 == 比较的不是地址吗,为什么两个地址会相等”。这两个疑问都非常正常,因为它们指向了同一个真相:Integer 对象其实会走一个“装箱后的对象缓存”逻辑,而 100 恰好落进了缓存范围,200 又没有落进去。
更有意思的是,如果你把代码改成这样:
java复制Integer a = new Integer(100);
Integer b = new Integer(100);
System.out.println(a == b); // false
那么即使值是 100,结果也会是 false。因为 new 关键字强行创建了两个全新对象,两个对象的堆内存地址当然不同。
也就是说,同样的值、同样的“对象比较”,会因为对象的诞生路径不同而出现完全不同的结果。这里马上就会引出一个核心结论:用 == 比较包装类对象,本质上是在比较对象的引用是否指向同一个内存对象,而不是在比较它们的数值是否相等。 之所以 Integer a = 100 和 Integer b = 100 能指向同一个对象,是因为 JVM 内部维护了一个叫 IntegerCache 的缓存池,自动装箱时优先从池子里拿对象。这个池子默认覆盖了 -128 到 127,所以 100 能命中池子,200 则每次都会新建对象。
我刚入行那几年,对这种现象的理解也就到“记住 127 边界”为止。后来踩了生产环境的坑才意识到,真正危险的从来不是记住这个边界,而是不知道哪些代码路径会绕过这个“默认约定”。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 拆开自动装箱:Integer a = 100 在字节码层面到底做了什么
要理解为什么 100 会“走缓存”,得先理解自动装箱的本质。Java 是编译型语言,我们写的是 Integer a = 100,但 javac 编译出来的字节码并不是“把 100 这个 int 直接塞给 Integer 引用”,而是调用了一个静态方法:Integer.valueOf(int)。
你可以用 javap -c 验证这一点:
bash复制javap -c TestInteger.class
假设有这样一个类:
java复制public class TestInteger {
public static void main(String[] args) {
Integer a = 100;
Integer b = 200;
}
}
反编译后你会看到类似下面的输出:
text复制public static void main(java.lang.String[]);
Code:
0: bipush 100
2: invokestatic #2 // Method java/lang/Integer.valueOf:(I)Ljava/lang/Integer;
5: astore_1
6: sipush 200
9: invokestatic #2 // Method java/lang/Integer.valueOf:(I)Ljava/lang/Integer;
12: astore_2
13: return
注意看字节码里的 invokestatic Integer.valueOf。也就是说,javac 帮我们把 Integer a = 100 翻译成了 Integer a = Integer.valueOf(100),把 Integer b = 200 翻译成了 Integer b = Integer.valueOf(200)。自动装箱不是 JVM 在运行时悄悄做的魔法,而是编译器在编译期插入的方法调用。
这里也解释了一个新手常见的误区:有些人以为“值比较时自动拆箱”,有人以为“对象比较时也会自动拆箱”。实际上,Integer a = 100; Integer b = 100; a == b 这段代码里,a 和 b 都是 Integer 引用,== 两侧都是引用类型,所以 Java 根本不会去拆箱。如果两侧一个是 Integer 一个是 int,那才会触发自动拆箱:
java复制Integer a = 200;
int b = 200;
System.out.println(a == b); // true,这里触发了拆箱
遇到这种情况,a 会自动调用 intValue() 转成基本类型再比较,所以结果是 true。很多人把这两种场景混在一起记,才会在分析问题时越绕越晕。
顺着字节码层面继续往下,Integer.valueOf(100) 和 Integer.valueOf(200) 都只是普通的方法调用。真正决定结果是 true 还是 false 的,就是这个 valueOf 方法内部发生了什么。
3. 打开 valueOf 和 IntegerCache 的源码:缓存池就是这样来的
我们直接看 OpenJDK 里 Integer 的 valueOf 实现。不同版本代码略有差异,但核心逻辑几十年没变过:
java复制public static Integer valueOf(int i) {
if (i >= IntegerCache.low && i <= IntegerCache.high) {
return IntegerCache.cache[i + (-IntegerCache.low)];
}
return new Integer(i);
}
逻辑非常简单:如果传入的 int 落在缓存区间内,就直接从预先生成的 cache 数组里返回对应对象;如果不在区间内,虽然代码里写的是 new Integer(i),这也有力地说明了为什么 200 创建出来的对象是新的。
再往下看 IntegerCache 这个私有静态内部类的实现,JDK 8 和不少教程里都有类似版本:
java复制private static class IntegerCache {
static final int low = -128;
static final int high;
static final Integer cache[];
static {
int h = 127;
String integerCacheHighPropValue =
sun.misc.VM.getSavedProperty("java.lang.Integer.IntegerCache.high");
if (integerCacheHighPropValue != null) {
try {
int i = Integer.parseInt(integerCacheHighPropValue);
i = Math.max(i, 127);
h = Math.min(i, Integer.MAX_VALUE - (-low) - 1);
} catch (NumberFormatException nfe) {
}
}
high = h;
cache = new Integer[(high - low) + 1];
int j = low;
for (int k = 0; k < cache.length; k++) {
cache[k] = new Integer(j++);
}
}
}
我们逐行理解这个类做了什么:
low固定是 -128。high默认初始化为 127,但会尝试读取 JVM 保存的系统属性java.lang.Integer.IntegerCache.high。- 如果需要扩大缓存池,传入的属性值不能小于 127(代码里用了
Math.max(i, 127)做下限保护),同时不能超过Integer.MAX_VALUE - (-low) - 1,防止数组太大越界。 - 类加载到 JVM 时,静态代码块会一次性创建
[-128, high]区间内的所有Integer对象,放在cache数组中。 valueOf命中区间时,通过下标i + (-low)找到对应对象。
这也解释了 100 和 200 的本质区别:100 在 cache 数组里已经有一个对象,Integer a = 100 和 Integer b = 100 拿到的都是数组里的同一个 Integer 实例;而 200 超过默认的 high=127,每次都走 new,所以 c 和 d 指向不同的堆对象。
有个细节值得单独强调:new Integer(200) 和 Integer.valueOf(200) 是两回事。valueOf 是官方推荐的装箱入口,IntegerCache 也是它内部使用的;而 new 是直接创建对象,永远不缓存。虽然 JDK 9 开始 new Integer(int) 构造器已经被标记为 @Deprecated(since="9"),但遗留下来的老代码仍然可能通过 new 构造包装类对象。再看开头那个例子,如果你写的是:
java复制Integer a = new Integer(100);
Integer b = new Integer(100);
System.out.println(a == b); // false
就能更直观地明白“代码路径决定是否命中缓存”这件事。
4. 为什么偏偏默认缓存到 127?语言规范、设计取舍与 JVM 参数调优
很多人背下了“-128 到 127”这个区间,却不明白为什么是这个区间。实际上,这背后有语言规范和现实性能的双重推动。
首先,《Java 语言规范》第 5.1.7 节对 boxing conversion 有一段要求,大意是:如果一个 boolean 值被装箱为 true 或 false、一个 byte 范围内的值被装箱为 Byte、一个位于 -128 到 127 之间的 short/int 被装箱为对应包装类、一个 char 值落在 \u0000 到 \u007f 之间被装箱为 Character,那么装箱结果应当保证两个转换后的引用可以相同。这不是绝对的必须实现,而是 JLS 鼓励的语义。OpenJDK 以及绝大多数主流 JVM 都遵循并在默认配置里实现了这一点。
从现实角度想,为什么是这个范围?核心原因是小整数的出现频率极高,而对象创建成本又远高于基本类型比较。循环里的计数器、状态码、索引、标志位,绝大多数业务代码中的整数值都集中在很小的范围内。JVM 选择在类加载阶段预生成 256 个 Integer 对象,用空间换时间,避免高频代码段因为自动装箱反复创建对象。如果缓存池扩大到所有 32 位整数,那需要 42 亿个对象,内存直接爆炸,显然不现实。所以设计上取了一个在内存占用和命中概率之间相对平衡的区间:-128 到 127。
这个范围不是完全锁死的。如果你有明确的场景需要扩大缓存池,可以通过 JVM 启动参数调整:
bash复制java -XX:AutoBoxCacheMax=2000 YourMainClass
也可以使用系统属性:
bash复制java -Djava.lang.Integer.IntegerCache.high=2000 YourMainClass
两种方式本质上都会影响上面看到的 IntegerCache.high。调整之后,200 也会落到缓存区间内,原本的 c == d 就可能从 false 变成 true。
这让我想到一个很有意思的推论:同样的代码,在不同的 JVM 参数下,可能产生不同的结果。 这也正是用 == 比较包装类最危险的地方——你的程序在某台机器上跑得好好的,换个环境启动参数变了,或者依赖的第三方框架在某个版本里调用方式变了,原来“碰巧正确”的比较逻辑就会突然翻车。比如:
java复制Integer type = loadFromConfig();
if (type == 100) { ... }
如果 loadFromConfig() 内部通过缓存池中的对象返回 100,这段代码可能很长时间都能正常工作;一旦某天 config 来源改为 JSON 反序列化、RPC 调用返回值,或者 someone 把它们封装成了 new Integer(100),这个 if 就可能永远进不去。要修复这个问题,不是去祈祷所有 100 都在缓存池里,而是应该把对象比较改成值比较,或者老老实实用 equals。
我看到网上很多讨论会顺带提一句“这是 Java 的 bug”,其实不是。这是语言规范和 JVM 实现共同设计出的优化行为,真正的问题在于开发者没有遵守“对象比较用 equals/Objects.equals,基本类型比较才用 ==”这条铁律。
5. 装进同一个坑里的不止 Integer:Long、Short、Character、Boolean 的缓存策略
如果你只记住了 Integer 的 -128 到 127,那其实还没有完全掌握这类问题的全貌。Java 中对包装类做缓存的并不只有 Integer 一家。我见过有同事排查 Long 也出现类似现象时一脸茫然:“为什么 Long a = 100L 比较是 true,改成 200L 又是 false?”这背后的原理和 Integer 几乎一模一样,因为 Long 也有自己的 LongCache。
简单列一下常见包装类的默认缓存范围:
| 包装类 | 缓存范围 | 说明 |
|---|---|---|
Byte |
全部 byte 范围:-128 ~ 127 | byte 只有 256 个值,干脆全缓存 |
Short |
-128 ~ 127 | 和 Integer 默认范围一致 |
Integer |
-128 ~ 127 | 可通过 AutoBoxCacheMax 调大 high |
Long |
-128 ~ 127 | 默认只缓存这一小段 |
Character |
0 ~ 127 | 对应 ASCII 和部分扩展字符 |
Boolean |
true、false |
只有两个实例 |
Float、Double |
不缓存 | 浮点数范围太大,缓存没意义 |
Long 的 valueOf 源码也验证了这一点:
java复制public static Long valueOf(long l) {
if (l >= LongCache.low && l <= LongCache.high) {
return LongCache.cache[(int)l + (-LongCache.low)];
}
return new Long(l);
}
LongCache 的默认范围同样是 -128 到 127。所以如果面试题换成“Long a = 100L, b = 100L 为什么是 true,改成 200L 为什么是 false”,解法是同一个思路。
Boolean 就更特殊了,它是个对象,但只存在两个实例,分别对应 TRUE 和 FALSE。JVM 中 Boolean.valueOf(boolean) 内部直接返回这两个静态实例:
java复制public static Boolean valueOf(boolean b) {
return (b ? TRUE : FALSE);
}
因此 Boolean a = true; Boolean b = true; a == b 必然是 true,不需要记忆具体边界,因为只有两个值。
和整型缓存机制最容易混淆的是字符串常量池。String a = "abc"; String b = "abc"; a == b 为 true,是因为字符串字面量进入了运行时常量池。但 String c = new String("abc") 创建的又是新对象,比较结果同样为 false。所以对象池优化思路在 Java 里是一脉相承的,但每种类型的实现细节都不完全相同,不能一概而论。
对比表里最需要注意的是 Float 和 Double。它们没有缓存机制,因为浮点数的取值范围过于庞大,做固定区间缓存没有收益。如果在代码里写:
java复制Double a = 1.0;
Double b = 1.0;
System.out.println(a == b); // false
这不是什么 bug,而是所有直接装箱的 Double 都各自创造了新对象。这种“同样写法,Integer 可能是 true、Double 一定是 false”的差异,更能说明问题的根源是各类内部实现不同,而不是 Java 在某个值范围内有统一规则。
6. 从一次缓存边界事故说起:实际项目中包装类 == 比较的排查与修复
文章开头我提到老项目里 1 到 12 没问题,状态 13 突然出问题。那次事故的代码大概长这样:
java复制public static final Integer STATUS_1 = 1;
public static final Integer STATUS_2 = 2;
public static final Integer STATUS_13 = 13;
// 某个清理数据的方法
if (dataStatus(rawStatus) == STATUS_13) {
doClean();
}
rawStatus 是从数据库读出来的,数据库返回的是 Integer。在 JDBC、MyBatis 等持久层框架中,读取后的结果一般会经过 Integer.valueOf,所以 1 到 12 都命中缓存,但 13 呢?13 也在默认缓存范围内啊。当时我们排查时发现,13 不在缓存范围内的假设根本不成立。最后真正的问题出在,这个模块的 JVM 启动参数里设置了某个全局缓存清理工具,它会对“小 Integer 对象”做自定义处理,而且把其中一个“13”状态对象强行走了一次 new,再把两个引用放进了反射调用链里。由于代码里数据流转的路径在不同批次中被不同的工具类处理,有些路径返回缓存对象,有些路径返回新对象,== 的判定结果就变得极不稳定。
那次修复其实很简单,把整段代码里对包装类对象的所有 == 比较全部改成 Objects.equals:
java复制if (Objects.equals(dataStatus(rawStatus), STATUS_13)) {
doClean();
}
对我个人来说,那次事故最重要的收获是:永远别用“它现在看起来是对的”来纵容代码中“可能依赖内部缓存”的比较逻辑。 面试里我们讨论 100 和 200,是为了理解机制;生产环境里,我们不应该把自己的业务正确性押注在 JVM 的优化实现上。
后来我在自己带的项目里定了两条简单直接的代码规范:
- 基本类型之间才允许使用
==。 - 只要有一侧是包装类,需要判断数值是否相等时,统一使用
Objects.equals,这个方法在 Java 7 之后已经内置,既会做 null 判断,又能规避所有包装类缓存问题:
java复制Objects.equals(null, 100); // false
Objects.equals(100, 100); // true
Objects.equals(200, 200); // true,因为它内部调用 Integer.equals
java.util.Objects.equals 的实现是:
java复制public static boolean equals(Object a, Object b) {
return (a == b) || (a != null && a.equals(b));
}
它先做引用判断,再做 equals 值判断,所以不会踩包装类缓存的坑。如果追求极致的性能,也可以先手动拆箱再比较:
java复制if (a.intValue() == b.intValue()) { ... }
但前提是你能保证两个对象都不为 null。项目里如果 null 值还可能存在,我倾向于用 Objects.equals,因为代码阅读成本低,不容易被 later 维护者改错。
还有一类常见陷阱发生在反序列化和 RPC 返回值上。比如某接口返回一个 Integer 字段,经过 JSON 序列化后,再被 Jackson 反序列化为 Integer 对象,不同框架内部可能调用 Integer.valueOf,也可能直接通过反射创建新对象。把这种“外部输入”对象拿去做 == 比较,基本等于靠运气写业务逻辑。
所以在代码评审时,我一般看到 Integer、Long、Short、Character、Double、Float 这些包装类变量出现在 == 两侧,都会停下来问一句:“这里比较的到底是对象还是值?”如果是包装类对象,就建议直接改成 Objects.equals 或拆箱比较。与其去记 127、127L、\u007f 这些边界值,不如把“包装类不用 ==”刻在代码规范里,一劳永逸。
回过头来看,Integer a = 100, b = 100 为什么是 true,改成 200 为什么变成 false,机制层面的答案我已经在上文完整拆解了:javac 将自动装箱转成 Integer.valueOf 调用,valueOf 根据 IntegerCache 是否命中来决定返回缓存对象还是新建对象,默认缓存范围是 -128 到 127,100 命中所以 true,200 未命中所以 false。这件事的底层逻辑不难,难的是在真实的工程代码里,永远把对象比较和值比较区分清楚。如果你在代码评审里看到有人用 == 比较两个包装类对象,不妨提醒他去看一眼这个经典问题,然后顺手把代码改成 Objects.equals——至少我现在的团队,已经很久没有因为包装类比较问题半夜爬起来排查线上事故了。
