Введение
Скоро в Java (предположительно, что уже непосредственно в JDK 26) попадут изменения, которые сначала будут выдавать warning-и при попытках изменения final полей, а потом и вовсе запретят изменения final полей в общем случае (это уже, предположительно, в следующих релизах).
Я периодически катаюсь на различные европейские конференции, и уже довольно давно на них от ключевых архитекторов Java, таких как Brian Goetz, Stuart Marks (довольно известный на конференциях по прозвищу "Dr. Deprecator"), Mark Reinhold и др. превалировал сентимент, который впоследствии выразился в идеологию под названием "Integrity By Default" ("Целостность по умолчанию"). В рамках Spring АйО мы уже как-то вскользь о ней упоминали, но не формализовывали. Я предлагаю сначала начать с неё, и потом уже раскручивать непосредственно всю эпопею с final в Java.
Integrity By Default. Откуда ноги растут
"Integrity By Default" это не конкретный JEP, не конкретное изменение в Java платформе, в языке, в VM и т.д. Это скорее общий вектор развития, и общая политика, которой руководствуются разработчики платформы Java. Довольно хорошее формальное определение подходу "Integrity By Default" я находил в презентации Рона Пресслера, который также довольно плотно работает над Java платформой (Если я не ошибаюсь, презентация была для доклада на JVM Language Summit 2023, но это не точно):
Integrity by Default: Every exception to integrity must be explicitly acknowledged by the application in a centralized program configuration
Я думаю, что формулировка выше довольно хорошо объясняет общий подход. И, соответственно, JEP 500 один из шагов к тому, чтобы Java-платформа работала в соответствии с "Integrity By Default". Иными словами, если разработчик написал
final int x = 42;
то наверное он имел в виду, что x всегда будет и должен быть 42. Он не имел в виду, что x = 42 на данный момент, но на деле его можно менять как угодно и когда угодно.
Возникает закономерный вопрос - а как же так вышло-то, что вообще final поля в Java можно менять? Для человека, который не имел шанса погрузиться в историю вопроса сам факт того, что final поля можно менять, кажется чем-то из ряда вон выходящим. Действительно, если уже язык одной рукой дает тебе средства для объявления неизменяемого состояния, то как минимум очень странно, что другой рукой он эту гарантию у нас забирает.
Как же так вышло? Давайте разбираться.
Начнём с небольшой исторической справки
Друзья, смотрите, архитекторы Java действительно очень аккуратно подходят к введению новых фич в язык, так как понимают принцип, золотое правило, описанное ещё Joshua Bloch-ом в книжке Effective Java:
«When in doubt — leave it out»
«You can always add, but you can never remove»
Этим во многом объясняется относительно недавняя ситуация со String Templates или тот факт, что Project Valhalla претерпевает уже который прототип за 10+ лет развития.
Итого: добавление в язык каких-то более менее крупных фич это всегда трейд офф и к этому подходят очень аккуратно, так как можно наломать дров!
И вот в чём соль - дров уже наломали, например с final и с сериализацией!
Когда-то давно, когда я жевал в детском саде манную кашу и домогался до девчонок в ясельной группе (речь про воспитательниц), в Java, наряду с RMI (Remote Method Invocation) завезли сериализацию (ObjectInputStream , Serializable и т.п). И сериализация была такой, как предполагалось, "киллер-фичей". Её активно пиарили и, предполагалось, она будет являться одной из весомых причин выбора Java как платформы.
Конечно, сейчас мы понимаем, что у сериализации есть огромный ряд проблем:
Десериализация происходит путем создания "пустого" объекта (то есть вызова конструктора без аргументов, даже если вы его не определяли - VM может всё!). Это ломает integrity объекта - инварианты вашего конструктора в коде потенциально просто не будут соблюдены. Это ещё и огромный security exploit.
Создается очень сильный coupling между рантаймами на стороне как получателя, так и отправителя данных - формат сериализованного одной VM объекта должен быть понятен другой VM.
Отсутствие type-safety (объявляются какие-то "магические" методы для сериализации/десериализации
writeObject/readObject)
И на самом деле, ключевое для нас сейчас следующее. Представим себе вот такой вот setup (Это простой Scratch-файл созданный в IDE):
class Scratch {
public static void main(String[] args) throws IOException, ClassNotFoundException {
var baos = new ByteArrayOutputStream();
var objectOutputStream = new ObjectOutputStream(baos);
Payment original = new Payment(Instant.now());
objectOutputStream.writeObject(original);
var objectInputStream = new ObjectInputStream(new ByteArrayInputStream(baos.toByteArray()));
Payment deserialized = (Payment) objectInputStream.readObject();
System.out.println(original);
System.out.println(deserialized);
}
static class Payment implements Serializable {
private final String id;
private final Instant settledAt;
Payment(Instant settledAt) {
this.id = UUID.randomUUID().toString();
this.settledAt = settledAt;
}
@Override
public String toString() {
return "Payment{" +
"id='" + id + '\'' +
", settledAt=" + settledAt +
'}';
}
}
}
Исполняя данный main() я получаю примерно следующее (у вас, очевидно, output будет другой):
Payment{id='92477f74-45e4-4c73-873a-c6eca6ce19a7', settledAt=2025-11-10T16:02:45.899368Z}
Payment{id='92477f74-45e4-4c73-873a-c6eca6ce19a7', settledAt=2025-11-10T16:02:45.899368Z}
Суть в том, что в момент десериализации был вызван не конструктор класса Payment (в таком случае бы id в original отличался бы от id в deserialized). То есть, друзья, как можно понять, механизм десериализации установил поле id самостоятельно в обход конструктора, несмотря на то, что поле-то на самом деле final.
Я больше того скажу, чтобы мутировать final много ума не надо, и наш любимый Spring так тоже умеет:
public static void main(String[] args) {
Payment payment = new Payment(Instant.now());
Arrays.stream(Payment.class.getDeclaredFields()).filter(field -> {
return field.getName().equals("id");
}).findFirst().ifPresent(field -> {
field.setAccessible(true);
try {
Field modifiers = Field.class.getDeclaredField("modifiers");
modifiers.setAccessible(true);
modifiers.setInt(field, field.getModifiers() & ~Modifier.FINAL);
field.set(payment, null);
} catch (NoSuchFieldException | IllegalAccessException e) {
throw new RuntimeException(e);
}
});
System.out.println(payment);
}
Вывод вы получите примерно такой:
Payment{id='null', settledAt=2025-11-10T13:41:55.756150Z}
Я почему сейчас привёл этот пример - суть в том, что некоторые фичи самой платформы Java, на данный момент, требуют возможности мутирования final полей. Так просто исторически сложилось.
Закончить эту секцию хочу следующим: разработчики платформы Java прекрасно понимают, что сериализация была спроектирована не очень хорошо, и что сейчас от этого у платформы много проблем. Опять же, возвращаемся к мысли о том, насколько дорого стоят платформе Java "проблемные" фичи.
Теперь к делу. Что на самом деле происходит
Ну и теперь довольно короткая часть о сути изменений. На самом деле идея состоит в том, чтобы сделать простую, банальную вещь: final должен быть неизменяемый... всегда... ну, почти всегда... только если кто-то очень не попросит. Но по умолчанию менять его будет нельзя!
Это означает, что, предположительно, подобного рода трюки, что я привёл выше, будут работать на JDK 26, но будут warning-и, а в последующих версиях Java платформы код выше не будет работать из коробки.
Почему это важно вообще для Java платформы
Одна из ключевых оптимизаций языка в Java - constant folding, оно же свертывание констант. Это одна из первых оптимизаций, которая в общем и целом применяется в общей цепочке. Она во многом влияет на то, какие оптимизации далее сможет применить VM (например inlining или loop unrolling и т.п.). И сам факт того, что final поле на самом деле не final очень сильно мешает constant-фолдить константы. А это влияет на дальнейшую оптимизацию. Об этом пишут архитекторы в самом JEP 500.
Я больше того скажу, в Java довольно давно была аннотация @Stable, которая, помимо всего прочего:
В теории могла быть поставлена как над
final, так и не надfinalполемПо сути давала VM "гарантию", что значение данному аннотированному полю будет присвоено всего один раз.
Вам, как рядовым разработчикам, недоступна
Но она дает разработчикам JDK у себя в проекте сказать VM-у: "Чувак, мне верить можно, я тебе гарантирую, что
finalвот тут реальноfinal. Я вот серьёзно. Прямо отвечаю, менять не буду. Можешь констант фолдить."
И она очень давно была своего рода "затычкой" в этой бочке, когда оптимизировать код как-то всё-таки надо, но семантика final полей в Java мешает.
Заключительное слово
В общем, посмотрим, что из этого выйдет. Я очень рад тому, как Java платформа после перехода на шестимесячный релизный цикл развивается. Пожелаем ей успехов. А пока, друзья, имейте эти изменения в виду. Однозначно, что подобного рода изменения потребуют адаптации со стороны крупных фреймворков. Тем не менее, возможно, они в том числе и заденут Ваши самописные велосипеды.
Всем успехов!
Комментарии (13)

novoselov
10.11.2025 15:52наш любимый Spring так тоже умеет
В JDK21+ уже не умеет, т.к. отсутствует поле
modifiersБолее того попытка сделать то же самое через Unsafe (для private static final) может работать в Debug, но в релизе не давать никакого результата (как и ошибок). Особенно если используется простой код типа getValue() {return VALUE; }, что оптимизируется компилятором до возвращения константы и заканчивается удалением поля.

kemsky
10.11.2025 15:52Когда надо эти выступления носят излишне критический характер, а когда не надо - все хорошо, просто вы не так пользуетесь. У них было 30 лет подумать, поисправлять, закрыть дыры. Вопросы будут всегда с любой сериализацией, особенно если есть желание посидеть на всех стульях сразу.

ant1free2e
10.11.2025 15:52то наверное он имел в виду, что
xвсегда будет и должен быть42. Он не имел в виду, чтоx = 42на данный момент, но на деле его можно менять как угодно и когда угодно.вроде не первое апреля сегодня, я даж побежал проверять, ну мало ли, уже ничему не удивляешься

кровавая история с десериализацией, возможно, связанна стем что final подразумевает однократную запись, т.е. она разрешена если ранее значение было не определено. Именно поэтому, мне кажется, он final а не const. Это сделано для возможности отложенной или ленивой инициализации

poxvuibr
10.11.2025 15:52кровавая история с десериализацией, возможно, связанна стем что final подразумевает однократную запись, т.е. она разрешена если ранее значение было не определено. Именно поэтому, мне кажется, он final а не const.
Когда мы говорим про десериализацию, то имеется в виду модификатор final на полях объектов, а не модификатор final на локальных переменных.
И конкретно речь то том, что модификатор final на поле объекта не даст нам написать код, который может поменять значение поля после создания объекта. Но при этом мы можем поменять значение этого поля с помощью рефлекшна в любой момент. Вот эту возможность хотят потихоньку удалить

ant1free2e
10.11.2025 15:52кстати, странно что для Михаила не стала откровением полная изменяемость ссылочных типов объявленных как final. Но наверное это потому оно точно так работает везде включая const'ы в ненаглядном тайпскрипте.

poxvuibr
10.11.2025 15:52кстати, странно что для Михаила не стала откровением полная изменяемость ссылочных типов объявленных как final
Это не откровение потому что никто нам не помешает написать код, который поменяет значение полей объектов, даже если ссылка на объект записана в поле, на котором стоит final. А в том случае, котором мы говорим, нельзя написать код, который вписывает в final-поле новое значение и создаётся впечатление, что поменять значение этого поля вообще невозможно. А на самом деле это можно сделать с помощью рефлекшна.

ant1free2e
10.11.2025 15:52ну Михайл написал, что файнал можно менять всегда и любым способом, но на деле только рефлексией и сериализацией, т.е. магическими путями, а обычное присваивание не сработает. Мне кажется, правильнее было бы сделать настройку для модулей запрещающую изменение файналов или даже применение рефлексий, а не наоборот все ломать одним махом

poxvuibr
10.11.2025 15:52ну Михайл написал, что файнал можно менять всегда и любым способом
Всегда это в любой момент работы программы, а любым способом это на любое значение. Но может и правда тут преувеличение, которое может запутать.
но на деле только рефлексией и сериализацией, т.е. магическими путями, а обычное присваивание не сработает
В том то и проблема, что вроде нельзя, но на самом деле можно, но никакой анализатор кода не покажет нам, что значение этого поля меняется в рантайме. Я это воспринимаю как откровенный обман.
Мне кажется, правильнее было бы сделать настройку для модулей запрещающую изменение файналов или даже применение рефлексий, а не наоборот все ломать одним махом
Сложный вопрос. Но вот sun.misc.unsafe постепенно убирают насовсем, тут наверное та же история будет

ant1free2e
10.11.2025 15:52кстати еще, долгое время существовал обычай финалить все поля у классов, что-бы гарантировать инициализацию без коллизий в многопоточке, и наверняка эти коды уже успели обрасти "решениями" всевозможных проблем на основе рефлексий и теперь они станут необновляемыми. Но зато безопасники будут рады.

poxvuibr
10.11.2025 15:52кстати еще, долгое время существовал обычай финалить все поля у классов, что-бы гарантировать инициализацию без коллизий в многопоточке
Ну он никуда не делся ))
наверняка эти коды уже успели обрасти "решениями" всевозможных проблем на основе рефлексий и теперь они станут необновляемыми
Да, об этом есть замечание в статье
Но зато безопасники будут рады.
Безопасники вообще не знают что это всё такое. Рады будут разработчики, которые будут знать, что если они написали final, то это настоящий final, а не final курильщика

ant1free2e
10.11.2025 15:52т.е. теперь хулиганы будут оставлять еще больше мин в коде, об которые разбивают разработчиков? Рефлексия для того и существует, чтобы выкручиваться из ситуаций когда обычным образом сделать не получается, но очень надо. Если безопасники ничего не знают, то для менеджера неспособность решить задачу на подсистеме, где кто-то злоупотреблял инкапсуляцией, может быть основанием чтобы обвинить разработчика в проф. непригодности и уволить после 2го такого страйка

mipo256 Автор
10.11.2025 15:52Моё мнение, такое: язык и сама платформа делает шаги в сторону enforcement-а целостности программ, не только касаемо
final. Это опять же, в какой-то степени безусловно заденет фреймворки и вообще весь код, написанный на этом языке. Такие изменения в будущем сделают общее поведение системы болле понятным.
Не будет возникать вопросов по типу: "А как здесь библиотека 'X' умудрилась обновить это поле, оно жеfinal? А может быть, там под капотом внутри передается deep copy объекта?" и тп.
Я считаю, что это, учитывая все сложности и факторы миграции и т.д. net benefit для экосистемы.
poxvuibr
Статью надо было назвать Честный final!
Слово "integrity" можно перевести на русский по разному, но в разговорной речи оно чаще всего означает "честность". А в программировании его часто переводят как целостность и обычно это хорошо отражает смысл текста. Но тут был редкий случай перевести "integrity" как честность даже в контексте нашего любимого айти.
Integrity by Default в этой статье означает, что final в коде это действительно должен быть честный final, значение которого совершенно честно нельзя изменить никак и никому. Честность по умолчанию! Я бы перевёл так, жаль возможность упущена.