拓十年匠心定制 · 商业建站与技术教学双线并行 咨询热线:400-886-1026 service@lmnt.cn
ARTICLE DETAIL

资讯详情

深耕网站建设与运营推广的一线实战洞察。

Java并发轮询越界:从计数器溢出到无符号取模与掩码根治

Java并发轮询越界:从计数器溢出到无符号取模与掩码根治

先说一个我踩过的坑。做网关路由时,我需要在一组后端节点之间做轮询转发,核心逻辑只有一行:int index = counter.getAndIncrement() % workers.length。这行代码看起来人畜无害:计数器从 0 递增,对节点数取模,下标永远落在合法区间,节点被均匀轮询。直到某次灰度,线上开始偶发ArrayIndexOutOfBoundsException,异常日志里 index 是负数。我第一反应是并发写坏共享变量了,第二反应是扩容缩容时数组被换了,排查了半小时才发现问题就出在这一行%上——它安静地运行了几千万次,却在计数器翻过Integer.MAX_VALUE的那一刻把下标变成了负数。这篇文章就围绕"并发安全的轮询下标设计"展开,讲清楚越界的数学根源、为什么常见的补丁反而会引入更隐蔽的"状态依赖",以及用无符号取模或二进制掩码从根上消灭越界的具体做法,适合中间件开发者、Java 后端以及所有自己写过轮询分配器的同学参考。

1. 线上偶发越界:先别急着怀疑并发,检查你的%

1.1 一个"看着没毛病"的轮询实现

轮询分配是后端服务里最朴素的分流手段:数据库连接池从池子里选连接、网关从上游节点里选路由、多线程任务池从工作线程数组里选执行者。最常见的实现是拿一个AtomicInteger做递增计数器,再对数组长度取模:

public class WorkerPicker { private final AtomicInteger counter = new AtomicInteger(); private final List<Worker> workers = buildWorkers(); public Worker next() { int index = counter.getAndIncrement() % workers.size(); return workers.get(index); } }

这段代码写的时候觉得很稳:getAndIncrement()是原子操作,能保证两个线程不会同时拿到同一个计数器值;取模运算把无限增长的计数映射到有限的数组长度上;整个过程没有锁、没有显式状态判断,代码还短。但恰恰是这种"直觉上正确"的代码,最容易在极端情况下翻车——因为它把"暂态正确"当成了"长期正确"。

1.2 溢出不一定是"瞬间点",是一条持久故障带

很多人以为Integer溢出只是某一瞬间计数器变成负数,取模后下标越界一次,然后计数器继续递增回到正数就好了。这是错误的认知。int的取值是环形的:从2147483647自增后翻转为-2147483648,之后会一直停留在负数区域,直到递增4294967296次才能回到正数。在这段漫长的负数区间里,按 Java 的取模语义,几乎每一次取模结果都是负数。

光说不够,我们把计数器拨到溢出前的位置实际跑一遍,立刻就能看到问题。假设节点数是 8,初始计数器为Integer.MAX_VALUE - 2:

AtomicInteger counter = new AtomicInteger(Integer.MAX_VALUE - 2); int size = 8; for (int i = 0; i < 6; i++) { int v = counter.getAndIncrement(); int idx = v % size; System.out.printf("counter=%d, index=%d%n", v, idx); }

输出:

counter=2147483645, index=5 counter=2147483646, index=6 counter=2147483647, index=7 counter=-2147483648, index=0 counter=-2147483647, index=-7

注意看序列:2147483647对 8 取模是 7,下一个计数器变成-2147483648。因为2^31恰好能被 8 整除,这一次取模结果居然是 0,看起来安全着陆。可再下一个数-2147483647对 8 取模就是-7,数组下标直接越界。也就是说,溢出后可能先安全一次,紧接着就是连续的异常。这个"先安全一次"的假象非常坑人,会让第一次复现现场的人误认为是偶发的数据问题,而实际上故障带早就铺开了。

1.3 多久会溢出:6 小时还是 6 分钟?

我见过很多团队把这类 Bug 评级为"理论风险",理由是"我们的 QPS 没那么高,跑不到 21 亿"。但只要算一下时间,就会知道这根本不是理论风险。

溢出周期由AtomicInteger的递增调用频率决定,而不是业务 QPS。一个请求里可能调用多次轮询:路由选一次、失败重试再选一次、健康检查标记后又选一次。按调用频率估算:

每秒递增次数到达 2^31 所需时间
1,000 / 秒24.85 天
10,000 / 秒2.49 天
100,000 / 秒5.97 小时
1,000,000 / 秒35.79 分钟

计算过程很简单:2,147,483,647 / 100,000 = 21,474.83 秒 ≈ 5.97 小时。一个 10 万 QPS 的接入层服务,半天之内就会跨过整数边界。那些 QPS 不高的系统看似安全,但别忘了计数器是进程级的——只要 JVM 不重启,多个业务模块共享同一个统计周期,几十亿次调用在长稳运行两周后也会悄悄到来。等到线上真的抛异常时,往往伴随着流量涨幅或发布窗口,排查的人很容易把锅甩给最近的重构。

2. 深入负数取模:跨语言差异和三个"补丁"的重重陷阱

2.1 同一行%,在 Java、Python、JavaScript 里结果是两样的

要根治这个问题,得先理解取模语义。同样是-7 % 3,不同语言的结果截然不同:

表达式JavaPythonJavaScript
-7 % 3-12-1
-7 % 8-71-7

Java 和 JavaScript 的余数符号跟随被除数,所以负数对正数取模一定是负数;Python 则保证余数与被除数符号一致,或者说结果恒为非负。这就导致一个很有意思的现象:同样一份"计数器 + 取模"的轮询代码,在 Python 里可能永远不越界——因为 Python 整数无限精度,溢出问题本身也不存在。如果你先在一套 Python 原型的指引下写了一份能跑的逻辑,再把它原样翻译成 Java,取模结果的差异会把 Bug 藏得很深,线上出了越界还会以为是两个语言之间的数组行为不同。

理解这个语义差异很重要,因为所有"补丁方案"本质上都是在跟这个负数取模语义搏斗,而大多数补丁都失败了。

2.2 补丁一:Math.abs 越修越偏

看到负数下标,第一反应自然是绝对值。常见的写法有两种,两个都有问题。

第一种:Math.abs(counter.getAndIncrement()) % workers.size()。这个看似用绝对值消除了负号,但当getAndIncrement()返回Integer.MIN_VALUE时,Math.abs(Integer.MIN_VALUE)的结果还是Integer.MIN_VALUE——因为int的取值范围不对称,最小的负数没有对应的正数。这个值再对workers.size()取模,结果依然是负数(除非恰好被整除)。而且Integer.MIN_VALUE在每次溢出周期中确实会出现,只是出现一次而已。这就变成了一颗"运行几千万次才触发一次"的隐形炸弹,比持续越界更难复现、更难定位。

第二种看似更聪明:Math.abs(counter.getAndIncrement() % workers.size())。先把取模结果放进绝对值里,因为取模结果的绝对值一定小于 size,所以不会越界。但它破坏了轮询的均匀性。以 size=4 为例,正余数区间是{0,1,2,3},负余数区间是{-3,-2,-1}(注意-0就是 0,不产生新来源)。取绝对值后:

最终下标可能来源来源数
001
1+1, -12
2+2, -22
3+3, -32

下标 0 的命中频率只有其他下标的一半。当 workers 数量较大时偏斜不明显,但小规模的资源池会立刻出现"个别节点被过度选中"的现象。如果轮询目标是数据库连接池,这种偏斜会直接放大短板节点的压力。

2.3 补丁二:if 判断 + 重置计数器的"状态依赖"泥潭

更常见也更危险的思路是:检测到计数器为负数时,手动把它重置回 0,相当于让计数器"返老还童"。例如:

public Worker next() { int current = counter.getAndIncrement(); if (current < 0) { counter.compareAndSet(current, 0); return workers.get(0); } return workers.get(current % workers.size()); }

这个方案看着像解决了问题:负值被拦截了,计数器也会被重置。但深入推敲会发现它引入了三类新问题。

第一,CAS 只保证一个线程重置成功,其他同时拿到负值的线程依然要用手里的负数去取模,越界照旧。负数区间从Integer.MIN_VALUE一直延伸到-1,高并发下同一瞬间拿到负值的线程不止一个,CAS 竞争能成功重置一个,却救不了其余线程。

第二,如果伪代码里改成"不管 CAS 是否成功,失败线程一律访问下标 0",那下标 0 瞬间成为热点,一轮轮询下来大量请求压到第一个 worker 上,均匀性彻底报废。

第三,也是最关键的,这个方案把正确性寄托在"当前共享状态是否越界"这件事上:线程必须判断计数器的状态,然后修改它,别的线程又依赖修改后的状态来决定行为。这正是典型的"状态依赖"并发陷阱——每一步单独看都合理,但组合起来就出现竞态窗口、代际交错、热点倾斜,而且难以证明正确性。我管这种写法叫"用更复杂的共享状态去补救简单的数学缺陷",只要状态依赖存在,后面一定还有第二个 Bug 等着你。

3. 根治越界:无符号取模与二进制掩码

3.1 方案一:Long.remainderUnsigned,让溢出后的负数在无符号视角下继续正确轮询

既然问题根源是"有符号数取模得到负数",那最直接的办法就是"按无符号数来取模"。JDK 8 提供了Long.remainderUnsigned(long dividend, long divisor),它把第一个参数当作无符号 64 位整数参与除法。实现一个通用轮询器只需几行:

public final class UnsignedRoundRobin<T> { private final T[] elements; private final AtomicLong counter = new AtomicLong(); public UnsignedRoundRobin(T[] elements) { this.elements = elements; } public T next() { long seq = counter.getAndIncrement(); int idx = Long.remainderUnsigned(seq, elements.length); return elements[idx]; } }

这里的技巧是:seq从 0 一直递增到Long.MAX_VALUE后,再自增会变成负数,但无符号视角下,-1L其实表示2^64 - 1,是一个非常大的正整数。对任意正整数和任意elements.length取模,结果必然落在[0, elements.length - 1],所以无论计数器翻转多少次,下标都是合法的。

我们可以用几个极端值验证:

Long.remainderUnsigned(Long.MAX_VALUE, 8); // 7 Long.remainderUnsigned(Long.MIN_VALUE, 8); // 0 Long.remainderUnsigned(-1L, 8); // 7

Long.MIN_VALUE无符号视角是2^63,能被 8 整除所以余 0;-1L无符号视角是2^64 - 1,对 8 取模余 7。整个过程不需要判断、不需要重置、不需要任何共享状态。计数器在这里退化成纯粹的"单调序号",符号位彻底无关紧要。

有人会担心性能:Long.remainderUnsigned底层确实要做无符号除法,但两个参数里elements.length是固定值,JIT 在热点循环里通常会用"乘 magic number + 移位"的方式替代除法,实际开销并不大。和AtomicLong.getAndIncrement()本身的原子指令相比,取模通常不是瓶颈。

3.2 方案二:容量对齐 2 的幂,用& (capacity - 1)代替取模

如果你的资源池容量可以主动设计成 2 的幂,那有一个更快的方案:用位掩码代替取模。对 2 的幂取模,等价于截取低位二进制位:

public final class MaskedRoundRobin<T> { private final AtomicInteger counter = new AtomicInteger(); private final int mask; private final T[] elements; public MaskedRoundRobin(T[] elements, int capacity) { if ((capacity & (capacity - 1)) != 0) { throw new IllegalArgumentException("capacity must be power of 2"); } this.elements = elements; this.mask = capacity - 1; } public T next() { return elements[counter.getAndIncrement() & mask]; } }

为什么负数也安全?因为 Java 的int用补码表示负数,但按位与只关心二进制位本身。-2147483647 & 7的结果是取-2147483647二进制低 3 位,和符号无关,永远落在[0, 7]内。计数器从Integer.MAX_VALUE翻转到Integer.MIN_VALUE之后,位运算依然正确输出合法下标。这项操作甚至没有除法,是一条纯粹的AND指令,无除法、无分支、无预测失败,是性能上限最高的方案。

但两个前提必须钉死:容量必须是 2 的幂;"mask 与数组"必须是同一份快照。如果真实容量不是 2 的幂,硬用& (capacity - 1)会产生严重的槽位偏斜。比如容量为 3,却把 mask 设成1,那就只剩两个槽位,而且数字 12、13、14、15 的低位掩码结果都不同,均匀性会被打乱。所以这个方案适合"从设计上就固定容量"的场景,比如线程池大小固定为 8、16、32,或者连接池容量主动对齐到 2 的幂。

3.3 为什么这两种方案能"消灭状态依赖"

回到标题里的关键词:消灭状态依赖。对比一下两种正确方案和补丁方案的区别。

补丁方案的正确性依赖"当前计数器是否处于负数区间"这个共享状态,而判断与修改之间天然存在时间窗口。正确方案则完全不同:Long.remainderUnsigned(seq, len)和seq & mask都是纯函数,它们只依赖当前这一次调用看到的计数值,不依赖过去发生过什么、不依赖其他线程是否先重置了计数器。任意时刻,任意线程拿到任意一个序号,都能独立算出合法的数组下标。安全性由数学性质保证,而不是由某个共享标志的更新时机保证。

这就是我在做代码评审时的一个判断标准:如果一个轮询器内部出现了if (index < 0)或者"计数器需要复位"之类的逻辑,那八成是想用状态修正去补救数学缺陷。换成无符号取模或掩码之后,这些分支可以整段删掉,代码更短,正确性也更显然。

4. 动态扩缩容下的状态一致性:不可变快照轮询器

4.1 问题:size 和数组引用不是同一次读取

取模把"计数器越界"这个坑填了,但轮询越界还有一个容易被忽略的来源:动态扩缩容。很多系统在发布时会新增或摘除 worker,如果数组容器是ArrayList或者其他可变结构,workers.size()和workers.get(index)是两次独立的读取。

设想一个并发场景:线程 A 调用next(),先读取了workers.size() = 8,正准备取index = counter.getAndIncrement() % 8时,扩容线程把底层数组换成了长度为 16 的新数组。A 线程继续用旧的 size 算出 index,却在新的数组上执行get(index)。只要旧 size 和 index 的组合在新数组越界,异常就出现了。更隐蔽的反向场景:先读到新 size,后访问旧数组,同样可能越界。这本质上是另一种"状态依赖"——一个逻辑需要两个共享状态字段,而这两个字段无法在一次原子操作里同时读到一致版本。

4.2 解法:volatile 持有不可变 State

正确做法是把"数组引用 + 长度(或 mask)"打包成一个不可变对象,用单个volatile引用发布。轮询时先拿一次引用,后续所有计算都基于这个快照:

private static final class State { final int mask; final Worker[] workers; State(int mask, Worker[] workers) { this.mask = mask; this.workers = workers; } } private volatile State state; public Worker next() { State s = state; int index = counter.getAndIncrement() & s.mask; return s.workers[index]; }

next()只读取一次volatile字段,数组和 mask 一定来自同一个State对象。由于State的字段都是final,构造完成后再发布给其他线程,不会有半初始化的中间态。无符号取模版本同理,只需把 mask 换成workers.length即可。

这个模式同样解决了"读 size 与读数组不一致"的问题:数组长度是State的一部分,和数组引用天然一起发布,不可能出现一个来自新版本、另一个来自旧版本的撕裂状态。

4.3 扩容发布流程与计数器是否需要重置

扩容时只需要构造新的State并写入volatile字段:

public void resize(Worker[] newWorkers) { if ((newWorkers.length & (newWorkers.length - 1)) != 0) { throw new IllegalArgumentException("capacity must be power of 2"); } State newState = new State(newWorkers.length - 1, newWorkers); state = newState; // volatile 发布 }

这里的关键点有两个。第一,必须先把新数组和 mask 完整构造好,最后再写state。千万不要先发布数组引用、再补写 mask 字段——那就会退化成"两个独立共享字段"的问题。第二,不需要重置 counter。计数器作为一个单调递增序号,继续递增下去,低位掩码会自动映射到新容量上。从旧容量到新容量,下标可能跳变,但轮询的均匀性不会因此被破坏。这也是"计数器只是一个纯序号,不承载任何业务状态"这一设计哲学的又一次体现。

需要注意:如果容量从 2^n 缩容到 2^m,mask变小,旧计数器低位可能大于新容量,但& mask的结果天然不超过mask,所以不会越界。从 2^m 扩容到 2^n 同理。条件仍然是容量始终保持 2 的幂。

5. 实测要点与工程落地建议

5.1 三种实现的开销辨析

在实际选择方案前,先把各实现的开销梳理清楚。我列一个对比表,供你结合场景判断:

方案核心操作是否除法是否分支溢出安全适用前提
counter % size有符号除法是否否不适用
Math.abs变体绝对值+除法是是部分不适用
Long.remainderUnsigned无符号除法是否是通用
& (size - 1)位与否否是容量必须为 2 的幂

从指令层面看,掩码方案没有任何除法,也无分支,在纯 CPU 指令数上当然是最优的。真实应用里,两者差距会被AtomicLong.getAndIncrement()的原子指令放大或淹没:多线程争抢同一个计数器,cache line 的独占和同步才是主要瓶颈,取模方式的差异往往不是热点。因此我的建议是:如果容量天然是 2 的幂,用掩码方案;否则直接上无符号取模,不要为了省一次除法去改变容量结构。

5.2 原子计数器的争用与 Padding

轮询器在高并发下还有一个隐藏的串行点:所有线程都敲同一个AtomicLong。在 x86 平台上,getAndIncrement通常对应lock xadd指令,它会把目标缓存行锁定,其他线程必须等待。如果你用 JMH 观察吞吐,可能会发现线程数从 1 增加到 8 后吞吐不再线性上涨,那通常不是取模的问题,而是计数器本身成了热点。

要不要做 cache line padding,取决于你的轮询频率。轻量场景完全不用;如果确实是千万 QPS 级别的热路径,可以考虑把计数器字段加@jdk.internal.vm.annotation.Contended注解,或者在类里手动填充 64 字节的占位字段,让计数器独占缓存行。更激进的做法是把共享计数器换成 ThreadLocal 游标,让每个线程维护自己的轮询指针,但这样又要处理"不同线程起点不同导致的结果偏斜",得不偿失。我个人的态度是:先保证正确性,再用 JMH 实测判断是否值得优化,而不是一开始就把方案搞复杂。

这里必须提醒一个反模式:别用LongAdder或LongAccumulator做轮询下标。它们只维护一个可累加的和,不能提供"调用顺序对应的单调序号",在并发下你拿不到一个确定的下标。LongAdder适合统计计数,不适合做分配器游标。

5.3 把计数器拨到溢出点附近的快速验证法

普通单元测试很难覆盖溢出路径,因为计数器通常从 0 开始,测试跑几万次也到不了 21 亿。我的经验是在测试里直接把计数器的值拨到溢出边缘,然后连续调用几十次next(),验证不抛异常且下标分布合法。对 Java 8 以上版本,反射修改私有字段即可:

@Test void pickerShouldSurviveIntegerOverflow() throws Exception { MaskedRoundRobin<Worker> picker = new MaskedRoundRobin<>(8, Worker[]::new); Field f = AtomicInteger.class.getDeclaredField("value"); f.setAccessible(true); f.set(picker.counter, Integer.MAX_VALUE - 2); for (int i = 0; i < 64; i++) { Worker w = picker.next(); assertNotNull(w); } }

JDK 17 以后强模块系统可能限制反射访问内部字段,如果被拦,可以在轮询器上加一个@VisibleForTesting的包级私有方法直接设置计数器。把这类测试放进 CI,是防止"重写轮询逻辑后再次踩溢出坑"的性价比之选。

5.4 最终设计清单

把要点收拢成一份可以直接照着写的清单:

  • 计数器选择AtomicLong,从 0 开始递增,把它当作纯序号,不要做有符号运算。
  • 取模统一用Long.remainderUnsigned(seq, len),下标结果永远落在合法区间。
  • 若容量可对齐 2 的幂,优先用& (capacity - 1),并把 mask 与数组放进同一个不可变 State。
  • 轮询路径只读一次volatile字段,禁止在next()里分两次读取数组引用和长度。
  • 扩容只替换不可变 State,不重置计数器,不引入额外的锁。
  • CI 中加入"计数器拨到溢出前水位"的压力测试。

我在实际排查这类问题最大的体会是:并发 Bug 的可怕之处不是逻辑复杂,而是它在一段时间里始终表现正常。计数器溢出就是这样——它只在流量足够大、进程足够长、数学恰好走到边界时才爆发,平时单测和低负载冒烟全绿。后来我把所有轮询器统一改成"无符号取模 + volatile 不可变快照",并在每次改动后都做溢出边缘测试,这类越界就再没有出现过。最后再分享一个小技巧:判断一个轮询器写得是否安全,不用逐行读代码,直接搜索它里面有没有if (index < 0)或"重置计数器"这类分支。如果存在,大概率是在用状态修正数学缺陷;换成无符号取模或掩码后,你会发现这些分支可以整个删掉,代码变短,正确性也一眼可见。

返回列表