拓冰建站拓冰建站
首页 / 资讯中心 / 正文

Java整型溢出详解:补码回绕原理与Math.addExact防坑指南

先别往下翻先想一个问题Java 整型溢出在面试题里几乎必考Integer.MAX_VALUE 1的结果到底是什么估计不少人会脱口而出“溢出报错”实际上代码并不会抛任何一个异常它安静地返回-2147483648。这个行为让第一次接触的人觉得离谱也让各种隐形 bug 有了藏身之处。这篇文章就围绕 Java 整型溢出这个话题把现象、补码原理、真实生产事故、面试考点以及工程防护手段一次聊透。适合准备 Java 面试的朋友、刚学基础语法的新手也包括那些写了几年代码但没认真抠过边界的老开发。1. 先看现象边界值附近做四则运算分别会得到什么1.1 跑一段代码把“惨剧”完整看一遍空谈没用先上实测。下面这段代码把整型边界附近最常见的运算都列了出来任何 JDK 版本都能跑public class OverflowShow { public static void main(String[] args) { int max Integer.MAX_VALUE; // 2147483647 int min Integer.MIN_VALUE; // -2147483648 System.out.println(max 1 (max 1)); System.out.println(min - 1 (min - 1)); System.out.println(max * 2 (max * 2)); System.out.println(max * 3 (max * 3)); System.out.println(max - min (max - min)); System.out.println(-min (-min)); System.out.println(Math.abs(min) Math.abs(min)); System.out.println(min / -1 (min / -1)); } }输出结果max 1 -2147483648 min - 1 2147483647 max * 2 -2 max * 3 2147483645 max - min -1 -min -2147483648 Math.abs(min) -2147483648 min / -1 -2147483648注意最后一行min / -1。数学上-2147483648 / -1应该等于2147483648但这个数已经超过 int 的上限所以除法也会溢出结果仍然是最小值。这里没有抛异常也没有被钳制到Integer.MAX_VALUE。再补一个面试中经常被埋坑的“类型提升”例子int max Integer.MAX_VALUE; System.out.println(max 1L); // 21474836481L 是 long整个加法提升为 long没溢出 long c max 1; // 右边先按 int 运算得到 -2147483648再赋给 long System.out.println(c); // -2147483648而不是 2147483648很多人以为赋值给 long 就安全了实际不是。运算发生在那一步就用那一步的类型赋给更宽类型不会回头拯救已经溢出的中间结果。1.2 溢出结果的规律不是随机是“回绕”看完现象后把这些结果放到一起会发现它们遵循一个非常一致的规律对 2 的 32 次方取模然后按补码解释。运算数学结果Java 实际输出位级说明max 12147483648-21474836480x7FFFFFFF 1 0x80000000min - 1-214748364921474836470x80000000 - 1 0x7FFFFFFFmax * 24294967294-20x7FFFFFFF * 2 0xFFFFFFFEmax - min4294967295-10x7FFFFFFF - 0x80000000 0xFFFFFFFF-min2147483648-21474836480x80000000 取反加一还是自己也就是说int 的 32 个 bit 就像一个环。加法就是在环上走走到最高位再往前一步就会回到环的另一端。结果是确定、可复现的不是语言运行时抽风。这里先记住结论Integer.MAX_VALUE 1 Integer.MIN_VALUEInteger.MIN_VALUE - 1 Integer.MAX_VALUE。后面所有面试题和实战排查都绕不开这个。2. 为什么一定是这个结果补码与回绕不是 bug是设计2.1 从二进制看 int32 个 bit 构成的圆环int 在 Java 里固定占 32 位采用二进制补码表示有符号整数。补码的核心规则可以浓缩成一句负数的二进制表示 对应正数取反再加一。比如1 0x00000001 -1 取反 0xFFFFFFFE 1 0xFFFFFFFF再比如最小值-2147483648 0x80000000为什么补码好用因为加减法可以完全忽略符号位统一用一套加法电路完成。2 (-1)的位运算就是0x00000002 0xFFFFFFFF结果是0x00000001溢出到第 33 位的进位直接丢掉答案正确。把 int 整体想象成钟表可能更好懂钟表上 12 点过了 1 小时回到 1 点int 过了最大值后也从另一端出来。0x7FFFFFFF 1 0x80000000最高位变成 1于是被解释为负数最小值。这种循环结构自然地解释了为什么 int 的范围是-2147483648 ~ 2147483647正数最大到2^31 - 1负数却能多一个-2^31——因为0x80000000表示负数没有对应的正数。2.2 JLS 的明确声明溢出会“静默回绕”很多语言会在溢出时报错但 Java 不会。Java 语言规范对整数加法有明确描述如果整数加法溢出则结果是采用某种足够宽的二进制补码格式表示的实际数学和的低阶比特位也就是回绕。注意这不是 JVM 实现层面的默认行为而是规范写死的要求。这意味着同一个表达式在任何 JVM、任何操作系统上跑出来都是同一个值不存在平台差异。为什么 Java 作者要这么设计第一是性能。CPU 的原生整数指令就是补码回绕如果要检查溢出每次运算都要额外插入比较和分支对老 Java 时代本来就敏感的运行时开销是雪上加霜。第二是可预测性。规范明确后编译器可以放心地做各种优化程序员也只需要记住一条规律不会出现 C/C 那种“有符号溢出是未定义行为”的灰色地带。2.3 和其他语言对比各有各的取舍有对比才有体感。C/C 的有符号整数溢出是未定义行为编译器可能优化出完全无法预料的代码优化前后行为都可能不一致Python 的整数可以无限扩张根本不存在溢出这个概念代价是每个整数都是对象运算开销更大Java 选择的是固定范围 确定回绕既躲开了 C 的“未定义行为”坑又保持了接近底层的性能模型。代价也很明显溢出后不报错数错了也是“正常”的。于是各种业务 bug 静默潜伏直到数据量大起来才集中爆发。接下来这部分就是我在真实项目和 JDK 源码里见到的典型翻车现场。3. 那些真实生产环境里的溢出翻车现场3.1 二分查找的 mid 公式一个改了几年的经典问题凡是写过二分查找的人大概率背过这个公式int mid (low high) / 2;问题是当low和high都很大时low high会先溢出为负数再除以 2 就得到负数下标。早年 JDK 的Arrays.binarySearch和Collections.binarySearch就是这么写的Joshua Bloch 还专门写过文章讲这个 bug。现在标准写法有两种int mid low ((high - low) 1); // 或者 int mid (low high) 1;第一种避免了low high溢出第二种用无符号右移把溢出后的负数当无符号数处理。我建议两个都记住面试时问到这个不只是背答案还能讲清楚为什么旧代码会炸。3.2 集合扩容、缓存容量负数 threshold 一抓一把另一个高频雷区是容量计算。ArrayList 的grow方法里有这么一段int newCapacity oldCapacity (oldCapacity 1); if (newCapacity - minCapacity 0) { newCapacity minCapacity; } if (newCapacity MAX_ARRAY_SIZE) { newCapacity hugeCapacity(minCapacity); }如果oldCapacity已经接近Integer.MAX_VALUEoldCapacity (oldCapacity 1)直接溢出成负数。JDK 源码里专门做了hugeCapacity检查if (minCapacity 0) throw new OutOfMemoryError();——这里判断minCapacity 0就是在检测溢出。类似的坑也常见于自研缓存、连接池。很多人写框架时直接int threshold (int) (capacity * loadFactor);如果capacity给到2_000_000_000capacity * loadFactor的中间结果可能直接溢出成负数threshold变成负数后扩容逻辑会一片混乱。HashMap 本身有用MAXIMUM_CAPACITY兜底但自己写的代码未必有。3.3 时间换算与 ID 生成整型溢出在偷偷啃数据时间戳相关的溢出特别隐蔽因为它不是必现而是“几年后必现”。先看经典的 2038 问题。System.currentTimeMillis()返回的是 long但不少老系统的表结构里用的是 int 存秒数甚至有人为了省空间把毫秒时间戳强转成 int 存。2038 年 1 月 19 日之后32 位有符号秒数就会溢出整个时间系统倒退回 1901 年。这个我有印象网上已经有很多人开始推进 64 位时间戳迁移了。再看实际开发里更容易踩的类型提升问题long timeout 24 * 60 * 60 * 1000 * 30; // 30 天的毫秒数这里所有字面量都是 int24 * 60 * 60 * 1000 * 30 2592000000超过 int 上限结果溢出成了负数。生产环境超时时间变成负数连接池立刻失效。正确写法要给第一个数字加Llong timeout 24L * 60 * 60 * 1000 * 30;还有分布式 ID。标准 Snowflake 算法为什么不直接用完整毫秒时间戳左移 22 位而是要先减去一个起始时间除了节省位数还有一个容易被忽略的原因如果直接用System.currentTimeMillis()左移高位会顶到符号位生成的 ID 变成负数。虽然很多实现做了处理但我在 review 里确实见过直接左移然后被线上告警追着跑的例子。3.4 Math.abs(Integer.MIN_VALUE)取绝对值反而得到负数接着看 Java 标准库里一个“名正言顺”的坑int n new Random().nextInt(); int idx Math.abs(n) % 100;nextInt()可以返回Integer.MIN_VALUE而Math.abs(Integer.MIN_VALUE)结果还是Integer.MIN_VALUE取模后得到负数。一旦用这个负数去访问数组直接ArrayIndexOutOfBoundsException。JDK 文档其实写得很清楚但很少有人会去翻Math.abs的文档。要正确取到非负下标应该用int idx Math.floorMod(n, 100);类似的还有Math.negateExact与-Integer.MIN_VALUE。负负得正的朴素直觉在这里不成立因为根本没有对应的正数可以表示。4. 面试官最常问的几个溢出变体4.1 连问五题题题都在边界上很多 Java 面试官喜欢把溢出做成连环题。我整理过一组高频问题大家可以自测// 问题 1输出什么 System.out.println(Integer.MAX_VALUE 1); // 问题 2这个循环会不会退出 for (int i 0; i Integer.MAX_VALUE; i) { // 循环体 } // 问题 3Math.abs(Integer.MIN_VALUE) 等于多少 // 问题 4long result Integer.MAX_VALUE 1; result 是多少 // 问题 5如何判断两个 int 相加是否溢出答案分别是-2147483648也就是Integer.MIN_VALUE。永远不会退出。i到Integer.MAX_VALUE后执行i结果回绕成Integer.MIN_VALUE条件i Integer.MAX_VALUE恒为真。Integer.MIN_VALUE本身一个负数。-2147483648。右边先按 int 运算完再赋值long 救不了中间结果。用位运算判断public static boolean isAddOverflow(int a, int b) { int r a b; return ((a ^ r) (b ^ r)) 0; }原理是如果a、b同号而结果r与它们异号说明符号位被进位篡改了就是溢出。4.2 辨析题类型提升什么时候能救命面试官经常在类型提升上埋陷阱。核心规律是Java 的二元算术运算会先把操作数提升为较大类型再做计算。int int结果是 int可能溢出。int long先提升为 long再相加不会发生 int 溢出。long float提升为 float可能丢精度但不是整型溢出。long c Integer.MAX_VALUE 1右边两个操作数都是 int先按 int 计算溢出后再赋给 long。很多业务 bug 都出在最后一种明明目标变量是 long但忘了给其中某个操作数补一个L或者强转。正确做法是long c Integer.MAX_VALUE 1L; // 21474836484.3 无符号视角把 int 当 0 到 2^32-1 来看补码世界里同一个二进制串可以解释成有符号数也可以解释成无符号数。Java 8 起为整型提供了一套无符号 APIString s Integer.toUnsignedString(-1); // 4294967295 int result Integer.divideUnsigned(-1, 2); // 2147483647 int cmp Integer.compareUnsigned(0x80000000, 0x7fffffff); // 1这套 API 在处理哈希、位图、协议字段时很有用。有些领域习惯把 32 位当成无符号整数本身比如网络字节序、压缩算法Java 的做法是“有符号存储、无符号解释”。面试时能顺带提一嘴观感会明显不一样。5. 能防溢出的几种工程化方案5.1 Math.exact 系列把静默错误变成快速失败Java 8 开始Math类加了一批带Exact后缀的方法Math.addExact(a, b); Math.subtractExact(a, b); Math.multiplyExact(a, b); Math.negateExact(a); Math.incrementExact(a); Math.decrementExact(a); Math.toIntExact(longValue);这些方法在溢出时直接抛ArithmeticException语义从“静默回绕”变成“快速失败”。我个人的习惯是在账务累加、计数统计、积分计算这类不允许出错的场景默认使用addExact和multiplyExact。比如try { total Math.addExact(total, bonus); } catch (ArithmeticException e) { // 记录日志、告警、走降级逻辑 }很多人觉得 try-catch 有性能损耗就在所有地方裸用。这里有个更现实的成本加法和乘法的溢出一旦发生往往要排查好几天catch 一次的开销比这低了好几个数量级。5.2 升级计算宽度int 换成 longlong 换成 BigInteger防溢出最直接的办法就是让中间结果有足够宽的容器。涉及金额、数量相乘的临时结果先用long计算再转回int或者直接用long存储。真的会超过 long 范围才考虑BigInteger或BigDecimal。金额优先用“分”为单位的长整型而不是浮点类型跨系统对接时再用字符串传输。举个例子long total (long) price * count; // 先把 price 提升到 long int totalInt Math.toIntExact(total); // 需要 int 时再显式收窄并检查关键不是背代码而是养成一个习惯每次看到 int 乘法、累加、时间换算都默认它会溢出先把它放到足够宽的类型里再算。5.3 位运算判断与静态检查兜底第三种方案是代码里加显式判断。加法的判断前面给过乘法的判断更简单直接放大到 longpublic static boolean isMultiplyOverflow(int a, int b) { long result (long) a * b; return result ! (int) result; }收窄时用Math.toIntExact或者自己判断long value ...; if (value Integer.MAX_VALUE || value Integer.MIN_VALUE) { throw new IllegalArgumentException(数值超出 int 范围); } int n (int) value;工程层面还可以引入静态检查工具。SpotBugs 的规则里有ICAST_INTEGER_MULTIPLY_CAST_TO_LONG专门检查“int 相乘结果转 long”这类问题Error Prone 也有Overflow检查。CI 里挂一道静态检查比靠 code review 肉眼盯靠谱得多。提示code review 时凡是看到 int 类型变量参与乘法、自增、时间换算、容量估算都属于高危代码值得停下来算一下边界值。这句话值得写进团队规范里。最后说点个人经验。我排查线上问题遇到数值非常离谱的“灵异事件”第一反应就是去翻代码里有没有 int 类型参与乘法、累加、时间换算的地方。不是这种理论多高深而是实际翻车太多次了。溢出问题不属于“知道不知道”的知识点而属于“有没有形成肌肉记忆”的实践点。下次面试被问到这类题别只说Integer.MAX_VALUE 1等于多少把补码回绕、JLS 规范、Math.addExact还有 JVM 里那几个真实 bug 的来龙去脉讲一遍面试官基本就知道你是真懂了。
分享:

看完干货,该让你的企业上线了

免费需求沟通 · 48 小时内出具建站方案 · 河南本地可上门