从 AGP 9.2.0 开始,R8 会将大多数 Atomic*FieldUpdater 调用优化为 Unsafe 变体,从而使常见操作的性能提升 2 到 4 倍。这对为 kotlinx.coroutines 实现原子性的 kotlinx.atomicfu 库有特别大的影响,使启动和取消协程的速度最多可提高 1 倍。如需享受这些福利,请将 AGP 更新到 9.2.0 或更高版本。
随着大多数 Android 应用采用 Kotlin 作为其主要语言,kotlinx.coroutines 已成为异步编程的事实标准。该库提供了一种设计合理且结构化的方式来管理 Kotlin 原生的并发数据流。Jetpack Compose 也不例外,它采用协程来管理指针事件、动画和其他互动。在撰写本文时,Compose 中的大多数并发 API 在底层都会调用 suspend 函数,并启动和/或取消协程来处理更新。
随着 Compose 团队开始调查性能,他们发现对于许多在组合之外发生的操作,协程是瓶颈。举例来说,在创建和更新 Modifier.clickable 上花费的时间中有 80% 用于启动和取消处理 InteractionSource 更新的内部协程。根据这些观察结果,早期的大部分性能工作都侧重于从默认路径中移除协程,并延迟初始化直到必要时才进行。
协程的开销
在 Android 上分析函数的内部行为的最简单方法是捕获 Android 运行时 (ART) 方法轨迹。ART 方法轨迹是一种用于记录应用执行流程的工具,可准确显示所调用的方法、它们的顺序以及在每个方法中花费的时间,从而让开发者能够识别性能瓶颈。对于空的 LaunchedEffect { } 调用,它看起来会像这样:
上面的方法轨迹可以分为三部分:
- 正在初始化新的协程
- 启动协程
- 完成协程(因为协程会立即退出)
取消 LaunchedEffect 类似于正常完成,只不过它还会创建 CancellationException。
从上面的配置文件中,我们可以立即发现一个可疑之处,那就是频繁调用 java.util.concurrent.AtomicReferenceFieldUpdater(带有 j… 标签的紫色或绿色框)。虽然每次调用都相对较快,但调用频率令人担忧;任何分布在多次调用中的不可忽略的开销都可能会累积成明显的回归。放大某个调用后,发现大部分时间都花在了反射检查上?
协程实现了用于父子关系的无锁树结构,从而实现了结构化并发。事实证明,kotlinx.atomicfu 库使用广为人知的 JVM 基元 AtomicReferenceFieldUpdater 实现无锁原子操作。更新程序使用类引用和字段名称在运行时执行原子操作,并且必须运行多个反射安全检查,以确保字段存在且可访问。协程中的每项操作(启动、暂停、取消、完成)至少会调用一项原子操作,因此如果原子操作速度较慢,协程的性能就不会很好。
调查 AtomicReferenceFieldUpdater
不过,我们还是先从基础知识开始讲起。AtomicReferenceFieldUpdater 在 JVM 上已经过 10 多年的充分优化,方法轨迹可能会捕获被虚拟机级优化(即时 [JIT] 或预先 [AOT] 编译)完全消除的开销。为了验证性能,我们编写了一些基准来衡量来自 kotlinx.atomicfu 和 java.util.concurrent.atomic 的原子引用之间的差异。
@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<Boolean>(false) @Test fun atomicReference_compareAndSet() { benchmarkRule.measureRepeated { atomicReference.compareAndSet(true, false) atomicReference.compareAndSet(false, true) } } @Test fun atomicRef_compareAndSet() { benchmarkRule.measureRepeated { atomicRef.compareAndSet(true, false) atomicRef.compareAndSet(false, true) } } /* measuring other methods from the method traces above */ }
在 Pixel 5 上运行此基准测试(同时确保在预热期间 AtomicReferenceFieldUpdater#compareAndSet 经过 JIT 编译),会在 Pixel 5 (API 33) 上产生以下结果:
50.7 ns atomicReference_compareAndSet 135 ns atomicRef_compareAndSet
测量结果证实了这一差距,kotlinx.atomicfu 版本的速度明显慢了大约 2.7 倍。这证实了 ART 不会执行任何隐藏的优化,并且反射访问检查会在运行时增加实际开销。
回顾原始方法轨迹,AtomicReferenceFieldUpdater 执行的唯一有意义的工作是对 Unsafe.getObjectVolatile 的内部调用,该调用实际上会执行底层原子操作。在大多数情况下,更新程序初始化程序是静态的,并且可以根据周围类的结构证明其始终正确。因此,可以在编译期间静态分析大多数 AtomicReferenceFieldUpdater 用法,并将其替换为内部 Unsafe 变体。恰好 Android build 工具链有自己的优化编译器,可以做到这一点。
使用 R8 进行优化
Atomic*FieldUpdater 类支持细致、动态和基于反射的使用,但通常以静态显式模式使用。这既解释了基准性能为何较差,也说明了为何需要进行优化。R8 是一款全程序优化编译器,非常适合用于看穿简单的模式,从而减少反射安全检查的开销。R8 在 Java 或 Kotlin 编译器之后接收 JVM 字节码,但为了便于阅读,这些示例以 Java 语法呈现。这就是 AtomicReferenceFieldUpdater 没有类型实参的原因。
class Example { volatile String data = ""; static final AtomicReferenceFieldUpdater updater = AtomicReferenceFieldUpdater.newUpdater(Example.class, String.class, "data"); void example() { // ... updater.compareAndSet(this, "", "new"); // ... } }
基本示例会创建一个静态 final 更新程序,该更新程序使用简单的常量实参来访问易失性字段,这些实参用于表示持有者、类型和字段名称。所使用的反射完全透明。很明显,此更新程序引用了有效字段,并且更新程序的创建位置对该字段具有有效访问权限。
从本质上讲,Atomic*FieldUpdater 是对字段偏移量和对 Unsafe 的调用的封装。优化的最佳方案是将更新程序字段替换为偏移量字段,并将更新程序调用替换为对 Unsafe 的调用。
优化 Atomic*FieldUpdater
优化分为三个部分:插桩、替换和清理。
插桩
第一步是引入偏移量字段以及更新程序字段,以便通过 Unsafe 调用直接访问。
static final long updater$offset = SyntheticUnsafe.UNSAFE.objectFieldOffset(Example.class.getDeclaredField("data"))
该字段通过反射进行访问,并使用 Unsafe 提取类中的字段偏移量。如果您忽略反射验证,此代码表示 Atomic*FieldUpdater 的内部结构。相反,更新器的持有者类型和易变字段的字段类型会在编译器中静态跟踪。
请注意,原始字段及其初始化保持不变。优化过程会乐观地促进和优化使用,然后进行清理。这是一种简单的实现方法,但同时也允许对更新程序字段进行部分优化,即某些用途保持不变,而另一些用途则经过优化。
替换
在编译器的这一阶段,在合适的并发连接点之后,我们有一个已插桩的更新程序字段列表。这意味着,我们可以根据一些条件单独优化每个调用点。以一个示例调用为例:
updater.compareAndSet(holder, expectedValue, newValue);
Atomic*FieldUpdater 需要满足以下条件:
updater是否来自插桩字段?也就是说,静态分析能否将对象的值追溯到检测到的更新程序的字段读取?holder是否与最初定义的持有者类型是同一类或子类?newValue是否与最初定义的字段类型是同一类或子类?
如果满足所有条件,则该调用会被替换为对 Unsafe 的调用,而不会进行任何反射检查。
SyntheticUnsafe.UNSAFE.compareAndSwapObject(holder, Example.updater$offset, expectedValue, newValue)
这个新调用更快、更简单,但在处理 updater 和 holder 中的 null 值方面与原始调用不同。除非静态排除,否则会为两者插入 null 检查。
清理
此时,持有类具有原始更新器字段和新的偏移量字段,以及可能使用这两个字段中任一字段的调用点。如果没有优化任何调用点,则应移除偏移量字段;如果优化了所有调用点,则应移除更新程序字段。在这两种情况下,还应删除初始化调用。编译器中已完成对未使用的字段的删除和对无用代码的移除,但移除此处的初始化代码还需要一些技巧。
对 newUpdater 和 getDeclaredField 的调用都可能会产生副作用,因为它们可能会抛出异常(并且它们的实现也是未知的,因为它们取决于 API 版本)。这意味着,通过通用优化,无法安全地移除它们。因此,此清理需要明确考虑插桩字段,因为这些字段在静态情况下已知不会出现异常。
最后,经过优化,上面显示的简单更新程序示例如下所示:
结果
经过这些优化后,kotlinx.atomicfu 和大多数 AtomicInt/Long/ReferenceFieldUpdater 的显式使用现在与应用了 R8 的 AtomicReference 性能相匹配。事实上,在某些基准测试中,它的速度甚至更快;kotlinx.atomicfu 有一个编译器插件,可以将 atomic 实例内嵌到字段中,从而减少创建原子更新字段所需的分配。
Jetpack Compose 是这项工作的主要受益者。Compose 运行时有许多微基准,可密切跟踪协程性能,以便尽早发现性能回归。当基准更新到新版 R8 时,我们注意到在 LaunchedEffect 中启动和取消协程时,性能提升了 2 倍!
除此之外,ART 团队还在虚拟机级层以原生方式实现这些优化。如果您的应用以 API 36 为目标平台,并且在最新版 Android 上运行,则您的设备可能已经以类似的方式优化了协程。上述协程基准在最近版本的 ART 中更新 JIT 后,性能提升了约 15%。
升级到 AGP 9.2.0 或直接使用 R8 9.2.0 时,您的应用将默认获得此优化。如需了解详情,请参阅 D8 dexer 和 R8 shrinker。
-
案例研究众所周知,性能下降问题很难重现,这使得性能下降成为移动开发者的巨大瓶颈。
-
案例研究最近,在 Wear OS 上,FotMob 的安装受众群体在单日内增加了 5 年来最多的人数,达到日平均增幅的 2-3 倍。秘诀是什么?简单的跨设备安装流程,可帮助用户直接通过手机发现 Wear OS 应用。
Garan Jenkin • 阅读用时:3 分钟 -
案例研究正念应用《Gratitude》通过每日微型日记、自我肯定和愿景板来鼓励用户保持一致性。该应用的下载量已超过 600 万次,获得了 15 万次五星评级,并记录了 1 亿篇日记条目。
Amrit Sanjeev, Ash Nohe • 阅读用时:3 分钟
每周通过电子邮件接收最新的 Android 开发洞见。