LongAdder为什么快?探针getProbe与advanceProbe源码解析
前几个月我在优化一个并发计数服务线程一多AtomicLong的CAS自旋肉眼可见地变慢perf采集热点一直指向内存屏障。后来把计数结构换成LongAdder同样的压测脚本P999时延直接掉了一个量级。也就是那次性能排查让我第一次耐着性子把Striped64的源码读了一遍然后看到了两个非常不起眼的内部方法ThreadLocalRandom#getProbe和ThreadLocalRandom#advanceProbe。这两个方法别说业务代码很多写框架的人都未必注意过。但它们几乎定义了LongAdder、ForkJoinPool这类底层组件在高并发下的分布策略。这篇文章我准备把这两个方法的源码、位运算原理、实际应用场景以及我在阅读JDK源码和调优过程中踩过的坑一次讲清楚。适合已经了解并发基础、准备深入并发包源码或者单纯好奇LongAdder为什么快的朋友。1. ThreadLocalRandom与探针一对容易被忽视的搭档1.1 从Random到ThreadLocalRandom解决竞争问题Java里早期生成随机数基本离不开java.util.Random。它的核心操作是更新一个AtomicLong种子每个随机数生成都用一次CAS。问题就在这单线程用没什么问题但高并发下所有线程抢同一条CAS总线缓存行在核心之间来回失效吞吐量随线程数增加反而下滑。很多人后来改用ThreadLocalRandom因为它在每个Thread对象里单独存了一个threadLocalRandomSeed线程各自更新自己的种子压根不需要抢共享状态并发随机数性能自然高出一大截。不过ThreadLocalRandom的价值不止于生成随机数。它还在每个线程上维护了一个额外的threadLocalRandomProbe字段翻译过来是“探针”。这个字段的用途不是生成随机序列而是给线程打一个“哈希身份标记”让并发数据结构能够按这个标记快速把线程分流到不同槽位。你可以把probe想象成Thread的“坐标”getProbe就是读取当前坐标advanceProbe就是给自己换一个新坐标。这个整套设计非常聪明因为坐标值不要求唯一只要求足够分散而且更换坐标的成本极低。1.2 probe、seed、secondarySeed别搞混阅读ThreadLocalRandom源码时最容易混淆的是Thread类里的几个相关字段threadLocalRandomSeed、threadLocalRandomProbe、threadLocalRandomSecondarySeed。Seed是真正参与随机数递推计算的种子SecondarySeed用于ForkJoinPool等组件生成辅助随机数而Probe就是本文要聊的“探针”。三者相互独立更新逻辑也各不相同。getProbe返回的是当前线程的threadLocalRandomProbe值advanceProbe接收旧probe用位运算算出一个新值写回同时返回新值。两行方法配合起来就是一次标准“重哈希”。这样做的前提是并发数据结构认为某个槽位竞争太激烈可以让当前线程主动换一个槽位。换槽位的依据不是锁、不是CAS超时重试而是一个伪随机数。这也解释了为什么LongAdder在高并发下表现那么好——它本质上在做“概率性分散”而不是让线程在一棵热点树上互相等待。1.3 为什么底层用Unsafe直接访问字段getProbe和advanceProbe在JDK里是通过sun.misc.Unsafe来读写Thread对象的内存偏移量而不是调用普通getter。原因很简单性能。这些方法可能被并发包核心路径高频调用比如每次累加操作都需要获取probe并选择Cell槽位。用Unsafe绕过方法调用同时减少一次JIT内联的不确定性能把读取延迟降到极限。对普通应用开发者来说不需要模仿这种写法但理解这一点对读懂JDK底层设计很有帮助。2. getProbe()一次内存读取完成线程定位2.1 源码只有一行背后的偏移量计算不简单JDK 8里getProbe的实现很紧凑基本就是这条语句static final int getProbe() { return UNSAFE.getInt(t, PROBE); }t是Thread.currentThread()的缓存引用PROBE则是通过unsafe.objectFieldOffset(Thread.class.getDeclaredField(threadLocalRandomProbe))计算出来的字段偏移量。有了偏移量每次读取就直接从线程对象的内存区域取出那个int值不需要任何同步操作。从语义上讲这行代码就是在问“当前线程现在落在哪个哈希桶上”。由于probe和线程之间的关联是持久的只要线程不主动调用advanceProbe这个值就不会变。所以它不像随机数那样每次生成一个全新的结果它更像一个“卡号”告诉你可以在哪个窗口排队卡号不理想的时候再申请换一张。2.2 初始值为什么不能是0在ThreadLocalRandom.current()方法的开头JDK会检查当前线程的probe是否等于0如果是0就调用localInit()做初始化。这个判断很关键因为0被用来标记“这个线程还没有初始化探针”。如果probe正巧是0又没有被修复那么在Striped64之类的算法里h与数组长度减一相与之后会固定落到下标0所有新线程一起涌向第一个Cell热点瞬间爆炸。为了保证第一轮probe非零localInit()会从一个全局的probeGenerator原子变量中获取一个递增序列然后通过mix32做一次混合把混合后的值写入threadLocalRandomProbe。这个混合函数会把看起来有一定规律的连续值打乱让不同线程的初始probe在32位空间里尽可能均匀分布。所以在JDK内部probe并不等于线程ID也不是一个简单的计数器而是经过特殊处理后的伪随机数。2.3 为什么不用线程ID直接做哈希很多人会问直接用Thread.getId()来做槽位分配不是更直观吗行是行但线程ID的低位分布很差。线程ID通常是连续递增的假如Cell数组长度是4那么线程ID对4取模后所有ID尾数为0、1、2、3的线程会分别扎堆来自同一批创建的线程几乎永远是同一个槽位冲突概率非常高。对比之下probe经过xorshift这类位运算扰动后相邻线程ID产生的新值在32位空间里已经看不出关联再与数组长度取模分布要均匀得多。而且线程ID获取需要调用JVM接口probe读取只是内存load两者成本不在一个量级。所以JDK选择probe并不是炫技而是综合考虑了分布质量和性能成本之后的结果。3. advanceProbe()xorshift扰动背后的门道3.1 三行位运算怎么实现的“换坐标”advanceProbe的源码短短几行static final int advanceProbe(int probe) { probe ^ probe 13; probe ^ probe 17; probe ^ probe 5; UNSAFE.putInt(Thread.currentThread(), PROBE, probe); return probe; }这是经典的Marsaglia xorshift随机数生成方式。第一行把probe左移13位后与原值异或第二行把结果右移17位再异或第三行再左移5位异或。三个方向的位移把32位数字的二进制位充分“搅拌”了一遍让原本可能存在的高低比特相关性被打破。整个过程是纯位运算没有乘法、没有除法更没有随机数生成器常见的同步StoreStore屏障。从直觉上理解整个过程可以看成“把一盒麻将牌重新洗一遍”。如果旧probe已经让线程扎堆那么新probe大概率会把线程转到其他槽位。由于xorshift的统计学特性新值和旧值之间的相关性很低连续推进几次之后基本上每个线程都能均匀散开到不同的Cell上。3.2 为什么偏偏是13、17、5这三个常数提一句经典背景xorshift参数组合很多但并非任意数值都成立。13、17、5这一组是经过验证的能在不损失随机性的前提下达到足够长的周期。JDK注释也提到“xorshift”这个名字实际上就是应用了一个周期接近2^32-1的伪随机递推。作为使用者完全不需要自己修改参数擅自换数字反而可能让序列周期变短或分布变差。与普通随机数生成不同advanceProbe不需要像nextInt那样返回一个完整随机数它只是要求“新值和旧值尽量不相关”。这个要求低得多因此三行位运算足够。静态检查工具经常建议用ThreadLocalRandom.nextInt代替手写xorshift其实在那个性能极敏感的循环里上下文已经知道probe确实是一个随机数且调用频率极高所以手写位运算反而更合理。3.3 为什么写回和返回值同时存在注意最后两行先通过UNSAFE.putInt把新探针写回当前线程对象然后又return新探针。为什么既要写回又要返回因为调用方在两个场景下需要它。Striped64进入longAccumulate后会先获取当前探针经过多轮尝试后如果没有进展就调用advanceProbe(h)拿到新探针。在同一轮循环里后续逻辑需要立刻用这个新值重新计算槽位下标所以函数需要返回新探针。另一方面当前线程的probe字段也必须更新否则下一次调用getProbe时又回到旧值会导致一个线程反复在同一个槽位重试。写回动作相当于把这个线程的门牌号永久迁走保证后续操作不再踩同一个坑。从并发安全角度看这个方法是线程私有的每个线程只在当前线程上操作自己的字段不需要加锁或CAS。即便它被多个并发工具类调用影响的也只是当前线程的局部状态不会产生跨线程共享写入。4. 探针机制的最大应用舞台Striped644.1 LongAdder的Cell定位是怎么完成的下标计算讲了这么多你可能更想知道这两个方法到底在哪用。JDK里最典型的场景就是Striped64。我们熟悉的LongAdder内部维护了一个Cell数组累加操作会把当前线程的某种标识映射到一个Cell槽位上。映射公式非常简单就是取probe的低位int h getProbe(); int index h (cells.length - 1);因为Cell数组长度始终是2的幂所以h与数组长度减一相与等价于对数组长度取模。这里有两层好处第一数组长度能用位运算快速取模第二probe的分布越均匀取模后落到各个Cell上的线程也就越均匀。当CAS更新Cell成功整个累加过程就结束了完全不需要锁。如果CAS失败Striped64不会立刻暴力重试而是会先根据冲突次数决定是否扩容或换槽位。这个决策过程非常细腻LongAdder能保持极低的竞争正是靠这一套分流和动态调整策略。4.2 longAccumulate里的rehash循环当Cell数组为空、目标Cell为空或者CAS失败时会进入longAccumulate方法。这个方法里有一个关键先手如果当前probe为0就通过ThreadLocalRandom.current()初始化并标记wasUncontended为true。接下来进入一个for循环循环内部分支很多但核心动作始终是“试一下新槽位”。我简化一下核心流程先看Cell数组是否已创建再看当前线程映射到的Cell是否为空如果空闲就先创建Cell放进去如果已存在就尝试CAS操作CAS失败说明这个槽位发生竞争此时不会立刻扩容而是先把collide标记置位并调用advanceProbe(h)换一个新探针重新进入循环。如果连续多次都发生冲突且Cell数组长度小于CPU数量才会考虑给Cell数组扩容。这个设计很有分寸感。CAS失败一次就扩容是浪费可能导致Cell数组比CPU数量还长CAS失败多次还死磕同一个槽位也不划算。advanceProbe在这里承担了“先散开再说”的角色。正因为每次换槽位成本极低Striped64才能在极低延迟内完成热线程的重新路由。4.3 探针、线程ID、随机数三者对比现在我们做一个清晰对比帮助你理解为什么最终选择探针机制。方案实现复杂度分布质量获取成本动态调整能力线程ID取模最简单低低几位聚集明显较高涉及JVM调用无每次随机数简单高高需要一次完整随机计算弱需要重建状态探针机制中等高且稳定极低一次内存load强advanceProbe随时重哈希线程ID方案最明显的缺点是分布质量前面文里已经提到了每次调用随机数获取下标虽然分布好但生成一个随机数需要更新种子并做mix64成本比读取现有probe高不少探针机制既有了固定身份又允许动态调整是一套非常精妙的工程折中。5. 应用场景延展与手写模拟5.1 不只是LongAdderForkJoinPool也靠probe做任务窃取Striped64是probe机制最显眼的用户但不是唯一用户。ForkJoinPool在工作窃取时需要选择一个victim队列去偷任务。如果所有窃取线程都盯上同一个最忙的队列那会形成新的热点。为了避免这种情况ForkJoinPool会使用类似探针的哈希值来随机选择一个队列下标发现偷不到任务时再通过advanceProbe换下一个位置继续尝试。整个过程中probe扮演的是“随机但可移动的起始位置”配合窃取算法保证了负载均衡。Java并发包之后引入的ConcurrentHashMap、以及某些分段计数方案也借鉴了类似“线程哈希分片”的思路。可以说getProbe和advanceProbe这两个方法的职责已经从ThreadLocalRandom内部工具变成了并发容器领域的一种标准技巧。5.2 用普通Java代码模拟probe调度逻辑很多人对Unsafe和偏移量有距离感我写了一个简化版模拟把probe放在对象字段里效果和JDK内部一样class ProbeSimulator { private int probe 1; int getProbe() { return probe; } int advanceProbe(int current) { current ^ current 13; current ^ current 17; current ^ current 5; this.probe current; return current; } int slot(int length) { return getProbe() (length - 1); } public static void main(String[] args) { ProbeSimulator sim new ProbeSimulator(); int length 4; for (int i 0; i 10; i) { System.out.println(probe sim.getProbe() , slot sim.slot(length)); sim.advanceProbe(sim.getProbe()); } } }运行这个代码你能直观看到probe会在每次advance之后跳到完全不同的slot。如果用连续的线程ID取模去执行同一段逻辑会发现前几个ID几乎落在同一个位置。同时这里也提醒了一个关键点模拟中的slot方法每次都会重新读取probe而advanceProbe后一定要调用getProbe或者用返回的新值不能再用旧的probe变量否则计算的slot就是旧的。5.3 自己在项目里复现一次性能收益去年我和同事在模拟项目X里做了一次对照实验用8个线程并发累加100万次随机数分别使用全局锁加普通加法、AtomicLong、LongAdder三种方式。结果和预期基本一致LongAdder的吞吐量大约是AtomicLong的3到5倍在更高线程数下差距还会拉大。这个收益主要就来自Striped64的Cell分片和probe调度而不是随机数本身。如果你在自己的代码里看到了类似“大量线程在同一时刻写同一个数组下标”的热点可以尝试用“探针”思想做一个槽位迁移机制。最简单的落地方式就是参考ThreadLocalRandom在每个线程的ThreadLocal里保存一个int哈希值冲突时用xorshift更新。整体架构会比“中央AtomicInteger分配槽位”优雅很多。6. 高频问题与避坑指南6.1 能不能用反射调用这两个方法很多人看到getProbe和advanceProbe觉得好用想在自己的代码里直接调用。这两个方法是包级私有静态方法外部类通过反射确实可以在JDK 8里访问。但从JDK 9开始模块化系统会默认阻止深度反射访问内部APIJDK 17之后更是强烈封装运行时大概率抛出InaccessibleObjectException。所以我非常不建议在生产环境通过反射调用它们。如果确实需要类似能力建议在业务代码中复制一份简化实现或者直接使用LongAdder、LongAccumulator等官方并发容器。如果你在写自己的并发库也不要在字节码层面依赖jdk.internal类的存在那会让你整个程序绑定到特定版本上。6.2 自研probe机制的三个硬性要求如果你打算借鉴这套机制我总结三条必须满足的要求。第一探针初始值不能为0最好用一个非零常数做种子否则会和“未初始化”状态冲突。第二目标数组长度必须是2的幂这样才能用按位与操作替代取模否则不仅慢而且无法保证映射均匀。第三repaint时写回线程局部状态不能只返回新值而不落盘。很多自研方案最后失效就是因为新探针没有更新到当前线程持有对象中导致下一轮直接用旧值。另外在调整探针时建议采用与JDK类似的策略先CAS失败两次以上再换槽位而不是一失败就换。频繁换槽位会导致缓存局部性变差因为每次换到新Cell都意味着一次新的缓存行加载。JDK中collide标志和扩容条件就是用来平衡这个开销的。6.3 伪共享问题在probe机制里存不存在Striped64里的Cell类内置了多个long补齐用来避免不同线程操作相邻Cell时引发伪共享。但probe字段存放在Thread对象内只会被当前线程自己读写所以不存在多线程同时写不同字段却共享同一个缓存行的风险。这点和Cell数组完全不同。不过如果你自己把probe放到一个共享对象里并且多个线程同时修改不同字段比如一个修改probe另一个修改seed这在逻辑上仍然可能因为这两个字段落在同一个缓存行而导致性能下降。因此在自己实现时优先考虑把probe放进ThreadLocal或者直接保存在线程私有的上下文对象里不要在共享类里顺手放一个probe字段。6.4 线程池复用导致probe漂移怎么办在线程池中同一个Thread对象会在不同任务之间复用因此probe不会在每个任务开始时自动重置。一个线程执行完任务A后它身上可能保留了某个探针值执行任务B时这个值还会延续。严格来说这不算什么问题因为probe本来就不保证任务隔离。如果你的算法要求每个任务独立进行一次重新哈希那么可以在任务开始时调用advanceProbe或者把probe记录到任务对象中。不要期待框架自动帮你做重置。读源码的时候很多人忽略这一点却在实际调优中抱怨同样的线程总是访问同一个槽位实际上责任在线程复用和旧探针的持久性。7. 最后再分享一点实操体会这两个方法虽然只有几行代码但它背后的思想启发了我对并发编程的理解。以前遇到热点我本能反应是加锁、减小临界区、优化CAS。可很多时候另一个思路是“别让所有人都挤在同一扇门里而是给每个人发一个会变动的门牌号”。ThreadLocalRandom#getProbe和advanceProbe就是“发门牌号”的基础设施。它们藏在JDK最底层默默支撑着LongAdder、ForkJoinPool这类高性能组件。我建议你下次读JDK源码的时候顺着ThreadLocalRandom.current()往下追把probe的初始化、读取、推进、写回都串一遍再回头看Striped64的分支逻辑会有一种豁然开朗的感觉。如果你身边也有人觉得LongAdder快得不可思议就把这篇内容转给他让他知道这份性能背后不只是宽松的CAS策略还有这一对被大多数人忽视的探针方法。