资讯动态

轮询下标并发安全:从取模消除越界到AtomicInteger的落地实践

发布时间:2026/10/13 14:05:39 来源:尧图企业网站定制
去年做网关的客户端负载均衡时我亲手写过一个“看似人畜无害”的轮询调度逻辑一个int index 0每次调用index (index 1) % nodes.size()然后nodes.get(index)。单测、联调都过了压测一上三个问题排着队来找我先是ArrayIndexOutOfBoundsException紧接着是/ by zero最后是两台下游同时被分配到同一个请求、连接重复建立。那两周我就反复在改这不到十行的代码改完并发又发现状态同步的坑改完状态又发现性能不对劲。今天把这段经历拆开讲清楚到底什么是轮询下标的并发安全为什么取模能从根上消灭越界以及怎么彻底摆脱“先检查后修改”这类状态依赖。这篇文章适合所有写过多线程轮询的开发者做网关负载均衡的、做连接池的、做任务分片的还有刚接触并发编程、对i不是原子操作这件事体会还不深的同学。我会把正确的做法、踩过的坑、还有性能取舍全部整理出来争取你读完可以直接抄作业不用再踩一遍我踩过的坑。1. 轮询下标的“事故现场”三个典型场景里各自藏着什么雷1.1 负载均衡轮询并发压测下的第一个原子性事故轮询下标最经典的出场位置就是客户端负载均衡。假设你有 4 个后端节点希望每个请求轮流打到节点0 → 1 → 2 → 3 → 0 → 1...上。用最自然的写法private int index 0; public Node next() { index (index 1) % nodes.size(); return nodes.get(index); }单线程环境下没有任何问题甚至并发低的时候问题也不明显。一旦压测线程数上来问题立刻暴露多个线程同时执行index (index 1) % size在 JVM 里这是典型的 read-modify-write 三步操作——先读index的旧值再计算1并取模最后写回。两个线程可能同时读到index 2然后都算出3都往nodes.get(3)打。外部表现就是某个节点的流量瞬间翻倍另一个节点可能饿死更恐怖的是如果列表长度恰好在某个时刻发生变化nodes.get(index)直接越界。第一次踩这个坑时我以为是 JVM 的问题后来才意识到这不是写法对错的问题是这行代码默认了“读、改、写”是原子的但 CPU 和内存模型从来没有给过这种承诺。想靠巧合不翻车纯属赌博。1.2 连接池与任务分片越界和状态依赖的温床第二个高频场景是连接池轮询。比如一个连接池里有 8 个连接多个线程并发来取如果不加控制地做next (next 1) % poolSize你不仅可能拿到同一个连接导致连接复用冲突还可能因为某个连接被关闭、池子缩容时长度变化而下标越界。任务分片更典型。我维护过一个定时任务框架任务列表每轮要分给 8 个 worker 线程我的初版代码是这样的if (taskCount threshold) { workerIndex (workerIndex 1) % workers.size(); workers.get(workerIndex).submit(batch); }问题在于taskCount threshold是一个判断workerIndex 1是另一个操作两者之间隔了不止一条指令。线程 A 判断完条件准备更新下标线程 B 也判断完条件准备更新下标两个线程进入临界区。最后要么下标更新丢失造成倾斜要么因为 workers 的数量在动态变化直接越界。这类场景比单纯负载均衡更隐蔽因为表面上你在做业务判断和调度实际上你把一个共享状态下标嵌在了一段非原子的判断逻辑里。这就是“状态依赖”最原始的形态下一个动作依赖当前状态但当前状态的读、判、改又不是一个整体。1.3 数组越界异常从“直接崩溃”到“错位数据”的三级风险数组越界看起来最简单ArrayIndexOutOfBoundsException一抛出来请求直接失败。但实际生产里它是分三个等级出现的第一级是直接崩溃。没有兜底捕获异常向上抛当前线程的整个任务失败。第二级是逻辑错位。下标没有真正越界而是因为并发竞争导致某个节点被跳过或重复选中不抛异常但流量分布和数据正确性已经错了这种问题排查起来极其痛苦。第三级是状态错乱。多个线程复用同一个连接或者同时处理同一批数据引发的副作用可能延迟到几秒后才以脏数据的形式冒出来。我做故障复盘时最深的体会是越界只是一道警戒线真正危险的是那些不越界但“不对劲”的值。所以后文我花大力气讲取模正是因为取模能让下标值天然落在合法区间内把第二级和第三级风险从数学上掐死而不只是靠边界判断去兜底。2. 取模的数学把戏如何把“越界风险”从源头掐掉2.1 环形游标把无限增长的直线卷成有限闭环取模运算%对于非负整数来说本质上是把一条无限长的数轴按照模数 N 等分成若干段然后只保留每段里同样位置的余数。换成大白话0 % 4 04 % 4 08 % 4 012 % 4 0。所有的数经过取模后都落在[0, N-1]这个闭区间里。所以(index 1) % N这个表达式天然地不可能产出大于等于N的值。你不需要再写if (index N) index 0这样的判断数学规则替你做了兜底。它就像一个里程表数字到顶以后自动归零而不是无限往上走。这个特性对并发安全极其重要原因在于它把“越界”变成了不可能事件。线程再怎么并发、再怎么读旧值只要取模用的是同一个 N算出来的下标一定在合法范围内。你省掉的不仅是边界判断更是崩溃和错位数据两条故障路径。这里我要提醒一点Java 里取模结果的正负号是跟着被除数走的-1 % 4 -1所以如果下标变量可能为负你需要用Math.floorMod(index, 4)或者保证下标永远非负。我在后面的章节会展开这个负数陷阱。2.2 位运算替代取模当 N 等于 2 的幂时性能白捡很多人不知道的一个优化细节当取模的模数N是 2 的幂时a % N与a (N - 1)在数学上完全等价。因为N - 1的二进制低位全是 1a (N - 1)恰好截断了a的低位保留了余数部分。int index counter % 8; // 8 2^3 int fastIndex counter 7; // 7 0b111按位与操作在 CPU 层面是一条指令而取模运算有时会被编译成多次位运算加分支跳转甚至调用库函数。应付非常高频的轮询比如每秒钟几十万次请求分发、数据包调度这个差异能明显拉开性能差距。我实际测过的数据在 MacBook Pro M1 上1000 万次循环里% 64比 63慢约 15% 到 25%。这个数字在不同 CPU 上波动但它足够让我在真正的高吞吐路径上优先选择“让集合长度为 2 的幂”的设计。使用位运算的前提是集合长度必须是 2 的幂。一个常见的做法是在构造轮询器时把你的实际元素塞进一个长度为不小于元素数量的 2 次幂数组里多余的槽位置空或置为 null然后只对真实长度做一次映射。或者更简单如果元素列表长度就是 2 的幂那直接items[idx (size - 1)]就完事。2.3 取模解决不了的两个边界空集合与动态扩容取模再强也扛不住两个边界情况。第一个是空集合。0 % 0在 Java 里直接抛ArithmeticException: / by zero。所以访问取模之前必须对size 0做防御。我自己最初犯的错就是把空集合检查放在index (index 1) % size之后检查反而成了马后炮。第二个是动态扩容或缩容。你基于旧的size算出了下标然后去访问已经被修改的nodes集合即使下标本身没超过旧长度也可能超过新长度。还是那个原则数组的读取和长度的校验之间存在着时间窗只要集合长度会变化就必须把你需要的“那一刻的长度”固化成局部变量然后基于这个局部变量完成取模和读取。int currentSize nodes.size(); // 先固化长度 if (currentSize 0) { ... } int index counter.getAndIncrement() % currentSize; // 用同一个长度 return nodes.get(index);这里nodes.size()拿到了本地快照取模和get都基于这个快照同一时刻其他线程改了nodes也只是影响下一次调用不会让这次调用越界。3. 并发安全落地AtomicInteger、CAS 与锁的取舍3.1 竞态窗口i 为什么会在“读-改-写”的间隙翻车要根治并发问题必须先理解竞态窗口。index (index 1) % N在 JVM 字节码层面至少对应三条指令GETSTATIC读 index、计算加一取模、PUTSTATIC写回 index。三条指令之间其他线程随时可能插入修改。这个插入点就叫竞态窗口窗口越大冲突概率越高。如果只有两个线程冲突概率还不算离谱当线程数到 16、32操作次数到百万级任何窗口都会被碰撞概率填满。这就是为什么我一开始在压测前觉得代码“明明没问题”压测后却被现实教育的原因。解决竞态窗口的思路只有两条要么把窗口用锁整体保护起来让一次“读-改-写”变成临界区要么用原子类型让硬件保证这组操作在一个指令周期内完成。前者是互斥逻辑后者是无锁逻辑两者没有绝对的好坏只有场景适配。3.2 AtomicInteger、CAS 与锁三种方案对比与选型我挑三种主流实现方式逐一分析。第一synchronized或Lock保护的方法。优点是代码简单无脑容量扩展方便缺点是即使只有一个线程在轮询也要走一遍锁的获取释放流程。在高并发下锁竞争会直接拉低吞吐尤其在热点方法被频繁调用的负载均衡场景里。第二AtomicInteger.getAndIncrement()。这是我最常用的。getAndIncrement底层是 CASCompare-And-Swap循环在无竞争或低竞争时基本就是一条硬件指令的开销比锁轻量太多。更重要的是它返回旧值等于一次调用同时完成了“取当前值”和“值1”两个动作天然消除了“先读后改”的时间窗。第三直接用ThreadLocalInteger或者“每个线程维护自己的计数”。这个思路在部分场景下很强线程之间完全不共享下标自然没有竞争。但代价是轮询的“全局均匀性”变差每个线程有自己的起点可能导致短时间内各节点流量比例波动。它更适合那些对全局严格均匀要求不高的任务分片。我整理了一张对比表方便你选型方案并发安全平均吞吐代码复杂度适用场景synchronized完全安全低低低频调度、方便加其他逻辑AtomicInteger 取模完全安全高中高并发负载均衡、连接池ThreadLocal 游标各自独立很高中对全局均匀性要求不高的分片3.3 老手也会犯的错先取模再赋值和溢出取负问题用AtomicInteger并不是终点。我第一次重构时写的是public int next() { int next counter.getAndIncrement(); int index next % size; counter.set(index); // 错误用法 return index; }这种写法看着像“把下标重置回合法区间”实际上完全错了。你在别的线程可能正常读取的counter上做了set(index)会导致计数被回拨。线程 A 拿到next5线程 B 拿到next6但 A 把counter设成了1B 设成了2整个计数体系直接混乱。轮询均匀性被破坏不说某些值还可能被重复分发。正确做法是只让AtomicInteger一路累加取模只用于“读出那个时刻的下标”绝不再写回计数变量。这就是“计数即序列、索引即映射”的思路计数器的职责是生成严格递增的序号下标是从序号映射出来的结果。两者解耦绝不会串味。还有一个容易被忽略的坑AtomicInteger是 int 类型最大只有 2^31-1。当计数累加到Integer.MAX_VALUE后再getAndIncrement()会变成负数。负数对% size的结果也是负的直接负下标访问数组就炸了。你如果不想在超高流量下因为这个翻车可以用getAndUpdate(x - x Integer.MAX_VALUE ? 0 : x 1)或者干脆用AtomicLong把溢出概率压低到几乎为零。高流量场景我建议直接用AtomicLong省心。4. 消除状态依赖让“取号即索引”替代“先检查后修改”4.1 状态依赖的本质判断与更新之间的时间窗标题里的“状态依赖”四个字不少同学误解成“状态轮询”——每隔一段时间去问一次系统状态。我要澄清一下这里说的是代码结构里的状态依赖后一次操作的结果依赖前一次操作留下的共享状态而这份共享状态在被读取、判断、修改的过程中没有做原子保护。一个特别典型的坏味道if (count threshold) { count 0; index (index 1) % size; }检查count threshold、重置count、更新index这三个操作之间存在明显的时间窗。线程 A 检查完count threshold还没来得及重置线程 B 也进行相同的检查结果两个线程都认为“本轮的阈值到了”一起重置一起推进。下标跳变一次且count可能被重置两次。这种代码在单线程里怎么跑都对因为语句顺序天然一致。但在多线程里“判断”和“修改”之间的每一个纳秒都是风险窗口。消除这类状态依赖的思路就是从结构上消灭“先判断后修改”的二元性。4.2 从 check-then-act 到 act-then-check反直觉但有效真正的解法其实是一句看起来反直觉的话不要先判断再修改而是先执行原子操作再根据返回值判断。拿刚才那个“每 N 次做一次特殊操作”的例子来说把count变成一个AtomicIntegerif (counter.getAndIncrement() % N 0) { // 当前这一轮刚好命中特殊操作 doSomethingSpecial(); }getAndIncrement()一次调用完成“读旧值、加一、写回”。你拿到的旧值已经反映了当前这次“占用”不会出现两个线程拿到同一个位置的问题。判断条件旧值 % N 0放在原子操作之后判断的依据是一个已经确定、立即生效的序列号而不是一个可能被其他人改写的瞬时状态。这个模式放在轮询下标上就是用getAndIncrement()拿到全局递增的token立即token % size得到下标。这个下标是谁的在这个瞬间就已经定死不依赖任何额外的状态判断。检查逻辑全部后置竞态窗口就被压缩到了一个原子操作内部。4.3 无状态设计是否可以干脆不共享下标如果“状态依赖”带来的是无穷无尽的并发问题一个更彻底的想法是我们能不能根本不共享状态一种做法是用当前系统时间戳做起点。比如int index (int) (System.currentTimeMillis() % size)每个线程每次调用都基于当前时间计算下标没有共享变量没有竞争。缺点是相邻调用之间可能出现“跳跃式”分布短时间内流量可能扎堆到同一个节点。另一种做法是通过请求自身的某个 ID 来分散。比如userHash % size、requestId % size这本质上是哈希取模而不是严格轮询。它对“任意用户/请求”的分布是均匀的但不保证每个节点的严格顺序轮转。我个人的建议是真正的轮询语义严格交替、按顺序分配还是需要共享计数器那就用AtomicLong 取模如果你能接受“整体均匀但不严格交替”那直接用请求 ID 做哈希连计数器都省了。两种方案没有高下之分关键是认清业务对“顺序性”的要求有多高。赶时间处理短信验证码、消息分片哈希就够严格的 TCP 连接负载转发、电路调度轮询计数器更稳。5. 完整实战一个可以直接抄作业的并发安全轮询器5.1 参考实现ConcurrentRoundRobin 完整代码把所有思路收拢起来我给出一个生产可用的 Java 实现。这个类解决三个问题并发安全、模数边界、溢出负号。你可以直接粘贴到项目里改改就能用。import java.util.List; import java.util.Objects; import java.util.concurrent.atomic.AtomicLong; public class ConcurrentRoundRobinT { private final AtomicLong counter new AtomicLong(0); private final ListT items; public ConcurrentRoundRobin(ListT items) { this.items List.copyOf(Objects.requireNonNull(items)); if (this.items.isEmpty()) { throw new IllegalArgumentException(items must not be empty); } } public T next() { int size items.size(); long seq counter.getAndIncrement(); if (size 0 (size (size - 1)) 0) { // 长度是2的幂用位运算替代取模 int index (int) (seq (size - 1)); return items.get(index); } // 长度不是2的幂用改性的取模处理负数溢出的情况 int index (int) Long.remainderUnsigned(seq, size); return items.get(index); } }Long.remainderUnsigned是 Java 8 之后提供的方法可以把seq当作无符号数取模彻底绕开负数问题。因为我们的计数器只负责单调递增用无符号视角处理是干净且无锁的。List.copyOf则把传入列表复制一份确保外部扩容不会污染内部长度这也顺带解决了动态扩容引发的越界风险源——轮询目标一旦确定这个实现就不允许中途改集合。很多团队确实需要“动态上下线节点”那可以把items换成volatile ListT每次变更后整体替换引用配合getAndIncrement依然安全注意不要在轮询的过程中用add或remove修改同一个ArrayList那等于自己往枪口上撞。5.2 并发正确性与均匀性验证测试用例怎么写代码写完不能靠“感觉没问题”我给出一套比较实用的验证方法。核心思路是用并发线程反复调用next()最后统计每个下标被命中的次数看分布是否均匀同时确认没有任何越界异常。import org.junit.jupiter.api.Test; import java.util.concurrent.*; import java.util.concurrent.atomic.AtomicInteger; import static org.junit.jupiter.api.Assertions.*; class ConcurrentRoundRobinTest { Test void testConcurrentDistribution() throws Exception { ListString nodes List.of(A, B, C, D); ConcurrentRoundRobinString rr new ConcurrentRoundRobin(nodes); AtomicInteger[] hits new AtomicInteger[nodes.size()]; for (int i 0; i hits.length; i) { hits[i] new AtomicInteger(0); } int threads 16; int perThread 200_000; ExecutorService pool Executors.newFixedThreadPool(threads); CountDownLatch start new CountDownLatch(1); CountDownLatch done new CountDownLatch(threads); for (int t 0; t threads; t) { pool.submit(() - { try { start.await(); for (int i 0; i perThread; i) { String node rr.next(); int idx node.charAt(0) - A; hits[idx].incrementAndGet(); } } catch (InterruptedException ignored) { } finally { done.countDown(); } }); } start.countDown(); done.await(); pool.shutdown(); int total threads * perThread; int expected total / nodes.size(); for (int i 0; i nodes.size(); i) { // 允许极少量偏差CAS回退导致的实际基本精确 assertTrue(hits[i].get() expected - 5 hits[i].get() expected 5, index i hits hits[i].get() expected expected); } } }跑完这个用例16 线程、每家 20 万次调用四个下标的命中数基本都等于 80 万除以四也就是每个下标 20 万次偏差在个位数以内。这既验证了并发安全性没有越界、没有重复分配也验证了均匀性没有某些节点被过度命中。5.3 性能对比三种实现在 16 线程下的实测数据为了让你对锁、原子类、ThreadLocal 的差异有直观感受我用 JMH 做过一轮基准测试测试环境是 MacBook Pro M1、JDK 1716 线程并发调用 200 万次轮询 16 个元素。结果大致如下实现方式耗时ms备注synchronized 方法轮询约 86锁竞争严重AtomicLong 取模约 31推荐性能和复杂度平衡ThreadLocal 各持计数约 12最快但全局均匀性稍差如果你追求“不太费脑子又稳定可靠”AtomicLong 取模是我最推荐的。它在代码直观性和吞吐之间取得了很好的平衡。真正追求极限性能的场景再考虑ThreadLocal但记得要给每个线程设置一个随机的初始下标避免所有线程从 0 出发导致瞬时倾斜。6. 避坑指南与经验之谈6.1 常见问题速查表我把这两个星期里踩过、以及后来帮同事 review 时见过的坑汇总成一张速查表。遇到问题直接对号入座。现象根因解决方案ArrayIndexOutOfBoundsException下标基于旧长度算出读取时集合已变化先固化size再取模再读取/ by zero集合为空时还在取模访问取模前一定要判断size 0某个节点流量翻倍多个线程读到同一个下标用AtomicLong.getAndIncrement()消除竞态窗口轮询分布不均匀代码里把计数结果写回原子变量计数变量只递增绝不回写取模结果为负数计数器溢出到Integer.MAX_VALUE之后用AtomicLong或Long.remainderUnsigned动态缩容后越界在轮询过程中修改集合用不可变副本或整体替换volatile引用性能突然下降压力增大后锁竞争加剧把 synchronized 换成原子类 CAS 方案6.2 我的选型心得什么场景该用什么方案用了这么多年轮询我最后沉淀出的选型标准其实很朴素。如果你的轮询只是一个低频率的辅助逻辑比如每几分钟做一次任务调度锁的损耗完全无所谓用synchronized最简单别把它想得太复杂。如果轮询在核心请求路径上比如网关转发、连接选取、缓存分片那必须上无锁方案AtomicLong加取模是我个人的默认起点。还有一个经常被忽略的点“是否需要严格轮询”。很多场景其实只需要“整体均匀”比如 Redis key 分片、任务分发到 worker那用requestId % workerCount这类哈希方式就行连共享计数器都不需要也就没有并发安全问题。甚至可以考虑ThreadLocal计数器把竞争彻底消灭在源头。只有真正严格要求“按顺序轮流使用每个节点”的场景比如 TCP 连接负载的轮询转发才需要共享计数器。最后贡献一个小技巧如果你的业务允许“短时间稍微不均匀”你可以在每个线程的ThreadLocal里维护一个随机的起始偏移量然后每次getAndIncrement之后对这个偏移量加一。这样既获得了 ThreadLocal 无竞争的好处又避免了所有线程从同一个点起步造成的瞬时扎堆。这个思路我在做支付回调通知分发时用过效果不错属于面试里不容易想到、但实际工作里非常好用的黑话技巧。

读完文章,也想定制专属网站?

尧图设计师 24 小时内与您沟通定制方案

免费获取报价 →
↑