О чём эта статья
В 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 такое поле помечается как “не перезаписываемое”.
Это даёт несколько важных оптимизаций:
Устранение повторных чтений. C2 может загрузить значение final-поля один раз и использовать его во всех местах метода, где оно требуется. Это сокращает число обращений к памяти и позволяет дольше хранить значение в регистре процессора.
Вынос чтения из циклов. Если значение final-поля не меняется, его не нужно загружать на каждой итерации. Компилятор может прочитать его перед циклом и использовать внутри.
Более раннее выполнение чтения. Записи в другие поля не могут изменить значение 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.


