29 KiB
title, description, tags
| title | description | tags | |||
|---|---|---|---|---|---|
| Kotlin в байт-коде | Во что компилируются свойства, лямбды, data class, inline, suspend, value class и null-проверки |
|
Понимание представления Kotlin в байт-коде отвечает на три практических вопроса: почему API выглядит из Java именно так, откуда берутся накладные расходы и что происходит при NullPointerException внутри сгенерированного кода.
Инструмент один - javap:
kotlinc Demo.kt -d out # компиляция в .class
javap -p -cp out DemoKt # сигнатуры, включая приватные
javap -c -p -cp out DemoKt # с байт-кодом
javap -v -p -cp out DemoKt # с аннотациями и константным пулом
fun add(a: Int, b: Int) = a + b
fun main() {
println(add(2, 3)) // 5
}
Эта функция компилируется в public static final int add(int, int) внутри класса DemoKt. Никакой обёртки нет - сигнатура такая же, как у Java-метода.
Где живёт код
| Конструкция Kotlin | Класс в байт-коде |
|---|---|
class Foo |
Foo |
функции верхнего уровня в Demo.kt |
DemoKt |
object Bar |
Bar с полем INSTANCE |
companion object в Foo |
Foo$Companion + поле Foo.Companion |
| вложенный класс | Foo$Inner |
| анонимный объект | Foo$method$1 |
| лямбда | invokedynamic, без отдельного класса |
enum class Color |
Color extends java.lang.Enum |
object Registry {
private val items = mutableListOf<String>()
fun add(item: String) { items += item }
fun size() = items.size
}
fun main() {
Registry.add("a")
println(Registry.size()) // 1
}
object становится классом с приватным конструктором, статическим полем INSTANCE и статическим инициализатором. Это классический singleton, и его потокобезопасность обеспечена гарантиями JVM на загрузку класса.
Свойства
Свойство - это всегда приватное поле плюс аксессоры:
class Holder {
var mutable: String = "a"
val immutable: Int = 1
private var hidden = 2
lateinit var late: String
fun touch() = hidden++
}
fun main() {
val h = Holder()
h.mutable = "b"
println("${h.mutable} ${h.immutable} ${h.touch()}") // b 1 2
}
В байт-коде:
private java.lang.String mutable;
private final int immutable;
private int hidden;
public java.lang.String late; // lateinit — поле публично!
public final java.lang.String getMutable();
public final void setMutable(java.lang.String);
public final int getImmutable();
public final java.lang.String getLate();
public final void setLate(java.lang.String);
Три наблюдения:
val даёт final поле и только геттер. Это не константа - значение может вычисляться в конструкторе.
lateinit var делает поле публичным. Компилятор не может сделать его приватным, потому что геттер должен различать «не инициализировано» и «значение null» - для этого нужна прямая работа с полем, а lateinit исключает nullable-тип. Побочный эффект: поле видно из Java напрямую.
Приватное свойство аксессоров не получает - доступ идёт к полю, если нет кастомного геттера.
Свойство с кастомным геттером поля не создаёт вообще:
class Rect(val w: Int, val h: Int) {
val area: Int get() = w * h
}
fun main() {
println(Rect(3, 4).area) // 12
}
area - только метод getArea(), считающий значение каждый раз. Подробнее: Свойства
Null-проверки
Для каждого ненулевого параметра публичной функции компилятор вставляет проверку:
fun nn(s: String) = s.length
fun nullable(s: String?) = s?.length
fun main() {
println(nn("текст")) // 5
println(nullable(null)) // null
}
Байт-код nn:
0: aload_0
1: ldc #9 // String s
3: invokestatic #15 // Intrinsics.checkNotNullParameter:(Object;String)V
6: aload_0
7: invokevirtual #21 // String.length:()I
10: ireturn
checkNotNullParameter бросает NullPointerException с сообщением «Parameter specified as non-null is null» - это и есть защита границы от Java-кода. Для nullable проверки нет, зато есть ветвление и упаковка в Integer:
public static final java.lang.Integer nullable(java.lang.String);
Возвращаемый тип Int? стал Integer - nullable-примитив всегда упакован.
Информация о nullability хранится в аннотациях:
public static final int nn(java.lang.String); @NotNull на параметре
public static final java.lang.Integer nullable(...); @Nullable на методе и параметре
Это org.jetbrains.annotations.@NotNull/@Nullable с RuntimeInvisible-удержанием. Их читают Java-IDE и другие JVM-языки. Подробнее: Null-безопасность
graph LR
A["String в Kotlin"] --> B["java.lang.String<br/>+ @NotNull<br/>+ checkNotNullParameter"]
C["String? в Kotlin"] --> D["java.lang.String<br/>+ @Nullable<br/>без проверки"]
E["Int в Kotlin"] --> F["int"]
G["Int? в Kotlin"] --> H["java.lang.Integer"]
data class
data class Point(val x: Int, val y: Int)
fun main() {
val p = Point(1, 2)
println(p) // Point(x=1, y=2)
println(p.copy(y = 5)) // Point(x=1, y=5)
println(p == Point(1, 2)) // true
}
Генерируется:
private final int x;
private final int y;
public Point(int, int);
public final int getX();
public final int getY();
public final int component1();
public final int component2();
public final Point copy(int, int);
public static Point copy$default(Point, int, int, int, java.lang.Object);
public java.lang.String toString();
public int hashCode();
public boolean equals(java.lang.Object);
component1()/component2() нужны для деструктуризации - компилятор вызывает их по позиции. copy$default реализует значения по умолчанию: четвёртый параметр int - битовая маска переданных аргументов, пятый Object - технический. Подробнее: Data class
Значения по умолчанию
class Service {
fun call(url: String, retries: Int = 3, timeout: Long = 1000L) =
"$url r=$retries t=$timeout"
}
fun main() {
println(Service().call("/api")) // /api r=3 t=1000
}
Два метода вместо одного:
public final java.lang.String call(java.lang.String, int, long);
public static java.lang.String call$default(Service, java.lang.String, int, long, int, java.lang.Object);
$default получает битовую маску: бит i установлен, если аргумент i не передан. Внутри он подставляет значение по умолчанию для каждого отсутствующего аргумента и вызывает основной метод. Отсюда два следствия: вызов со значениями по умолчанию стоит на один вызов дороже, а из Java $default недоступен (он синтетический и требует знания маски).
Лямбды и функциональные типы
fun noCapture(): () -> Int = { 42 }
fun withCapture(n: Int): () -> Int = { n }
fun main() {
println(noCapture()()) // 42
println(withCapture(7)()) // 7
}
Функциональный тип () -> Int - это интерфейс kotlin.jvm.functions.Function0<Integer>. Их 23: Function0 ... Function22.
public static final kotlin.jvm.functions.Function0<java.lang.Integer> noCapture();
0: invokedynamic #81, 0 // InvokeDynamic #0:invoke:()Lkotlin/jvm/functions/Function0;
Начиная с K2 лямбды компилируются через invokedynamic (как в Java), а не в отдельные классы Foo$lambda$1. Это уменьшает размер артефакта и ускоряет загрузку, но означает, что имя класса лямбды генерируется в рантайме - в стектрейсах появляются $$Lambda$....
Важная деталь: Function0<Integer> работает с упакованным Integer. Лямбда, возвращающая Int, упаковывает результат на каждом вызове - если это горячий цикл, нужна inline-функция. Подробнее: Лямбды и функциональные типы
inline
inline fun measure(block: () -> Unit) {
block()
}
fun useInline() {
measure { println("тело") }
}
fun main() {
useInline() // тело
}
Байт-код useInline не содержит вызова measure вообще:
public static final void useInline();
0: iconst_0
1: istore_0
2: iconst_0
3: istore_1
4: ldc #49 // String тело
6: getstatic #55 // System.out
9: swap
10: invokevirtual #61 // println
13: nop
14: nop
15: return
Тело лямбды подставлено прямо в метод: ни объекта Function0, ни вызова по ссылке. Это основа forEach, let, map и остальных операций stdlib - они не создают объектов. Цена - код дублируется в каждой точке вызова. Подробнее: Inline-функции
Сама measure всё равно существует как метод (для Java-клиентов и рефлексии):
public static final void measure(kotlin.jvm.functions.Function0<kotlin.Unit>);
suspend
import kotlinx.coroutines.delay
import kotlinx.coroutines.runBlocking
suspend fun fetch(id: Int): String {
delay(10)
return "ответ $id"
}
fun main() = runBlocking {
println(fetch(1)) // ответ 1
}
Сигнатура меняется радикально:
public static final java.lang.Object fetch(int, kotlin.coroutines.Continuation<? super java.lang.String>);
Три изменения: добавлен параметр Continuation, тип возврата стал Object (вместо String), и само тело превратилось в конечный автомат. Функция может вернуть либо результат, либо специальный маркер COROUTINE_SUSPENDED, означающий «я приостановилась, результат придёт через Continuation».
Конечный автомат - это анонимный класс с полем label, хранящим номер точки приостановки, и полями для всех локальных переменных, живущих через приостановку. При возобновлении функция вызывается снова, читает label и прыгает в нужную ветку switch. Это CPS-преобразование (continuation-passing style), выполняемое компилятором.
Отсюда свойства корутин: приостановка не блокирует поток (нет никакого потока - есть объект состояния), стектрейсы короткие, а Java-код вызвать suspend-функцию практически не может. Подробнее: Suspend-функции
value class
@JvmInline
value class UserId(val value: Int)
fun process(id: UserId) = "обработан ${id.value}"
fun main() {
println(process(UserId(42))) // обработан 42
}
Класс UserId в байт-коде существует, но в сигнатурах заменяется на обёрнутый тип:
public static final java.lang.String process-4ZD5Yi0(int);
Параметр стал int - обёртки в рантайме нет. Суффикс -4ZD5Yi0 (хеш от типов) нужен, чтобы process(UserId) и process(OrderId) не конфликтовали: после стирания у обеих сигнатура process(int).
Обёртка всё-таки создаётся в трёх случаях: когда value-класс используется как дженерик-параметр (List<UserId>), как nullable (UserId?) и при обращении через супертип. Подробнее: Value class
Делегированные свойства
class Config {
val expensive: String by lazy { "вычислено" }
}
fun main() {
println(Config().expensive) // вычислено
}
private final kotlin.Lazy v$delegate;
public final java.lang.String getV();
Поле хранит не значение, а делегат. Геттер вызывает delegate.getValue(). Поэтому каждое делегированное свойство - это дополнительный объект и один лишний вызов на чтение. Для by lazy с дефолтным режимом внутри ещё и synchronized-блок при первом доступе. Подробнее: Делегирование
Дженерики и вариантность
class Box<T : Comparable<T>>(val value: T) {
fun max(other: T) = if (value > other) value else other
}
fun sizes(l: List<out Number>, m: MutableList<in Int>) = l.size + m.size
fun main() {
println(Box(3).max(7)) // 7
println(sizes(listOf(1, 2), mutableListOf(3))) // 3
}
public final class Box<T extends java.lang.Comparable<? super T>>
public static final int sizes(java.util.List<? extends java.lang.Number>, java.util.List<? super java.lang.Integer>);
Проекции Kotlin становятся wildcards Java: out T → ? extends T, in T → ? super T. Дженерики стираются так же, как в Java - в рантайме Box<Int> и Box<String> неразличимы. Обойти это позволяет только reified в inline-функциях, где тип подставляется при компиляции. Подробнее: Вариантность, Reified и inline
Nothing и Unit
fun fail(): Nothing = throw IllegalStateException("всегда падает")
fun act(): Unit = println("действие")
fun main() {
act() // действие
println(runCatching { fail() }.isFailure) // true
}
Unit как возвращаемый тип становится void. Nothing - java.lang.Void (значение которого всегда null, потому что функция никогда не возвращается нормально). В обоих случаях Kotlin-компилятор знает больше, чем байт-код: Nothing делает код после вызова недостижимым, но Java-компилятор этого не увидит. Подробнее: Any, Unit, Nothing
Метаданные
Каждый Kotlin-класс несёт аннотацию kotlin.Metadata с закодированной информацией о nullable-типах, значениях по умолчанию, свойствах, suspend, вариантности, inline, sealed и прочем. Именно её читает Kotlin-компилятор, подключая скомпилированную библиотеку: .class-файла недостаточно, чтобы восстановить Kotlin-API.
Практические следствия:
Обфускация ломает Kotlin-interop. ProGuard/R8 должны сохранять kotlin.Metadata, иначе библиотека перестанет быть пригодной для Kotlin-клиентов (но останется пригодной для Java).
Рефлексия Kotlin требует kotlin-reflect. KClass.memberProperties читает метаданные, и для этого нужна отдельная зависимость весом ~2 МБ. Java-рефлексия (javaClass.declaredFields) работает без неё, но видит только байт-код. Подробнее: Рефлексия
Java-библиотека не знает про Kotlin-типы. Jackson, Hibernate и подобные видят только .class: конструктор со значениями по умолчанию, nullable-поле как обычное. Для корректной работы нужны модули-адаптеры (jackson-module-kotlin), которые читают kotlin.Metadata.
Сводная таблица
| Kotlin | Байт-код |
|---|---|
fun f() на верхнем уровне |
static final в ФайлKt |
object O |
класс + static final O INSTANCE |
companion object |
класс Cls$Companion + поле |
val x: T |
private final T x + getX() |
var x: T |
private T x + getX()/setX() |
lateinit var x |
public T x (поле публично) |
val x get() = ... |
только getX(), поля нет |
x: String (параметр) |
String + @NotNull + checkNotNullParameter |
x: String? |
String + @Nullable |
Int |
int |
Int? |
java.lang.Integer |
() -> Int |
Function0<Integer> |
| лямбда | invokedynamic |
inline fun |
тело подставлено + метод для Java |
suspend fun f(): T |
Object f(Continuation) + автомат |
@JvmInline value class |
обёрнутый тип + суффикс в имени |
by lazy |
поле x$delegate: Lazy |
out T / in T |
? extends T / ? super T |
Unit |
void |
Nothing |
java.lang.Void |
internal fun f() |
public final f$module() |
[!info] Отличия от Java
Java Kotlin исходник ≈ байт-код есть зазор, есть метаданные поля публичны, если так написано поля всегда приватны (кроме lateinit)геттер пишется руками генерируется лямбда → invokedynamicто же (с K2) нет значений по умолчанию синтетический $defaultнет inlineтело подставляется asyncчерез библиотекуsuspend→ конечный автоматintиIntegerв кодеIntиInt?Главное отличие - наличие метаданных. В Java
.classполностью описывает API: что видно в сигнатурах, то и есть. В Kotlin часть системы типов (nullability, значения по умолчанию,suspend,inline,sealed, вариантность) существует только в аннотацииkotlin.Metadata. Это значит, что Kotlin-компилятор и Java-компилятор видят разные API одной библиотеки: первый богаче. Отсюда и необходимость сохранять метаданные при обфускации, и зависимостьkotlin-reflect, и адаптеры для Jackson.Проверки на null стоят вызова метода на каждый параметр. В Java такой проверки нет вообще -
NullPointerExceptionвозникает при первом использовании. Kotlin переносит сбой на границу функции, что делает диагностику проще (понятно, какой параметр виноват), но добавляетinvokestaticна каждый ненулевой параметр публичной функции. В приватных иinline-функциях проверок нет: там компилятор уже гарантировал тип.
inline- возможность, которой в Java нет на уровне языка. JIT в Java тоже встраивает вызовы, но это решение рантайма, непредсказуемое и не применимое к лямбдам с захватом. Kotlin встраивает на этапе компиляции, гарантированно и вместе с телом лямбды. Это и позволяетcollection.map { }не создавать объектов, и даётreified, и делает возможнымиreturnиз лямбды. Цена - рост байт-кода и невозможность изменить тело без перекомпиляции клиентов.
suspendреализован компилятором, а не рантаймом. В Java асинхронность требует либо потоков, либоCompletableFutureс цепочками колбэков, либо виртуальных потоков (Loom), которые меняют JVM. Kotlin делает CPS-преобразование на этапе компиляции и работает на любой JVM 8+. Следствие: корутины не требуют поддержки платформы, но и не интегрируются с Java-кодом.Value-классы ближе к будущим Project Valhalla, чем к Java сегодня. В Java любая обёртка над
int- это объект в куче. Kotlin убирает обёртку на этапе компиляции, заменяя тип в сигнатурах. Цена - манглирование имён и обёртка в дженериках, где стирание не позволяет обойтись примитивом.
[!warning] Подводные камни
lateinit varсоздаёт публичное поле. Это нарушает инкапсуляцию неожиданным образом: Java-код и рефлексия получают прямой доступ в обход геттера, минуя проверку инициализации. Если это важно, используйтеvar x: T? = nullс приватным сеттером.Nullable-примитив всегда упакован:
fun main() { val plain: Int = 1 val nullable: Int? = 1 println(plain == nullable) // true println(nullable!!::class.java.name) // java.lang.Integer val a: Int? = 1000 val b: Int? = 1000 println("identity: ${a === b}") // identity: false }
Int?этоInteger, поэтому===на значениях вне кеша-128..127даётfalse. В горячем кодеInt?в коллекциях и лямбдах создаёт объекты. Подробнее: Числа и переполнениеЗначения по умолчанию стоят лишнего вызова.
call("/api")идёт черезcall$default, который разбирает битовую маску и вызываетcall. Для горячего пути с миллионами вызовов разница измерима - там пишут явные перегрузки.
inlineраздувает байт-код. Большаяinline-функция, вызванная в сотне мест, копируется сто раз. Компилятор предупреждает про «expected performance impact... insignificant» для функций без лямбда-параметров - это намёк, чтоinlineтам не нужен.Изменение тела
inline-функции требует перекомпиляции клиентов. Старое тело уже вкомпилировано в их байт-код. Для публичных библиотек это серьёзное ABI-ограничение:inlineв публичном API фиксирует реализацию.Делегированное свойство - это объект и вызов.
by lazyна каждом из сотни свойств даёт сотню объектовLazyи сотню косвенных вызовов. Для простых случаев дешевлеprivate var x: T? = nullс проверкой в геттере.Стектрейс корутины не показывает путь вызова. Конечный автомат возобновляется из диспетчера, поэтому в стектрейсе видна только текущая точка приостановки. Для отладки включают
-Dkotlinx.coroutines.debugили зависимостьkotlinx-coroutines-debug. Подробнее: КорутиныОбфускация без правил для
kotlin.Metadataломает библиотеку для Kotlin-клиентов. Код продолжит работать, но исчезнет nullability, пропадут значения по умолчанию,kotlin-reflectперестанет видеть свойства. В правилах R8 нужно-keep class kotlin.Metadata.
javapне показывает Kotlin-API. Он читает байт-код, не метаданные:suspend-функция выглядит как метод сContinuation, аdata class- как обычный класс с кучей методов. Чтобы увидеть Kotlin-вид скомпилированного кода, нуженkotlinpили IDE («Show Kotlin Bytecode» → «Decompile»).Синтетические методы видны рефлексии.
component1(),copy$default,access$...,Foo$Companion- всё это попадёт вgetDeclaredMethods(). Код, перебирающий методы класса (сериализаторы, валидаторы), должен фильтровать поisSynthetic().
internalне защищает. Манглированное имяhelper$moduleпублично. Java-код в том же артефакте вызовет его без препятствий, как и любая рефлексия.Проверки
checkNotNullParameterможно отключить, но не стоит. Флаг-Xno-param-assertionsубирает их, давая небольшой выигрыш. Цена -NullPointerExceptionбудет возникать глубоко внутри вместо границы функции, и диагностика станет заметно хуже.
Далее: Инструментарий