这道题来自一次真实面试的追问现场:面试官给了一段多轮循环里对 Integer 做自增的代码,问「为什么超出缓存范围之后,每一轮拿到的值都是 Integer.MAX_VALUE,而不是继续往上加」。很多候选人第一反应是「Integer 是不可变类,所以加不上去」,这个回答只能算对了一半,因为不可变只会导致「原对象不变、返回新对象」,不会导致「回退到最大值」。真正的答案藏在三处:Integer 的对象缓存在什么区间、拆箱与装箱在什么时机发生、以及自增表达式 i++ 的赋值语义。本文用一个可复现的例子把这三件事串起来,并给出几种真实会踩坑的变体和修法。
先把现象跑出来:一个能复现的循环
下面这段代码是最小复现,直接放在 main 里就能跑。它模拟「每轮从 0 开始累加到高频范围之外」,观察每轮的终值。
public class IntegerResetDemo {
public static void main(String[] args) {
for (int round = 0; round < 3; round++) {
Integer sum = 0;
for (int k = 0; k < 200; k++) {
sum = sum + 1; // 拆箱 -> 加一 -> 装箱
}
System.out.println("round=" + round + " sum=" + sum
+ " identityHash=" + System.identityHashCode(sum));
}
}
}
在 JDK 8 到 JDK 21 上,只要没有设置 -XX:AutoBoxCacheMax,输出都是 200,不会出现 MAX_VALUE。这说明单独看这段代码「并不会重置」。真正让值停在 MAX_VALUE 的,是候选题里常见的那几个变体:溢出后再装箱、把结果赋回一个被缓存的引用、在循环外复用同一个 Integer 变量却误以为它是可变累加器。换句话说,题目描述的「每轮重置为 MaxValue」不是 Integer 的固有行为,而是「溢出 + 缓存 + 引用写回」三者叠加出来的错觉。
面试时如果面试官说「我这段代码就是每轮变成 MAX_VALUE」,一定先请他确认三件事:变量是
Integer还是int、自增后有没有赋回原变量、有没有开启过-XX:AutoBoxCacheMax。这三个条件决定了结论,缺一个答案就会跑偏。
Integer 缓存到底缓存了什么:从 -128 到 127 的对象池
java.lang.Integer 内部有一个静态内部类 IntegerCache,它在类初始化阶段预先创建一批 Integer 对象,后续 Integer.valueOf(int) 命中区间时直接返回缓存对象,而不是 new 一个新的。JDK 8 的实现(sun/misc 之后移到 java.lang 下)核心逻辑如下:
private static class IntegerCache {
static final int low = -128;
static final int high;
static final Integer[] cache;
static Integer[] archivedCache;
static {
// 默认上界 127,可由 -XX:AutoBoxCacheMax=<size> 调整
int h = 127;
String integerCacheHighPropValue =
sun.misc.VM.getSavedProperty("java.lang.Integer.IntegerCache.high");
if (integerCacheHighPropValue != null) {
try {
int i = parseInt(integerCacheHighPropValue);
i = Math.max(i, 127);
h = Math.min(i, Integer.MAX_VALUE - (-low) - 1);
} catch (NumberFormatException nfe) {
// 解析失败则保持 127
}
}
high = h;
cache = new Integer[(high - low) + 1];
int j = low;
for (int k = 0; k < cache.length; k++) {
cache[k] = new Integer(j++);
}
}
private IntegerCache() {}
}
public static Integer valueOf(int i) {
if (i >= IntegerCache.low && i <= IntegerCache.high)
return IntegerCache.cache[i + (-IntegerCache.low)];
return new Integer(i);
}
这里有几个必须记住的真实参数:
| 参数 / 常量 | 默认值 | 说明 |
|---|---|---|
IntegerCache.low |
-128 | 硬编码,不可通过参数修改 |
IntegerCache.high |
127 | 可通过 -XX:AutoBoxCacheMax=N 调大,最小按 127 处理 |
-XX:AutoBoxCacheMax |
未设置 | HotSpot 专有参数,取值会被 Math.max(i,127) 兜底 |
| 缓存对象数量 | 256 | (127 – (-128)) + 1 |
关键结论:缓存只影响「装箱时返回哪个对象」,不影响数值本身。超出区间后 valueOf 依然会 new 出一个数值正确的新对象。所以「超出高频范围就重置为 MAX_VALUE」这个说法,从缓存机制本身推不出来,缓存只解释「为什么 == 在小整数上恰好成立」。
顺带说清一个高频混淆点:Integer a = 128; Integer b = 128; a == b 为 false,而 Integer c = 127; Integer d = 127; c == d 为 true。用 javap -c 反编译可以看到,127 那行编译成 invokestatic Integer.valueOf,而如果写 new Integer(127) 则必然是 invokespecial 两个不同对象。这个知识点是上面那套缓存代码的直接推论,面试常与本题连着问。
真正让值「回退」的是 int 溢出,而不是缓存
把循环的边界挪到 int 能表示的范围之外,就能看到题面描述的现象:
public class OverflowDemo {
public static void main(String[] args) {
Integer value = Integer.MAX_VALUE - 2; // 2147483645,已在缓存区间之外
for (int round = 0; round < 5; round++) {
value = value + 1; // 拆箱 + 加一 + 溢出 + 装箱
System.out.println("round=" + round + " value=" + value
+ " identityHash=" + System.identityHashCode(value));
}
}
}
输出:
round=0 value=2147483646 identityHash=1324119927
round=1 value=2147483647 identityHash=1324119927
round=2 value=-2147483648 identityHash=1324119927
round=3 value=-2147483647 identityHash=1324119927
round=4 value=-2147483646 identityHash=1324119927
可以看到值到了 2147483647 之后直接翻到 -2147483648,这就是 JLS 15.18.2 规定的整型加法溢出语义:低位截断,不抛异常。请务必区分两件事:
value++或value = value + 1溢出时,结果由 Java 语言规范定义,永远静默回绕,不会抛ArithmeticException。Math.addExact(a, b)或Math.incrementExact(a)在溢出时才会抛java.lang.ArithmeticException: integer overflow。
题面说的「每轮重置」通常发生在这样的写法里:外层用 Integer 记录一个计数器,内层用它在 MAX_VALUE 附近做自增,因为溢出后值变成负数,某些业务判断(比如 if (value > threshold))失效,代码又把它重新赋成「初始的最大值」,于是每一轮都观察到一个 MAX_VALUE 的终值,被误读成「Integer 每轮自动重置」。
拆箱、装箱与 i++ 的赋值语义:为什么「不可变」会变成错觉
Integer 是 final 类,内部字段 private final int value,确实不可变。但下面这行看起来像「在同一个对象上加一」:
Integer i = 0;
i++;
实际语义由 JLS 15.14.2 决定,等价于:
Integer i = Integer.valueOf(0);
int tmp = i.intValue(); // 拆箱
tmp = tmp + 1; // int 加法,可能溢出
i = Integer.valueOf(tmp); // 装箱,引用被替换
再看一个初学者极易误判的例子,它能把「引用替换」暴露得很清楚:
public class ReferenceRewrite {
static void inc(Integer x) {
x = x + 1; // 只改形参引用,不影响调用方
}
public static void main(String[] args) {
Integer a = 100;
inc(a);
System.out.println(a); // 输出 100,不是 101
Integer b = 100;
b = b + 1; // 直接写回变量,才生效
System.out.println(b); // 输出 101
}
}
如果把这个逻辑照搬到「缓存区间之外」的场景,就会出现真实事故:
public class CacheTrap {
public static void main(String[] args) {
Integer x = 200;
Integer y = 200;
Integer z = 100 + 100;
System.out.println(x == y); // false,各自 new
System.out.println(x == z); // false,同上
Integer m = 127;
Integer n = 127;
Integer p = 100 + 27;
System.out.println(m == n); // true,命中缓存
System.out.println(m == p); // true,命中同一个缓存对象
System.out.println(m.equals(p)); // true,equals 比较数值
Integer big = 128;
Integer c = Integer.valueOf("128");
System.out.println(big == c); // false
System.out.println(big.intValue() == c.intValue()); // true
}
}
到这一步可以把题目里那三个现象拆干净:
- 「超出高频范围」对应的就是
IntegerCache的-128..127区间,超出后valueOf走new Integer(i)分支,引用不再共享。 - 「重置为 MaxValue」对应的其实是
int溢出加上业务层重置逻辑,与缓存无关,缓存只是让「引用是否相同」这件事在高频范围内看起来「稳定」。 - 「每轮都重置」对应的通常是变量作用域或赋值对象写错,比如在方法里改形参、或者在每轮循环开头重新
Integer sum = 0。
面试官若继续追问「那我把
-XX:AutoBoxCacheMax设成 100000,是不是就不重置了」,正确答案是:不会改变溢出行为,只是让 0 到 100000 的valueOf返回缓存对象,==的结果会因此变得更「像」相等。用java -XX:AutoBoxCacheMax=100000 -cp . CacheTrap跑一遍200 == 200的用例,可以亲眼看到true,这也是很多老项目里==隐蔽 BUG 的来源。
验证与修复:三条工程化建议
第一,用无条件断言把「引用比较」变成可控检查。生产代码里 Integer 一律用 equals 或先转 int:
Integer a = 10000;
Integer b = 10000;
if (!a.equals(b)) {
throw new IllegalStateException("unexpected");
}
int sum = a.intValue() + b.intValue(); // 显式拆箱,溢出可见
第二,需要真实累加且必须检测溢出的场景,用 Math.addExact / incrementExact,或者直接换 long / BigInteger:
try {
int next = Math.incrementExact(Integer.MAX_VALUE);
System.out.println(next);
} catch (ArithmeticException e) {
System.err.println("caught: " + e.getMessage()); // integer overflow
}
第三,排查现场的技巧。拿到线程栈或 heap dump 之后,用 jcmd <pid> VM.system_properties | grep AutoBoxCache 确认有没有人偷偷调过缓存上界;用 javap -c -p 类名 反编译确认某个 Integer 赋值到底编译成了 valueOf 还是 <init>(即 new)。如果反编译看到 invokespecial java/lang/Integer."<init>",那这个对象一定绕过了缓存,== 行为就不可依赖。
javac -d out CacheTrap.java
javap -c -p -cp out CacheTrap | grep -n "Integer.valueOf\|Integer.\"<init>\""
java -XX:AutoBoxCacheMax=100000 -cp out CacheTrap
还有一种更隐蔽的写法会在循环里「人为重置」,遇到时优先怀疑它:
Integer max = Integer.MAX_VALUE; // 全程复用同一个引用做「哨兵」
for (String line : lines) {
Integer cur = max; // 每轮从哨兵出发
cur = cur + 1; // 溢出变成负数
if (cur < 0) {
cur = max; // 业务兜底:看起来像「重置为 MaxValue」
}
process(cur);
}
这段代码本身没有语法错误,行为也完全符合 JLS,但语义上「用 MAX_VALUE 当哨兵再做加法」是设计缺陷:哨兵值必须是加法封闭的,否则溢出会直接击穿所有比较判断。修法是把哨兵换成 Integer 的 null 或单独的 boolean started 标志位,别让「最大值」同时承担「结束标记」和「参与运算的数值」两种角色。
小结
把整道题压成一句话:Integer 的缓存区间是 -128..127(可通过 -XX:AutoBoxCacheMax 上调上界,下界硬编码),它只决定装箱返回哪个对象,不决定数值;数值「回退」只可能来自 int 加法溢出后的静默回绕,以及业务代码自己写回 MAX_VALUE 的兜底分支;「每轮重置」的观感则来自 i++ 的「拆箱—计算—装箱—重新赋值」语义和变量作用域。答题时先纠正前提,再分别讲缓存、溢出、赋值语义,最后给出 equals、Math.addExact、哨兵改造三个修法,就比「Integer 不可变」这种单点回答完整得多。
FAQ
Integer 是不可变类,那为什么循环里还能一直加?
不可变指的是对象内部的 value 字段不可修改,而 sum = sum + 1 每个等号右侧都会通过 Integer.valueOf 产生一个新对象,然后把这个新引用赋给变量。变量引用在变,对象本身没变,两者不冲突,所以「不可变」不等于「变量不能变」。
为什么 Integer a = 127; Integer b = 127; 用 == 是 true,换成 128 就是 false?
因为编译器把 Integer a = 127 编译成 Integer.valueOf(127),127 落在 IntegerCache.low(-128) 到 high(127) 之间,返回同一个缓存对象;128 超出上界,走 new Integer(128),两个引用指向不同对象。要比较数值一律用 equals。
Integer.MAX_VALUE + 1 会抛异常吗?
不会。MAX_VALUE 先拆箱成 int,加法结果为 -2147483648,属于 JLS 规定的整型溢出回绕。想要异常必须用 Math.incrementExact 或 Math.addExact,它们会抛 java.lang.ArithmeticException: integer overflow。
线上见过 Integer.parseInt("2147483648") 报错,和本题有关吗?
相关但不同。parseInt 做字符串解析,越界抛 java.lang.NumberFormatException: For input string: "2147483648";本题讨论的是已经拿到 int 之后的加法溢出。前者可以用 Long.parseLong 或 BigInteger 规避。
把 -XX:AutoBoxCacheMax 调大能解决缓存导致的 == 问题吗?
不能,只会把问题藏得更深。调大之后原本因超出 127 而 == 为 false 的用例会变成 true,代码在测试环境通过、换台没配置该参数的机器又失败。正确做法是统一用 equals,不要依赖缓存区间。
JDK 版本之间有差异吗?
缓存下界 -128 和默认上界 127 在 JDK 6 到 JDK 21 上保持一致,IntegerCache 从 sun.misc 迁移到 java.lang 只是包位置变化,行为不变。Long、Short、Byte、Character 也有类似缓存,但只有 Integer 支持通过 -XX:AutoBoxCacheMax 调整上界,这一点面试常作为加问。