轮询计数器溢出:Math.abs 也会返回负数
一个客户端负载均衡器用原子计数器轮询实例,平时运行正常。某天服务连续运行了几天之后,开始零星地抛出
ArrayIndexOutOfBoundsException: Index -2 out of bounds for length 3,重启后又好了。
用计数器取模选择实例、用哈希值取模选择分片,是非常常见的写法。它们在 Java 里有两个容易忽略的边界:整数溢出之后计数器变成负数,而 Math.abs 并不能把所有负数变成正数;% 对负数的结果也是负数。两件事叠在一起,就得到一个负的下标。
本文在 JDK 25 上复现这几个边界,并给出不越界的写法。实验见文末。
一、Math.abs 的唯一例外
Math.abs(Integer.MIN_VALUE) = -2147483648;Math.abs(Long.MIN_VALUE) = -9223372036854775808
Math.absExact(Integer.MIN_VALUE):ArithmeticException:Overflow to represent absolute value of Integer.MIN_VALUE32 位补码能表示 -2147483648 到 2147483647,最小值的相反数比最大值大 1,表示不了。Math.abs(Integer.MIN_VALUE) 溢出后又回到了它自己,是一个负数。Javadoc 明确写了这个行为;需要「溢出就报错」时,用 Java 15 起提供的 Math.absExact。
二、% 的结果和被除数同号
-7 % 3 = -1,Math.floorMod(-7, 3) = 2Java 的 % 是截断除法的余数,结果的符号跟被除数一致。Python 的 % 是向下取整除法的余数,-7 % 3 等于 2;同一段逻辑在两种语言里移植时,这里经常出错。Java 里要得到非负的余数,用 Math.floorMod。
三、轮询计数器越过最大值
原子计数器每次自增后取模选实例:
nodes.get(Math.abs(counter.incrementAndGet()) % nodes.size());把计数器的起点设在 Integer.MAX_VALUE - 2,模拟运行了很久之后:
计数器从 MAX_VALUE-2 开始,Math.abs(n) % 3 连取 4 次:n0,n1,ArrayIndexOutOfBoundsException:Index -2 out of bounds for length 3,n1计数器越过最大值变成 Integer.MIN_VALUE,Math.abs 还是它本身,对 3 取余得到 -2。之后计数器变成 MIN_VALUE + 1,Math.abs 又能正常取反,所以只有这一次越界:这就是开头「零星出现、重启就好」的原因。下一次溢出又要再等同样长的时间:
每秒 10000 次自增,从 0 到 Integer.MAX_VALUE 需要约 2.5 天;换成 long 约 2925 万年(按固定速率推算,不是测量。)每秒一万次请求的服务,两天半就会遇到一次。
两种不越界的写法:
同样起点,Math.floorMod(n, 3) 连取 6 次:n0,n1,n1,n2,n0,n1(溢出处连续两次选中 n1,分配有一次不均,但不会越界)
同样起点,(n & Integer.MAX_VALUE) % 3 连取 6 次:n0,n1,n0,n1,n2,n0Math.floorMod 对负数也给出非负的余数;n & Integer.MAX_VALUE 去掉符号位,结果总在 0 到 Integer.MAX_VALUE 之间。两种写法在溢出那一刻都会让顺序跳一下,对轮询来说无关紧要。如果连这一次不均都不能接受,可以用 long 计数器,或者用 compareAndSet 在到达节点数时归零。
四、哈希值取模也一样
用 hashCode() 选分片时,问题更隐蔽,因为负的哈希值很常见:
"key-0"…"key-99999" 的 hashCode() % 16 为负数的有 940 个(共 100000 个)所以常见的写法是先取绝对值:Math.abs(key.hashCode()) % n。它在绝大多数情况下是对的,除了哈希值恰好等于 Integer.MIN_VALUE:
"polygenelubricants".hashCode() = -2147483648;Math.abs(hash) % 10 = -8;Math.floorMod(hash, 10) = 2这个字符串是一个著名的例子,说明这种输入真实存在,而且可以被构造出来。如果分片键来自用户输入,攻击者可以专门找出这样的值。(当 n 是 2 的幂时,Integer.MIN_VALUE % n 恰好是 0,问题被掩盖了,换一个模数就会出现。)用 Math.floorMod(hash, n) 或 (hash & 0x7fffffff) % n,不要用 Math.abs。
一致性哈希等更复杂的分片方式,本身也要处理哈希值的符号,见一致性哈希。
五、检查清单
- 取模之前不用
Math.abs处理可能为MIN_VALUE的值;用Math.floorMod或去掉符号位。 - 长期自增的计数器考虑溢出:用
long,或者在合适的位置归零。 - 需要「溢出就报错」时,用
Math.addExact、Math.absExact等精确运算。 - 跨语言移植取模逻辑时,核对负数的余数定义。
- 为计数器的溢出点写测试:把起点设在最大值附近,而不是等它自然溢出。
配套实验
- codesphere-labs/java/counter-overflow:JDK 25.0.4.1:Math.abs 与 absExact、负数取余、轮询计数器越过最大值、floorMod 与掩码、hashCode 为 MIN_VALUE 的字符串(验证记录)
参考资料