Начиная с AGP 9.2.0, R8 преобразует большинство вызовов Atomic*FieldUpdater в варианты на основе Unsafe, которые выполняют распространённые операции в 2–4 раза быстрее. Особенно заметно это влияет на библиотеку kotlinx.atomicfu, которая реализует атомарные операции для kotlinx.coroutines: запуск и отмена корутин становятся до двух раз быстрее. Чтобы воспользоваться оптимизацией, обновите AGP до версии 9.2.0 или новее.
Поскольку большинство Android-приложений используют Kotlin в качестве основного языка, kotlinx.coroutines фактически стала стандартом асинхронного программирования. Библиотека предоставляет хорошо спроектированный и структурированный способ управления параллельными потоками выполнения, естественный для Kotlin. Jetpack Compose также использует корутины для обработки событий указателя, анимаций и других взаимодействий. На момент написания статьи большинство параллельных API в Compose внутри вызывают suspend функции, а для обработки обновлений запускают и отменяют корутины.
Когда команда Compose начала исследовать производительность, выяснилось, что корутины являются узким местом для многих операций, выполняемых вне композиции. Например, 80% времени, затрачиваемого на создание и обновление Modifier.clickable, уходило на запуск и отмену внутренних корутин, обрабатывающих обновления InteractionSource. На основании этих наблюдений значительная часть первых работ по оптимизации была направлена на исключение корутин из стандартного пути выполнения и откладывание инициализации до момента, когда она действительно понадобится.
Стоимость корутины
Самый простой способ проанализировать внутреннее поведение функции на Android — записать трассировку методов среды выполнения Android, или ART. Трассировка методов ART фиксирует поток выполнения приложения и показывает, какие методы вызываются, в каком порядке и сколько времени занимает каждый из них. Это позволяет разработчикам находить узкие места производительности. Для пустого вызова LaunchedEffect { } трассировка выглядела бы примерно так:
Трассировка метода LaunchedEffect, визуализированная в интерфейсе Perfetto.
Приведённую выше трассировку можно разделить на три части:
- Инициализация новой корутины
- Запуск корутины
- Завершение корутины, поскольку она сразу заканчивает выполнение.
Отмена LaunchedEffect похожа на обычное завершение, однако дополнительно создаёт исключение CancellationException.
При изучении профиля сразу вызывают подозрение частые обращения к java.util.concurrent.AtomicReferenceFieldUpdater — фиолетовые или зелёные блоки с подписями, начинающимися с j. Каждый отдельный вызов выполняется относительно быстро, но их количество вызывает беспокойство: даже небольшие накладные расходы, распределённые между множеством вызовов, в сумме могут привести к заметному снижению производительности. Если приблизить один из таких вызовов, становится видно, что большая часть времени уходит на… проверки рефлексии?
Увеличенный фрагмент трассировки метода AtomicReferenceFieldUpdater.get во время инициализации LaunchedEffect.
Корутины используют неблокирующую древовидную структуру для представления отношений между родительскими и дочерними задачами. Именно она делает возможной структурированную параллельность. Оказалось, что библиотека kotlinx.atomicfu реализует неблокирующие атомарные операции с помощью известного примитива JVM — AtomicReferenceFieldUpdater.
Этот обновитель (updater) получает ссылку на класс и имя поля, чтобы выполнять атомарные операции в рантайме. При этом ему приходится проводить несколько проверок безопасности с помощью рефлексии, чтобы убедиться, что поле существует и доступно. Каждая операция с корутиной — запуск, приостановка, отмена или завершение — выполняет как минимум одну атомарную операцию. Поэтому, если атомарные операции работают медленно, корутины тоже не смогут работать быстро.
Исследование AtomicReferenceFieldUpdater
Но не будем торопиться с выводами. На JVM AtomicReferenceFieldUpdater хорошо оптимизирован уже более десяти лет, а трассировка методов может показывать накладные расходы, которые полностью устраняются оптимизациями на уровне виртуальной машины: компиляцией во время выполнения, 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 с API 33 — при условии, что AtomicReferenceFieldUpdater#compareAndSet был скомпилирован JIT во время предварительного прогрева, — дал следующие результаты:
50,7 нс atomicReference_compareAndSet
135 нс atomicRef_compareAndSet
Измерения подтверждают разрыв: вариант из kotlinx.atomicfu оказался примерно в 2,7 раза медленнее. Это означает, что ART не выполняет скрытой оптимизации, а проверки доступа через рефлексию действительно создают накладные расходы во время работы приложения.
Если снова посмотреть на исходную трассировку методов, единственная содержательная работа, выполняемая AtomicReferenceFieldUpdater, — внутренний вызов Unsafe.getObjectVolatile, который непосредственно выполняет атомарную операцию. В большинстве случаев инициализатор обновителя является статическим, а его корректность можно доказать на основании структуры окружающего класса. Следовательно, большинство случаев использования AtomicReferenceFieldUpdater можно проанализировать статически и во время компиляции заменить внутренним вариантом на основе Unsafe. К тому же в наборе инструментов сборки Android уже есть собственный оптимизирующий компилятор, способный сделать именно это.
Оптимизация с помощью R8
Классы Atomic*FieldUpdater поддерживают сложные динамические сценарии и использование рефлексии, но на практике часто применяются в шаблонах, которые легко определить статически. Это одновременно объясняет низкую исходную производительность и необходимость оптимизации.
R8 является оптимизирующим компилятором, анализирующим программу целиком. Поэтому он хорошо подходит для распознавания простых шаблонов и устранения накладных расходов, связанных с проверками безопасности через рефлексию.
R8 получает байт-код JVM после компилятора Java или Kotlin. Однако для удобства чтения примеры ниже представлены с использованием синтаксиса Java. Именно поэтому у AtomicReferenceFieldUpdater отсутствуют параметры типов.
class Example {
volatile String data = "";
static final AtomicReferenceFieldUpdater updater =
AtomicReferenceFieldUpdater.newUpdater(Example.class, String.class, "data");
void example() {
// ...
updater.compareAndSet(this, "", "new");
// ...
}
}
В исходном примере создаётся статический неизменяемый updater, который обращается к полю volatile. Класс-владелец, тип поля и имя поля передаются в виде простых постоянных аргументов. Использование рефлексии здесь полностью прозрачно. Очевидно, что обновитель ссылается на существующее поле, а место создания обновителя имеет право обращаться к этому полю.
По своей сути Atomic*FieldUpdater является оболочкой над смещением поля и вызовами Unsafe. В идеальном случае оптимизация должна заменить поле обновителя полем со смещением, а вызовы обновителя — прямыми вызовами Unsafe.
Оптимизация Atomic*FieldUpdater
Оптимизация состоит из трёх этапов.
Подготовка
На первом этапе рядом с полем обновителя создаётся поле со смещением. Оно позволяет обращаться к полю напрямую через вызов Unsafe.
static final long updater$offset =
SyntheticUnsafe.UNSAFE.objectFieldOffset(Example.class.getDeclaredField("data"))
Доступ к полю выполняется через рефлексию, после чего Unsafe извлекает смещение этого поля внутри класса. Этот код соответствует внутренней реализации Atomic*FieldUpdater, если не учитывать проверки корректности через рефлексию. Вместо выполнения таких проверок во время работы приложения компилятор статически отслеживает тип владельца обновителя и тип поля volatile.
Обратите внимание, что исходное поле и его инициализация пока остаются без изменений. Оптимизация сначала подготавливает и преобразует подходящие случаи использования, а затем удаляет ненужный код. Такой подход упрощает реализацию и позволяет выполнять частичную оптимизацию полей обновителя: одни случаи использования можно оставить без изменений, а другие оптимизировать.
Замена
На этом этапе компилятор уже прошёл подходящую точку объединения параллельных ветвей анализа и располагает списком подготовленных полей обновителей.
Теперь каждый вызов можно оптимизировать отдельно на основании нескольких условий. Рассмотрим следующий вызов:
updater.compareAndSet(holder, expectedValue, newValue);
Для выполнения операции Atomic*FieldUpdater должны соблюдаться следующие условия:
- Происходит ли значение
updaterиз подготовленного поля? Иными словами, может ли статический анализ проследить значение объекта до чтения подготовленного поля обновителя? - Относится ли
holderк тому же классу, что и исходный тип владельца, либо к его подклассу? - Относится ли
newValueк тому же классу, что и исходный тип поля, либо к его подклассу?
Если все условия выполнены, вызов заменяется прямым вызовом Unsafe, не содержащим проверок через рефлексию:
SyntheticUnsafe.UNSAFE.compareAndSwapObject(holder, Example.updater$offset, expectedValue, newValue)
Новый вызов проще и быстрее, однако его поведение отличается от исходного при обработке значений null в updater и holder. Если статический анализ не может гарантировать, что эти значения не равны null, компилятор добавляет проверки для обоих объектов.
Очистка
К этому моменту класс содержит исходное поле обновителя, новое поле со смещением и места вызова, которые могут использовать любое из них. Если ни один вызов не был оптимизирован, поле со смещением следует удалить. Если были оптимизированы все вызовы, следует удалить поле обновителя. В обоих случаях необходимо также удалить код инициализации. Удаление неиспользуемых полей и мёртвого кода уже поддерживается компилятором. Однако удаление инициализирующего кода требует дополнительных действий.
Вызовы newUpdater и getDeclaredField потенциально имеют побочные эффекты, поскольку могут выбрасывать исключения. Кроме того, их реализация неизвестна компилятору, так как зависит от версии API. Поэтому обычная оптимизация не может безопасно удалить эти вызовы. На этапе очистки приходится отдельно учитывать подготовленные поля, поскольку компилятору статически известно, что соответствующая инициализация не приведёт к исключению.
После оптимизации простой пример с обновителем выглядит следующим образом:
class Example {
volatile String data = "";
static final long updater$offset =
SyntheticUnsafe.UNSAFE.objectFieldOffset(Example.class.getDeclaredField("data"))
void example() {
// ...
SyntheticUnsafe.UNSAFE.compareAndSwapObject(this, Example.updater$offset, "", "new")
// ...
}
}
Результаты
После применения этих оптимизаций kotlinx.atomicfu и большинство явных случаев использования AtomicIntFieldUpdater, AtomicLongFieldUpdater и AtomicReferenceFieldUpdater при включённом R8 достигают производительности обычного AtomicReference. В некоторых тестах они работают даже быстрее. У kotlinx.atomicfu есть подключаемый модуль компилятора, способный встраивать атомарные объекты непосредственно в поля. Благодаря этому уменьшается количество объектов, создаваемых для полей с атомарным обновлением.
Главным получателем преимуществ от этой работы стал Jetpack Compose. В среде выполнения Compose есть несколько небольших тестов производительности, которые внимательно отслеживают скорость работы корутин и позволяют заранее обнаруживать ухудшения. После обновления тестов до новой версии R8 команда заметила двукратное ускорение запуска и отмены корутин внутри LaunchedEffect.
Бенчмарк, показывающий время запуска и отмены корутин в LaunchedEffect: чем меньше значение, тем лучше. Изменение на графике соответствует обновлению R8 и демонстрирует двукратное ускорение.
Кроме того, команда ART реализует аналогичные оптимизации непосредственно на уровне виртуальной машины. Если приложение рассчитано на API 37 и работает на новой версии Android, возможно, устройство уже оптимизирует корутины похожим образом. В приведённых выше тестах корутин обновления JIT в новых версиях ART обеспечили дополнительное повышение производительности примерно на 15%.
Приложение автоматически получит эту оптимизацию после обновления до AGP 9.2.0 или при непосредственном использовании R8 9.2.0. Дополнительную информацию можно найти в документации по преобразователю D8 в формат DEX и оптимизатору R8.

