openjdk.ruOpenJDK на русском

JEP draft: Lazy Constants (Fourth Preview)

Lazy Constants (четвёртая версия Preview (предварительная версия))

АвторPer Minborg & Maurizio Cimadamore
ОтветственныйPer-Ake Minborg
ТипFeature
ОбластьSE
СтатусDraft
Компонентcore-libs / java.lang
ТрудоёмкостьS
ДлительностьS
Создан2026/08/05 12:33
Обновлён2026/09/02 13:33
Задача8389764

Аннотация

Мы вводим API для Lazy Constants — объектов, которые хранят неизменяемые данные. JVM считает lazy-константы настоящими константами, поэтому к ним применимы те же оптимизации производительности, что и к полю, объявленному как final. Однако, в отличие от полей final, lazy-константы дают больше свободы в выборе момента инициализации. Это API в статусе Preview.

История

Впервые этот API появился в статусе Preview в JDK 25 в рамках JEP 502. С учётом полученного опыта и отзывов мы переработали API и повторно представили его в статусе Preview в JDK 26 в рамках JEP 526. Изменения были существенными:

  • Мы переориентировали API на высокоуровневые сценарии использования и удалили низкоуровневые методы orElseSet, setOrThrow и trySet. Остались только фабричные методы, которые принимают функции для вычисления значения.

  • Мы переименовали API из StableValue в LazyConstant. Первое название подходило низкоуровневому API, близкому к механизму JVM, на котором он реализован. Второе лучше отражает основной задуманный высокоуровневый сценарий, а именно одно константное значение, которое инициализируется лениво.

  • Чтобы методы было проще найти, мы перенесли фабричные методы для lazy-списков и lazy-отображений в интерфейсы java.util.List и java.util.Map соответственно.

  • Мы ещё больше упростили API: удалили фабричные методы function и intFunction, которые давали лишь незначительные преимущества по сравнению с lazy-списками и lazy-отображениями.

  • Мы запретили null в качестве вычисленного значения, чтобы повысить производительность и лучше согласовать lazy-константы с такими конструкциями, как неизменяемые коллекции и ScopedValue.

В JDK 27 мы доработали API и снова выпустили его в статусе Preview в рамках JEP 531. В частности, мы предложили:

  • Удалить низкоуровневые методы isInitialized и orElse, поскольку их можно было использовать способами, не соответствующими целям проектирования API.

  • Добавить новый фабричный метод Set.ofLazy(...), который может создавать стабильный Set из заранее заданных элементов-кандидатов. После этого добавления появятся ленивые версии всех трёх основных типов коллекций: List, Set и Map.

Мы предлагаем снова выпустить API в статусе Preview в JDK 28 без изменений.

Цели

  • Дать возможность инициализировать состояние приложения постепенно, по требованию, а не целиком сразу, и тем самым сократить время запуска приложения.

  • Отделить создание lazy-констант от их инициализации без существенных потерь производительности.

  • Гарантировать, что lazy-константы инициализируются не более одного раза, даже в многопоточных программах.

  • Дать пользовательскому коду возможность получить выгоду от оптимизаций свёртки констант, которые раньше были доступны только внутреннему коду JDK.

Что не является целью

  • Мы не ставим цель расширить язык программирования Java средствами для объявления ленивых полей.

  • Мы не ставим цель изменить семантику полей final.

Мотивация

Большинство Java-разработчиков слышали совет «предпочитайте неизменяемость» или «минимизируйте изменяемость» (Effective Java, третье издание, совет 17). Неизменяемость даёт много преимуществ: неизменяемый объект может находиться только в одном состоянии, поэтому его можно свободно разделять между несколькими потоками.

Основной инструмент платформы Java для работы с неизменяемостью — поля final. К сожалению, у полей final есть ограничения. Их нужно задавать заранее: для полей экземпляра — при создании объекта, для полей static — при инициализации класса. Более того, порядок инициализации полей final определяется текстовым порядком, в котором они объявлены. Из-за этих ограничений final во многих реальных приложениях применим не везде.

Неизменяемость на практике

Рассмотрим простой компонент приложения, который записывает события через объект-логгер:

class OrderController {

    private final Logger logger = Logger.create(OrderController.class);

    void submitOrder(User user, List<Product> products) {
        logger.info("order started");
        ...
        logger.info("order submitted");
    }

}

Поскольку logger — это поле final класса OrderController, его нужно инициализировать заранее, при каждом создании экземпляра OrderController. Значит, создание нового OrderController может быть медленным: в конце концов, получение логгера иногда требует дорогостоящих операций, например чтения и разбора конфигурационных данных или подготовки хранилища, куда будут записываться события журнала.

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

class Application {
    static final OrderController   ORDERS   = new OrderController();
    static final ProductRepository PRODUCTS = new ProductRepository();
    static final UserService       USERS    = new UserService();
}

Эта работа по инициализации не только вредит запуску приложения, но и может быть вовсе не нужна. В конце концов, некоторым компонентам может так и не понадобиться записать ни одного события — зачем же выполнять всю эту дорогостоящую работу заранее?

Изменяемость ради более гибкой инициализации

По этим причинам мы часто откладываем инициализацию сложных объектов на как можно более поздний момент, чтобы они создавались только при необходимости. Один из способов добиться этого — отказаться от final и выражать изменение не более одного раза через изменяемые поля:

class OrderController {

    private Logger logger = null;

    Logger getLogger() {
        if (logger == null) {
            logger = Logger.create(OrderController.class);
        }
        return logger;
    }

    void submitOrder(User user, List<Product> products) {
        getLogger().info("order started");
        ...
        getLogger().info("order submitted");
    }

}

Поскольку logger больше не является полем final, его инициализацию можно перенести в метод getLogger. Этот метод проверяет, существует ли уже объект-логгер; если нет, он создаёт новый объект-логгер и сохраняет его в поле logger. Такой подход ускоряет запуск приложения, но у него есть недостатки:

  • В коде появляется новый неочевидный инвариант: все обращения к полю logger в OrderController должны проходить через метод getLogger. Если этот инвариант нарушить, можно обратиться к ещё не инициализированному полю, что приведёт к NullPointerException.

  • Если приложение многопоточное, изменяемые поля создают проблемы с корректностью и эффективностью. Например, одновременные вызовы метода submitOrder могут привести к созданию нескольких объектов-логгеров; даже если это корректно, это, скорее всего, неэффективно.

  • Можно было бы ожидать, что JVM оптимизирует доступ к полю logger, например, выполнив свёртку констант для доступа к уже инициализированному полю logger или убрав проверку logger == null в методе getLogger. К сожалению, поскольку поле больше не final, JVM не может полагаться на то, что его содержимое не изменится после первоначального присваивания. Гибкая инициализация на основе изменяемых полей неэффективна.

К отложенной неизменяемости

Короче говоря, способы управления инициализацией полей, которые даёт язык Java, либо слишком ограничены, либо недостаточно ограничены. С одной стороны, поля final слишком ограничены: инициализация должна происходить в самом начале жизни объекта или класса, что часто замедляет запуск приложения. С другой стороны, при гибкой инициализации через изменяемые поля без final сложнее рассуждать о корректности. Противоречие между неизменяемостью и гибкостью вынуждает разработчиков прибегать к несовершенным приёмам, которые не решают основную проблему и приводят к ещё более хрупкому и трудному в сопровождении коду. (Другие примеры приведены ниже.)

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

Описание

Lazy-константа — это объект типа java.lang.LazyConstant, который хранит одно значение данных, своё содержимое. Lazy-константа должна быть инициализирована до первого получения её содержимого. Для этого используется вычисляющая функция, обычно лямбда-выражение или ссылка на метод, которая передаётся при создании. После инициализации lazy-константа становится неизменяемой. Lazy-константа — это способ добиться отложенной неизменяемости.

Вот класс OrderController, переписанный так, чтобы логгер хранился в lazy-константе:

class OrderController {

    // OLD:
    // private Logger logger = null;

    // NEW:
    private final LazyConstant<Logger> logger
        = LazyConstant.of(() -> Logger.create(OrderController.class));

    void submitOrder(User user, List<Product> products) {
        logger.get().info("order started");
        ...
        logger.get().info("order submitted");
    }

}

Поле logger хранит lazy-константу, созданную статическим фабричным методом LazyConstant.of(...). Изначально lazy-константа не инициализирована, то есть не имеет содержимого.

Метод submitOrder вызывает logger.get(), чтобы получить логгер. Если lazy-константа уже инициализирована, метод get возвращает её содержимое. Если lazy-константа не инициализирована, метод get инициализирует её содержимое значением, которое возвращает лямбда-выражение, переданное при создании, и lazy-константа становится инициализированной; затем метод возвращает это значение. Таким образом, метод get гарантирует, что к моменту его возврата lazy-константа инициализирована.

Хотя после инициализации lazy-константа неизменяема, мы не обязаны инициализировать её содержимое в конструкторе или, для значения static, в инициализаторе класса. Вместо этого мы инициализируем её по требованию. Более того, метод get гарантирует, что переданное лямбда-выражение вычисляется только один раз, даже если logger.get() вызывается одновременно из нескольких потоков. Это свойство очень важно, поскольку вычисление лямбда-выражения может иметь побочные эффекты; например, вызов Logger.create(...) может создать новый файл в файловой системе.

Это API в статусе Preview, по умолчанию оно отключено

Чтобы использовать Lazy Constants API, нужно включить Preview-возможности:

  • Скомпилируйте программу с javac --release 26 --enable-preview Main.java и запустите её с java --enable-preview Main; или

  • Если вы используете средство запуска исходного кода, запустите программу с java --enable-preview Main.java; или

  • Если вы используете jshell, запустите его с jshell --enable-preview.

Гибкая инициализация с помощью lazy-констант

Lazy-константы дают ту же гарантию инициализации, что и неизменяемые поля final, и при этом сохраняют гибкость изменяемых полей без final. Поэтому они закрывают пробел между этими двумя видами полей:

Число обновлений Где вычисляется значение Свёртка констант? Одновременные попытки обновления?
Поле final 1 Конструктор или статический инициализатор Да Нет
LazyConstant [0, 1] Вычисляющая функция Да, после инициализации Да, обновляет победивший
Поле без final [0, ∞) Где угодно Нет Да

(Чтобы к lazy-константе могла применяться свёртка констант, она должна храниться в поле final.)

Гибкость lazy-констант позволяет по-новому организовать инициализацию целых приложений. В частности, lazy-константы можно составлять из других lazy-констант. Точно так же, как мы использовали lazy-константу для хранения логгера в компоненте OrderController, можно использовать lazy-константу для хранения самого компонента OrderController и связанных с ним компонентов:

class Application {

    // OLD:
    // static final OrderController   ORDERS   = new OrderController();
    // static final ProductRepository PRODUCTS = new ProductRepository();
    // static final UserService       USERS    = new UserService();

    // NEW:
    static final LazyConstant<OrderController>   ORDERS   = LazyConstant.of(OrderController::new);
    static final LazyConstant<ProductRepository> PRODUCTS = LazyConstant.of(ProductRepository::new);
    static final LazyConstant<UserService>       USERS    = LazyConstant.of(UserService::new);

    public static OrderController orders() {
        return ORDERS.get();
    }

    public static ProductRepository products() {
        return PRODUCTS.get();
    }

    public static UserService users() {
        return USERS.get();
    }

}

Приложение запускается быстрее, потому что больше не инициализирует свои компоненты, такие как OrderController, заранее. Вместо этого оно инициализирует каждый компонент по требованию, через вычисляющую функцию и метод get соответствующей lazy-константы. Более того, каждый компонент точно так же по требованию инициализирует свои подкомпоненты, например свой логгер.

Кроме того, между lazy-константами и средой выполнения Java есть «механическая симпатия». Внутри содержимое lazy-константы хранится в поле без final, помеченном внутренней аннотацией JDK @Stable. Эта аннотация часто встречается в низкоуровневом коде JDK. Она утверждает, что, хотя поле не является final, его значение не изменится после первого и единственного присваивания. Благодаря этому JVM может считать содержимое lazy-константы настоящей константой при условии, что поле, ссылающееся на lazy-константу, объявлено как final. Таким образом, JVM может применять оптимизации свёртки констант к коду, который обращается к неизменяемым данным через несколько уровней lazy-констант, например Application.orders().getLogger().

В результате разработчикам больше не нужно выбирать между гибкой инициализацией и максимальной производительностью.

Lazy-списки

Во многих приложениях используются коллекции, элементы которых сами являются данными с отложенной неизменяемостью и инициализируются по похожей логике.

Рассмотрим, например, приложение, которое создаёт не один OrderController, а пул таких объектов. Разные запросы приложения могут обслуживаться разными объектами OrderController, и нагрузка распределяется по пулу. Объекты пула должны создаваться не заранее, а только тогда, когда приложению нужен новый объект. Этого можно добиться с помощью lazy-списка:

class Application {

    // OLD:
    // static final OrderController ORDERS = new OrderController();

    // NEW:
    static final List<OrderController> ORDERS
        = List.ofLazy(POOL_SIZE, _ -> new OrderController());

    public static OrderController orders() {
        long index = Thread.currentThread().threadId() % POOL_SIZE;
        return ORDERS.get((int)index);
    }

}

Здесь ORDERS уже не lazy-константа, а lazy-список, то есть List, каждый элемент которого хранится в lazy-константе. При создании lazy-списка через List.ofLazy(...) его размер фиксируется, в данном случае на POOL_SIZE. Lazy-константы, в которых хранятся элементы списка, пока не инициализированы.

Чтобы получить OrderController, метод orders вызывает ORDERS.get(...) и передаёт ему не ORDERS.get(), а индекс, вычисленный из числового идентификатора текущего потока. Первый вызов ORDERS.get(...) с определённым индексом вызывает вычисляющую функцию, переданную при создании, в данном случае лямбду, которая игнорирует индекс и вызывает конструктор OrderController. Полученный объект OrderController используется для инициализации содержимого lazy-константы, лежащей в основе элемента с этим индексом, после чего этот объект возвращается. Последующие вызовы ORDERS.get(...) с тем же индексом сразу возвращают содержимое элемента.

Элементы lazy-списка инициализируются независимо друг от друга, по мере необходимости. Например, если приложение работает в одном потоке, то будет создан и добавлен в ORDERS только один OrderController.

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

Lazy-отображения

Вместо этого пул объектов OrderController можно реализовать с помощью lazy-отображения, то есть Map, ключи которого известны при создании, а значения хранятся в lazy-константах и инициализируются по требованию вычисляющей функцией, которая тоже передаётся при создании:

class Application {

    // NEW:
    static final Map<String, OrderController> ORDERS
        = Map.ofLazy(Set.of("Customers", "Internal", "Testing"),
                     _ -> new OrderController());

    public static OrderController orders() {
        String threadName = Thread.currentThread().getName();
        return ORDERS.get(threadName);
    }

}

В этом примере экземпляры OrderController связаны с именами потоков, то есть "Customers", "Internal" и "Testing", а не с целочисленными индексами, вычисленными из идентификаторов потоков. Lazy-отображения допускают более выразительные идиомы доступа, чем lazy-списки, а в остальном обладают всеми теми же преимуществами.

Lazy-множества

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

Этого можно добиться с помощью lazy-множества, то есть Set, в котором признак принадлежности элемента множеству хранится в lazy-константе. После вычисления каждый такой признак может быть свёрнут JVM как константа:

class Application {

    enum Option { VERBOSE, DRY_RUN, STRICT }

    // Return true when the given Option is enabled
    private static boolean isEnabled(Option option) {
        // Parse command line, read configuration file, load database
        ...
    }

    // Lazily initialized Set of Options
    static final Set<Option> OPTIONS =
            Set.ofLazy(EnumSet.allOf(Option.class), Application::isEnabled);

    public static void process() {
        if (OPTIONS.contains(Option.DRY_RUN)) {
            // Skip processing in DRY_RUN mode
            return;
        }
        // Actual processing logic
        ...
    }

}

Дальнейшая работа

Lazy Constants покрывают распространённые высокоуровневые сценарии ленивой инициализации. В будущем мы можем рассмотреть возможность предоставлять семантику стабильного доступа напрямую, на более низком уровне, для ссылочных, массивных и примитивных полей. Это позволило бы охватить, например, сценарии, в которых вычисляющая функция, связанная с lazy-константой, неизвестна при создании.

Альтернативы

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

Идиома класса-держателя

Распространённый приём — так называемая идиома класса-держателя. Она обеспечивает отложенную неизменяемость с семантикой «не более одного раза», используя ленивость процесса инициализации классов в JVM:

class OrderController {

    public static Logger getLogger() {

        class Holder {
            private static final Logger LOGGER = Logger.create(...);
        }

        return Holder.LOGGER;
    }
}

Хотя эта идиома допускает оптимизации свёртки констант, она применима только к полям static. Кроме того, если нужно обработать несколько полей, для каждого поля требуется отдельный класс-держатель. Из-за этого приложения труднее читать, они медленнее запускаются и потребляют больше памяти.

Блокировка с двойной проверкой

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

class OrderController {

    private volatile Logger logger;

    public Logger getLogger() {
        Logger v = logger;
        if (v == null) {
            synchronized (this) {
                v = logger;
                if (v == null) {
                    logger = v = Logger.create(...);
                }
            }
        }
        return v;
    }

}

Поскольку logger — изменяемое поле, оптимизации свёртки констант здесь применить нельзя. Что ещё важнее, чтобы идиома двойной проверки работала, поле logger должно быть объявлено как volatile. Это гарантирует, что значение поля читается и обновляется согласованно в нескольких потоках.

Блокировка с двойной проверкой для массивов

Реализовать конструкцию блокировки с двойной проверкой, поддерживающую массивы значений с отложенной неизменяемостью, сложнее, поскольку объявить массив с элементами volatile невозможно. Вместо этого клиент должен явно организовать volatile-доступ к элементам массива с помощью объекта VarHandle:

class OrderController {

    private static final VarHandle LOGGERS_HANDLE
        = MethodHandles.arrayElementVarHandle(Logger[].class);

    private final Object[] mutexes;
    private final Logger[] loggers;

    public OrderController(int size) {
        this.mutexes = Stream.generate(Object::new).limit(size).toArray();
        this.loggers = new Logger[size];
    }

    public Logger getLogger(int index) {
        // Volatile is needed here to guarantee we only
        // see fully initialized element objects
        Logger v = (Logger)LOGGERS_HANDLE.getVolatile(loggers, index);
        if (v == null) {
            // Use distinct mutex objects for each index
            synchronized (mutexes[index]) {
                // A plain read suffices here since updates to an element
                // always take place under the same mutex as for this read
                v = loggers[index];
                if (v == null) {
                    // Volatile is needed here to establish a happens-before
                    // relation with future volatile reads
                    LOGGERS_HANDLE.setVolatile(loggers, index,
                                               v = Logger.create(... index ...));
                }
            }
        }
        return v;
    }

}

Этот код запутан и подвержен ошибкам: теперь для каждого элемента массива нужен отдельный объект синхронизации, и при каждом обращении нужно не забыть указать правильную операцию (getVolatile или setVolatile). Хуже того, доступ к элементам массива неэффективен, потому что оптимизации свёртки констант применить нельзя.

Конкурентное отображение

Отложенной неизменяемости можно добиться и с помощью потокобезопасных отображений, таких как ConcurrentHashMap, через метод computeIfAbsent:

class OrderController {

    private final Map<Class<?>,Logger> logger = new ConcurrentHashMap<>();

    public Logger getLogger() {
        return logger.computeIfAbsent(OrderController.class, Logger::create);
    }

}

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

Риски и допущения

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

К сожалению, основной API рефлексии позволяет произвольно изменять поля экземпляра final, за исключением полей, принадлежащих скрытым классам или Records (записи). В долгосрочной перспективе мы намерены ограничить API рефлексии так, чтобы всем полям экземпляра final можно было доверять, в рамках более широкого перехода к целостности по умолчанию. Однако до тех пор изменяемость большинства полей экземпляра final будет ограничивать оптимизации свёртки констант, которые дают lazy-константы.

К счастью, API рефлексии не позволяет произвольно изменять поля static final, поэтому свёртка констант для таких полей не только возможна, но и выполняется повсеместно. Таким образом, приведённые выше примеры, в которых lazy-константы, отображения или списки хранятся в полях static final, будут иметь хорошую производительность.