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
onlength
) - Сравнение длины с константой 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
, она вызывает специальный метод, известный как метод начальной загрузки, для инициализации процесса вызова:
Метод начальной загрузки — это обычный фрагмент кода 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.