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 ListWorker 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 - 2AtomicInteger 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); }输出counter2147483645, index5 counter2147483646, index6 counter2147483647, index7 counter-2147483648, index0 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-7Java 和 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所以不会越界。但它破坏了轮询的均匀性。以 size4 为例正余数区间是{0,1,2,3}负余数区间是{-3,-2,-1}注意-0就是 0不产生新来源。取绝对值后最终下标可能来源来源数00111, -1222, -2233, -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 UnsignedRoundRobinT { 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); // 7Long.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 MaskedRoundRobinT { 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^mmask变小旧计数器低位可能大于新容量但 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 { MaskedRoundRobinWorker 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)或重置计数器这类分支。如果存在大概率是在用状态修正数学缺陷换成无符号取模或掩码后你会发现这些分支可以整个删掉代码变短正确性也一眼可见。
返回列表