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

JEP 502: Stable Values (Preview)

Stable Values (стабильные значения), версия Preview (предварительная версия)

АвторPer Minborg & Maurizio Cimadamore
ОтветственныйPer-Ake Minborg
ТипFeature
ОбластьSE
СтатусClosed / Delivered
Выпуск25
Компонентcore-libs / java.lang
Обсуждениеcore dash libs dash dev at openjdk dot org
ТрудоёмкостьS
ДлительностьS
Связан сJEP 526: Lazy Constants (Second Preview)
РецензентыAlex Buckley, Brian Goetz
ОдобренMark Reinhold
Создан2023/07/24 15:11
Обновлён2025/07/16 19:11
Задача8312611

Аннотация

Добавить API для Stable Values — объектов, которые хранят неизменяемые данные. JVM рассматривает Stable Values как константы, и для них доступны те же оптимизации производительности, что и при объявлении поля final. Однако по сравнению с полями final Stable Values дают больше свободы в выборе момента инициализации. Это API в статусе Preview.

Цели

  • Ускорить запуск Java-приложений, разбив монолитную инициализацию состояния приложения на части.

  • Отделить создание Stable Values от их инициализации без существенной потери производительности.

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

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

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

  • Не ставится цель добавить в язык программирования Java средство для объявления Stable Values.

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

Мотивация

Большинство Java-разработчиков слышали совет «предпочитайте неизменяемость» или «минимизируйте изменяемость» (Effective Java, Third Edition, Item 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 широкую свободу в планировании и оптимизации инициализации таких полей и позволило бы избежать издержек, от которых страдают альтернативные подходы. Полноценная поддержка отложенной неизменяемости закрыла бы важный пробел между неизменяемыми и изменяемыми полями.

Описание

Stable Value — это объект типа StableValue, который хранит одно значение данных, своё содержимое. Stable Value должен быть инициализирован в какой-то момент до первого получения содержимого, а после этого он неизменяем. Stable Value — способ добиться отложенной неизменяемости.

Вот класс OrderController, переписанный так, чтобы хранить логгер в Stable Value:

class OrderController {

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

    // NEW:
    private final StableValue<Logger> logger = StableValue.of();

    Logger getLogger() {
        return logger.orElseSet(() -> Logger.create(OrderController.class));
    }

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

}

Поле logger хранит Stable Value, созданный статическим фабричным методом StableValue.of(). Изначально Stable Value не установлен, т. е. не содержит содержимого.

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

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

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

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

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

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

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

Гибкая инициализация с помощью Stable Values

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

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

Гибкость Stable Values позволяет по-новому взглянуть на инициализацию целых приложений. В частности, Stable Values можно составлять из других Stable Values. Так же как мы использовали Stable Value для хранения логгера в компоненте OrderController, можно использовать Stable Value и для хранения самого компонента 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 StableValue<OrderController>   ORDERS   = StableValue.of();
    static final StableValue<ProductRepository> PRODUCTS = StableValue.of();
    static final StableValue<UserService>       USERS    = StableValue.of();

    public static OrderController orders() {
        return ORDERS.orElseSet(OrderController::new);
    }

    public static ProductRepository products() {
        return PRODUCTS.orElseSet(ProductRepository::new);
    }

    public static UserService users() {
        return USERS.orElseSet(UserService::new);
    }

}

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

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

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

Задание инициализации в месте объявления

До сих пор в наших примерах Stable Values инициализировались в месте использования, например вызовом logger.orElseSet(...) в методе getLogger. Так содержимое можно вычислить с помощью информации, доступной методу getLogger. К сожалению, это также означает, что любое обращение к Stable Value logger должно проходить через этот метод.

В этом случае было бы удобнее указать, как инициализировать Stable Value, при его объявлении, не инициализируя его на самом деле. Это можно сделать с помощью stable supplier:

class OrderController {

    // OLD:
    // private final StableValue<Logger> logger = StableValue.of();
    //
    // Logger getLogger() {
    //     return logger.orElseSet(() -> Logger.create(OrderController.class));
    // }

    // NEW:
    private final Supplier<Logger> logger
        = StableValue.supplier(() -> Logger.create(OrderController.class));

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

}

Здесь logger — уже не Stable Value, а stable supplier, т. е. Supplier содержимого базового Stable Value, созданного из исходного Supplier, который может вычислить содержимое по требованию. Когда stable supplier только создан через StableValue.supplier(...), содержимое базового Stable Value ещё не инициализировано.

Чтобы получить логгер, клиенты вызывают logger.get() вместо getLogger(). Первый вызов logger.get() вызывает исходный поставщик, т. е. лямбда-выражение, переданное в StableValue.supplier(...). Полученное значение используется для инициализации содержимого базового Stable Value этого stable supplier, после чего результат возвращается клиенту. Последующие вызовы logger.get() сразу возвращают содержимое.

Использование stable supplier вместо Stable Value упрощает сопровождение. Объявление и инициализация поля logger теперь находятся рядом, и код читается легче. Классу OrderController больше не нужно документировать инвариант, согласно которому любое обращение к логгеру должно проходить через метод getLogger, и этот метод теперь можно удалить.

Разумеется, JVM может выполнять оптимизации свёртки констант для кода, который обращается к содержимому Stable Values через stable supplier.

Агрегирование Stable Values

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

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

class Application {

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

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

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

}

Здесь ORDERS — уже не Stable Value, а stable list, т. е. List, каждый элемент которого — содержимое базового Stable Value. При создании через StableValue.list(...) stable list имеет фиксированный размер, в данном случае POOL_SIZE. Содержимое Stable Values, лежащих в основе его элементов, ещё не инициализировано.

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

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

Stable list сохраняет многие преимущества stable supplier, поскольку функция инициализации элементов списка задаётся при определении списка. JVM, как обычно, может выполнять оптимизации свёртки констант для кода, который обращается к содержимому Stable Values через stable list.

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

Сегодня отложенную неизменяемость в коде на 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 не может рассчитывать, что содержимое записи отображения не будет обновлено после её первого добавления в отображение, поэтому оптимизации свёртки констант здесь неприменимы. Кроме того, метод computeIfAbsent враждебен к null: если вычисляющая функция возвращает null, новая запись в отображение не добавляется, и в некоторых случаях это решение непрактично.

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

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

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

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