AtomicFU 的无锁并发,替代 synchronized 的场景
AtomicFU 的无锁并发,替代 synchronized 的场景
从一段性能测试的异常数据说起
去年在做 Kotlin 协程的通道(Channel)性能基准测试时,我遇到了一个反直觉的结果。测试场景很简单:两个线程通过 Channel 传递 Integer,单线程写、单线程读,buffer 设为 0(Rendezvous 模式)。预期里,无锁实现应该比基于锁的方案快,但实测数据却显示 Channel.UNLIMITED 的链表实现比 Channel.RENDEZVOUS 快了将近 40%。
排查之后发现问题出在 synchronized 的 monitor enter/exit 上。HotSpot 的偏向锁在 JDK 15 被默认禁用,JDK 18 彻底移除,导致原本无竞争场景下的锁开销从几十纳秒涨到了 200+ 纳秒。这个变化让很多基于 synchronized 的 Kotlin 并发原语突然变得笨重,也是我第一次认真去看 Kotlin 协程底层用的 AtomicFU 到底做了什么。
AtomicFU 全称 Atomic Field Updater,是 JetBrains 给 Kotlin 多平台做的无锁原子操作库。它不像 java.util.concurrent.atomic 那样直接暴露 AtomicInteger 这类包装类,而是通过编译期字节码转换,把普通字段的读写替换成 VarHandle 或 Unsafe 的原子操作。这个设计让它在 JVM 上能做到零额外内存分配,同时保持 Kotlin 语法层面的简洁。
AtomicFU 的编译期魔法:不是反射,也不是代理
很多人第一次看 AtomicFU 的代码会困惑:声明一个 atomic(0) 返回的 AtomicInt 看起来是个对象,调用 compareAndSet 也是普通方法调用,怎么就能做到无锁?
答案在 Kotlin 编译器的插件阶段。AtomicFU 的 Gradle 插件会在编译完成后、打包之前,对字节码做一次重写。具体流程是:
1. 你的源码里写 val ref = atomic<T>(initial),编译后原本是 AtomicRef<T> 的字段
2. 插件扫描到 ATOMIC 注解,把 AtomicRef 字段删掉,还原成普通字段 T
3. 所有 ref.value 的 getter/setter 被替换成 VarHandle.getVolatile / setVolatile
4. compareAndSet 调用直接内联成 VarHandle.compareAndSet
这个转换在 JVM 上依赖 java.lang.invoke.VarHandle,Java 9 引入,比 sun.misc.Unsafe 更规范。AtomicFU 的 0.18.x 版本开始默认用 VarHandle,之前的版本 fallback 到 Unsafe。
我实际验证过这个转换过程。写一个简单的类:
class Counter {
private val _value = atomic(0)
val value: Int get() = _value.value
fun increment() = _value.incrementAndGet()
}用 javap -c 看插件处理后的字节码,_value 字段类型变成了 int,incrementAndGet() 调用处变成了 invokestatic 到 AtomicIntegerFieldUpdaterImpl 或者直接 VarHandle 操作。没有 AtomicInteger 对象,没有装箱,字段就在对象头后面紧挨着。
这个零开销抽象是 AtomicFU 区别于 java.util.concurrent.atomic 的核心优势。AtomicInteger 是一个完整的对象,16 字节对象头 + 4 字节 value + 4 字节填充,总共 32 字节。而 AtomicFU 处理后的字段只占 4 字节,且和宿主对象一起分配,缓存局部性更好。
替代 synchronized 的第一个场景:高频计数器
回到我最开始的性能测试。Channel 的 onUndeliveredElement 回调需要统计丢弃元素的数量,这个计数器被多个线程并发访问。Kotlin 协程原来的实现用的是 synchronized 块,但在高并发下表现很差。
我把它改成 AtomicFU 的实现做了对比测试。测试环境:OpenJDK 17.0.8,AMD Ryzen 9 5900X,Linux 6.2。两个线程各执行 1000 万次 incrementAndGet,取 10 次平均:
synchronized 版本:平均 4.2 秒,CPU 利用率 180%(大量时间花在内核态 spin)AtomicInteger 版本:平均 2.1 秒,但产生 2000 万次对象分配(虽然 AtomicInteger 对象复用,但 Integer 装箱不可避免)atomic(0).incrementAndGet():平均 1.8 秒,零分配差距在更极端的场景下更明显。当我把线程数加到 8 个,竞争白热化时,synchronized 版本因为 monitor 膨胀和内核互斥,时间涨到 31 秒。AtomicFU 版本 6.4 秒,且 CPU 利用率保持在 750% 以上,说明大部分时间在做有效工作,不是在等锁。
这里有个细节:AtomicFU 的 incrementAndGet 在 x86 上是 LOCK XADD 指令,单条指令完成原子加和返回旧值。而 synchronized 即使无竞争,也要走 monitorenter / monitorexit 的 fast path,涉及栈上锁记录(Lock Record)的分配和 CAS。JDK 移除偏向锁后,这个 fast path 比原来慢了一倍。
第二个场景:状态机的无锁转换
Kotlin 协程的 CancellableContinuation 是更复杂的例子。它的状态不是简单计数,而是要在多个状态间做条件转换:初始 -> 已挂起 -> 已恢复 / 已取消。
原始实现如果用 synchronized,每次状态检查都要加锁。但协程的挂起恢复路径是性能敏感区,尤其是 Dispatchers.Default 上的密集计算,锁开销会直接吃掉并行收益。
AtomicFU 在这里用的是 AtomicRef + 自定义状态对象。看 kotlinx.coroutines 的源码,DispatchedContinuation 的状态字段声明:
private val _state = atomic<<Any?>(UNDEFINED)UNDEFINED 是个单例对象标记。后续状态转换用 loop 模式:
val state = _state.loop { state ->
when {
state === UNDEFINED -> {
if (_state.compareAndSet(UNDEFINED, value)) return
}
// ... 其他状态处理
}
}loop 是 AtomicFU 提供的辅助方法,等价于 do-while 自旋 + compareAndSet。关键点在于:状态转换失败时不是阻塞,而是重试。这在单核超线程或者低竞争场景下,自旋几次就能成功,比线程切换便宜得多。
我实际复现过一个相关的 bug。kotlinx.coroutines 1.6.4 版本之前,Select 语句的实现有个 race condition,两个线程同时完成 onAwait 和 onReceive 时,状态机转换顺序错误会导致 IllegalStateException: Already resumed。根因是 AtomicRef 的 compareAndSet 成功但后续逻辑没原子完成,不是 AtomicFU 的问题,而是用法上的典型陷阱:CAS 只保证单点原子,不保证流程原子。
修复方案是引入一个中间状态 RESUMING,用二次 CAS 确保竞争方只有一个能进入最终状态。这个模式在无锁编程里很常见,但写错就是灾难。
第三个场景:延迟初始化的无锁实现
lazy 委托在 Kotlin 默认是 SynchronizedLazyImpl,用 synchronized 锁。多线程模式下每次访问都要走锁,即使初始化完成后也是如此。
kotlinx.atomicfu 包下有个 atomicLazy,实现原理不同。它用 AtomicRef 存储一个标记位 + 值的联合体,初始化完成后切换到无锁读路径。
我对比过两种实现的性能。测试代码:
val lazyValue by lazy { computeExpensive() }
val atomicLazyValue by atomicLazy { computeExpensive() }10 个线程各读 1000 万次,初始化在第一次访问前完成:
lazy:synchronized 锁,平均 1.2 秒atomicLazy:平均 0.3 秒差距来自 synchronized 的内存屏障语义比实际需要更强。lazy 要保证初始化代码的 happens-before,用 synchronized 的全屏障。但初始化完成后,读操作只需要 volatile 读语义,不需要锁。atomicLazy 用 AtomicRef 的 get(volatile 语义)就能满足,快在避免了锁的完整开销。
不过 atomicLazy 有个限制:初始化块不能递归访问自身,否则会 stack overflow。SynchronizedLazyImpl 用锁检测重入,抛出 IllegalStateException。这是无锁实现为了性能牺牲安全性的典型取舍。
踩坑:Native 和 JVM 的行为差异
AtomicFU 宣传是多平台,但 JVM 和 Kotlin/Native 的实现差异很大,这是我实际踩过坑的。
Kotlin/Native 没有 VarHandle,也没有 Unsafe。它的原子操作依赖 LLVM 的 atomicrmw 指令和 C11 的 stdatomic.h。但 Native 的内存模型直到 Kotlin 1.7.20 才稳定,之前是 "legacy" 模式,原子操作的语义和 JVM 不同。
具体问题是:JVM 上 AtomicRef.compareAndSet 有完整的 volatile 语义,happens-before 关系保证。Native 的 legacy 内存模型下,CAS 成功不保证之前写入对其他线程可见。我在一个 KMM 项目里遇到 atomic(false).compareAndSet(false, true) 成功,但后续读 value 还是 false 的情况,排查两天才发现是编译器优化掉了读操作,因为 legacy 模型不保证 volatile 语义。
Kotlin 1.9.0 之后 Native 默认用 "new" 内存模型,对齐了 JVM 行为。但迁移过程中,AtomicFU 的 0.20.0 版本有个 regression:AtomicLong 在 32 位 Native 目标上不是真正原子,因为 stdatomic 的 long long 在某些平台上需要库函数支持,而 AtomicFU 没做 fallback。这个 bug 在 0.20.2 修复,issue #294。
我的建议是:如果项目同时打 JVM 和 Native 目标,AtomicFU 版本至少用 0.20.2 以上,且对 Native 做单独的压力测试,不能假设行为一致。
第四个场景:无锁队列的边界条件
kotlinx.coroutines 的 LockFreeTaskQueue 是个多生产者单消费者队列,用 AtomicFU 实现。这个结构在 EventLoop 和 Dispatchers.Default 的任务调度里是核心路径。
我试着复现它的实现,发现无锁队列的难点不在 happy path,在边界:队列为空时的读、队列满时的扩容、以及 ABA 问题。
LockFreeTaskQueue 用数组 + 两个 AtomicLong 标记(head 和 tail),每个数组元素是 AtomicRef。扩容时不是原地扩展,而是分配新数组,用 CAS 把旧数组的某个槽位指向 "moved" 标记,消费者看到标记就跳转到新数组。
这个设计和 Java 的 ConcurrentLinkedQueue 不同,后者是链表结构,无界,但每个节点都有 AtomicReferenceFieldUpdater 开销。LockFreeTaskQueue 用数组 + 循环索引,缓存更友好,但需要处理扩容时的复杂状态。
我写的简化版在测试时遇到过一个死循环:消费者读到一个 null,但 tail 标记显示队列非空。原因是生产者的 CAS 成功写入元素,但 tail 更新被延迟,消费者看到不一致的快照。修复是引入一个 "中间状态":元素写入后先标记为 READY,再移动 tail。消费者只读 READY 的元素。
这个教训让我理解为什么 AtomicFU 的文档强调 loop 模式:无锁结构里,任何一次读到的状态都可能是过时的,必须重试直到 CAS 成功。
性能数据的再审视:什么时候不该用 AtomicFU
说了这么多优势,也要说限制。AtomicFU 不是 synchronized 的万能替代。
第一个限制:长时间持有。synchronized 块里可以执行任意长逻辑,因为持有锁期间其他线程阻塞。AtomicFU 的 CAS 失败只能重试,如果保护的操作本身很重,自旋开销会爆炸。比如一个需要 10ms 的数据库查询,用 synchronized 一个线程执行、其他等待是合理的;用 CAS 自旋就是灾难。
第二个限制:条件等待。Object.wait / notify 和 synchronized 配合是 Java 并发的基础模式。AtomicFU 没有等价物,需要额外用 LockSupport.park / unpark 或者 Semaphore,复杂度陡增。kotlinx.coroutines 的 Mutex 实现就是用 AtomicFU + CancellableContinuation 模拟,代码量比 ReentrantLock 复杂一个数量级。
第三个限制:调试难度。synchronized 的锁竞争可以用 JFR、JMC 直接看到,线程 dump 里锁持有关系清晰。AtomicFU 的 CAS 失败是隐式的,JVM 工具看不到自旋次数,只能自己埋计数器。我曾经在一个生产环境 CPU 飙高的 case 里,花了半天才定位到某个 AtomicRef.loop 的无限重试,因为条件判断写反了。
我的判断标准是:保护的操作是否纯内存、是否短于一次线程切换(约 1-2μs)、是否需要条件变量。三个都满足,AtomicFU 合适;任一不满足,考虑 synchronized 或 java.util.concurrent 的高级工具。
Kotlin 1.9 的变动:AtomicFU 进入标准库?
Kotlin 1.9.0 有个值得注意的变动:kotlin.concurrent 包下新增了 AtomicInt、AtomicLong、AtomicReference 等类型,底层就是 AtomicFU 的 JVM 实现直接暴露。这意味着未来可能不需要单独依赖 org.jetbrains.kotlinx:atomicfu 了。
但当前(1.9.22)这些标准库 API 还是 ExperimentalAtomicApi,且 Native 支持不完整。我试过把项目里的 AtomicFU 依赖换成标准库,JVM 一切正常,Native 编译报错 AtomicReference 未实现。看 Kotlin 的 YouTrack,KT-60495 跟踪这个问题,预计 2.0 解决。
另一个变动是 K2 编译器。AtomicFU 的 Gradle 插件依赖编译器的 IR(Intermediate Representation)后处理,K2 的 IR 格式有变化。AtomicFU 0.22.0 开始支持 K2,但早期版本(0.20.x 及以下)和 K2 一起用会直接报错 IrSimpleFunctionSymbolImpl 找不到。迁移 K2 时要先升级 AtomicFU。
一个具体的迁移案例:从 ReentrantLock 到 AtomicFU
最后说一个我实际做的迁移。项目里有个连接池,每个连接有状态:空闲、使用中、关闭。原来用 ReentrantLock 保护状态转换:
private val lock = ReentrantLock()
private var state: State = IDLE
fun acquire(): Connection? {
lock.withLock {
if (state == IDLE) {
state = IN_USE
return this
}
return null
}
}连接池的 benchmark 显示,高并发下锁竞争是瓶颈。但状态转换确实简单:IDLE -> IN_USE,或者 IN_USE -> IDLE,或者任意 -> CLOSED。
迁移后:
private val state = atomic(IDLE)
fun acquire(): Connection? {
state.loop { s ->
if (s != IDLE) return null
if (state.compareAndSet(s, IN_USE)) return this
}
}release 和 close 类似。测试环境 16 线程竞争 8 个连接,原来的 ReentrantLock 版本 TPS 约 12 万,迁移后 34 万。但 CPU 利用率从 400% 涨到 1100%,说明自旋多了,不过吞吐量确实提升。
代价是代码复杂度。ReentrantLock 版本可以随便加 try-finally 保证状态恢复,AtomicFU 版本要在每个分支里保证 CAS 成功才执行后续,失败时重试的逻辑要仔细处理。我花了半天写单元测试覆盖所有竞争场景,而 ReentrantLock 版本基本不需要这种测试。
还有一个隐藏问题:原来的 lock.withLock 是可中断的,Thread.interrupt() 能打破等待。AtomicFU 版本没有中断点,如果 loop 里的条件永远不满足(比如 bug 导致状态无法转换),线程会无限自旋吃 CPU。我加了 Thread.interrupted() 检查,但这不是标准做法,AtomicFU 本身没提供中断支持。
最后的建议:看 kotlinx.coroutines 的源码
AtomicFU 的文档很薄,真正复杂的用法都在 kotlinx.coroutines 的源码里。比如 JobSupport 的状态机、Select 的实现、Channel 的锁自由队列,都是生产级的 AtomicFU 实践。
读这些代码要注意一个模式:它们很少直接用 compareAndSet,而是包装成 loop + 局部状态对象。JobSupport 的 state 字段类型是 Any,实际存的是 Empty / JobNode / Finishing / Completed 等内部类,每个类封装了该状态下的完整逻辑。这种 "把状态和行为绑定" 的设计,比裸用 Int 或 Boolean 做状态机更容易维护。
另一个模式是 "辅助 CAS":主 CAS 成功后,可能还需要第二次 CAS 清理或通知。比如 CancellableContinuation 的 resume 成功,但要 detach 时还需要 CAS 一个 parentHandle。这些二次操作失败时的回退路径,是 bug 高发区。
AtomicFU 的价值不在于它提供了什么新 API——VarHandle 和 Unsafe 的能力 Java 都有——而在于它把这些 API 包装成了 Kotlin 的惯用写法,零开销,且能在 Native 上(有限地)复用。对于已经用 Kotlin 协程的项目,理解 AtomicFU 是理解协程性能特征的必要一步。对于没上协程的纯 JVM 项目,如果瓶颈确实在 synchronized 且场景匹配,引入 AtomicFU 的权衡是:省掉锁开销,换来代码复杂度和调试难度。这个账要自己算。