Перейти к основному содержимому

Введение в вызов динамики в JVM

· 9 мин. чтения

1. Обзор

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

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

2. Познакомьтесь с Invoke Dynamic

Начнем с простой цепочки вызовов Stream 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 создает анонимный внутренний класс, производный от Predicate , а затем передает этот экземпляр методу фильтра . Но мы ошибаемся.

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 каким-то образом отвечает за создание экземпляра Predicate .

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

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

c -> c.length() > 3

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

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

2.3. Проблема

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

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

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

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

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

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

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

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

./4a6011e7f7e1f338f764a41f258e8a9b.svg

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

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

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

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

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

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

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

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

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

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

Чтобы увидеть таблицу методов начальной загрузки для лямбда-примера, мы должны передать параметр -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

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

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

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

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

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

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

3.2. Различные типы CallSite

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

Затем метод начальной загрузки инкапсулирует сгенерированный внутренний класс внутри специального типа CallSite , известного как ConstantCallSite . Этот тип CallSite никогда не изменится после установки. Поэтому после первой настройки для каждой лямбда-выражения 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 или hashcode, 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

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

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

При более внимательном рассмотрении байт-кода видно, что метод начальной загрузки — это ObjectMethods#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;

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

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.