JEP draft: Lazy Static Final Fields
Ленивые статические final-поля
| Ответственный | John Rose |
| Тип | Feature |
| Область | JDK |
| Статус | Draft |
| Компонент | tools |
| Создан | 2018/08/25 06:49 |
| Обновлён | 2023/04/27 07:49 |
| Задача | 8209964 |
Аннотация
Расширить поведение final-переменных в языке и в JVM, добавив необязательные шаблоны ленивого вычисления. Тем самым перенести существующие в Java механизмы ленивого вычисления с нынешнего уровня отдельного класса на уровень отдельной переменной.
Мотивация
Java повсеместно использует ленивое вычисление. Почти любая операция связывания потенциально запускает ленивое вычисление, например выполнение метода <clinit> (байт-кода инициализатора класса) или вызов bootstrap-метода (для точки вызова invokedynamic или константы CONSTANT_Dynamic).
Инициализаторы классов более грубые по сравнению с механизмами на основе bootstrap-методов. По их контракту выполняется весь код инициализации для всего класса, а не отдельная инициализация, которая может относиться к конкретному полю этого класса. Из-за такой грубой инициализации особенно трудно предсказать и изолировать побочные эффекты использования одного статического поля класса, поскольку вычисление значения одного поля влечёт за собой вычисление всех статических полей того же класса.
Таким образом, обращение к одному полю затрагивает их все. В AOT-компиляторах из-за этого трудно оптимизировать ссылку на статическое поле, даже если у поля есть константное значение, которое легко проанализировать. Достаточно одного особо сложного статического поля в классе, чтобы все поля стали неоптимизируемыми. Похожая проблема возникает у предлагаемых механизмов свёртки констант (во время javac) для константных полей со сложными инициализаторами.
В качестве примера особо сложной инициализации статического поля, которая в некоторых кодовых базах встречается почти в каждом файле, рассмотрим инициализацию логгера:
private static final Logger LOGGER = Logger.getLogger("com.foo.Bar");
Эта безобидная на вид инициализация запускает огромный объём скрытой работы во время инициализации класса — хотя маловероятно, что логгер понадобится во время инициализации класса или вообще когда-либо. Если отложить создание до первого использования, инициализация станет проще, а в результате оптимизации инициализация может быть и вовсе устранена.
Final-переменные очень полезны: это основной механизм, с помощью которого API Java обозначают константные значения. Ленивые переменные тоже хорошо себя зарекомендовали. Начиная с Java 7 они становятся всё более важной частью внутреннего устройства JDK и выражаются через внутреннюю аннотацию @Stable. JIT может оптимизировать как final-, так и «стабильные» переменные полнее, чем другие переменные. Добавление ленивых final-полей позволит использовать эти полезные шаблоны проектирования в большем числе мест. Наконец, их внедрение позволит библиотекам, таким как JDK, меньше полагаться на код <clinit>, что, вероятно, улучшит запуск и AOT-оптимизации.
Что не является целью
Цель не в том, чтобы изменить существующие правила инициализации обычных статических final-полей, поскольку это невозможно сделать, не нарушив неочевидным образом работу многих видов пользовательского кода. Эта возможность будет предоставляться пользователям по их явному выбору. Мы надеемся, что IDE помогут пользователям применять её, находя и выполняя рефакторинги, которые выиграют от большей ленивости.
Многие преимущества ленивого вычисления, упомянутые в этом JEP, могли бы относиться и к аналогичной возможности, допускающей ленивые переменные экземпляра, то есть нестатические поля. Это оставлено как возможность на будущее. Кодирование неинициализированного состояния потребует особой аккуратности, чтобы избежать чрезмерных затрат на задержку и занимаемую память, поскольку это кодирование должно независимо повторяться во всех экземплярах объекта.
Некоторые преимущества ленивого вычисления, упомянутые в этом JEP, предположительно могли бы относиться и к аналогичной возможности, позволяющей делать ленивыми не-final-поля, то есть поля, которым пользовательский код может присваивать значения и которые он может читать, либо «на полной скорости», либо с медленным изменением по мере развития некоторой глобальной «эпохи». Такие идеи оставлены как возможности на будущее.
Цель не в том, чтобы определить «сверхэнергичные» статические поля, которые можно инициализировать до того, как объявляющий их класс выполнит свой метод-инициализатор. Такие статические поля можно имитировать вручную с помощью отдельного класса по идиоме holder. Однако ожидается, что многие ленивые поля, отвязанные от совместной инициализации с соседними полями, (при дальнейшем анализе) покажут, что их можно сдвигать в обоих направлениях, так же как сегодня можно сдвигать константные выражения.
Описание
Поле может быть объявлено с новым модификатором lazy — контекстным ключевым словом, которое распознаётся только как модификатор. Такое поле называется ленивым полем и также должно быть статическим и final.
(В других изложениях этой идеи ленивые статические поля помечаются другими вариантами синтаксиса модификаторов, например
__LazyStaticилиlazy-static. Детали будут определены позже.)
Ленивому статическому полю обязательно должен быть задан инициализатор. Компилятор и среда выполнения обеспечивают выполнение инициализатора при первом использовании переменной, а не при инициализации содержащей её области (класса).
(Поэтому инициализация ленивого статического поля, которая обрабатывается этим специальным механизмом, не присутствует дополнительно ни в каком методе
<clinit>, сгенерированном компилятором.)
Каждое ленивое статическое final-поле на этапе компиляции связывается с элементом пула констант, который предоставляет его значение. Поскольку элементы пула констант сами вычисляются лениво, этого достаточно, чтобы присвоить чётко определённое значение любой статической ленивой final-переменной, связанной с элементом пула констант. Атрибут называется LazyValue, и он должен ссылаться на элемент пула констант, к которому можно применить ldc и получить значение, преобразуемое к типу ленивого поля. Допустимые преобразования те же, что используются в MethodHandle.invoke.
(В принципе, с одним элементом пула констант можно связать более одной ленивой переменной, хотя это не рассматривается как полезная возможность. Такие шаблоны группировки можно построить вручную поверх базовой языковой возможности без группировки.)
Таким образом, ленивое статическое поле можно рассматривать как именованный псевдоним элемента пула констант внутри класса, в котором определено поле. Такие инструменты, как компиляторы, могут использовать это свойство.
Ленивое статическое поле никогда не является константной переменной (в смысле JLS 4.12.4) и явно исключено из участия в константных выражениях (в смысле JLS 15.28). Поэтому у него никогда нет атрибута ConstantValue, даже если его инициализатор является константным выражением. Вместо этого у ленивого поля есть атрибут class-файла нового вида, LazyValue, к которому JVM обращается при связывании ссылки на это конкретное поле. Формат нового атрибута похож на формат старого, поскольку он тоже указывает на элемент пула констант, в данном случае на тот, который разрешает значение поля.
При связывании ленивого статического поля обычный процесс выполнения инициализаторов класса не пропускается. Вместо этого любой метод <clinit> объявляющего класса выполняется по правилам JVMS 5.5. Другими словами, байт-код getstatic для ленивого статического поля выполняет все действия связывания, относящиеся к любому статическому полю, кроме ленивых.
(Когда класс инициализируется из-за связывания обычного статического поля, инициализация класса в этот момент никак не затрагивает ленивые статические поля; всё происходит так, как будто они не объявлены, именно потому, что они не инициализируются действиями какого-либо блока
<clinit>.)
После инициализации (или во время уже начатой инициализации в текущем потоке) JVM разрешает элемент пула констант, связанный с полем, и сохраняет значение этого элемента пула констант в это поле.
Поскольку ленивые статические final-поля не могут быть пустыми final-полями (blank finals), им нельзя присваивать значения, даже в тех ограниченных контекстах, где присваивание пустым final-полям допускается.
В Java есть правило, по которому статическая переменная может появляться только в инициализаторах статических переменных, расположенных дальше в теле класса. Это правило снижает (но не устраняет) вероятность того, что несвоевременное чтение статической переменной получит значение этой переменной по умолчанию, а не её начальное значение.
class C {
static int x = y; //error: illegal forward reference
static int y = 42;
}
Эти ограничения на порядок соблюдаются и для ленивых статических полей, как если бы они не были объявлены ленивыми. Таким образом, инициализатор ленивого статического поля может ссылаться только на статическое поле того же класса, расположенное раньше в том же исходном файле.
Если в каком-то случае два ленивых значения должны зависеть друг от друга циклически, цикл можно скрыть с помощью private static метода. В этом случае настоящая циклическая зависимость вызовет ошибку переполнения стека. В случае неленивых статических полей аналогичный цикл привёл бы к тому, что стало бы видно значение по умолчанию.
class C {
//lazy static final Object x = y, y = x; //error
lazy static final Object x = ycycle(), y = x;
private static Object ycycle() { return y; }
}
Любой инициализатор неленивого статического поля или блок инициализации класса тоже может ссылаться на значение ленивого статического поля, расположенного раньше в исходном файле. Обычно это нежелательно, поскольку сводит на нет преимущество ленивого поля, но может быть полезно в сочетании с условными выражениями или управляющими конструкциями.
Цель правила о порядке — потребовать от пользователя указать номинальный порядок инициализации ленивых статических полей. Фактический динамический порядок инициализации может отличаться, но номинальный порядок служит для статической демонстрации того, что между статическими полями, ленивыми и прочими, нет непреднамеренных циклических зависимостей.
Ленивые поля может распознавать core reflection API с помощью двух новых точек API в java.lang.reflect.Field. Новый метод-запрос isLazy возвращает true тогда и только тогда, когда поле было объявлено ленивым. Новый метод-запрос isAssigned возвращает false тогда и только тогда, когда поле ленивое и не инициализировано на момент вызова метода. (При следующем же вызове в том же потоке он может вернуть true, в зависимости от состояния гонки.) Кроме isAssigned, нет способа узнать, инициализировано ли уже ленивое поле.
(Рефлективный вызов isAssigned предоставляется только для помощи в отдельных проблемах с циклическими зависимостями инициализации. Возможно, мы обойдёмся без его реализации, хотя те, кто пишет код с ленивыми переменными, иногда хотят аккуратно узнать, установлена ли уже ленивая переменная, так же как пользователи мьютексов иногда хотят узнать, заблокирован ли мьютекс, не захватывая при этом блокировку.)
Чтобы сохранить свободу реализации, контракт isAssigned сведён к минимуму. Если JVM может доказать, что ленивую статическую переменную можно инициализировать без наблюдаемых побочных эффектов, она может сделать это в любой момент; в таком случае запрос isAssigned сообщит true ещё до выполнения какого-либо getfield. Минимальный контракт isAssigned таков: если он возвращает false, текущий поток ещё не наблюдал ни одного из побочных эффектов инициализации этой переменной, а если он возвращает true, то текущий поток в будущем сможет наблюдать все побочные эффекты инициализации. Этот контракт позволяет компиляторам заменять getstatic для собственных полей на ldc и позволяет JVM не отслеживать детальные состояния инициализации final-полей с общими или вырожденными элементами пула констант.
Несколько потоков могут одновременно пытаться инициализировать ленивое final-поле. Как это уже происходит с элементами пула констант CONSTANT_Dynamic, JVM выбирает произвольного победителя такой гонки, передаёт его значение всем участвующим в гонке потокам и сохраняет это значение для всех будущих обращений. Поэтому реализации JVM могут разрешать гонки с помощью операций CAS, если платформа их поддерживает.
Когда JVM записывает значение в ленивое final-поле, она выполняет операцию заморозки. Эта заморозка происходит до того, как какой-либо инструкции getstatic будет позволено увидеть значение поля. Так существующие правила безопасной публикации применяются к ленивым final-полям.
Ленивое final-поле действует почти так же, как static final поле, определённое в собственном классе, в котором нет других static final полей.
class C { lazy static final Object x = xval(), y = yval(); }
f() { ... getstatic C.x ... }
=>
class C_x { static final Object x = xval(); }
class C_y { static final Object y = yval(); }
f() { ... getstatic C_x.x ... }
Разница в том, что настоящая циклическая зависимость между ленивыми статическими полями приведёт к переполнению стека, а не к наблюдению значения по умолчанию.
Обратите внимание, что класс может превратить статическое поле в ленивое статическое, не нарушая двоичную совместимость. Инструкция getstatic у клиента в обоих случаях одинакова. Когда объявление переменной становится ленивым, инструкция getstatic связывается иначе.
Описание работы
Класс без ленивых статических полей инициализируется за один проход. Класс с N ленивыми статическими полями инициализируется за 1+N проходов. Первый проход всегда инициализирует все обычные статические поля (но не ленивые статические поля) в том порядке, в котором они встречаются в исходном коде, как во всех предыдущих версиях Java Language Specificaiton. Все проходы запускаются потоками, которые пытаются обратиться к классу, но проходы также могут выполняться в разных потоках.
Первый проход выполняется до конца в потоке, выбранном из тех потоков, которые первыми выполняют инициализирующее обращение (любого вида) к классу. Конкурирующие потоки приостанавливаются, пока выбранный поток не завершит инициализацию. Гонка разрешается захватом блокировки инициализации «LC», определённой в JVMS 5.5, — в разделе, где определяется инициализация класса.
Если инициализирующий поток читает обычное статическое поле до того, как этот же поток его инициализировал, в качестве значения этого поля будет видно начальное значение по умолчанию, например null.
Если инициализация какой-либо обычной статической переменной завершается исключением, то инициализация класса в целом завершается неудачей. Для диагностики этой ошибки сохраняется Error (возможно, экземпляр ExceptionInInitializerError), как указано в шаге 11 JVMS 5.5.
Каждый из остальных проходов (для ленивого статического поля) выполняется до конца в потоке, выбранном из тех потоков, которые первыми выполняют инициализирующее обращение к этому ленивому статическому полю. Проход вычисляет инициализатор ленивого статического поля и либо выдаёт для него начальное значение правильного типа, либо выбрасывает исключение.
Если выброшено исключение, то обо всех использованиях ленивого статического поля, включая первое и использования в любых потоках, конкурирующих за инициализацию, сообщается через Error, который выбрасывается во всех конкурирующих потоках, опять же как указано в шаге 11 JVMS 5.5. Как и в случае ошибок инициализатора класса, эта же ошибка сохраняется и будет выбрасываться при будущих попытках обращения к тому же ленивому статическому полю. В отличие от обычных статических полей, ошибка инициализации не приводит к неудачной инициализации класса в целом.
(Если только инициализатор не вернёт значение по умолчанию для типа этого поля, ни одно использование этого ленивого статического поля не увидит значение по умолчанию для типа поля. Этим поведение отличается от поведения обычных статических полей.)
Точно так же, как при неленивой инициализации класса, гонки между потоками за инициализацию ленивого статического поля разрешаются захватом блокировки инициализации «LC». Это значит, что для любого класса выражения инициализации статических полей никогда не выполняются конкурентно друг с другом. Это также значит, что если инициализатор класса обращается к ленивому статическому полю, инициализатор этого поля гарантированно будет разрешён в том же потоке и это разрешение завершится до завершения инициализатора класса.
Если инициализирующий поток прямо или косвенно читает значение ленивого статического поля до того, как значение инициализации выбрано, вместо завершения инициализации будет выброшена ошибка переполнения стека. Если этот поток и есть тот, который выбран для инициализации ленивого статического поля, то эта ошибка будет результатом чтения этого поля во всех потоках и в любой момент времени.
(Поскольку блокировка «LC» разрешает гонки для всех ленивых полей одного класса, взаимная блокировка невозможна; в этом преимущество перед схемами, которые опираются на несколько классов и могут попасть во взаимную блокировку, если инициализация начинается с разных классов в разных потоках.)
(Правила инициализации ленивых статических полей согласованы с правилами для обычных статических полей и поэтому неизбежно отличаются от правил для констант
CONSTANT_Dynamicв пулах констант. Сериализацию на «LC» и обёртывание ошибок вExceptionInInitializerError, которых требуют ленивые статические поля, реализует не JVM, а стратегия трансляции, вероятно, в тщательно написанном bootstrap-методе.)
Если первое связываемое поле класса — ленивое статическое поле, то первый проход инициализирует все обычные статические поля. Затем инициализируется ленивое статическое поле. Если несколько потоков пытаются инициализировать одно и то же ленивое статическое поле, один из них выбирается для инициализации всех обычных статических полей, а остальные приостанавливаются. После этого прохода, во втором проходе, выбирается другой поток (произвольно, через «LC») для вычисления инициализатора, и результат (значение или исключение) сохраняется для ленивого статического поля.
(Обычно, но не обязательно, оба прохода выполняет один и тот же поток. Инициализацию обычных статических полей можно свободно упорядочивать относительно инициализации любого ленивого статического поля, при условии что инициализация обычных статических полей началась до начала инициализации любого ленивого статического поля.)
(Если какая-либо будущая система позволит инициализировать статические поля до входа в метод
main, эти правила упорядочения всё равно будут действовать, а значит, для некоторых классов все обычные статические поля и некоторые ленивые статические поля могут быть инициализированы до вызова методаmain. В такой системе может быть желательно преобразовать обычные статические поля класса в ленивые, чтобы убрать узкое место в виде метода<clinit>.)
После этого связывание любого из остальных N-1 ленивых статических полей инициализирует только это ленивое статическое поле, снова выбирая один из запрашивающих потоков, который выдаст значение или исключение.
Если в итоге будут связаны все ленивые статические поля класса, то число проходов для полной инициализации класса равно 1+N.
(В любом случае все обычные статические поля инициализируются в первом проходе. Такая «энергичная» инициализация обычных статических полей при связывании первого статического поля соответствует предыдущим выпускам Java.)
Альтернативы
Использовать вложенные классы по идиоме holder для отдельных ленивых переменных. Раньше это работало для небольшого числа переменных, но масштабируется недостаточно хорошо, чтобы можно было переделать в ленивые большое число static final полей. (Уже одни только объёмные изменения кода были бы неприемлемы, как и затраты на загрузку множества новых маленьких классов.) Но такой масштабный рефакторинг, по-видимому, желателен, чтобы расширить охват и повысить качество статического анализа, выполняемого преобразованиями конденсации Leyden, по крайней мере теми, которые предполагают переупорядочивание эффектов инициализации классов.
Определить какой-либо библиотечный API для управления ленивыми значениями или (в более общем случае) монотонными данными. У этого подхода также были бы проблемы с масштабированием.
Переделать потенциальные ленивые статические переменные в статические методы без аргументов и тем или иным способом заполнить их тела инструкциями ldc для констант CONSTANT_Dynamic. У этого подхода та же проблема, что и у идиомы holder: он часто требует изменения API, а также значительных изменений кода.
Использовать не-final переменные для публикации лениво вычисляемых данных, следя за тем, чтобы не изменять их, и ограждая их инициализацию барьерами для безопасной публикации. (Те же возражения, что и к идиоме holder, плюс большая подверженность ошибкам в коде из-за барьеров.)
(Примечание. Перечисленные обходные пути не дают двоично совместимого способа избавить существующие статические константы от их текущей зависимости от <clinit>. Для не-private статических полей это проблема в дополнение к изменениям кода и занимаемой памяти.)
Если двигаться в сторону добавления большей функциональности, можно разрешить ленивым полям быть нестатическими и/или не-final, сохранив нынешние соответствия и аналогии между поведением статических и нестатических полей. Пул констант не может служить хранилищем для нестатических полей, но он всё равно может предоставлять bootstrap-методы (зависящие от текущего экземпляра). Для замороженных массивов (если они будут реализованы), возможно, можно будет сделать ленивые варианты. Такие исследования выглядят возможными как последующие проекты после текущего предложения. (См. также раздел «Что не является целью».)
Набросок стратегии трансляции
С простой «зацепкой» в виде атрибута LazyValue есть несколько возможных тактик трансляции ленивых статических полей в байт-код. Вот одна комбинация, которая выглядит удачной.
-
Для каждого инициализатора ленивого статического поля компилятор Java (например,
javac) собирает отдельный блок кода, содержащий операторreturn, который возвращает значение инициализатора, возможно упакованное какObject. -
Все такие блоки объединяются в оператор
switch, в котором выражение — строка, а метки case — имена полей. (Заметим, что все они должны отличаться друг от друга.) -
Оператор switch помещается в синтетический статический метод с именем
$lazyinit, который принимает один строковый аргумент, возвращает нетипизированную ссылку на объект и выбрасывает произвольный набор исключений. -
Генерируется единственный элемент
CONSTANT_Dynamic, который принимает ссылку на$lazyinit(в виде method handle) и с помощью подходящего bootstrap-метода (вjava.lang.runtime) создаёт объект или method handle, который «умеет» принимать строку с именем ленивого статического поля и разрешать гонки в процессе ленивой инициализации, как описано выше. (Детали зависят от возможно скорректированной JVMS.) Назовём это «константой-контроллером ленивой инициализации». -
Для каждого ленивого статического поля генерируется элемент
CONSTANT_Dynamic. Имя и тип элемента копируются из самого поля. Bootstrap-метод и/или аргументы ссылаются на ранее определённую константу-контроллер ленивой инициализации. (Детали зависят от возможно скорректированной JVMS.) -
Для каждого ленивого статического поля генерируется атрибут
LazyValue, указывающий на соответствующий элементCONSTANT_Dynamic.
Накладные расходы этой схемы для N ленивых статических полей:
-
N блоков кода инициализации, каждый из которых возвращает значение как
Object. (Сопоставимо с кодом неленивой инициализации, который заканчивается инструкциейputstaticправильного типа.) -
Один синтетический метод со switch по строке и его метаданные. (Примерно 100 байт.)
-
Ровно два элемента bootstrap-методов в пуле констант.
-
N+1 элементов
CONSTANT_Dynamicв пуле констант. -
N элементов
CONSTANT_NameAndTypeв пуле констант. (Те же, что нужны дляgetstaticилиputstatic, поэтому дополнительных затрат нет.)
Таким образом, дополнительные затраты на ленивое статическое поле можно свести всего к нескольким байтам в class-файле. Затраты на раскрутку во время выполнения и на занимаемую память — другой вопрос, и они сильно зависят от того, поддерживает ли JVMS реализацию арбитража «LC» непосредственно поверх пула констант или же код JDK должен дублировать состояния, необходимые для такого арбитража. Этот вопрос изучается дальше: рассматривается расширение (TBD) протокола bootstrap-методов, которое открыло бы bootstrap-методу более широкий набор состояний пула констант.