跪拜 Guibai
← Back to the summary

R8 Eliminates Reflection Checks to Double Coroutine Launch Speed in Compose

This question was actually discussed earlier in our article "Starting from AGP 9.2, Coroutine Launch and Cancellation Speed Doubles on Android". Since the PR has finally been merged, starting from Android Gradle Plugin 9.2.0, R8 will optimize most Atomic*FieldUpdater calls into more direct Unsafe operations.

This change improves common atomic write operations in kotlinx.atomicfu by roughly 2–4 times, ultimately boosting the launch and cancellation performance of coroutines in Jetpack Compose's LaunchedEffect by up to about 2 times.

The main implementation involves R8 verifying the types of the calling object and the written value ahead of time during the application build phase, bypassing the dynamic type checks that Atomic*FieldUpdater repeatedly performs before each atomic write operation.

Previously, the common assumption was that the main cost of coroutines came from thread switching, task scheduling, or the computation within the coroutine body itself. However, during Compose performance analysis, the official team discovered that in some high-frequency UI operations, the very act of creating, launching, cancelling, and completing a coroutine could account for a significant proportion of the performance overhead.

In Compose, many concurrent APIs call suspend functions under the hood and then use coroutines to handle pointer events, animations, and interaction states, such as Modifier.clickable:

In the previous implementation, about 80% of the time spent creating and updating Modifier.clickable was consumed by launching and cancelling the internal coroutine used to handle InteractionSource.

From this perspective, it becomes understandable why the Compose team's early performance optimizations attempted to:

But this approach essentially reduces the number of times coroutines are used, without solving the fundamental problem of why launching and cancelling coroutines is so expensive in the first place.

To pinpoint the issue, Google used ART Method Trace to record the complete method call of an empty LaunchedEffect:

LaunchedEffect(Unit) {
    // Does nothing
}

Even with an empty coroutine body, this call goes through at least three phases:

If the coroutine is cancelled, the flow is similar to a normal completion but requires creating a CancellationException. The original Perfetto diagram shows a large number of fine-grained calls landing on java.util.concurrent.AtomicReferenceFieldUpdater. While a single call might not seem slow, it appears repeatedly during coroutine initialization, state transitions, parent-child task linking, cancellation, and completion.

The main reason here is that kotlinx.coroutines needs to maintain a lock-free parent-child task relationship to implement structured concurrency.

For example, a Job can have multiple child Jobs. Cancelling a parent task needs to propagate downwards, and after a child task completes, it needs to update the parent node's state. This type of concurrent data structure cannot simply use ordinary field reads and writes; it requires atomic operations like CAS to ensure thread safety.

When kotlinx.atomicfu implements these atomic operations on the JVM and Android, it uses mechanisms similar to the following:

class Example {
    volatile String data = "";

    static final AtomicReferenceFieldUpdater updater =
        AtomicReferenceFieldUpdater.newUpdater(
            Example.class,
            String.class,
            "data"
        );

    void update() {
        updater.compareAndSet(this, "", "new");
    }
}

Here, AtomicReferenceFieldUpdater is not an ordinary AtomicReference object. By "holding the object's type, field type, and field name," it can locate and update a specific volatile field in the target object at runtime.

This design reduces the need for extra atomic object wrappers, but it also introduces a problem:

When created, AtomicReferenceFieldUpdater uses reflection to find the field, verify access permissions, field type, and volatile attribute, and calculate the field offset. Then, during each atomic write operation, it still needs to check the runtime types of the target object and the new value being written.

While the actual CAS operation might take very little time, the surrounding reflection safety checks cannot be ignored. This becomes a trade-off between runtime performance and safety.

Google wrote a simple test using AndroidX Benchmark to compare a plain AtomicReference with kotlinx.atomicfu.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(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)
        }
    }
}

On a Pixel 5 running Android API 33, even after ensuring that AtomicReferenceFieldUpdater.compareAndSet had been JIT-compiled during the warm-up phase, the results still showed a clear gap:

It can be seen that the problem is mainly concentrated on write operations. Simple reads show almost no difference, but CAS, swap, and lazy writes are typically 2–4 times slower. This set of tests also ruled out one possibility:

ART does not automatically eliminate these reflection checks entirely during the JIT or AOT phases. Even after the method has been JIT-compiled, atomicfu's CAS is still 2.7 times slower, indicating that these checks indeed constitute a real runtime cost.

This is where R8 comes into play. R8 is a whole-program optimizing compiler that takes JVM bytecode produced by the Java or Kotlin compiler, analyzes the entire application and its dependencies, and ultimately generates optimized DEX.

In other words, R8 can see static information that is invisible at runtime, for example:

static final AtomicReferenceFieldUpdater updater =
    AtomicReferenceFieldUpdater.newUpdater(
        Example.class,
        String.class,
        "data"
    );

For R8, the information here is almost entirely constant:

Since these conditions can be proven at compile time, there is no need to re-check them during every compareAndSet at runtime. So, what R8 does here is essentially transform:

updater.compareAndSet(
    holder,
    expectedValue,
    newValue
);

into something like:

SyntheticUnsafe.UNSAFE.compareAndSwapObject(
    holder,
    Example.updater$offset,
    expectedValue,
    newValue
);

The original call path was roughly:

AtomicReferenceFieldUpdater
        ↓
Check target object type
        ↓
Check new value type
        ↓
Read saved field offset
        ↓
Unsafe atomic operation

After optimization, the path becomes:

Complete type and field verification at compile time
        ↓
Directly read field offset
        ↓
Unsafe atomic operation

The atomic operation itself hasn't changed; what's reduced are the repeated safety checks performed before each call.

In fact, R8's optimization is divided into three phases. The first phase is Instrumentation, where R8 first adds a field offset next to the original updater field:

static final long updater$offset =
    SyntheticUnsafe.UNSAFE.objectFieldOffset(
        Example.class.getDeclaredField("data")
    );

At this point, the original updater is not immediately deleted. The purpose is to allow R8 to analyze call sites one by one. Calls that can be safely optimized use the offset, while calls that cannot be proven safe continue to retain the original implementation.

The second phase is Replacement. For each updater call, R8 checks several conditions:

Only when all these conditions are met will R8 replace the call with Unsafe. If it cannot statically rule out the updater or holder being null, R8 will also insert corresponding null checks to maintain the behavior of the original Java API.

The third phase is Clean-up. After completing the replacement, the class might simultaneously contain:

Finally, if all call sites have been optimized, R8 can delete the original updater. If no call site has been optimized, the newly added offset is deleted. If only some call sites can be optimized, both are retained.

There is also a compiler-level detail here: ordinary dead code elimination cannot arbitrarily delete newUpdater and getDeclaredField because they could theoretically throw exceptions. Therefore, R8 must use the conclusions from the previous static analysis to explicitly prove that these initializations will not fail before it can safely remove them.

The final generated code will roughly look like:

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"
        );
    }
}

After optimization, kotlinx.atomicfu and explicitly used AtomicIntegerFieldUpdater, AtomicLongFieldUpdater, and AtomicReferenceFieldUpdater can achieve performance comparable to a plain AtomicReference in most benchmarks.

In some tests, atomicfu is even slightly faster because atomicfu also has its own compiler plugin that can inline an atomic property directly as a field within the host object. This avoids the need to create an extra, independent AtomicReference wrapper object, thus bypassing both the FieldUpdater's reflection checks and the extra object allocation.

For example, a normal approach might require two objects:

class State {
    val value = AtomicReference<String>("")
}

While atomicfu can be transformed and retained as something like:

class State {
    @Volatile
    private var value: String = ""
}

Subsequently, R8 converts the updater calls targeting this field into direct Unsafe operations. So the final result isn't simply about swapping atomicfu back to AtomicReference. The benefits on top of this include:

Of course, the "2x" here doesn't mean all Kotlin coroutine code will become twice as fast overall. This "2x" mainly corresponds to the Compose Runtime micro-benchmark:

After upgrading to the new version of R8 containing this optimization, the time consumed for launching and cancelling coroutines in LaunchedEffect was halved.

Therefore, if a coroutine's main time is spent on network requests, database access, image decoding, or complex computation, atomic state operations only account for a very small proportion. The overall task cannot become twice as fast because of this. So, while it's said to be "2x faster," it's not a very intuitively perceptible improvement.

Finally, R8's solution is currently a build-time optimization, but the ART team is also trying to identify and optimize similar Atomic*FieldUpdater patterns at the virtual machine level.

Google mentioned that when an application targets API 37 and runs on an Android device supporting the new version of ART, the runtime might already be able to complete similar optimizations. Related coroutine benchmarks have also observed about a 15% performance improvement in the updated JIT.

Links

https://android-developers.googleblog.com/2026/07/how-r8-made-kotlin-coroutines-2x-faster.html