О чём эта статья

В JDK 26 есть JEP 500 — Prepare to Make Final Mean Final. Теперь JVM предупреждает о попытках изменить через deep reflection final-поле объекта, не объявленное как static. В одном из будущих релизов такие операции планируется запрещать, если владелец приложения не разрешил их явно. В статье разберём:

  • какие гарантии даёт final языку, модели памяти и JVM;

  • как reflection позволяет обойти эти гарантии;

  • какие фреймворки могут зависеть от такого поведения;

  • как подготовить проект к более строгому режиму.

Что означает final для JVM (JLS 17.5)

final-поле объекта инициализируют при объявлении или в конструкторе, и после этого присвоить ему другое значение обычными средствами Java нельзя. Однако запрет повторного присваивания — лишь часть семантики: такие поля участвуют в правилах Java Memory Model и дают JVM информацию для оптимизации кода.

При завершении конструирования объекта происходит freeze action, которое связывает запись внутри конструктора с последующими чтениями из других потоков. Благодаря этому Java Memory Model гарантирует, что другие потоки увидят значение final-поля даже без отдельной синхронизации — но только при корректной публикации объекта, т.е. если в конструкторе не происходит передачи this в другой поток или обработчик, способный выполниться до окончания инициализации. В HotSpot требуемая видимость обеспечивается барьером памяти, который компилятор добавляет перед нормальным выходом из конструктора (это деталь реализации, а не требование спецификации).

Как final помогает JIT оптимизировать код

Модификатор final в Java может влиять на оптимизации, которые выполняет JIT-компилятор (в частности, C2). Однако его воздействие зависит от того, где именно он применяется.

Для локальных переменных и параметров final практически не даёт JIT-компилятору дополнительной информации. Он лишь запрещает повторное присваивание на уровне исходного кода, что проверяет javac. C2 и без этого строит граф потока данных и отслеживает, откуда приходит каждое значение и где оно используется. Поэтому локальный final не расширяет возможности оптимизаций.

Основной интерес для C2 представляют именно final-поля объектов. Чтение обычного (не final) поля – это загрузка из памяти. Между двумя чтениями поле могло быть изменено. Если компилятор не может доказать отсутствие таких записей, он вынужден каждый раз выполнять реальное обращение к памяти.

С final-полем ситуация иная: во внутренней модели памяти C2 такое поле помечается как “не перезаписываемое”.

Это даёт несколько важных оптимизаций:

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

  1. Вынос чтения из циклов. Если значение final-поля не меняется, его не нужно загружать на каждой итерации. Компилятор может прочитать его перед циклом и использовать внутри.

  1. Более раннее выполнение чтения. Записи в другие поля не могут изменить значение final-поля, поэтому C2 не обязан ждать их завершения, чтобы прочитать final-поле. Это уменьшает число зависимостей между операциями с памятью и даёт больше свободы при планировании инструкций.

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

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

Подстановка конкретного значения (constant folding) возможна только в случаях, когда компилятор может определить конкретный объект (например, после инлайнинга конструктора) или когда поле принадлежит классам, для которых мутация через reflection запрещена. К таким классам относятся record и скрытые классы (hidden classes).

Отдельный случай — static final поля. После инициализации класса их значения могут рассматриваться как настоящие константы времени компиляции. Более того, такие поля нельзя изменить даже через Field::set. Поэтому C2 может смело подставлять их значения в код.

Как final меняют через reflection

import java.lang.reflect.Field;

public final class FinalMutationDemo {

    static final class Token {

        private final int value;

        Token(int value) { this.value = value; }

        int value() { return value; }

        @Override public String toString() { return "Token[value=" + value + "]"; }

    }

    public static void main(String[] args) throws Exception {

        Token token = new Token(100);

        Field field = Token.class.getDeclaredField("value");

        field.setAccessible(true);

        System.out.println("before: " + token);

        field.setInt(token, 200);

        System.out.println("after:  " + token);

    }

}

Результат выполнения:

before: Token[value=100]
after:  Token[value=200]

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

В модульной системе (Java 9+) одного setAccessible(true) может быть недостаточно — целевой модуль должен быть открыт для вызывающего модуля через opens или --add-opens.

Коротко о opens и --add-opens. В модульной системе Java по умолчанию рефлексивный доступ к непубличным членам классов из другого модуля запрещён. Чтобы его разрешить, нужно либо указать opens в module-info.java для нужного пакета, либо передать при запуске параметр --add-opens <модуль>/<пакет>=<целевой модуль>. Эти механизмы дают возможность вызвать setAccessible(true) и получить доступ к полю. Но они не разрешают изменение final-полей.

Зачем такой доступ понадобился фреймворкам

Сериализаторам, ORM, контейнерам dependency injection и тестовым инструментам приходится работать с классами, которые проектировались без учёта требований конкретного фреймворка. 

Им может понадобиться:

  • создать объект без публичного конструктора;

  • восстановить объект из байтов или строки базы данных;

  • заполнить закрытые поля;

  • подменить зависимость в тесте;

  • поддержать старую модель без конструктора или фабричного метода с нужными параметрами.

Поэтому reflection и Unsafe стали использоваться для обхода инкапсуляции и прямого изменения состояния объекта.

Какие проблемы создаёт мутация final

В конструкторе обычно проверяют входные данные и не позволяют создать объект с недопустимым состоянием. После создания объекта остальной код рассчитывает, что его final-поля больше не изменятся. Reflection позволяет заменить значение в обход этих проверок.

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

Что изменилось в JDK 26

При запуске кода с мутацией final-поля JVM выведет предупреждение:

WARNING: Final field value in class com.example.class has been mutated reflectively by class com.example.class in unnamed module @5674cd4d (file:/.../target/classes/)
WARNING: Use --enable-final-field-mutation=ALL-UNNAMED to avoid a warning
WARNING: Mutating final fields will be blocked in a future release unless final field mutation is enabled

В JDK 26 добавили параметр --illegal-final-field-mutation:

  • allow — выполнить запись без предупреждения;

  • warn — выполнить запись и вывести первое предупреждение для каждого модуля;

  • debug — при каждой записи вывести предупреждение и stack trace;

  • deny — отклонить запись: Field::set выбросит IllegalAccessException.

Пока по умолчанию используется режим warn. В будущем deny должен применяться без дополнительного параметра JVM, но конкретный релиз в JEP 500 не указан.

Также добавили флаг, позволяющий временно разрешить мутацию для кода:

java --enable-final-field-mutation=ALL-UNNAMED MyApp

Для модульного приложения можно перечислить конкретные модули:

java --enable-final-field-mutation=my.module,my.other.module -m my.app/com.example.Main

Этот параметр разрешает выполнять мутацию коду из указанных модулей. Он не открывает целевой пакет автоматически: правила opens и --add-opens продолжают действовать.

Такой флаг — средство совместимости на время миграции.

Что делать в проекте

Найти проблемные места

Сначала приложение следует запустить на JDK 26 в диагностическом режиме:

java --illegal-final-field-mutation=debug MyApp

В этом режиме каждая попытка изменить final-поле сопровождается stack trace. По нему можно определить библиотеку и путь вызовов, который привёл к записи.

Для длительного или нагрузочного запуска можно использовать JFR:

java -XX:StartFlightRecording:filename=recording.jfr MyApp

jfr print --events jdk.FinalFieldMutation recording.jfr

Исправить модель объекта

Вместо мутации final-полей инициализируйте их в конструкторе, статической фабрике или билдере. Для Spring используйте constructor injection, в тестах передавайте моки через конструктор.

Плюсы такого решения:

  • инварианты проверяются в одном месте;

  • значение final-поля действительно задаётся при создании объекта;

  • код не зависит от устаревающего обходного механизма.

Если запись выполняет библиотека, нужно проверить её актуальную версию и настройки. Сериализаторы часто позволяют выбрать конструктор, фабричный метод, билдер или пользовательский адаптер. Для ORM может потребоваться изменить модель хранения или отделить entity от неизменяемой доменной модели.

Выводы

final в Java — это не только запрет повторного присваивания в исходном коде. На нём основаны гарантии видимости Java Memory Model, инварианты объектов и часть решений, которые может принимать JIT-компилятор.

JDK 26 делает скрытую reflection-запись заметной: операция пока выполняется, но JVM выводит предупреждение. В одном из будущих релизов такая запись должна завершаться IllegalAccessException, если владелец приложения не разрешил её явно.

JEP 500 укладывается в более общий курс OpenJDK — integrity by default: опасные возможности должны быть видимыми и включаться явно. Поэтому ждать момента, когда режим deny начнёт применяться по умолчанию, не стоит. Проект уже можно проверить с --illegal-final-field-mutation=debug, временные разрешения зафиксировать как технический долг, а собственный код перевести на инициализацию final-полей в конструкторах, фабриках или билдерах.

Дополнительный разбор миграции опубликован в блоге Inside Java: Avoiding final field mutation.