面试题:Integer 在高频范围之外为什么每轮会重置为 MaxValue

这道题来自一次真实面试的追问现场:面试官给了一段多轮循环里对 Integer 做自增的代码,问「为什么超出缓存范围之后,每一轮拿到的值都是 Integer.MAX_VALUE,而不是继续往上加」。很多候选人第一反应是「Integer 是不可变类,所以加不上去」,这个回答只能算对了一半,因为不可变只会导致「原对象不变、返回新对象」,不会导致「回退到最大值」。真

这道题来自一次真实面试的追问现场:面试官给了一段多轮循环里对 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
    }
}

到这一步可以把题目里那三个现象拆干净:

  1. 「超出高频范围」对应的就是 IntegerCache 的 -128..127 区间,超出后 valueOf 走 new Integer(i) 分支,引用不再共享。
  2. 「重置为 MaxValue」对应的其实是 int 溢出加上业务层重置逻辑,与缓存无关,缓存只是让「引用是否相同」这件事在高频范围内看起来「稳定」。
  3. 「每轮都重置」对应的通常是变量作用域或赋值对象写错,比如在方法里改形参、或者在每轮循环开头重新 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 调整上界,这一点面试常作为加问。