За кулисами: как на самом деле работают лямбда-выражения в Java
За кулисами: как на самом деле работают лямбда-выражения в Java?

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

Как выглядит лямбда-выражение в коде Java и в JVM? Очевидно, что это некоторый тип значения, а Java допускает только два типа значений: примитивные типы и ссылки на объекты. Лямбды, очевидно, не являются примитивными типами, поэтому лямбда-выражение должно быть каким-то выражением, которое возвращает ссылку на объект.

Давайте рассмотрим пример:
Статью в оригинале можно прочитать тут

public class LambdaExample {
    private static final String HELLO = "Hello World!";

    public static void main(String[] args) throws Exception {
        Runnable r = () -> System.out.println(HELLO);
        Thread t = new Thread(r);
        t.start();
        t.join();
    }
}
Программисты, знакомые с внутренними классами, могут догадаться, что лямбда - это просто синтаксический сахар для анонимной реализации Runnable. Однако компиляция вышеуказанного класса генерирует единственный файл: LambdaExample.class. Никакого дополнительного файла класса для внутреннего класса не существует.

Это означает, что лямбды не являются внутренними классами; скорее, это должен быть какой-то другой механизм. На самом деле декомпиляция байткода с помощью javap -c -p выявляет две вещи. Во-первых, тело лямбды было скомпилировано в частный статический метод, который появляется в главном классе:

private static void lambda$main$0();
    Code:
       0: getstatic     #7                  // Field java/lang/System.out:Ljava/io/PrintStream;
       3: ldc           #9                  // String Hello World!
       5: invokevirtual #10                 // Method java/io/PrintStream.println:(Ljava/lang/String;)V
       8: return
Можно предположить, что сигнатура частного метода body совпадает с сигнатурой лямбды, и это действительно так. Лямбда, подобная следующей:

public class StringFunction {
    public static final Function<string, integer=""> fn = s -> s.length();
}
</string,>
породит такой метод body, который принимает строку и возвращает целое число, что соответствует сигнатуре метода интерфейса

private static java.lang.Integer lambda$static$0(java.lang.String);
    Code:
       0: aload_0
       1: invokevirtual #2                  // Method java/lang/String.length:()I
       4: invokestatic  #3                  // Method java/lang/Integer.valueOf:(I)Ljava/lang/Integer;
       7: areturn
Во-вторых, на что следует обратить внимание в байткоде, - это форма главного метода:

public static void main(java.lang.String[]) throws java.lang.Exception;
    Code:
       0: invokedynamic #2,  0              // InvokeDynamic #0:run:()Ljava/lang/Runnable;
       5: astore_1
       6: new           #3                  // class java/lang/Thread
       9: dup
      10: aload_1
      11: invokespecial #4                  // Method java/lang/Thread."<init>":(Ljava/lang/Runnable;)V
      14: astore_2
      15: aload_2
      16: invokevirtual #5                  // Method java/lang/Thread.start:()V
      19: aload_2
      20: invokevirtual #6                  // Method java/lang/Thread.join:()V
      23: return
</init>
Обратите внимание, что байткод начинается с вызова invokedynamic. Эта операция была добавлена в Java в версии 7 (и это единственная операция, когда-либо добавленная в байткод JVM). О вызове метода можно узнать в статьях "Обработка байткода в реальном мире с помощью ASM" и "Понимание вызова метода Java с использованием invokedynamic", которые вы можете прочитать в качестве дополнения к этой статье.

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

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

  1. call sites (места вызова),

  2. method handles (хэндл метод, методические обработчики, MH)

  3. bootstrapping (функция стартовой загрузки, инициализация, BSM)

Call sites (места вызова)

Место в байткоде, где встречается инструкция вызова метода, называется местом вызова.

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

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

В данном случае места вызова invokedynamic представлены в виде объектов CallSite в куче Java. . Это не удивительно: Java делает что-то похожее с Reflection API со времен Java 1.1 с такими типами, как Method и, раз уж на то пошло, Class. Java имеет множество динамических поведений во время выполнения, поэтому нет ничего удивительного в том, что Java теперь моделирует места вызовов, а также другую информацию о типе во время выполнения.

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

Объект места вызова - это необходимый уровень косвенности, позволяющий связанной с ним цели вызова (т. е. методическому обработчику) меняться с течением времени.

Существует три доступных подкласса CallSite (который является абстрактным): ConstantCallSite, MutableCallSite и VolatileCallSite. Базовый класс имеет только конструкторы с доступом к пакету, в то время как у трех подклассов есть публичные конструкторы. Это означает, что пользовательский код не может напрямую создавать подклассы CallSite, но можно создавать подклассы трех доступных подклассов. Например, язык JRuby использует invokedynamic в рамках своей реализации и создает подкласс MutableCallSite.

Примечание: Некоторые места вызова invokedynamic фактически просто лениво вычисляются, и метод, на который они нацелены, никогда не изменится после их первого выполнения. Это очень распространенный случай использования ConstantCallSite, который включает в себя лямбда-выражения.

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

Method handles (хэндл метод, методические обработчики)

Рефлексия (Reflection) - - это мощная техника для выполнения действий во время выполнения, но у нее есть ряд недостатков в конструкции (здесь можно проводить мудрые выводы впоследствии), и сейчас она определенно устарела. Одной из ключевых проблем рефлексии является производительность, особенно потому, что рефлективные вызовы трудно встраивать в компилятор Just-In-Time - "точно в срок" (JIT).

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

Вторая проблема заключается в том, что рефлексивные вызовы связываются каждый раз, когда обнаруживается место вызова Method.invoke(). Это означает, например, что выполняются проверки доступа к безопасности. Это очень расточительно, потому что эта проверка, как правило, либо успешна, либо не успешна при первом вызове, и если она успешна, то будет продолжаться в течение всей жизни программы. Однако рефлексия снова и снова выполняет эту связывание. Таким образом, рефлексия несет много ненужных издержек на перелинковку и потерю вычислительного времени.

Для решения этих (и других) проблем в Java 7 появилось новое API, java.lang.invoke, которое часто вскользь называют "методическими обработчиками" (хэндл метод, MH) из-за названия главного класса, введенного в этом API.

Хэндл метод (MH) - это версия безопасного указателя функции с сохранением типа в Java. Это способ ссылаться на метод, который код может захотеть вызвать, аналогично объекту Method из рефлексии Java. У MH есть метод invoke(), который фактически выполняет базовый метод, так же, как рефлексия.

На одном уровне MH - это просто более эффективный механизм рефлексии, который ближе к низкоуровневому; все, что представлено объектом из Reflection API, может быть преобразовано в эквивалентный MH. Например, объект рефлексии Method может быть преобразован в MH с помощью Lookup.unreflect(). Созданные MH обычно являются более эффективным способом доступа к базовым методам.

MH могут быть адаптированы с помощью вспомогательных методов класса MethodHandles различными способами, такими как композиция и частичное связывание аргументов метода ("каррирование" - currying).

Обычно связывание методов требует точного совпадения дескрипторов типов. Однако метод invoke() в MH имеет специальную полиморфную сигнатуру, которая позволяет выполнять связывание независимо от сигнатуры вызываемого метода.

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

Поскольку Java является статически типизированным языком, возникает вопрос о том, насколько можно сохранить безопасность типов при использовании такого фундаментально динамического механизма. В API MH этот вопрос решается с помощью типа MethodType, который представляет собой неизменяемое представление аргументов, принимаемых методом: сигнатуру метода.

Внутренняя реализация MH была изменена в ходе развития Java 8. Новая реализация называется лямбда-формами, и она обеспечила значительное повышение производительности: теперь MH более эффективны, чем рефлексия, во многих случаях использования.
Bootstrapping (метод загрузки)

Когда в потоке инструкций байткода впервые встречается каждый конкретное место вызова invokedynamic, JVM не знает, какому методу оно соответствует. Фактически, с этой инструкцией не связан объект места вызова.

Место вызова должно быть загружено, и JVM достигает этого, выполнив метод загрузки (bootstrap method или BSM) для создания и возврата объекта места вызова.

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

Декомпиляция места вызова invokedynamic, например, из моего исходного примера с интерфейсом Runnable, показывает следующую форму:

0: invokedynamic #2,  0
А в пуле констант файла класса обратите внимание, что запись #2 - это константа типа CONSTANT_InvokeDynamic. Соответствующие части пула констант выглядят следующим образом:

#2 = InvokeDynamic      #0:#31
   ...
  #31 = NameAndType        #46:#47        // run:()Ljava/lang/Runnable;
  #46 = Utf8               run
  #47 = Utf8               ()Ljava/lang/Runnable;
Наличие 0 в константе - это намек. Записи пула констант нумеруются от 1, поэтому 0 напоминает вам, что фактический BSM находится в другой части файла класса.

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

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

BSM принимает как минимум три аргумента и возвращает CallSite. Стандартные аргументы имеют следующие типы:

  • MethodHandles.Lookup: Объект поиска класса, в котором находится место вызова.

  • String: Имя, указанное в NameAndType.

  • MethodType: Разрешенный дескриптор типа NameAndType

За этими аргументами следуют любые дополнительные аргументы, которые необходимы BSM. В документации они называются дополнительными статическими аргументами.

Общий случай BSM предоставляет крайне гибкий механизм, и разработчики, не связанные с языком Java, используют его. Однако в языке Java нет языкового конструкта для создания произвольных мест вызова invokedynamic.

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

Декодирование метода бутстрапа лямбда-выражения

Используйте аргумент -v в javap, чтобы увидеть BSM. Это необходимо, потому что BSM находятся в специальной части файла класса и ссылаются обратно на пул констант. Для этого простого примера Runnable в этой части находится один метод загрузки:

BootstrapMethods:
  0: #28 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:
      #29 ()V
      #30 REF_invokeStatic LambdaExample.lambda$main$0:()V
      #29 ()V
Это немного трудно читать, поэтому давайте расшифруем это.

Метод загрузки для этого места вызова находится в записи #28 в пуле констант. Это запись типа MethodHandle (тип пула констант, который был добавлен в стандарт в Java
Теперь давайте сравним это с примером строковой функции:

0: #27 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:
      #28 (Ljava/lang/Object;)Ljava/lang/Object;
      #29 REF_invokeStatic StringFunction.lambda$static$0:(Ljava/lang/String;)Ljava/lang/Integer;
      #30 (Ljava/lang/String;)Ljava/lang/Integer;
Хэндл метод, который будет использоваться в качестве BSM, - это тот же статический метод LambdaMetafactory.metafactory( ... ).

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

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

Метафабрики лямбда-выражений

BSM выполняет вызов этого статического метода, который в конечном итоге возвращает объект места вызова. Когда выполняется инструкция invokedynamic, хэндл метода, содержащийся в сайте вызова, возвращает экземпляр класса, реализующего целевой тип лямбды.

Исходный код метода metafactory относительно прост:

public static CallSite metafactory(MethodHandles.Lookup caller,
                                       String invokedName,
                                       MethodType invokedType,
                                       MethodType samMethodType,
                                       MethodHandle implMethod,
                                       MethodType instantiatedMethodType)
            throws LambdaConversionException {
        AbstractValidatingLambdaMetafactory mf;
        mf = new InnerClassLambdaMetafactory(caller, invokedType,
                                             invokedName, samMethodType,
                                             implMethod, instantiatedMethodType,
                                             false, EMPTY_CLASS_ARRAY, EMPTY_MT_ARRAY);
        mf.validateMetafactoryArgs();
        return mf.buildCallSite();
}
Объект lookup соответствует контексту, в котором находится инструкция invokedynamic. В данном случае это тот же класс, в котором была определена лямбда, поэтому контекст поиска будет иметь правильные разрешения для доступа к приватному методу, в который было скомпилировано тело лямбды.

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

В текущей реализации метафабрика делегирует выполнение кода, использующего внутреннюю копию библиотеки байткода ASM, для создания внутреннего класса, реализующего целевой тип.

Если лямбда не получает никаких параметров из своей области видимости, получаемый объект не имеет состояния (stateless), поэтому реализация оптимизируется путем предварительного вычисления единственного экземпляра - по сути, класс реализации лямбды становится синглтоном:

jshell> Function<string, integer=""> makeFn() {
   ...>   return s -> s.length();
   ...> }
|  created method makeFn()

jshell> var f1 = makeFn();
f1 ==> $Lambda$27/0x0000000800b8f440@533ddba

jshell> var f2 = makeFn();
f2 ==> $Lambda$27/0x0000000800b8f440@533ddba

jshell> var f3 = makeFn();
f3 ==> $Lambda$27/0x0000000800b8f440@533ddba
</string,>
Это одна из причин, почему документация настоятельно не рекомендует программистам Java полагаться на любую форму семантики идентичности для лямбда-выражений.
Заключение

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

Попутно мы обсудили invokedynamic и API методов обработки. Это две ключевые техники, которые являются основными частями современной платформы JVM. Оба этих механизма находят все большее используются в экосистеме; например, invokedynamic был использован для реализации новой формы конкатенации строк в Java 9 и выше.

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