Kotlin 与原子操作
从竞态条件出发掌握原子变量:kotlinx.atomicfu 与标准库 kotlin.concurrent.atomics 两套 API、CAS 原理、无锁结构与常见陷阱。
概述
原子操作(Atomic Operation)是不可被中断的操作。在多线程环境中,多个线程可能同时读写同一个变量,导致数据竞争和不确定的结果。原子操作通过硬件级别的指令(如 CAS,Compare-And-Swap)保证操作的原子性,不需要加锁就能实现线程安全。
Kotlin/JVM 生态有两条主流路径:
kotlinx.atomicfu:多平台原子库,通过编译器插件在编译期把原子变量转换为 JVM 的AtomicInteger、AtomicReference等(或 Native/JS 的对应实现),运行时零额外包装开销,也是协程库内部的基础设施。kotlin.concurrent.atomics(标准库):Kotlin 2.1.20 起引入的跨平台原子类型(AtomicInt、AtomicLong、AtomicBoolean、AtomicReference、AtomicArray等),JVM 上直接委托java.util.concurrent.atomic对应类;发布初期为实验性 API(需@OptIn(ExperimentalAtomicApi::class)),后续版本逐步稳定,定位是长期取代 atomicfu 的多平台原子类型。
基础概念
- 原子变量:所有读写与”读-改-写”操作都不可分割的变量
- CAS(Compare-And-Swap):先比较当前值是否等于预期值,相等则更新为新值,整个过程不可中断;失败则返回 false,通常配合自旋重试
- lock-free:无锁编程,通过原子操作而非锁来实现并发安全,线程不会被阻塞
- 竞态条件(Race Condition):多个线程交错执行”读-改-写”导致的更新丢失——原子变量解决的核心问题
快速上手:先看清问题
不用原子变量的经典错误——复合操作不是原子的:
class BrokenCounter {
var count = 0 // 普通变量,非线程安全
fun increment() {
count = count + 1 // 读 -> 加 1 -> 写回,三步可能被其他线程插入
}
}
fun main() {
val counter = BrokenCounter()
val threads = (1..10).map {
Thread { repeat(1000) { counter.increment() } }
}
threads.forEach { it.start() }
threads.forEach { it.join() }
println("期望 10000,实际: ${counter.count}") // 输出不稳定,常见 6000~9999
}count = count + 1 是”读-改-写”三步复合操作,两个线程同时读到同一个旧值就会丢失一次更新。换成原子变量即可修复:
import java.util.concurrent.atomic.AtomicInteger
class Counter {
private val count = AtomicInteger(0)
fun increment(): Int = count.incrementAndGet()
fun get(): Int = count.get()
}
fun main() {
val counter = Counter()
val threads = (1..10).map {
Thread { repeat(1000) { counter.increment() } }
}
threads.forEach { it.start() }
threads.forEach { it.join() }
println("最终计数: ${counter.get()}") // 一定是 10000
}
// 预期输出:最终计数: 10000详细用法
kotlinx.atomicfu:多平台首选
// build.gradle.kts
plugins {
kotlin("jvm") version "2.2.0"
id("org.jetbrains.kotlinx.atomicfu") version "0.27.0" // 版本随时间更新,查官方仓库
}
dependencies {
implementation("org.jetbrains.kotlinx:atomicfu:0.27.0")
}import kotlinx.atomicfu.atomic
class AtomicIntDemo {
private val value = atomic(0)
fun demo() {
val current = value.value // 读取当前值(volatile 语义)
value.value = 10 // 设置新值
value.incrementAndGet() // 先加 1,再返回新值
value.getAndIncrement() // 先返回当前值,再加 1
value.addAndGet(5) // 加 5,返回新值
// CAS 操作:如果当前值等于预期值,则更新,返回是否成功
value.compareAndSet(10, 20)
}
}原子引用与”读-改-写”循环的封装:
import kotlinx.atomicfu.atomic
import kotlinx.atomicfu.update
import kotlinx.atomicfu.getAndUpdate
class AtomicRefDemo {
private val state = atomic("INITIAL")
fun demo() {
state.compareAndSet("INITIAL", "RUNNING")
state.update { it.lowercase() } // 内部就是 CAS 自旋循环
val oldValue = state.getAndUpdate { it.uppercase() }
println("旧值: $oldValue, 新值: ${state.value}")
}
}标准库 kotlin.concurrent.atomics(Kotlin 2.1.20+)
// Kotlin 2.1.20 起提供,实验性 API 需要 opt-in
import kotlin.concurrent.atomics.*
@OptIn(ExperimentalAtomicApi::class)
class StdlibCounter {
private val count = AtomicInt(0)
fun increment() {
count.incrementAndFetch() // 对应 incrementAndGet
// 其他成员:load() / store() / fetchAndAdd() / compareAndSet() / compareAndExchange()
}
fun get(): Int = count.load()
}
@OptIn(ExperimentalAtomicApi::class)
fun main() {
val c = StdlibCounter()
repeat(100) { c.increment() }
println(c.get()) // 100
}命名差异速记:atomicfu 的 value 属性对应标准库的 load()/store(),incrementAndGet() 对应 incrementAndFetch()。标准库方案的编译期开销为零(无插件),JVM 实现即 java.util.concurrent.atomic 的薄封装。
原子布尔:标志位与”只执行一次”
import kotlinx.atomicfu.atomic
class Service {
private val running = atomic(false)
fun start() {
// 如果当前是 false,则设为 true(防止重复启动)
if (running.compareAndSet(false, true)) {
println("启动成功")
} else {
println("已经在运行中")
}
}
fun stop() {
running.value = false
}
fun isRunning(): Boolean = running.value
}
fun main() {
val service = Service()
service.start() // 启动成功
service.start() // 已经在运行中
service.stop()
}原子引用更新不可变对象
import kotlinx.atomicfu.atomic
import kotlinx.atomicfu.update
data class Config(val host: String, val port: Int, val timeout: Int)
class ConfigManager {
// 原子引用持有不可变配置对象
private val config = atomic(Config("localhost", 8080, 30000))
fun getConfig(): Config = config.value
// 原子更新配置:创建新对象替换旧对象,而不是修改旧对象内部状态
fun updateHost(newHost: String) {
config.update { it.copy(host = newHost) }
}
}
fun main() {
val manager = ConfigManager()
manager.updateHost("example.com")
println(manager.getConfig())
// 预期输出: Config(host=example.com, port=9090, timeout=30000)
}关键纪律:原子引用里放不可变对象(data class + copy)。如果直接修改对象内部字段,其他线程可能读到”改了一半”的对象,原子性就失效了。
常见场景
CAS 自旋:限流器
import kotlinx.atomicfu.atomic
class RateLimiter(private val maxRequests: Int, private val windowMs: Long) {
private val count = atomic(0)
private val windowStart = atomic(System.currentTimeMillis())
fun tryAcquire(): Boolean {
while (true) {
val now = System.currentTimeMillis()
val start = windowStart.value
// 时间窗口过期:CAS 竞争重置窗口(只有一个线程能成功)
if (now - start >= windowMs) {
if (!windowStart.compareAndSet(start, now)) continue // 别人先重置了,重读
count.value = 0
}
// CAS 自旋扣减配额
while (true) {
val current = count.value
if (current >= maxRequests) return false
if (count.compareAndSet(current, current + 1)) return true
}
}
}
}
fun main() {
val limiter = RateLimiter(maxRequests = 5, windowMs = 1000)
repeat(8) {
println("请求 $it: ${if (limiter.tryAcquire()) "通过" else "拒绝"}")
}
// 预期输出:请求 0-4 通过,请求 5-7 拒绝(同一时间窗口内)
}注意 compareAndSet(start, now) 失败后 continue 重读的写法——CAS 的”预期值”必须来自本轮循环内最新读到的值,否则会引入竞态。
无锁栈(Treiber Stack)
import kotlinx.atomicfu.atomic
class LockFreeStack<T> {
private val top = atomic<Node<T>?>(null)
private class Node<T>(val value: T, val next: Node<T>?)
fun push(value: T) {
while (true) {
val currentTop = top.value
val newNode = Node(value, currentTop)
if (top.compareAndSet(currentTop, newNode)) return
}
}
fun pop(): T? {
while (true) {
val currentTop = top.value ?: return null
if (top.compareAndSet(currentTop, currentTop.next)) {
return currentTop.value
}
}
}
fun isEmpty(): Boolean = top.value == null
}
fun main() {
val stack = LockFreeStack<Int>()
stack.push(1); stack.push(2); stack.push(3)
println(stack.pop()) // 3(后进先出)
println(stack.pop()) // 2
}与协程配合:并发计数
import kotlinx.atomicfu.atomic
import kotlinx.coroutines.*
class CoroutineCounter {
private val count = atomic(0)
suspend fun countConcurrently() = coroutineScope {
val jobs = List(100) {
launch(Dispatchers.Default) {
repeat(1000) { count.incrementAndGet() }
}
}
jobs.forEach { it.join() }
println("最终计数: ${count.value}") // 一定是 100000
}
}
fun main() = runBlocking {
CoroutineCounter().countConcurrently()
}提示:协程内部大量状态共享时,优先考虑不可变数据 + 通道/Actor 式消息传递,原子变量适合”计数器、标志位、配置引用”这类简单共享状态。
注意事项与常见陷阱
- 复合操作依然不是原子的:
atomicInt.value = atomicInt.value + 1的读与写是两次独立操作,一样会丢更新。所有”读-改-写”必须走incrementAndGet/update/显式 CAS 循环。 - CAS 循环不做耗时操作:竞争激烈时自旋会空转浪费 CPU,临界区内不要做 IO;冲突概率高的场景改用
Mutex或锁。 - atomicfu 的插件优化:不添加编译器插件时 atomicfu 退化为普通包装实现(有额外开销),生产工程应按官方文档配置插件;标准库
kotlin.concurrent.atomics则无此要求。 - ABA 问题:值从 A 变 B 又变回 A 时 CAS 无法察觉。多数业务场景(计数、状态标志)无影响;无锁数据结构中可用版本号(如
AtomicStampedReference思路)规避。 @Volatile只保证可见性:@Volatile var让读看到最新值,但不保证复合操作原子性——它不是原子变量的替代品。
底层原理:CAS 为什么「不阻塞」却安全
把 CAS 拆成一条 CPU 指令(x86 的 lock cmpxchg、ARM 的 ldxr/stxr 对)就懂了:
「比较 + 交换」在硬件层是一条不可分割的指令,中间不存在被其他线程插入的缝隙。
它的安全性完全建立在「先读、后试、失败重来」的自旋循环上:
读 current → 算 new → CAS(current, new)
失败?说明 current 之后被人改过 → 重读再试
成功?说明「读到的时刻」与「生效的时刻」之间值没变过 → 更新成立两个推论值得写进直觉:
- CAS 保证的是「从读到写没人动过」,不保证「值没被改过又改回来」——这就是 陷阱 4 的 ABA 问题:CAS 只比较值,不比较历史。计数器场景无感,栈/队列这类 「结构」场景就会出事(节点被弹出又被压回,top 指针看着没变,next 已经换了)。
- 「不阻塞」不等于「无代价」:高竞争下自旋线程反复失败重试,消耗的是 CPU 周期与缓存一致性流量(CAS 失败会让缓存行在核间来回失效);锁的等待线程则让出 CPU。所以临界区小、冲突少选 CAS,临界区大或竞争激烈选锁——两条路没有绝对 优劣,只有场景适配。
与协程的关系一句话:协程的挂起发生在「可挂起的等待」上(Mutex、Channel),而 CAS 自旋是不可挂起的忙等——在 Dispatchers.Default 上长时间自旋会占住共享 线程池的线程,这正是陷阱 2 在协程语境下的具体形态。
面试题思路:三个高频考法
- 「@Volatile 和 AtomicInteger 的区别?」——答两层:
@Volatile只保证可见性 与禁止指令重排(读一定看到最新写入),不保证复合操作原子性;AtomicInteger在可见性之上用 CAS 保证「读-改-写」不可分割。追问「什么场景 @Volatile 就够」:一个线程写、其他线程读的单次写入标志位(如 shutdown 开关)。 - 「CAS 有什么问题?」——标准三连:ABA、自旋开销、只能保证单个变量的原子性
(多个变量联动需要 AtomicReference 包一个不可变对象,或上锁)。能顺着 ABA
说出「版本号思路」(atomicfu 里可以用
AtomicLong序号 + 引用一起放进不可变 数据类)是加分线。 - 「无锁一定比锁快吗?」——不一定:无竞争时 CAS 快(没有加锁开销),高竞争时 自旋空转可能比锁的线程挂起更浪费;且无锁代码难以保证公平性(可能有线程长期 抢不到)。结论是「按竞争度与临界区大小选型」,并强调基准测量(见 Kotlin 基准测试)。
动手实验
- 丢更新可视化:跑「快速上手」的
BrokenCounter,把线程数从 10 调到 2、 次数从 1000 调到 100000,记录丢失率变化;再用-XX:+PrintAssembly或 JITWatch 级别的工具太重,退而求其次:在increment()里加一行Thread.yield()放大交错概率,观察丢失率飙升——亲手验证「竞态是概率性事件」。 - CAS 循环显微镜:给限流器的内层 CAS 循环加失败计数器,多线程压测时打印 「CAS 尝试次数 / 成功次数」,直观看到竞争激烈时自旋的浪费。
- ABA 复现:用
AtomicReference<String>与两个线程编排:线程 1 读到 “A” 后暂停;线程 2 把值改成 “B” 再改回 “A”;线程 1 恢复执行compareAndSet("A", "C")——CAS 成功,但它「以为没变过」的假设已经破产。 思考:换成不可变版本号对象(data class Versioned(val v: String, val rev: Int)) 后如何让这个 CAS 失败。 - 两套 API 对照:把
CoroutineCounter分别用 atomicfu 与kotlin.concurrent.atomics各写一遍,跑同样的并发测试,对比 API 命名与 opt-in 要求,写三句选型备注。
小练习(先自己做,再展开参考实现)
练习 1:原子对象池。实现 class IdPool(max: Int):acquire(): Int? 分发
0 到 max-1 的未占用 id,release(id) 归还。要求:多线程并发 acquire 不重复、
release 后 id 可再被分发;内部只用一个 AtomicReference<Set<Int>> 或一个
AtomicLong 位图(二选一,说明理由)。
提示:位图版用 fetchOr/fetchAnd 风格的自旋;引用版用 update {}。
参考实现(引用版)。先说一个常见错误动机:想用 update {} 一次搞定,然后「从
update 的返回值反推自己分到了哪个 id」——这条路在并发下不可靠:update 返回的是
最终集合,可能已经包含其他线程后来的分配,反推结果可能不是你成功占住的那个。
正确姿势是显式 CAS 自旋,让「选中的 id」与「写成功的那个集合」原子地绑定:
import kotlinx.atomicfu.atomic
import kotlinx.atomicfu.update
class IdPool(private val max: Int) {
private val taken = atomic(emptySet<Int>()) // 原子引用持有不可变集合
fun acquire(): Int? {
while (true) { // CAS 自旋
val busy = taken.value
val free = (0 until max).firstOrNull { it !in busy } ?: return null
if (taken.compareAndSet(busy, busy + free)) return free
}
}
fun release(id: Int) {
taken.update { it - id }
}
}自检问题:为什么 update { it - id } 的 release 不需要自旋循环?
(答:update 内部就是 CAS 自旋——「从集合里减一个元素」不需要先决定写什么,
重试自然收敛;acquire 则必须「决定写什么」与「写成功」原子地绑在一起,所以
要显式循环。)
练习 2:竞态复现测试。写一个 JUnit 测试 test("concurrent increments are not lost"):100 线程 x 1000 次 incrementAndGet,断言结果恰好 100000;再写一个
「故意保留竞态」的对照测试(用普通 var),断言它大概率失败。思考:第二个
测试为什么不能作为回归测试稳定运行?
参考实现:
import java.util.concurrent.atomic.AtomicInteger
import kotlin.concurrent.thread
import kotlin.test.Test
import kotlin.test.assertEquals
class AtomicTest {
@Test
fun `concurrent increments are not lost`() {
val counter = AtomicInteger(0)
val threads = List(100) { thread { repeat(1000) { counter.incrementAndGet() } } }
threads.forEach { it.join() }
assertEquals(100_000, counter.get())
}
}对照测试的答案:竞态测试是概率性失败——CI 上可能偶发通过,不能作为「预期 失败」的回归用例;正确的用法是把它当作演示素材(本文快速上手一节的角色), 而不是断言工具。
挑战题(不给参考实现):把 Treiber 栈扩成「带大小上限的有界栈」:
push 在满时返回 false、pop 在空时返回 null,全部用 CAS 自旋实现。写完自查:
你的「满」判断读的是哪个值?它和 CAS 用的值是否是同一轮读到的?(如果不是,
就引入了新的竞态窗口。)
小结
- 竞态的根源是”读-改-写”复合操作被交错;修复手段是原子变量或锁,前者无阻塞、更轻。
- 两套 API:
kotlinx.atomicfu(多平台、配合编译器插件零开销)与标准库kotlin.concurrent.atomics(2.1.20 起实验性引入,长期方向)。 - 核心方法就三类:读/写(
value或load/store)、自增加减(incrementAndGet等)、CAS(compareAndSet+ 自旋循环)。 - 无锁结构(限流器、Treiber 栈)是 CAS 循环的典型应用;原子引用必须指向不可变对象。
- 并发安全全景(内存模型、锁、不可变设计)见 Kotlin 并发安全;性能验证用 Kotlin 基准测试。