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

Сериализация лямбды в Java

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

1. Обзор

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

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

2. Лямбда и сериализация

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

2.1. Неудачная лямбда-сериализация

В исходном файле воспользуемся интерфейсом Runnable для построения лямбда-выражения:

public class NotSerializableLambdaExpression {
public static Object getLambdaExpressionObject() {
Runnable r = () -> System.out.println("please serialize this message");
return r;
}
}

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

Когда JVM встречает лямбда-выражение, она использует встроенный ASM для создания внутреннего класса. Итак, как же выглядит этот внутренний класс? Мы можем вывести этот сгенерированный внутренний класс, указав свойство jdk.internal.lambda.dumpProxyClasses в командной строке:

-Djdk.internal.lambda.dumpProxyClasses=<dump directory>

Здесь будьте осторожны: когда мы заменяем <dump directory> нашим целевым каталогом, этот целевой каталог должен быть пустым, потому что JVM может сбросить довольно много неожиданно сгенерированных внутренних классов, если наш проект зависит от сторонних библиотек.

После создания дампа мы можем проверить этот сгенерированный внутренний класс с помощью соответствующего декомпилятора Java:

./f80648677bef862edaca20c1643904f8.png

На приведенном выше рисунке сгенерированный внутренний класс реализует только интерфейс Runnable , который является целевым типом лямбда-выражения. Кроме того, в методе run код будет вызывать метод NotSerializableLambdaExpression.lambda$getLambdaExpressionObject$0 , который генерируется компилятором Java и представляет нашу реализацию лямбда-выражения.

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

2.2. Как сериализовать лямбда

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

Например, давайте объединим Runnable и Serializable в тип пересечения:

Runnable r = (Runnable & Serializable) () -> System.out.println("please serialize this message");

Теперь, если мы попытаемся сериализовать вышеуказанный объект Runnable , это удастся.

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

interface SerializableRunnable extends Runnable, Serializable {
}

Тогда мы можем использовать его:

SerializableRunnable obj = () -> System.out.println("please serialize this message");

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

interface SerializableConsumer<T> extends Consumer<T>, Serializable {
}

Затем мы можем выбрать System.out::println в качестве его реализации:

SerializableConsumer<String> obj = System.out::println;

В результате это приведет к NotSerializableException . Это связано с тем, что эта реализация будет захватывать в качестве аргумента переменную System.out , класс которой — PrintStream , которая не сериализуема.

3. Основной механизм

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

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

public class SerializableLambdaExpression {
public static Object getLambdaExpressionObject() {
Runnable r = (Runnable & Serializable) () -> System.out.println("please serialize this message");
return r;
}
}

3.1. Скомпилированный файл класса

После компиляции мы можем использовать javap для проверки скомпилированного класса:

javap -v -p SerializableLambdaExpression.class

Параметр -v выводит подробные сообщения, а параметр -p отображает частные методы.

И мы можем обнаружить, что компилятор Java предоставляет метод $deserializeLambda$ , который принимает параметр SerializedLambda :

./e0e1875456c66010eae4c134e7e7b714.png

Для удобочитаемости давайте декомпилируем приведенный выше байт-код в код Java:

./5f371a17d24ff799c9b7e926a7094f2f.png

Основная обязанность вышеуказанного метода $deserializeLambda$ — создание объекта. Во-первых, он проверяет методы getXXX SerializedLambda с различными частями деталей лямбда-выражения. Затем, если все условия соблюдены, он вызовет ссылку на метод SerializableLambdaExpression::lambda$getLambdaExpressionObject$36ab28bd$1 для создания экземпляра. В противном случае будет выдано исключение IllegalArgumentException . ``

3.2. Сгенерированный внутренний класс

Помимо проверки скомпилированного файла класса, нам также необходимо проверить только что сгенерированный внутренний класс. Итак, давайте воспользуемся свойством jdk.internal.lambda.dumpProxyClasses для вывода сгенерированного внутреннего класса:

./cf235f76d39a9a6c0ef13af95264f9b7.png

В приведенном выше коде только что сгенерированный внутренний класс реализует интерфейсы Runnable и Serializable , что означает, что он подходит для сериализации. Кроме того, он также предоставляет дополнительный метод writeReplace . Чтобы заглянуть внутрь, этот метод возвращает экземпляр SerializedLambda , описывающий детали реализации лямбда-выражения.

Чтобы сформировать замкнутый цикл, не хватает еще одной вещи: сериализованного лямбда-файла.

3.3. Сериализованный лямбда-файл

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

./de676795a160a021353f2e455f491913.png

В сериализованном потоке шестнадцатеричная « AC ED » («rO0» в Base64) — магический номер потока, а шестнадцатеричная «00 05» — версия потока. Но остальные данные не читаются человеком.

Согласно Object Serialization Stream Protocol остальные данные можно интерпретировать:

./c3444403d4eb5a871bc9fcadefff13fa.png

На приведенном выше рисунке мы можем заметить, что сериализованный лямбда-файл на самом деле содержит данные класса SerializedLambda . Если быть точным, он содержит 10 полей и соответствующих значений. И эти поля и значения класса SerializedLambda являются мостами между методом $deserializeLambda$ в скомпилированном файле класса и методом writeReplace в сгенерированном внутреннем классе .

3.4. Собираем все вместе

Теперь пришло время объединить разные части вместе:

./a6e354e20af3752406944a960ebc4584.png

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

Затем, когда мы используем ObjectInputStream для десериализации сериализованного лямбда-файла, создается экземпляр SerializedLambda . Затем ObjectInputStream будет использовать этот экземпляр для вызова readResolve, определенного в классе SerializedLambda . Кроме того, метод readResolve вызовет метод $deserializeLambda$ , определенный в классе захвата. Наконец, мы получаем десериализованное лямбда-выражение.

Подводя итог, можно сказать, что класс SerializedLambda является ключом к процессу лямбда-сериализации .

4. Вывод

В этой статье мы сначала рассмотрели неудачный пример лямбда-сериализации и объяснили, почему это не удалось. Затем мы представили, как сделать лямбда-выражение сериализуемым. Наконец, мы изучили основной механизм лямбда-сериализации.

Как обычно, исходный код этого руководства можно найти на GitHub .