Kotlin, компиляция в байткод и производительность (часть 1)

в 10:09, , рубрики: java, kotlin, байткод, Блог компании ИНФОРИОН, Компиляторы, Программирование, разработка

Kotlin, компиляция в байткод и производительность (часть 1) - 1

О Kotlin последнее время уже очень много сказано (особенно в совокупности с последними новостями c Google IO 17), но в то же время не очень много такой нужной информации, во что же компилируется Kotlin.
Давайте подробнее рассмотрим на примере компиляции в байткод JVM.

Это первая часть публикации. Вторую можно посмотреть тут

Процесс компиляции это довольно обширная тема и чтобы лучше раскрыть все ее нюансы я взял большую часть примеров компиляции из выступления Дмитрия Жемерова: Caught in the Act: Kotlin Bytecode Generation and Runtime Performance. Из этого же выступления взяты все бенчмарки. Помимо ознакомления с публикацией, настоятельно рекомендую вам еще и посмотреть его выступление. Некоторые вещи там рассказаны более подробно. Я же больше внимания акцентирую именно на компиляции языка.

Содержание:

Функции на уровне файла
Primary конструкторы
data классы
Свойства в теле класса
Not-null типы в публичных и приватных методах
Функции расширения (extension functions)
Тела методов в интерфейсах
Аргументы по умолчанию
Лямбды

Но прежде чем рассмотрим основные конструкции языка и то, в какой байткод они компилируются, нужно упомянуть о том, как непосредственно происходит сама компиляция языка:

Kotlin, компиляция в байткод и производительность (часть 1) - 2

На вход компилятора kotlinc поступают исходные файлы, причем не только файлы kotlin, но и файлы java. Это нужно чтобы можно было свободно ссылаться на Java из Kotlin, и наоборот. Сам компилятор прекрасно понимает исходники Java, но не занимается их компиляцией, на этом этапе происходит только компиляция файлов Kotlin. После полученные *.class файлы передаются компилятору javaс вместе с исходными файлами *.java. На этом этапе компилируются все java файлы, после чего становится возможным собрать вместе все файлы в jar (либо каким другим образом).

Для того чтобы посмотреть в какой байткод генерируется Kotlin, в Intellij IDEA можно открыть специальное окно из Tools -> Kotlin -> Show Kotlin Bytecode. И после, при открытие любого файла *.kt, в этом окне будет виден его байткод. Если в нем не будет ничего такого, что нельзя представить в Java, то также будет доступна возможность декомпилировать его в Java код кнопкой Decompile.

Kotlin, компиляция в байткод и производительность (часть 1) - 3

Если посмотреть на любой *.class файл kotlin, то там можно увидеть большую аннотацию @Metadata:

@Metadata(
   mv = {1, 1, 6},
   bv = {1, 0, 1},
   k = 1,
   d1 = {"u0000u0014nu0002u0018u0002nu0002u0010u0000nu0002bu0002nu0002u0010bnu0002bu0003u0018u00002u00020u0001Bu0005¢u0006u0002u0010u0002Ru0014u0010u0003u001au00020u0004Xu0086D¢u0006bnu0000u001au0004bu0005u0010u0006¨u0006u0007"},
   d2 = {"LSimpleKotlinClass;", "", "()V", "test", "", "getTest", "()I", "production sources for module KotlinTest_main"}
)

Она содержит всю ту информацию, которая существует в языке Kotlin, и которую невозможно представить на уровне Java байткода. Например информацию о свойствах, nullable типов и т.п. С этой информацией не нужно работать напрямую, но с ней работает компилятор, и к ней можно получить доступ используя Reflection API. Формат метадаты это на самом деле Protobuf cо своими декларациями.
Дмитрий Жемеров

Давайте теперь перейдем к примерам, в которых рассмотрим основные конструкции и то, в каком виде они представлены в байткоде. Но чтобы не разбираться в громоздких записях байткода в большинстве случаев рассмотрим декомпилированный вариант в Java:

Функции на уровне файла

Начнем с самого простого примера: функция на уровне файла.

//Kotlin, файл Example1.kt
fun foo() { }

В Java нет аналогичной конструкции. В байткоде она реализуется с помощью создания дополнительного класса.

//Java
public final class Example1Kt {
   public static final void foo() {
   }
}

В качестве названия для такого класса используется имя исходного файла с суффиксом *Kt (в данном случае Example1Kt). Существует также возможность поменять имя класса с помощью аннотации file:JvmName:

//Kotlin
@file:JvmName("Utils")
fun foo() { }

//Java
public final class Utils {
   public static final void foo() {
   }
}

Primary конструкторы

В Kotlin есть возможность прямо в заголовке конструктора объявить свойства (property).

//Kotlin
class A(val x: Int, val y: Long) {}

Они будут параметрами конструктора, для них будут сгенерированы поля и, соответственно, значения, переданные в конструктор, будут записаны в эти поля. Также будут созданы getter, позволяющие эти поля прочитать. Декомпилированный вариант примера выше будет выглядеть так:

//Java
public final class A {
   private final int x;
   private final long y;
 
   public final int getX() {
      return this.x;
   }
 
   public final long getY() {
      return this.y;
   }
 
   public A(int x, long y) {
      this.x = x;
      this.y = y;
   }
}

Если в объявлении класса A у переменной x изменить val на var, то тогда еще будет сгенерированы setter. Стоит также обратить внимание на то, что класс A будет объявлен с модификатором final и public. Это связано с тем что все классы в Kotlin по умолчанию final и имеют область видимости public.

data классы

В Kotlin есть специальный модификатор для класса data.

//Kotlin
data class B(val x: Int, val y: Long) { }

Это ключевое слово говорит компилятору о том, чтобы он сгенерировал для класса методы equals, hashCode, toString, copy и componentN функции. Последние нужны для того, чтобы класс можно было использовать в destructing объявлениях. Посмотрим на декомпилированный код:

//Java
public final class B {
   // --- аналогично примеру 2   
 
   public final int component1() {
      return this.x;
   }
 
   public final long component2() {
      return this.y;
   }
 
   @NotNull
   public final B copy(int x, long y) {
      return new B(x, y);
   }
 
    public String toString() {
      return "B(x=" + this.x + ", y=" + this.y + ")";
   }
 
   public int hashCode() {
      return this.x * 31 + (int)(this.y ^ this.y >>> 32);
   }
 
   public boolean equals(Object var1) {
      if(this != var1) {
         if(var1 instanceof B) {
            B var2 = (B)var1;
            if(this.x == var2.x && this.y == var2.y) {
               return true;
            }
         }
 
         return false;
      } else {
         return true;
      }
 }

На практике модификатор data очень часто используется, особенно для классов, которые участвуют во взаимодействии между компонентами, либо хранятся в коллекциях. Также data классы позволяют быстро создать иммутабельный контейнер для данных.

Свойства в теле класса

Свойства также могут быть объявлены в теле класса.

//Kotlin
class C {
    var x: String? = null
}

В данном примере в классе С мы объявили свойство x типа String, которое еще к тому же может быть null. В этом случае в коде появляются дополнительные аннотации @Nullable:

//Java
import org.jetbrains.annotations.Nullable;

public final class C {
   @Nullable
   private String x;
 
   @Nullable
   public final String getX() {
      return this.x;
   }
 
   public final void setX(@Nullable String var1) {
      this.x = var1;
   }
}

В этом случае в декомпилированном варианте мы увидим getter, setter (так как переменная объявлена с модификатором var).Аннотация @Nullable необходима для того, чтобы те статические анализаторы, которые понимают данную аннотацию, могли проверять по ним код и сообщать о каких-либо возможных ошибках.

Если же нам не нужны getter и setter, а просто нужно публичное поле, то мы можем добавить аннотацию @JvmField:

//Kotlin
class C {
    @JvmField var x: String? = null
}

Тогда результирующий Java код будет следующий:

//Java
public final class C {
   @JvmField
   @Nullable
   public String x;
}

Not-null типы в публичных и приватных методах

В Kotlin существует небольшая разница между тем, какой байткод генерируется для public и private методов. Посмотрим на примере двух методов, в которые передаются not-null переменные.

//Kotlin
class E {
    fun x(s: String) {
        println(s)
    }
 
    private fun y(s: String) {
        println(s)
    }
}

В обоих методах передается параметр s типа String, и в обоих случаях этот параметр не может быть null.

//Java
import kotlin.jvm.internal.Intrinsics;

public final class E {
   public final void x(@NotNull String s) {
      Intrinsics.checkParameterIsNotNull(s, "s");
      System.out.println(s);
   }
 
   private final void y(String s) {
      System.out.println(s);
   }
}

В таком случае для публичного метода генерируется дополнительная проверка типа (Intrinsics.checkParameterIsNotNull), которая проверяет что переданный параметр действительно не null. Это сделано для того, чтобы публичные методы можно было вызывать из Java. И если вдруг в них передается null, то этот метод должен падать в этом же месте, не передавая переменную дальше по коду. Это необходимо для раннего диагностирования ошибок. В приватных методах такой проверки нет. Из Java его просто так нельзя вызвать, только если через reflection. Но с помощью reflection можно вообще много чего сломать при желании. Из Kotlin же компилятор сам следит за вызовами и не даст передать null в такой метод.

Такие проверки, конечно, не могут совсем не влиять на быстродействие. Довольно интересно померить на сколько же они ее ухудшают, но простыми бенчмарками это сделать тяжело. Поэтому посмотрим на данные, которые удалось получить Дмитрию Жемерову:

Проверка параметров на null

Kotlin, компиляция в байткод и производительность (часть 1) - 4

Для одного параметра стоимость такой проверки на NotNull вообще пренебрежимо мала. Для метода с восемью параметрами, который больше ничего не делает, кроме как проверяет на null, уже получается что какая-то заметная стоимость есть. Но в любом случае в обычной жизни эту стоимость (приблизительно 3 наносекунды) можно не учитывать. Более вероятна ситуация, что это последнее, что придется оптимизировать в коде. Но если все же нужно убрать излишние проверки, то на данный момент это возможно с помощью дополнительный опций компилятора kotlinc: -Xno-param-assertions и -Xno-call-assertions (важно!: прежде чем отключать проверки, действительно подумайте, в этом ли причина ваших бед, и не будет ли такого, что это принесет больше вреда чем пользы)

Функции расширения (extension functions)

Kotlin позволяет расширять API существующих классов, написанных не только на Kotlin, но и на Java. Для любого класса можно написать объявление функции и дальше в коде ее можно использовать у этого класса так, как будто эта функция была при его объявлении.

//Kotlin (файл Example6.kt)
class T(val i: Int)
 
fun T.foo(): Int {
	return i
}
 
fun useFoo() {
	T(1).foo()
}

В Java генерируется класс, в котором будет просто статический метод с именем, как у функции расширения. В этот метод передается инстанс расширяемого класса. Таким образом, когда мы вызываем функцию расширения, мы на самом деле передаем в стическую функцию сам элемент, на котором вызываем метод.

//Java
public final class Example6Kt {
   public static final int foo(@NotNull T $receiver) {
      Intrinsics.checkParameterIsNotNull($receiver, "$receiver");
      return $receiver.getI();
   }
 
   public static final void useFoo() {
      foo(new T(1));
   }
}

Почти вся стандартная библиотека Kotlin состоит из функций расширений для классов JDK. В Kotlin очень маленькая своя стандартная библиотека и нет объявления своих классов коллекций. Все коллекции, объявляемые через listOf, setOf, mapOf, которые в Kotlin выглядят на первый взгляд своими, на самом деле обычные Java коллекции ArrayList, HashSet, HashMap. И если нужно передать такую коллекцию в библиотеку (или из библиотеки), то нет никаких накладных расходов на конвертацию к своим внутренним классам (в отличие от Scala <-> Java) или копирование.

Тела методов в интерфейсах

В Kotlin есть возможность добавить реализацию для методов в интерфейсах.

//Kotlin
interface I {
    fun foo(): Int {
        return 42
    }
}
 
class D : I {  }

В Java 8 такая возможность также появилась, но по причине того, что Kotlin должен работать и на Java 6, результирующий код в Java выглядит следующим образом:

public interface I {
   int foo();
 
   public static final class DefaultImpls {
      public static int foo(I $this) {
         return 42;
      }
   }
}

public final class D implements I {
   public int foo() {
      return I.DefaultImpls.foo(this);
   }
}

В Java создается обычный интерфейс, с декларацией метода, и появляется декларация класса DefaultImpls с реализацией по умолчанию для нужных методов. В местах же использования методов появляется вызов реализаций из объявленного в интерфейсе класса, в методы которого передается сам объект вызова.

У команды Kotlin есть планы для перехода на реализацию этой функциональности с помощью методов по умолчанию (default method) из Java 8, но на данный момент присутствуют трудности с сохранением бинарной совместимости с уже скомпилированными библиотеками. Можно посмотреть обсуждение этой проблемы на youtrack. Конечно большой проблемы это не создает, но если в проекте планируется создание api для Java, то нужно учитывать эту особенность.

Аргументы по умолчанию

В отличие от Java, в Kotlin есть аргументы по умолчанию. Но их реализация сделана достаточно интересно.

//Kotlin (файл Example8.kt)
fun first(x: Int = 11, y: Long = 22) {
    println(x)
    println(y)
}
 
fun second() {
    first()
}

Для реализации аргументов по умолчанию в байткоде Java используется синтетический метод, в который передается битовая маска mask с информацией о том, какие аргументы отсутствуют в вызове.

//Java
public final class Example8Kt {
   public static final void first(int x, long y) {
      System.out.println(x);
      System.out.println(y);
   }
 
   public static void first$default(int var0, long var1, int mask, Object var4) {
      if((mask & 1) != 0) {
         var0 = 11;
      }
 
      if((mask & 2) != 0) {
         var1 = 22L;
      }
 
      first(var0, var1);
   }
 
   public static final void second() {
      first$default(0, 0L, 3, (Object)null);
   }
}

Единственный интересный момент, зачем генерируется аргумент var4? Сам он нигде не используется, а в местах использования передается null. Информацию по назначению этого аргумента я не нашел, может yole сможет прояснить ситуацию.

Ниже показаны оценки затрат на такие манипуляции:

Аргументы по умолчанию

Kotlin, компиляция в байткод и производительность (часть 1) - 5

Стоимость аргументов по умолчанию уже становится немного заметной. Но все равно потери измеряются в наносекундах и при обычной работе такими потерями можно пренебречь. Существует также способ заставить компилятор Kotlin по другому сгенерировать в байткоде аргументы по умолчанию. Для этого нужно добавить аннотацию @JvmOverloads:

//Kotlin
@JvmOverloads
fun first(x: Int = 11, y: Long = 22) {
    println(x)
    println(y)
}

В таком случае, помимо методов из предыдущего примера, еще будут сгенерированы перегрузки метода first под различные варианты передачи аргументов.

//Java
public final class Example8Kt {
   //-- методы first, second, first$default из предыдущего примера
 
   @JvmOverloads
   public static final void first(int x) {
      first$default(x, 0L, 2, (Object)null);
   }
 
   @JvmOverloads
   public static final void first() {
      first$default(0, 0L, 3, (Object)null);
   }
}

Лямбды

Лямбды в Kotlin представляются практически также как и в Java (за исключением того что они являются объектами первого класса)

//Kotlin (файл Lambda1.kt)
fun <T> runLambda(x: ()-> T): T = x()

В данном случае функция runLambda принимает инстанс интерфейса Function0 (объявление которого находится в стандартной библиотеке Kotlin), в котором есть функция invoke(). И соответственно это все совместимо с тем, как это работает в Java 8, и, конечно, работает SAM-конверсия из Java. Результирующий байткод будет выглядеть следующим образом:

//Java
public final class Lambda1Kt {
   public static final Object runLambda(@NotNull Function0 x) {
      Intrinsics.checkParameterIsNotNull(x, "x");
      return x.invoke();
   }
}

Компиляция в байткод сильно зависит от того, если ли захват значения из окружающего контекста или нет. Рассмотрим пример, когда есть глобальная переменная value и лямбда, которая просто возвращает ее значение.

//Kotlin (файл Lambda2.kt)
var value = 0
 
fun noncapLambda(): Int = runLambda { value }

В Java в данном случае, по сути, создается синглтон. Сама лямбда ничего из контекста не использует и соотвественно не нужно создавать разные инстансы под все вызовы. Поэтому просто компилируется класс, который реализует интерфейс Function0, и, как результат, вызов лямбды происходит без аллокации и весьма дешево.

//Java
final class Lambda2Kt$noncapLambda$1 extends Lambda implements Function0 {
   public static final Lambda2Kt$noncapLambda$1 INSTANCE = new Lambda2Kt$noncapLambda$1()
 
  public final int invoke() {
    return Lambda2Kt.getValue();
  }
}

public final class Lambda2Kt {
   private static int value;
 
   public static final int getValue() {
      return value;
   }
 
   public static final void setValue(int var0) {
      value = var0;
   }
 
   public static final int noncapLambda() {
      return ((Number)Lambda1Kt.runLambda(Lambda2Kt$noncapLambda$1.INSTANCE)).intValue();
   }
 
}

Рассмотрим другой пример с использованием локальных переменных с контекстами.

//Kotlin (файл Lambda3.kt)
fun capturingLambda(v: Int): Int = runLambda { v }

В данном случае синглтоном уже не обойтись, так как каждый конкретный инстанс лямбды должен иметь свое значение параметра.

//Java
public static final int capturingLambda(int v) {
      return ((Number)Lambda1Kt.runLambda((Function0)(new Function0() {
          public Object invoke() {
            return Integer.valueOf(this.invoke());
         }
 
         public final int invoke() {
            return v;
         }
      }))).intValue();
 }

Лямбды в Kotlin также умеют менять значение не локальных переменных (в отличие от лямбд Java).

//Kotlin (файл Lambda4.kt)
fun mutatingLambda(): Int {
    var x = 0
    runLambda { x++ }
    return x
}

В этом случае создается обертка для изменяемой переменной. Сама обертка, аналогично предыдущему примеру, передается в создаваемую лямбду, внутри которой и происходит изменение исходной переменной через обращение к обертке.

public final class Lambda4Kt {
   public static final int mutatingLambda() {
      final IntRef x = new IntRef();
      x.element = 0;
      Lambda1Kt.runLambda((Function0)(new Function0() {
         public Object invoke() {
            return Integer.valueOf(this.invoke());
         }
 
         public final int invoke() {
            int var1 = x.element++;
            return var1;
         }
      }));
      return x.element;
   }
}

Попробуем сравнить производительность решений на Kotlin, с аналогами на Java:

Лямбда

Kotlin, компиляция в байткод и производительность (часть 1) - 6

Как видно, возня с обертками (последний пример) занимает заметное время, но, с другой стороны, в Java такое не поддерживается из коробки, а если делать руками подобную реализацию, то и затраты будут аналогичные. В остальном разница не так заметна.

Также в Kotlin есть возможность передавать ссылки на методы (method reference) в лямбды, причем они, в отличие от лямбд, сохраняют информацию о том, на что же указывают методы. Ссылки на методы компилируется похожим образом на то, как выглядят лямбды без захвата контекста. Создается синглтон, который помимо значения еще знает на что же эта лямбда ссылается.

У лямбд в Kotlin есть еще одна интересная особенность: их можно объявить с модификатором inline. В этом случае компилятор сам найдет все места использования функции в коде и заменит их на тело функции. JIT тоже умеет инлайнить некоторые вещи и сам, но никогда нельзя быть уверенным в том, что он будет инлайнить, а что пропустит. Поэтому иметь свой управляемый механизм инлайна никогда не помешает.

//Kotin (файл Lambda5.kt)
fun inlineLambda(x: Int): Int = run { x }
 
//run это функция из стандартной библиотеки:
public inline fun <R> run(block: () -> R): R = block()

//Java
public final class Lambda5Kt {
   public static final int inlineLambda(int x) {
      return x;
   }
}

В примере выше не происходит никакой аллокации, никаких вызовов. По сути, код функции просто “схлопывается”. Это позволяет очень эффективно реализовывать всякие filter, map и т.п. Тот же оператор synchronized тоже инлайнится.

Продолжение в части 2

Спасибо за внимание!
Надеюсь вам понравилась статья. Прошу всех тех, кто заметил какие-либо ошибки или неточность, написать об этом мне в личном сообщении.

Автор: nerumb

Источник


* - обязательные к заполнению поля


https://ajax.googleapis.com/ajax/libs/jquery/3.4.1/jquery.min.js