Рубрики
Без рубрики

Введение в вызов Dynamic в JVM

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

Автор оригинала: Ali Dehghani.

1. Обзор

InvokeDynamic (также известный как Indy) был частью JSR 292 , предназначенной для расширения поддержки JVM для динамически типизированных языков. После своего первого выпуска в Java 7 код операции invokedynamic довольно широко используется динамическими языками на основе JVM, такими как JRuby, и даже статически типизированными языками, такими как Java.

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

2. Познакомьтесь с Динамикой

Давайте начнем с простой цепочки вызовов API потока:

public class Main { 

    public static void main(String[] args) {
        long lengthyColors = List.of("Red", "Green", "Blue")
          .stream().filter(c -> c.length() > 3).count();
    }
}

Сначала мы могли бы подумать, что Java создает анонимный внутренний класс, производный от Предиката , а затем передает этот экземпляр методу filter . Но мы были бы неправы.

2.1. Байт-Код

Чтобы проверить это предположение, мы можем взглянуть на сгенерированный байт-код:

javap -c -p Main
// truncated
// class names are simplified for the sake of brevity 
// for instance, Stream is actually java/util/stream/Stream
0: ldc               #7             // String Red
2: ldc               #9             // String Green
4: ldc               #11            // String Blue
6: invokestatic      #13            // InterfaceMethod List.of:(LObject;LObject;)LList;
9: invokeinterface   #19,  1        // InterfaceMethod List.stream:()LStream;
14: invokedynamic    #23,  0        // InvokeDynamic #0:test:()LPredicate;
19: invokeinterface  #27,  2        // InterfaceMethod Stream.filter:(LPredicate;)LStream;
24: invokeinterface  #33,  1        // InterfaceMethod Stream.count:()J
29: lstore_1
30: return

Несмотря на то, что мы думали, не существует анонимного внутреннего класса и, конечно, никто не передает экземпляр такого класса в фильтр метод .

Удивительно, но инструкция invokedynamic каким-то образом отвечает за создание экземпляра Предиката .

2.2. Лямбда-Специфические Методы

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

private static boolean lambda$main$0(java.lang.String);
    Code:
       0: aload_0
       1: invokevirtual #37                 // Method java/lang/String.length:()I
       4: iconst_3
       5: if_icmple     12
       8: iconst_1
       9: goto          13
      12: iconst_0
      13: ireturn

Этот метод принимает Строку в качестве входных данных, а затем выполняет следующие действия:

  • Вычисление входной длины (invokevirtual on length )
  • Сравнение длины с константой 3 ( if_icmple и iconst_3 )
  • Возврат false если длина меньше или равна 3

Интересно, что на самом деле это эквивалентно лямбде, которую мы передали методу filter :

c -> c.length() > 3

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

В ходе этой статьи мы увидим, как этот вызов работает внутри. Но сначала давайте определим проблему, которую invokedynamic пытается решить.

2.3. Проблема

До Java 7 JVM имела только четыре типа вызова методов: invokevirtual для вызова обычных методов класса, invokestatic для вызова статических методов, invokeinterface для вызова методов интерфейса и invokespecial для вызова конструкторов или частных методов.

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

Существует два основных способа обойти это ограничение: один во время компиляции, а другой во время выполнения. Первый обычно используется такими языками, как Scala или Kotlin , а второй является решением выбора для динамических языков на основе JVM, таких как JRuby.

Подход во время выполнения обычно основан на отражении и, следовательно, неэффективен.

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

Теперь, когда мы лучше понимаем проблему, давайте посмотрим, как решение работает внутри компании.

3. Под капотом

invokedynamic позволяет нам запустить процесс вызова метода любым способом, который мы хотим . То есть, когда JVM впервые видит код операции invokedynamic , он вызывает специальный метод, известный как метод начальной загрузки, для инициализации процесса вызова:

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

Как только метод начальной загрузки завершится нормально, он должен вернуть экземпляр Call Site . Этот Сайт вызова инкапсулирует следующие фрагменты информации:

  • Указатель на фактическую логику, которую должна выполнять JVM. Это должно быть представлено в виде MethodHandle .
  • Условие, представляющее действительность возвращенного Сайта вызова.

Отныне каждый раз, когда JVM снова увидит этот конкретный код операции, он пропустит медленный путь и напрямую вызовет базовый исполняемый файл . Кроме того, JVM будет продолжать пропускать медленный путь до тех пор, пока не изменится условие в Сайте вызова .

В отличие от API отражения, JVM может полностью видеть MethodHandle s и попытается оптимизировать их, следовательно, повысить производительность.

3.1. Таблица методов начальной загрузки

Давайте еще раз взглянем на сгенерированный invokedynamic байт-код:

14: invokedynamic #23,  0  // InvokeDynamic #0:test:()Ljava/util/function/Predicate;

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

  • тест является единственным абстрактным методом в предикате
  • ()Ljava/util/функция/Предикат представляет сигнатуру метода в JVM – метод ничего не принимает в качестве входных данных и возвращает экземпляр интерфейса Предиката

Чтобы увидеть таблицу методов начальной загрузки для примера лямбды, мы должны передать параметр -v в javap:

javap -c -p -v Main
// truncated
// added new lines for brevity
BootstrapMethods:
  0: #55 REF_invokeStatic java/lang/invoke/LambdaMetafactory.metafactory:
    (Ljava/lang/invoke/MethodHandles$Lookup;
     Ljava/lang/String;
     Ljava/lang/invoke/MethodType;
     Ljava/lang/invoke/MethodType;
     Ljava/lang/invoke/MethodHandle;
     Ljava/lang/invoke/MethodType;)Ljava/lang/invoke/CallSite;
    Method arguments:
      #62 (Ljava/lang/Object;)Z
      #64 REF_invokeStatic Main.lambda$main$0:(Ljava/lang/String;)Z
      #67 (Ljava/lang/String;)Z

Метод начальной загрузки для всех лямбд-это статический метод metafactory | в классе LambdaMetafactory |.

Как и все другие методы начальной загрузки, этот метод принимает по крайней мере три аргумента следующим образом :

  • Аргумент Ljava/lang/invoke/MethodHandles$Lookup представляет контекст поиска для invokedynamic
  • Ljava/lang/String представляет имя метода на сайте вызова – в этом примере имя метода test
  • Java/lang/invoke/MethodType является сигнатурой динамического метода сайта вызова – в данном случае это ()Ljava/util/функция/предикат

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

  • (Java/lang/Object;)Z – это стертая сигнатура метода, принимающая экземпляр Object и возвращающая логическое значение.
  • REF_invokeStaticMain.lambda$main$0:(Java/lang/String;)Z – это MethodHandle , указывающий на фактическую лямбда-логику.
  • (Java/lang/String;)Z -это не стираемая сигнатура метода, принимающая одну Строку и возвращающая логическое значение.

Проще говоря, JVM передаст всю необходимую информацию методу начальной загрузки. Метод начальной загрузки, в свою очередь, будет использовать эту информацию для создания соответствующего экземпляра предиката . Затем JVM передаст этот экземпляр методу filter .

3.2. Различные типы сайтов вызовов

Как только JVM впервые увидит invokedynamic в этом примере, он вызовет метод начальной загрузки. На момент написания этой статьи метод начальной загрузки лямбды будет использовать InnerClassLambdaMetafactory для создания внутреннего класса для лямбды во время выполнения.

Затем метод начальной загрузки инкапсулирует сгенерированный внутренний класс внутри специального типа Сайта вызова , известного как ConstantCallSite . Этот тип Сайта вызова никогда не изменится после настройки. Поэтому после первой настройки для каждой лямбды JVM всегда будет использовать быстрый путь для прямого вызова лямбда-логики.

Хотя это наиболее эффективный тип invokedynamic, это, безусловно, не единственный доступный вариант. На самом деле Java предоставляет MutableCallSite и VolatileCallSite для удовлетворения более динамических требований.

3.3. Преимущества

Таким образом, для реализации лямбда-выражений вместо создания анонимных внутренних классов во время компиляции Java создает их во время выполнения через invokedynamic.

Можно было бы возразить против отсрочки создания внутреннего класса до времени выполнения. Однако подход invokedynamic имеет несколько преимуществ по сравнению с простым решением во время компиляции.

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

Кроме того, большая часть логики связи перемещается из байт-кода в метод начальной загрузки. Поэтому байт-код invokedynamic обычно намного меньше, чем альтернативные решения . Меньший байт-код может увеличить скорость запуска.

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

Наконец, написание логики начальной загрузки и компоновки в Java обычно проще, чем прохождение AST для создания сложного фрагмента байт-кода. Таким образом, invokedynamic может быть (субъективно) менее хрупким.

4. Дополнительные Примеры

Лямбда-выражения-не единственная функция, и Java, безусловно, не единственный язык, использующий invokedynamic. В этом разделе мы познакомимся с несколькими другими примерами динамического вызова.

4.1. Java 14: Записи

Записи – это новая функция предварительного просмотра в Java 14 , предоставляющая приятный краткий синтаксис для объявления классов, которые должны быть тупыми носителями данных.

Вот простой пример записи:

public record Color(String name, int code) {}

Учитывая эту простую однострочность, компилятор Java генерирует соответствующие реализации для методов доступа, toString, equals, и hashcode.

Для реализации toString, equals, или хэш-кода Java использует invokedynamic . Например, байт-код для equals выглядит следующим образом:

public final boolean equals(java.lang.Object);
    Code:
       0: aload_0
       1: aload_1
       2: invokedynamic #27,  0  // InvokeDynamic #0:equals:(LColor;Ljava/lang/Object;)Z
       7: ireturn

Альтернативное решение состоит в том, чтобы найти все поля записи и сгенерировать логику equals на основе этих полей во время компиляции. Чем больше у нас полей, тем длиннее байт-код .

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

Более пристальный взгляд на байт-код показывает, что метод начальной загрузки-это Object Methods#bootstrap :

BootstrapMethods:
  0: #42 REF_invokeStatic java/lang/runtime/ObjectMethods.bootstrap:
    (Ljava/lang/invoke/MethodHandles$Lookup;
     Ljava/lang/String;
     Ljava/lang/invoke/TypeDescriptor;
     Ljava/lang/Class;
     Ljava/lang/String;
     [Ljava/lang/invoke/MethodHandle;)Ljava/lang/Object;
    Method arguments:
      #8 Color
      #49 name;code
      #51 REF_getField Color.name:Ljava/lang/String;
      #52 REF_getField Color.code:I

4.2. Java 9: Конкатенация строк

До Java 9 нетривиальные конкатенации строк были реализованы с помощью StringBuilder. В рамках JEP 280 , конкатенация строк теперь использует invokedynamic. Например, давайте объединим постоянную строку со случайной переменной:

"random-" + ThreadLocalRandom.current().nextInt();

Вот как выглядит байт-код для этого примера:

0: invokestatic  #7          // Method ThreadLocalRandom.current:()LThreadLocalRandom;
3: invokevirtual #13         // Method ThreadLocalRandom.nextInt:()I
6: invokedynamic #17,  0     // InvokeDynamic #0:makeConcatWithConstants:(I)LString;

Кроме того, методы начальной загрузки для конкатенаций строк находятся в классе String Concat Factory :

BootstrapMethods:
  0: #30 REF_invokeStatic java/lang/invoke/StringConcatFactory.makeConcatWithConstants:
    (Ljava/lang/invoke/MethodHandles$Lookup;
     Ljava/lang/String;
     Ljava/lang/invoke/MethodType;
     Ljava/lang/String;
     [Ljava/lang/Object;)Ljava/lang/invoke/CallSite;
    Method arguments:
      #36 random-\u0001

5. Заключение

В этой статье, во-первых, мы познакомились с проблемами, которые пытается решить инди.

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

Наконец, мы перечислили несколько других примеров indy в последних версиях Java.