Перевополщение Stable Values в JDK 26

от автора

Ленивая инициализация полей в Java чревата ошибками и подрывает свёртывание констант (он же constant folding). В JDK 26 появился JEP 526, который в режиме preview предлагает LazyConstant — тип, лениво инициализирующий значение через заданный Supplier.

Добро пожаловать в Inside Java Newscast, где мы рассказываем о свежих событиях в сообществе OpenJDK, и сегодня мы подробно разберём lazy constants — preview-возможность JDK 26. Возможно, вы уже знаете их как stable values — preview из 25-й версии, где изменилась не только их «вывеска», но и API, причём довольно существенно. Вообще, эта эволюция многое говорит о том, как в OpenJDK развивают фичи — от низкоуровневого механизма к более высокоуровневым концепциям; об этом я поговорю ближе к концу видео. Но прежде мы разберём, зачем нужна “ленивость”, какие с ней сложности, и посмотрим на API LazyConstant и ленивых коллекций. Готовы? Тогда погружаемся!

Ленивость

Когда мы говорим о “ленивости” (lazy) в программировании, речь не только о разработчиках, которые «ленятся», пока компилируется код, или о том, как наш Claude громит кодовую базу. Ленивость — это ещё и откладывание вычислений. Откалывать вычисления это реально бывает полезно, ведь

  • Вычисление может быть долгим, а может быть, нам вообще не придётся его выполнять

  • Оно может иметь лучший результат, если мы выполним его позже, имя больше информации

В рантайме Java многие процессы ленивые — назову лишь два примера:

Java загружает и инициализирует классы только тогда, когда выполняющийся код впервые к ним обращается. Или, например, она очищает мусор и освобождает память только тогда, когда нужна свободная память.

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

Комментарий от Михаила Поливаха

В защиту Spring Framework скажу, что Spring Boot  инициализирует DispatcherServlet лениво по-умолчанию, а именно наш веб слой (@RestController и т.д.) — вот его инциализиуерт eagerly.

У этого, кстати, есть причина. Причина такая, что, с одной стороны, хочется иметь более быстрый старт Spring Boot приложения, но с другой стороны, у eager инициализации есть важное преимущество — все проблемы будут известны ещё при старте, а не в рантайме.

Поэтому, Spring Boot делает так, что самую тяжелую часть веб слоя (DispatcherServlet и т.д.) — это всё инфраструктурный код, в котором создатели  framework-а уверены, вот он поднимается лениво. А вот ваш веб слой, который они не контролируют, вот он поднимается eagerly, чтобы все проблемы при инициализации бинов сразу вскрылись.

Это такой некий баланс, между скоростью запуска и надежностью приложения.

И не всегда понятно, какой вариант правильный, потому что, как и большинство вещей в программировании, ленивость приносит компромиссы. Не делать ничего, пока это точно не нужно, означает, что вы можете получить лучший результат или сделать меньше работы — и то и другое хорошо. Но выполнение “on-demand” может привести к тому, что конкретный запрос, например, будет выполняться дольше — а это плохо.

А ещё есть специфические для Java минусы ленивости. Инфраструктура Java программы задаётся объектами, которые обычно ссылаются друг на друга через поля. Поэтому ленивое создание части программы часто означает ленивую инициализацию поля, и здесь возникают две конкретные проблемы:

  • код становится сложнее, и его трудно надёжно сделать «правильно», особенно при конкуренции потоков. Т.е. условно проинициализировать поле лениво в рамках конкурецнии можно, но немного геморно.

  • Ленивая инициализация может мешать использовать ключевое слово final, что делает код более хрупким и хуже оптимизируемым, потому что поле можно переназначить позже

Чтобы было понимание, о чем речь, взгляните на пример:

public class UserController {private volatile LoginService login;public UserController() {// field `login` is lazily initialized// by `getLogin` instead of the// constructor;// that also means, you can never use// `login` directly as it may be null}// one variant to initialize lazily,// called "double-checked locking"private LoginService getLogin() {var login = this.login;if (login == null) {synchronized (this) {login = this.login;if (login == null)this.login = login =LoginService.initialize();}}return login;}}

Так что, чтобы уравнять шансы, Java не помешал бы API, который делает ленивую инициализацию простой и даёт нам — и рантайму — гарантию, что значение, однажды вычисленное и присвоенное, остаётся константным. На сцену выходит JDK Enhancement Proposal 526 и lazy constant-ы.

LazyConstant

JEP 526 предлагает новый тип LazyConstant. Вместо того чтобы создавать final-поле типа T и вычислять его значение при конструировании, вы объявляете final-поле типа LazyConstant и создаёте его с «рецептом» вычисления. Для этого вызываете его статический фабричный метод of с Supplier. А позже, когда вам нужно значение, просто вызываете LazyConstant.get:

public class UserController {private final LazyConstant<LoginService> login;public UserController() {this.login = LazyConstant.of(LoginService::initialize);// using `login` later...IO.println(login.get());}}

И это весь API. Ну, по крайней мере, это будет всё API в JDK 27 — но об этом чуть позже, когда будем обсуждать эволюцию. А пока давайте внимательнее посмотрим на «lazy» и «constant».

Как вы наверняка уже поняли, supplier, с которым вы создали lazy constant, выполняется, чтобы вычислить значение, которое возвращает get. Но — максимум один раз, при первом вызове get. Если несколько вызовов происходят параллельно, supplier выполняет только один поток, а все остальные ждут результат; и, разумеется, все последующие вызовы get просто возвращают тот же результат. Так решается сложность «инициализировать поле лениво максимум один раз», даже при конкуренции.

Но! Со стороны кажется, что большинство опытных Java-разработчиков могут написать такой Lazy тип и сами. Тем не менее, тут есть нюансы.

Отличие LazyConstant — не в «lazy», а в «constant». Потому что после вычисления значение присваивается полю, помеченному аннотацией @Stable, которая сообщает рантайму Java, что поле никогда не будет переназначено; что оно константно. То есть ваша ссылка на lazy constant — final, а его ссылка на значение — константная, и это открывает дверь оптимизации под названием constant folding, когда цепочку константных ссылок можно «свернуть» до одной загрузки.

К сожалению, как вы, возможно, помните из Inside Java Newscast #101, рефлексия может менять final-поля экземпляров у обычных классов, так что они на самом деле не константны — пока что константами являются только final static-поля, компоненты record, final-поля экземпляров в hidden class-ах, и теперь — значения LazyConstant. Но как только «суперспособности» рефлексии будут ограничены опцией командной строки, все final-поля станут константными, что заметно расширит пространство для этой оптимизации.

Поведение LazyConstant

Окей, «быстрый раунд» по нескольким свойствам поведения:

  • LazyConstant не сериализуем

  • LazyConstant отвергает null, так что не заставляйте supplier возвращать его.

  • Если Supplier выбрасывает исключение, оно выйдет наружу из get, и если вы попробуете снова позже, Supplier будет вызван снова — возможно, в этот раз повезёт. Так что технически это не «максимум один раз», а «максимум один раз успешно».

  • Если Supplier в итоге вызывает get по циклу, вы в беде, но LazyConstant замечает это и прерывает вероятно бесконечный цикл, выбрасывая IllegalStateException.

  • Если Supplier блокируется навсегда, вы в очень большой беде, потому что ни поток, который его выполняет, ни потоки, ожидающие результата, из этого не выйдут. API не предлагает ни таймаутов, ни отмены.

  • LazyConstant очень строго стоит на «lazy» и не хочет вычислять значение при вызове equals, поэтому сравнивать остаётся только identity (т.е. double equals проверка) самого LazyConstant. И даже если бы он захотел вычислять значения (а сейчас мы увидим связанный случай, где это требуется), поведение становится очень быстро трудно интуитивным. Если хотите это разыграть — оставьте комментарий, и мы обсудим.

Ленивые коллекции

LazyConstant даёт связь 1:1 между владеющим классом и нужным значением, но что если вам нужно 1:n? Можно, конечно, объявить LazyConstant от списка, но тогда весь список придётся вычислять сразу:

private final LazyConstant<List> list = LazyConstant.of(() -> List.of("0", "1", "2"))

Во многих случаях это, вероятно, нормально, но в других — не очень. Поэтому JEP 526 также предлагает «ленивый» список и «ленивую» карту, но они не выставлены в системе типов. Вместо этого вы вызываете List.ofLazy и Map.ofLazy и получаете экземпляр List или Map соответственно, который реализует ленивость «под капотом».

private final List list = List.ofLazy(3, index -> "" + index);private final Map<Integer, String> map = Map.ofLazy(Set.of(0, 1, 2), key -> "" + key);

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

Как и ожидается, функции выполняются ровно один раз — когда элемент по данному индексу или ключу впервые понадобился, — даже при конкурентном доступе. Помимо вычисления on-demand, эти коллекции неизменяемы, а рантайм может применять оптимизации constant folding к коду, который обращается к содержимому lazy constants через ленивые коллекции.

Комментарий от Михаила Поливаха

Для тех, кто Николая не понял. Речь о том, что использовать LazyConstant<List> конечно можно, но в таком случае, как только Вам понадобиться из этого List элемент под индексом, например 3, то вы LazyConstant вычислит весь List, а не только элемент под индексом 3. Аналогичная проблема и с Map-ой если делать LazyConstant<Map>

Тут JDK приходит на помощь и говорит — ты мне заране скажи, сколько будет элементов в ленивой коллекции, и как высчитать значение для N-ого элемента, и я только для него высчитаю.

Ещё раз, Вы могли бы написать такое сами, но если напишите, то HotSpotVM не сможет делать на вашем кастомном типе Constant Folding.

Подобная фича может быть удобна для тривиальных in-memory cache-ей, которым не нужна особая стратегия eviction-а, поддержки конерентности кеша и т.д.

Теперь про equals: эти коллекции не могут быть столь же равнодушны, как LazyConstant, потому что и List, и Map требуют корректной реализации, а она может потребовать вычисления некоторых значений. Каких именно? Всех — если два списка или карты действительно равны…

var eagerList = List.of("0", "1", "2");var lazyList = List.ofLazy(3,index -> {IO.println("Computing " + index);return "" + index;});IO.println(eagerList.equals(lazyList));/*--< OUTPUT >--*/// Computing 0// Computing 1// Computing 2// true

… но, возможно, меньшего числа, если они не равны. Я заглянул в исходники и мог бы рассказать, что и когда происходит, но Javadoc это поведение не специфицирует, а значит, это деталь реализации — полагаться на неё было бы ошибкой. Так что не буду. Буду принимать аплодисменты за самообладание в комментариях.

Эволюция API

Дайте я включу вам короткий фрагмент из разговора, который у меня был с Джоном Роузом, старшим архитектором виртуальной машины Java.

У нас в JDK есть нечто под названием «stable values», и мы используем их буквально повсюду. Со временем эти штуки становились всё полезнее и полезнее, но оставались «упакованными» внутри JDK — и это был необходимый шаг, чтобы поначалу иметь решение только для JDK: мы называем это «для своих», для друзей и семьи, — пока мы учимся правильно с этим обращаться и учим VM корректно их оптимизировать. [Per-Ake Minborg] рассказал о своём «StableValue API», построенном поверх этих стабильных переменных, и в итоге мы наконец разобрались: (1) как их оптимизировать и (2) как их причесать и довести до состояния, подходящего для приличного общества, чтобы выставить их на улицу — на углу — а не держать только у себя в гостиной.

И эволюция на этом не остановилась. Как объяснил Джон, в JVM есть концепция stable values, которые помечаются упомянутой выше аннотацией @Stable. Первым шагом к подъёму этого в API для нас стали stable values в JDK 25. Обратите внимание: название было явно основано на низкоуровневой концепции, а API включало фабричные методы коллекций и много императивной функциональности.

Для JDK 26 имя сменили на более понятное для нас, и по мере того, как концепция оформлялась, стало ясно, что ленивые коллекции в первую очередь — не про ленивость, а про коллекции, поэтому их фабричные методы переехали туда. А по мере прояснения концепции из API убрали большую часть императивного «хлама». Впрочем, не всё. В JDK 26 вы всё ещё можете спросить у LazyConstant, инициализирован ли он, и у вас всё ещё есть метод orElse, позволяющий обработать случай, когда он не инициализирован. Но это отвлекает от целевого сценария использования, и поэтому JDK 27, скорее всего, уберёт эти методы — вот почему мы не обсуждали их раньше.

По мере того как lazy constants становятся чётко определённой концепцией, остаётся своего рода вакуум для более низкоуровневого взаимодействия с аннотацией @Stable — и, вероятно, что-то его заполнит. А когда это случится, мы, конечно, расскажем об этом в Inside Java Newscast, так что подписывайтесь, если ещё не подписались.

Присоединяйтесь к русскоязычному сообществу разработчиков на Spring Boot в телеграм — Spring АйО, чтобы быть в курсе последних новостей из мира разработки на Spring Boot и всего, что с ним связано.

ссылка на оригинал статьи https://habr.com/ru/articles/1042294/