Привет, Хабр! В этой статье я расскажу об эволюции подсистемы сериализации one-nio — фреймворка для создания высоконагруженных сервисов. Прошлой осенью я работал над обновлением подсистемы и добавил в неё новый режим работы, совместимый с актуальными версиями JDK.

Ситуация, с которой мы столкнулись, довольно прозаична. Библиотеке больше десяти лет, и экстремально быстрая сериализация (превращение объекта в последовательность байтов и обратно) с самого начала строилась в ней на внутренних лазейках JVM, к которым обычный прикладной код доступа не имеет. Когда one-nio только писали, это был стандартный паттерн для высоконагруженных фреймворков.

Сейчас же платформа методично «закручивает гайки»: старые бэкдоры помечаются как устаревшие, а затем безжалостно удаляются. И перед нами встал серьёзный вызов: как перевести библиотеку на легальные API вплоть до JDK 25, сохранив производительность и не сломав то, что годами крутится в проде?

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

Зачем понадобилась ещё одна библиотека сериализации?

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

Ситуация усложнилась с массовым переходом на микросервисную архитектуру, которая начала набирать популярность в конце нулевых — начале 2010-х годов. Чтобы вернуть клиенту осмысленный результат, нужно сходить в несколько сервисов, а те, в свою очередь, инициируют каскад вызовов к другим компонентам системы. В облаке, где работают тысячи узлов, расходы на взаимодействие становятся критическими. Чтобы был понятен масштаб: на 2026 год суточная аудитория «Одноклассников» измеряется десятками миллионов пользователей, нагрузка достигает сотен тысяч RPS, а инфраструктура объединяет тысячи различных сервисов, распределённых по десяткам тысяч реплик и сотням тысяч вычислительных ядер в облаке.

Примерно в то же время (чуть раньше, в середине нулевых) появились системы для обработки огромных объёмов данных — Hadoop, Kafka, Cassandra и подобные. Им по их же архитектуре нужны были компактные и быстрые форматы хранения и передачи. Перед индустрией стояло несколько вызовов, которые требовали новых решений.

Использовать стандартную сериализацию Java для таких задач было невозможно. Она создавалась в совершенно другую эпоху — в конце 90-х — и строилась на принципе универсальности: переносе объектов (точнее, графов объектов) из одной JVM в другую. Универсальность стандартной сериализации влекла за собой много накладных расходов, связанных с передачей метаинформации: заголовки, дескрипторы, сведения о базовых классах. 

Всё это делает такой формат крайне избыточным и неповоротливым. На Flame Graph (диаграмме, которая показывает, на что уходит время и ресурсы процессора) сериализации простого объекта с двумя полями видно, что при десериализации накладные расходы в разы больше полезной нагрузки, а при сериализации полезная нагрузка и накладные расходы хотя бы сопоставимы.

В те годы индустрия активно переходила на REST API (поверх JSON или XML). Однако для по-настоящему высоконагруженных систем этот подход быстро уткнулся в архитектурный потолок. Во-первых, текстовый формат катастрофически избыточен: сетевой трафик раздувается из-за многократного дублирования ключей, а числа и даты вынужденно передаются строками. Во-вторых, парсинг сжигает такты процессора на бесконечную конвертацию текста обратно в машинные типы. В-третьих, накладные расходы сильно увеличивает сам протокол HTTP/1.1 с его тяжеловесными текстовыми заголовками на каждое сообщение. И ещё одно ограничение: плоский JSON из коробки не умеет работать со сложными графами объектов, где есть множественные и циклические ссылки, что делает его непригодным для передачи сложной топологии данных.

На эти вызовы индустрия ответила волной библиотек сериализации: Thrift, Protobuf, MessagePack, Avro, Kryo, FST и другие выходили примерно с 2007 по 2014 год и позже. В 2012 году этот ряд пополнила библиотека one-nio. Перед командой Одноклассников стояла двоякая задача: укротить колоссальную нагрузку на железо и при этом избавить разработчиков от рутины, замедляющей процессы. 

В библиотеку закладывали конкретные требования:

  • Скорость и компактность. Критическое условие для выживания сети и процессоров под трафиком социальной сети.

  • Минимум ручной работы: в инфраструктуре из тысяч сервисов ручное описание DTO и контрактов неконтролируемо замедляет разработку. Библиотека должна была уметь самостоятельно выводить топологию данных на лету прямо из Java-классов.

  • Бесшовная эволюция данных. Сервисы обновляются итеративно, поэтому среда исполнения должна обеспечивать прозрачную прямую и обратную совместимость (forward и backward compatibility) между старыми и новыми версиями объектов. 

Поверх этой системы сериализации и десериализации затем сделали фреймворк удалённого вызова процедур (RPC, remote procedure call).

Тут напрашивается логичный вопрос: а почему было не взять Protobuf, который к тому моменту уже существовал?

Дело в том, что Protobuf требует предварительного описания контрактов (proto-файлов) и кодогенерации отдельных классов. Когда друг с другом общаются тысячи сервисов, поддержка и синхронизация этих схем выливается в огромный инфраструктурный оверхед (ту самую ручную работу, которой мы хотели избежать). Философия one-nio заключалась в максимальной прозрачности для разработчика: ты просто отправляешь обычный Java-объект, и он «магическим» образом, со всеми своими связями, материализуется на другой стороне.

Как устроена сериализация в one-nio

Глобально процесс сериализации в JVM сводится к двум фундаментальным задачам: обход графа объектов для преобразования во внешнее представление (в нашем случае — бинарный поток) и физический доступ к состоянию (полям) этих объектов.

Начнём с графа. Возьмём корневой объект, который через два своих поля ссылается на два других. Его можно развернуть в плоскую структуру, примерная схема такая: пишем метаинформацию о том, что будет дальше, при необходимости пишем схему, а затем сами поля и их значения. Например, корень ссылается на объект Data с полем intField = 128 и на строку "Hello", и в потоке это выглядит как набор флагов, схем и значений, идущих друг за другом. 

Особая логика включается при наличии циклических или множественных ссылок на один и тот же объект. Сериализатор не имеет права писать объект в поток дважды. Вместо этого он ставит специальный флаг — ссылку (back-reference) на уже сериализованный участок. То есть алгоритм обязан вести строгий учёт встреченных объектов, чтобы восстанавливать именно исходный граф объектов. Этот момент ещё всплывёт ближе к концу, когда речь зайдёт о Java Records.

Как вся эта теория ложится на код? Архитектурно в one-nio есть две основные абстракции: DataStream отвечает за запись метаинформации в выходной поток и за выдачу конечного результата, а Serializer отвечает за превращение объектов в массивы байтов. 

Базовый интерфейс Serializer реализуют около 50 сущностей (примитивы, обёртки, строки, коллекции, перечисления). Но самое интересное прячется за фасадом GeneratedSerializer, который работает с пользовательскими классами.

GeneratedSerializer — это обёртка, которая в runtime получает пользовательский класс и пытается построить для него сериализатор и десериализатор. Для этого она использует генератор классов: тот с помощью рефлексии проходит по описанию пользовательского класса и создаёт runtime-компонент, который раскладывает объект на поля и сериализует их базовыми сериализаторами через DataStream. Этот же компонент отвечает за эволюцию схемы. Допустим, тип поля изменился с int на long. Генератор заметит разницу между схемой в потоке и текущим классом, автоматически добавив нужные преобразования. Это и обеспечивает ту самую бесшовную обратную совместимость, о которой мы говорили в начале.

Но чтобы разложить объект на поля, runtime-компоненту нужно как-то добраться до его состояния. Исторически это делали двумя механизмами: через наследование от класса MagicAccessorImpl и через утилиту Unsafe. 

MagicAccessorImpl — это внутренний класс JVM с феноменальным свойством: для всех его наследников виртуальная машина отключает верификацию байт-кода. Это означает, что сгенерированный в runtime наследник MagicAccessorImpl может обращаться к private-полям любых классов так, словно они public. В байт-коде это выглядит как обычная инструкция PUTFIELD или GETFIELD. Обычный компилятор javac такой код никогда не пропустит, но мы генерируем его на лету с помощью библиотеки ASM. 

package myPackage;

public class MyClass {
   
   private String data;
   
   private void sayHello() {
      println(data);
   }
}

package sun.reflect;
import mypackage.MyClass;

public class ConfMagic extends MagicAccessorImpl {
   
   public void doSomeWork(MyClass myClass) {
       myClass.data = "Hello Conf"; //PUT_FIELD в байткоде
       myClass.sayHello();
   }
}

Единственная проблема: через стандартный PUTFIELD нельзя изменить final поле после инициализации объекта. Эту брешь и закрывает Unsafe. Это внутренний низкоуровневый API  (sun.misc.Unsafe в JDK 8, и jdk.internal.misc.Unsafe в JDK 9+), позволяющий напрямую манипулировать оперативной памятью. На нём исторически держался весь high-load в Java (Netty, Cassandra, Hazelcast, Kafka). Инструмент полностью оправдывает своё название: одно неверное смещение может случайно уронить JVM. 

Зато он умеет то, что жизненно необходимо десериализатору:

  1. Создать пустой экземпляр объекта в обход любого конструктора (allocateInstance).

  2. Вычислить физическое смещение поля в памяти (objectFieldOffset).

  3. Записать новое значение поля прямо по этому смещению (putObject) — причём Unsafe полностью игнорирует модификатор final и правила инкапсуляции.

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

public class MyClass {
  
    private final String data;
    
    public MyClass(String data) {
      this.data = data;
    }
  
    public void sayHello() {
      println(data);
    }
}

public Unsafe getUnsafe() throws ... {
   Field field = Unsafe.class.getDeclaredField("theUnsafe");
   field.setAccessible(true);
   return (sun.misc.Unsafe) field.get(null);
}

public MyClass doSomeWork(Class<MyClass> cls) throws … {
   MyClass data = (MyClass) getUnsafe().allocateInstance(cls);
   long fieldOffset =                                  
        unsafe.objectFieldOffset(
          cls.getDeclaredField(“data”)
        );
      getUnsafe().putObject(data, fieldOffset, “New Value”);
   return data;
 }

Итого, фундамент старого режима one-nio держался на двух титанах: MagicAccessorImpl для работы с обычными полями, и Unsafe для работы с final-полями. Пока платформа закрывала на это глаза, всё работало безупречно.

Почему потребовался новый режим сериализации

Короткий ответ: платформа начала физически вырезать наш старый фундамент в новых релизах.

Если вы пишете high-load и следите за JEP (JDK Enhancement Proposals), то видите, что Oracle реализует стратегию по переходу к строгой инкапсуляции.

Старая схема с Unsafe и MagicAccessorImpl не стала работать хуже сама по себе. Просто JVM перестала доверять прикладным библиотекам. К JDK 23–24 этот процесс достиг кульминации: класс MagicAccessorImpl был окончательно удалён из недр виртуальной машины, а методы Unsafe начали планомерно выводить из эксплуатации (JEP 471).

Ждать, пока one-nio просто упадёт с NoSuchMethodError или SIGSEGV в продакшене на свежих версиях Java, было нельзя. Однако мы не стали бросаться в крайности и полностью переписывать или удалять старый, проверенный годами код. Многие проекты всё ещё работают на старых LTS-релизах JDK, где старые бэкдоры выдают максимальную производительность и работают абсолютно стабильно.

Вместо этого мы пошли по пути аккуратной архитектурной эволюции: добавили во фреймворк совершенно новый, легальный режим сериализации, который берёт управление на себя при запуске на современных версиях JVM. И основным кандидатом на роль нового ядра предсказуемо стал VarHandle.

Но тут мы столкнулись с главным архитектурным парадоксом новой Java. У VarHandle есть принципиальное ограничение, заложенное самими создателями язык: ему строго запрещено менять final-поля.

С точки зрения авторов платформы, final означает final. Если десериализатору нужно восстановить объект, у которого есть неизменяемые поля (а в современных распределённых системах иммутабельных объектов подавляющее большинство), то VarHandle просто откажется это делать.

Получается патовая ситуация:

  1. С одной стороны, JVM заставляет нас уходить на легальные API (VarHandle).

  2. С другой стороны, эти легальные API не позволяют реализовать базовую потребность любого сериализатора: восстановить состояние иммутабельного объекта без вызова конструктора (которого у нас может просто не быть).

Именно поэтому, забегая вперед, скажу: даже в нашем новом режиме мы не смогли на 100% избавиться от старого Unsafe. Мы оставили его исключительно как точечный, хирургический инструмент для одной-единственной задачи — записи значений в final-поля.

От Unsafe и MagicAccessorImpl к VarHandle

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

По своей сути это строго типизированная ссылка на ячейку памяти, которая поддерживает различные барьеры памяти и режимы доступа: обычное чтение/запись, volatile-доступ, атомарные операции (compare-and-set) и другие. Можно, например, получить неволатильный доступ к volatile-переменной и наоборот.

public class MyClass {
   private String data;
   ...
}

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

VarHandle getVarHandle() throws Exception {
  MethodHandles.Lookup lookup = 
     MethodHandles.privateLookupIn(
        MyClass.class,
        MethodHandles.lookup()
     );

  return lookup.findVarHandle(MyClass.class, “data”, String.class); 
}

Через MethodHandles.privateLookupIn мы запрашиваем у JVM такие же привилегии, какие есть у самого MyClass к своим private-полям. И тут кроется главная фишка архитектуры: все проверки безопасности (security checks) происходят исключительно в момент создания VarHandle. При дальнейшей работе с полем (вызов get или set) никаких проверок прав доступа уже не выполняется, что критически важно для производительности.

Но кому JVM вообще позволяет получить такой токен? Здесь платформа строго следит за границами модулей (система JPMS, появившаяся в JDK 9). Если вы запрашиваете доступ к внутреннему классу модуля из своего же модуля или обращаетесь к классу вне модуля вообще (в этом случае JVM автоматически помещает такой код в безымянный модуль — unnamed module, как в случае с нашим MyClass), — проблем нет, JVM без вопросов выдаст Lookup.

Но если мы попытаемся сделать privateLookupIn к закрытому классу в недрах чужого, жёстко инкапсулированного модуля (например, в системный java.base), то виртуальная машина ударит по рукам исключением IllegalAccessException. Чтобы это легально работало, целевой модуль должен явно открыть свои пакеты для глубокой рефлексии (через директиву opens или флаг запуска JVM --add-opens).

После инициализации VarHandle мы можем читать и писать значения:

public static void main(String[] args) throws ... {
    MyClass instance = new MyClass("Hello");
    VarHandle varHandle = getVarHandle();
    String value = (String) varHandle.get(instance);
    System.out.println(value);
    varHandle.set(instance, value + " Updated");
    System.out.println((String) varHandle.get(instance));
}

Но здесь кроется главная особенность VarHandle (а точнее, чудеса инлайнинга JVM). Его производительность сильно зависит от того, как именно вы храните ссылку на него. Мы сделали простой бенчмарк (JDK 21, Linux x86_64, i7-1185G7, измерение пропускной способности — оп/мс):

Способ доступа

операции/мс

directFieldAccess

3 617 924

staticFinalHandle

3 443 090

staticHandle

417 151

instanceFinalHandle

409 420

instanceHandle

409 084

Как видно из результатов бенчмарка, VarHandle способен тягаться с прямым доступом (PUTFIELD, GETFIELD), только если объявить его как static final. В этом случае JIT-компилятор относится к VarHandle как к абсолютной константе, агрессивно инлайнит его вызов и выкидывает лишние проверки. Любой другой вариант хранения замедляет код почти в 9 раз! Именно поэтому при генерации наших runtime-компонентов сериализатора мы сохраняем все VarHandle строго как статические константы.

Альтернативой мог бы стать обычный Reflection. Исторически он считался медленным, но в Java 18 (в рамках JEP 416) внутренности java.lang.reflect переписали поверх MethodHandle. Результат оказался интересным: доступ через константный Field в рефлексии ускорился почти в три раза, а вот остальные режимы заметно просели. Но даже с этими оптимизациями правильно приготовленный VarHandle работает примерно вдвое быстрее Reflection и не уступает прямому доступу к полю.

Анатомия сгенерированного байт-кода

Но как этот переход выглядит на уровне байт-кода? Давайте заглянем внутрь классов, которые сериализатор генерирует в runtime. Возьмём для примера простой DTO с двумя приватными полями:

class TestData implements Serializable {
    private String nonFinalField;
    private final String finalField;
      …
}

В старом режиме (через MagicAccessorImpl) десериализация нефинального поля выглядела как одна элегантная инструкция PUTFIELD. В новом легальном режиме всё становится сложнее. Нам нужно достать статический VarHandle, положить на стек сам объект и прочитанное значение, и вызвать полиморфный метод set:

public final read(Lone/nio/serial/DataStream;)Ljava/lang/Object;
    ...
    ALOAD 1
    INVOKEVIRTUAL one/nio/serial/DataStream.readObject ()Ljava/lang/Object;
    CHECKCAST java/lang/String
    //старый вариант с MagicAccessorImpl
    PUTFIELD TestData.nonFinalField : Ljava/lang/String;
    // новый с VarHandle
    GETSTATIC sun/reflect/Delegate0_TestData.$nonFinalField : Ljava/lang/invoke/VarHandle;
    … //переупорядочиваем аргументы на стеке
    INVOKEVIRTUAL java/lang/invoke/VarHandle.set (Ljava/lang/Object;Ljava/lang/String;)V

А вот для final-поля, как мы и обсуждали выше, ничего не изменилось. VarHandle не умеет писать в final, поэтому мы продолжаем использовать Unsafe (спрятанный у нас за утилитарным классом JavaInternals) по заранее вычисленному смещению памяти:

public final read(Lone/nio/serial/DataStream;)Ljava/lang/Object;
    ...
    // инициализация final по прежнему работает с Unsafe
    DUP
    ALOAD 1
    INVOKEVIRTUAL one/nio/serial/DataStream.readObject ()Ljava/lang/Object;
    CHECKCAST java/lang/String
    LDC 16L //смещение final поля
    INVOKESTATIC one/nio/util/JavaInternals.putObject (Ljava/lang/Object;Ljava/lang/Object;J)V
    ARETURN

В итоге мы получили комбинированную архитектуру: обычные поля легально пишутся через VarHandle, а final-поля — «грязно» через Unsafe.

Чтобы всё это работало и не превратилось в кашу из спагетти-кода, нам пришлось немного изменить архитектуру кодогенератора. Если раньше генератор был монолитным, то теперь над операциями доступа появилась абстракция (стратегия доступа к полям). Сейчас поддержаны две стратегии: старая (MagicAccessor) и новая (Handle-based). Мы можем переключать их в зависимости от версии JVM, а в будущем можем легко добавить экспериментальные движки.

За элегантность работы с VarHandle приходится платить только одним ресурсом — длительностью запуска. VarHandle и MethodHandle создаются и линкуются в статическом инициализаторе класса (<clinit>), что даёт дополнительную нагрузку при первичной загрузке сгенерированных runtime-компонентов. Зато на разогретом проде мы не теряем ни микросекунды.

Что показывают бенчмарки

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

Проверка паритета старого и нового режимов (условия: one-nio 2.2, OpenJDK 21, Linux x86_64, Intel Ultra 5 235H):

Мы замерили полный цикл (сериализация плюс десериализация). На графике по горизонтали — размер данных в байтах (узлы около 100, 1 000 и 10 000), по вертикали — время в микросекундах. Четыре линии показывают старый и новый режимы в двух конфигурациях.
Мы замерили полный цикл (сериализация плюс десериализация). На графике по горизонтали — размер данных в байтах (узлы около 100, 1 000 и 10 000), по вертикали — время в микросекундах. Четыре линии показывают старый и новый режимы в двух конфигурациях.

Графики идут практически внахлест. Заметной просадки времени после перехода с MagicAccessorImpl на VarHandle не произошло. 

One-nio по сравнению со стандартной сериализацией Java (те же условия тестирования):

У one-nio здесь представлены два режима: с записью схемы в поток (чуть медленнее) и без неё (быстрее).
У one-nio здесь представлены два режима: с записью схемы в поток (чуть медленнее) и без неё (быстрее).

График наглядно демонстрирует качественное превосходство. 

Внешний бенчмарк (первоначального режима):

Есть и внешний бенчмарк из статьи Андрея Чернова «Как мы упростили жизнь высоконагруженным сервисам с Platform V SessionsData» (апрель 2023). Там сравниваются Java standard, Bson4Jackson, Jackson JSON, Jackson Smile, Kryo, BSON MongoDB, one-nio и FST: по горизонтали byte[].length в DTO (data transfer object, объект для передачи данных) примерно от 100 до 10 000 байтов, по вертикали время в миллисекундах. В этом бенчмарке one-nio оказался среди наиболее быстрых вариантов, немного уступая FST. 
Есть и внешний бенчмарк из статьи Андрея Чернова «Как мы упростили жизнь высоконагруженным сервисам с Platform V SessionsData» (апрель 2023). Там сравниваются Java standard, Bson4Jackson, Jackson JSON, Jackson Smile, Kryo, BSON MongoDB, one-nio и FST: по горизонтали byte[].length в DTO (data transfer object, объект для передачи данных) примерно от 100 до 10 000 байтов, по вертикали время в миллисекундах. В этом бенчмарке one-nio оказался среди наиболее быстрых вариантов, немного уступая FST. 

Отдельно мы сравнили one-nio с Protobuf. Схемы работы сериализаторов немного разные: one-nio сериализует доменный объект сразу в массив байтов, а у Protobuf добавляется промежуточный proto-слой — сначала объект превращается в него, а затем уже в байты. 

Вывод: на этапе сериализации Protobuf оказывается производительнее, зато при десериализации лидерство переходит к one-nio. Но обобщать эту картину на все возможные сценарии нельзя: итоговая производительность будет сильно зависеть от структуры данных в вашем конкретном проекте.

Архитектурные парадоксы: Java Records и проблема циклических ссылок

Пока мы отлаживали новый режим, всплыла интересная особенность, связанная с Java Records. Напомню: record — это специальный вид классов, задуманный как прозрачный носитель данных. Спецификация гарантирует им лишь «неглубокую» (shallow) неизменяемость: все поля записи жестко фиксированы (final) и заполняются исключительно через канонический конструктор, даже если сами объекты по этим ссылкам вполне мутабельны.

При проектировании сериализации записей создатели JDK переосмыслили старые подходы и закрыли множество уязвимостей. Теперь при десериализации record виртуальная машина принудительно требует вызова канонического конструктора. Записать значения в финальные поля записи через Unsafe больше нельзя: при малейшей попытке вычислить смещение поля у record виртуальная машина немедленно выбросит исключение.

Казалось бы, вызывай конструктор и живи спокойно. Но тут начинается самое интересное — проблема циклических ссылок.

class Data implements Serializable {
   Object obj;
}

class Carrier(Data d) implements Serializable { }

// create an instance of both Data and Carrier, and a cycle between them
Data d1 = new Data();
Carrier c1 = new Carrier(d1);
d1.obj = c1;

Если класс обычный, то полный цикл спокойно сериализуется в поток и поднимается из него обратно: создаём объект, регистрируем ссылку на него, а потом дозаполняем поля. С record так нельзя.

class Data implements Serializable {
   Object obj;
}

record Carrier(Data d) implements Serializable { }

// create an instance of both Data and Carrier, and a cycle between them
Data d1 = new Data();
Carrier c1 = new Carrier(d1);
d1.obj = c1;

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

А как же раньше полный граф поднимался даже для записей? Работало это благодаря MagicAccessorImpl. При создании объекта в JVM генерируются две инструкции: выделение памяти (new) и вызов конструктора.

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

Если свести различия старого и нового режима к сути: раньше объекты инициализировались через new и конструктор, теперь — через Unsafe.allocateInstance; доступ к полям раньше шёл через Unsafe и MagicAccessorImpl, теперь через Unsafe и VarHandle; и появилась та самая доплата на создание обработчиков при загрузке классов. Плюс описанная разница с циклами в Java Records.

Что готовят грядущие релизы JDK?

Основное направление развития Java сейчас — сделать final по-настоящему неизменяемым. Инженерная логика здесь прозрачна: если поле помечено как final, то JIT-компилятор имеет право делать на этом допущении агрессивные оптимизации. Любая нелегальная мутация через Unsafe или Reflection ломает эти оптимизации и делает поведение системы непредсказуемым.

Разработчики JDK методично закрывают этот архитектурный долг через серию JEP:

  • JEP 471 (JDK 23): помечает методы Unsafe для доступа к памяти как устаревшие (deprecated) и подлежащие удалению.

  • JEP 498 (JDK 24): вводит дополнительные предупреждения при использовании этих методов, подготавливая экосистему к переходу.

  • JEP 500 (JDK 26): готовит почву под то, чтобы любая мутация final-полей после инициализации стала невозможной.

Остановимся на последнем JEP 500 подробнее. Раньше final можно было обойти не только через Unsafe, но и через обычную рефлексию:

class C {
  final int field;
  C() { 
    field = 100; 
  }
}


Field f = C.class.getDeclaredField("field");
f.setAccessible(true); // Делаем mutable
C obj = new C();
System.out.println(obj.field);  // 100
f.set(obj, 200);
System.out.println(obj.field);  // 200

JEP 500 вводит флаг --illegal-final-field-mutation, который регулирует поведение JVM на runtime. В режиме allow всё выполняется и мутации разрешены — это старое поведение. В режиме warn выполнение успешно, но на каждый модуль выдаётся одно предупреждение, и так работает по умолчанию в JDK 26. В режиме debug к этому добавляется сообщение и stack trace на каждую попытку записи. А в режиме deny запись приводит к IllegalAccessException — и именно он планируется стать по умолчанию в будущем. То есть миграция выстроена постепенно: предупреждения, затем отладочный режим, затем запрет.

Как же будет работать сериализация при наличии final-полей в данных?

И здесь возникает главный вопрос: если final-поля в будущем станут по-настоящему неизменяемыми, как тогда работать десериализации? Что будет, если библиотека окажется зажата между запретом Unsafe и отсутствием новых механизмов?

Честно отвечу: разработчики JDK осознают, что мир полон legacy-кода, который не может просто так перестать работать на новых версиях Java. Для плавной миграции необходима внятная альтернатива Unsafe.

Сейчас в сообществе активно обсуждается Serialization 2.0. В этом контексте стоит упомянуть доклад Виктора Кланга «Serialization 2.0: A Marshalling Update!». Основная идея в том, чтобы уйти от магии, которая случается где-то в середине процесса, к чему-то более структурированному и гибкому. Здесь вводится понятие «маршаллинга» (marshalling — способ описать внешнюю структуру объекта прямо в модели программирования) и обратной операции — «анмаршаллинга». 

В качестве решения Виктор Кланг предлагает отказаться от манипуляций с состоянием объекта в пользу декларативного описания схемы. Вместо попыток «пропихнуть» данные в уже существующий объект мы описываем его структуру отдельно. Вот как это выглядит на практике:

public class Point {
    final int x;
    final int y;
   
    public Point(int x, int y) {
        this.x = x;
        this.y = y;
    }
  
    //Record SchemaV1 описывает внешнюю структуру данных объекта Point
    public record SchemaV1(int x, int y) implements SchemaRecord<Point> {
        public SchemaV1(Point p) {
            this(p.x, p.y);
        }
      
        @Override
        public Point unmarshal() {
            return new Point(x, y);
        }
    }
}

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

Этот подход закроет много проблем стандартной сериализации, но потребует жертв: циклы и идентичность объектов (о чем упоминается в докладе, как и о других компромиссах). С большой вероятностью новая сериализация покроет 90% пользовательских сценариев. А для оставшихся, где циклы важны, пока неясно: возможно, появится какой-то опциональный механизм в духе Unsafe, работающий только для классов, реализующих Serializable, чтобы фреймворки могли плавно перейти и поддержать старый код. Подчеркну, что это разговор о возможном будущем, а не описание готового API.

Заключение

Если хотите потрогать новый режим руками — он экспериментальный и доступен начиная с релиза one-nio 2.2.0. В JDK 9-25+ он включается флагом -Done.nio.serial.gen.mode=method_handles. А начиная с версии библиотеки 2.2.1 при использовании JDK 24+ этот режим активируется по умолчанию. Чтобы посмотреть, какой байткод мы генерируем в runtime, пригодятся ещё два флага: -Done.nio.gen.dump=/path/to/dump/folder сдампит сгенерированные классы в папку, а -Done.nio.gen.debug.dump_generated_classes_as_text=true выведет их текстовое представление в логи или консоль.

Если тема низкоуровневого API JVM вас зацепила, то рекомендую выступления Андрея Паньгина (автора one-nio) на конференциях JPoint и Joker — там глубоко разбирается внутреннее устройство JVM в контексте high-load, когда стандартных инструментов недостаточно. А в самой библиотеке one-nio хватает интересного и помимо сериализации.

В заключение немного метарефлексии. С ростом сложности систем (о которых мы говорили в начале статьи), возникали задачи, требовавшие переосмысления старых подходов и поисков новых решений. Старой песочницы JVM не хватало и приходилось использовать нестандартные подходы. Это подтолкнуло разработчиков JDK к стандартизации API: сначала VarHandle, потом Foreign Functions и Memory.

JVM внутри стала сложнее, но именно за счёт этого системы, которые на ней строятся, в конечном итоге становятся прозрачнее и надёжнее. Система — это всегда баланс возможностей и ограничений. Рост вынуждает пересматривать принятые ранее компромиссы в поисках новой точки равновесия. Иногда ограничение — это и есть то самое место, откуда удобнее всего сделать следующий шаг вперёд.

Поэтому оставлю вас с двумя вопросами: 

  • В какое ограничение уперлась ваша система? 

  • О чём это ограничение для вас?

Благодарности 

Хочу выразить благодарность коллегам за помощь с подготовкой статьи и обкатку нового режима библиотеки в реальных условиях:

  • Дмитрию Головину, Елене Серебряковой и Татьяне Хохловой — за помощь в подготовке и редактуре текста.

  • Станиславу Стаценко и Федору Бобину — первым опробовавшим новый режим на своих продуктах и давшим ценную обратную связь.

Комментарии (2)


  1. denis_iii
    14.07.2026 20:31

    Не думали постепенно смотреть в сторону GraalVM?
    Генеративный Shenandoah в разработке и можно будет использовать в community edition. Еще из плюсов - интеграция с C и изоляты, со сборкой мусора и без. А в версии 25.1 добавили динамическую подгрузку и компиляцию классов (байткода) в режиме Native Image.


  1. mosinnik
    14.07.2026 20:31

    а не гоняли бенчи для сравнения с apache fory (бывший fury)?