Files
obsidian/Kotlin/Взаимодействие с Java/Kotlin в байт-коде.md
T

29 KiB
Raw Blame History

title, description, tags
title description tags
Kotlin в байт-коде Во что компилируются свойства, лямбды, data class, inline, suspend, value class и null-проверки
kotlin
java-interop
jvm

Понимание представления 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 убирает обёртку на этапе компиляции, заменяя тип в сигнатурах. Цена - манглирование имён и обёртка в дженериках, где стирание не позволяет обойтись примитивом.

Сравнить: JVM, Байт-код

[!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 будет возникать глубоко внутри вместо границы функции, и диагностика станет заметно хуже.

Далее: Инструментарий