这个问题其实我们之前 《AGP 9.2 开始,Android 上协程启动和取消速度提升两倍》就聊过了,因为 PR 终于被合并,所以现在从 Android Gradle Plugin 9.2.0 开始,R8 会将大部分 Atomic*FieldUpdater 调用会被优化为更直接的 Unsafe 操作。
这个变化让
kotlinx.atomicfu中常见的原子写操作提升大概 2~4 倍,最终让 Jetpack Compose 中LaunchedEffect的协程启动和取消性能最高提升大概 2 倍。
主要实现就是通过 R8 在应用构建阶段,在构建期提前验证调用对象和写入值的类型,绕过 Atomic*FieldUpdater 每次原子写操作前重复执行的动态类型检查。

实际上在之前,大家一直认为协程的主要成本主要来自线程切换、任务调度或者协程体本身的计算,但是在 Compose 的性能分析里,官方发现,在一些高频 UI 操作中,协程的创建、启动、取消和完成本身这个行为,就可能会占据性能消耗的相当大的比例。
在 Compose 里大量并发 API 底层都会调用挂起函数,然后通过协程处理指针事件、动画和交互状态,比如 Modifier.clickable:
在之前的实现里,创建和更新
Modifier.clickable所消耗的时间里,大约 80% 都花在了启动和取消用于处理InteractionSource的内部协程上。
从这个角度看,大概就可以理解为什么 Compose 团队早期的一些性能优化会尝试:
- 将协程移出默认执行路径
- 延迟初始化交互相关对象
- 只有真正需要时才创建内部协程
但这种做法本质上其实是在减少协程的使用次数,但是没有解决协程启动和取消本身为什么这么贵的问题。
为了定位问题,Google 使用 ART Method Trace 记录了一次空 LaunchedEffect 的完整方法调用:
scss
LaunchedEffect(Unit) {
// 什么也不做
}

就算协程实现为空,但是这次调用至少经历三个阶段:
- 初始化协程
- 启动协程
- 结束并完成协程
而如果是取消协程,流程和正常完成流程接近相似,但会需要创建一个 CancellationException,所以原文的 Perfetto 图中可以看到,大量细碎调用落在了 java.util.concurrent.AtomicReferenceFieldUpdater 上,虽然单独一次调用看起来并不算慢,但它会在协程初始化、状态切换、父子任务连接、取消和完成过程中反复出现。

这里的原因主要在于,kotlinx.coroutines 为了实现结构化并发,需要维护一套无锁的父子任务关系。
比如一个 Job 可以拥有多个子 Job,取消父任务需要向下传播,子任务完成后还要更新父节点状态,这类并发数据结构不能简单地用普通字段读写,而需要通过 CAS 等原子操作保证线程安全。
而 kotlinx.atomicfu 在 JVM 和 Android 上实现这些原子操作时,会使用类似下面的机制:
arduino
class Example {
volatile String data = "";
static final AtomicReferenceFieldUpdater updater =
AtomicReferenceFieldUpdater.newUpdater(
Example.class,
String.class,
"data"
);
void update() {
updater.compareAndSet(this, "", "new");
}
}
这里的 AtomicReferenceFieldUpdater 不是一个普通的 AtomicReference 对象,它通过"持有对象的类型、字段类型和字段名称",可以在运行时定位并更新目标对象中的某个 volatile 字段。
这种设计能够减少额外的原子对象包装,但也带来了一个问题:
AtomicReferenceFieldUpdater在创建时会通过反射查找字段、验证访问权限、字段类型和volatile属性,并计算字段偏移量;而在每次原子写操作时,仍然需要检查目标对象和新写入值的运行时类型。
而实际上真正执行 CAS 的操作可能只需要很短的时间,但是外围的反射安全检查也不能忽略,所以这就变成了运行时的性能和安全之间的博弈。
Google 用 AndroidX Benchmark 写了一个简单的测试,对比普通 AtomicReference 和 kotlinx.atomicfu.atomic:
kotlin
@RunWith(AndroidJUnit4::class)
class AtomicReferenceBenchmark {
@get:Rule
val benchmarkRule = BenchmarkRule()
private val atomicReference =
java.util.concurrent.atomic.AtomicReference(false)
private val atomicRef =
kotlinx.atomicfu.atomic(false)
@Test
fun atomicReferenceCompareAndSet() {
benchmarkRule.measureRepeated {
atomicReference.compareAndSet(true, false)
atomicReference.compareAndSet(false, true)
}
}
@Test
fun atomicfuCompareAndSet() {
benchmarkRule.measureRepeated {
atomicRef.compareAndSet(true, false)
atomicRef.compareAndSet(false, true)
}
}
}
在 Pixel 5、Android API 33 上,就算已经确保 AtomicReferenceFieldUpdater.compareAndSet 在预热阶段完成 JIT 编译,结果还是存在明显差距:

可以看到,问题主要集中在写入类操作上,简单读取几乎没有差异,但 CAS、交换和延迟写入通常会慢 2~4 倍,而且这组测试也排除了一个可能性:
ART 并没有在 JIT 或 AOT 阶段自动把这些反射检查完全消除,因为即使方法已经完成 JIT 编译,
atomicfu的 CAS 仍然慢 2.7 倍,说明这些检查确实构成了真实的运行时成本。
而这时候 R8 就起到作用了,R8 是一个全程序优化编译器,可以接收 Java 或 Kotlin 编译器产生的 JVM 字节码,通过分析整个应用及其依赖关系,最终生成优化后的 DEX。
也就是 R8 可以看到一些运行时看不到的静态信息,比如
arduino
static final AtomicReferenceFieldUpdater updater =
AtomicReferenceFieldUpdater.newUpdater(
Example.class,
String.class,
"data"
);
对于 R8 来说,这里的信息几乎都是常量:
- updater 属于
Example - 目标字段名称固定为
data - 字段类型固定为
String - updater 被保存在
static final字段中 - 调用位置访问的是确定的对象类型
所以既然这些条件在编译期已经能够证明,运行时就不需要在每次 compareAndSet 时重新检查,所以在这里 R8 所做的,本质上是将:
ini
updater.compareAndSet(
holder,
expectedValue,
newValue
);
改写为类似:
bash
SyntheticUnsafe.UNSAFE.compareAndSwapObject(
holder,
Example.updater$offset,
expectedValue,
newValue
);
也就是原本的调用路径大概是:
sql
AtomicReferenceFieldUpdater
↓
检查目标对象类型
↓
检查新写入值类型
↓
读取已经保存的字段 offset
↓
Unsafe 原子操作
然后现在优化后,路径变成:
sql
编译期完成类型和字段验证
↓
直接读取字段 offset
↓
Unsafe 原子操作
也就是原子操作本身没有改变,减少的是每次调用之前重复执行的安全检查。
而实际上R8 的优化分为三个阶段,第一阶段是 Instrumentation,R8 首先在原有 updater 字段旁边增加一个字段偏移量:
arduino
static final long updater$offset =
SyntheticUnsafe.UNSAFE.objectFieldOffset(
Example.class.getDeclaredField("data")
);
这个时候原来的 updater 不会立即删除,这样做的目的是为了让 R8 可以逐个分析调用点,能安全优化的调用使用 offset,对于无法证明安全的调用继续保留原始实现。
然后第二阶段 Replacement,对于每一个 updater 调用,R8 会检查几个条件:
- updater 是否能够追踪到已经完成插桩的静态字段
- holder 是否属于原来声明的持有类型或其子类
- 新写入的值是否符合字段类型
只有这些条件全部成立,R8 才会把调用替换成 Unsafe ,而如果不能静态排除 updater 或 holder 为 null`,R8 还会插入对应的空值检查,从而保持原始 Java API 的行为。
然后第三阶段是 Clean-up,完成替换后,类中可能同时存在:
- 原来的 updater 字段
- 新生成的 offset 字段
- 已优化和未优化的调用点
最后如果所有调用点都完成了优化,R8 就可以删除原始 updater;如果一个调用点都没有优化,那就删除新增的 offset;如果只有部分调用点可以优化,那两者都会被保留。
这里还有一个编译器层面的细节:普通的死代码消除不能随意删除
newUpdater和getDeclaredField,因为理论上它们可能抛出异常,所以 R8 必须利用前面静态分析得到的结论,明确证明这些初始化不会失败,才能安全地将其移除。
然后最终生成的代码大致会变成:
arduino
class Example {
volatile String data = "";
static final long updater$offset =
SyntheticUnsafe.UNSAFE.objectFieldOffset(
Example.class.getDeclaredField("data")
);
void update() {
SyntheticUnsafe.UNSAFE.compareAndSwapObject(
this,
Example.updater$offset,
"",
"new"
);
}
}
完成优化以后,kotlinx.atomicfu 和显式使用的 AtomicIntegerFieldUpdater、AtomicLongFieldUpdater、AtomicReferenceFieldUpdater,在大部分基准测试中已经能够达到普通 AtomicReference 的性能。
甚至哦在部分测试中 atomicfu 还会更快一些,因为 atomicfu 还有自己的编译器插件,可以把一个原子属性直接内联为宿主对象中的字段,所以不需要额外创建一个独立的 AtomicReference 包装对象,这样既绕过了 FieldUpdater 的反射检查,也避免了额外对象分配。
例如普通写法可能需要两个对象:
kotlin
class State {
val value = AtomicReference<String>("")
}
而 atomicfu 可以在转换后保留为类似:
kotlin
class State {
@Volatile
private var value: String = ""
}
随后再由 R8 把针对这个字段的 updater 调用转换成直接的 Unsafe 操作,所以最终结果不是说就简单地把 atomicfu 换回 AtomicReference,在这个基础上的收益还有:
- 字段级原子更新
- 更少的包装对象
- 没有重复的反射安全检查
- 直接的底层原子指令
当然,这里的两倍不是说所有 Kotlin 协程代码都会整体快 2 倍,这个 2 被主要是对应 Compose Runtime 的微基准:
升级到包含这项优化的新版 R8 后,
LaunchedEffect中启动和取消协程的耗时降低了一半。

所以如果一段协程的主要时间花在网络请求、数据库访问、图片解码或者复杂计算上,那么原子状态操作只占很小比例,整体任务不可能因此直接快 2 倍的,所以这个虽然说 2 倍,但是不是说一个可以很直观体验到的提升。
最后,R8 的方案目前属于构建期优化,而 ART 团队也在尝试从虚拟机层面识别和优化类似的 Atomic*FieldUpdater 模式。
Google 就提到了,当应用面向 API 37 ,同时运行在支持新版 ART 的 Android 设备上时,运行时可能已经能够完成类似优化,相关协程基准在更新后的 JIT 中还观察到了约 15% 的性能改善。