Android R8 为什么可以让 Kotlin 协程提速 2 倍?

这个问题其实我们之前 《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% 的性能改善。

链接

android-developers.googleblog.com/2026/07/how...

相关推荐
剪刀石头布啊2 分钟前
泛洪DFS、BFS
前端
qetfw2 分钟前
Windows Server AD CS:根 CA、Web 证书模板与域内自动注册
前端·windows·windows-server
剪刀石头布啊2 分钟前
为什么React组件很少前缀,而vue的不少组件都有前缀
前端
蓝速科技6 分钟前
政务自助终端信创选型与无人值守落地方案
android·大数据·数据库·人工智能·科技·技术分享·政务
剪刀石头布啊8 分钟前
gird网格布局
前端
啃火龙果的兔子10 分钟前
Google Chrome(谷歌浏览器)常用快捷键
前端·chrome
剪刀石头布啊12 分钟前
js真的是单线程实现异步并发么?
前端
剪刀石头布啊17 分钟前
js中改变this指向的操作有哪些
前端
剪刀石头布啊17 分钟前
vue、react 列表中使用 index 作为 key 的话,修改某一个元素后会发生什么
前端
方方洛19 分钟前
glowglow:语言无关的语法高亮器是怎么实现的
前端·前端框架