Достало, что в Java логгеры инициализируются в момент инициализации класса, отчего замусоривают весь запуск? Джон Роуз спешит на помощь!
Вот как это может выглядеть:
lazy private final static Logger LOGGER = Logger.getLogger("com.foo.Bar");
Этот документ расширяет поведение final-переменных, позволяя по желанию поддерживать ленивое выполнение — как в самом языке, так и в JVM. Поведение существующих механизмов ленивого вычисления предлагается улучшить, изменив гранулярность: теперь она будет не с точностью до класса, а с точностью до конкретной переменной.
Мотивация
В Java глубоко встроены ленивые вычисления. Почти каждая операция линковки может дергать ленивый код. Например, выполнение метода <clinit>
(байткод инициализатора класса) или использование bootstrap-метода (для invokedynamic call site или констант CONSTANT_Dynamic
).
Инициализаторы класса — это что-то очень грубое в смысле гранулярности, если сравнивать с механизмами, использующими bootstrap-методы, поскольку их контракт заключается в запуске всего инициализирующего кода для класса целиком, вместо того, чтобы ограничиться инициализацией, относящейся к конкретному полю класса. Эффекты такой грубой инициализации сложно предсказать. Сложно изолировать побочные эффекты использования одного статического поля класса, поскольку вычисление одного поля приводит к вычислению всех статических полей этого класса.
Если затронуть одно поле, тем самым ты затронешь их все. В AOT-компиляторах, это делает особо сложным оптимизацию статических ссылок на поля, даже для полей с легко анализируемым константным значением. Стоит среди полей затесаться хотя бы одному переусложнённому статическому полю, и совершенно все поля этого класса становится невозможно анализировать. Похожая проблема проявляется с ранее предложенными механизмами реализации свертки констант (во время работы javac) для константных полей со сложными инициализаторами.
Пример переусложненной инициализации поля, который в разных проектах встречается на каждом шагу, в каждом файле — это инициализация логгера.
private final static Logger LOGGER = Logger.getLogger("com.foo.Bar");
Эта безобидно выглядящая инициализация запускает под капотом огромную работу, которая выполнится во время инициализации класса — и тем не менее, крайне маловероятно, что логгер действительно нужен в момент инициализации класса, а может быть и не нужен вообще. Возможность отложить его создание до первого реального использования упростит инициализацию, а в ряде случаев — позволит вообще этой инициализации избежать.
Final-переменные очень полезны, они являются основным механизмом Java API для того, чтобы указать на константность значений. Ленивые переменные также хорошо себя зарекомендовали. Начиная с Java 7, они начали играть всё более важную роль во внутренностях JDK, будучи отмеченными аннотацией @Stable
. JIT может оптимизировать и финальные, и «stable» — переменные значительно лучше, чем просто какие-то переменные. Добавление ленивых финальных переменных позволит этому полезному паттерну использования стать более распространенным, даст возможность использоваться в большем количестве мест. Наконец, использование lazy final переменных позволит библиотеками, таким как JDK, уменьшить зависимость на код <clinit>
, что, в свою очередь, должно уменьшить время запуска и повысить качество AOT-оптимизаций.
Описание
Поле сможет быть объявлено с новым модификатором lazy
, который является контекстным ключевым словом, воспринимаемым исключительно как модификатор. Такое поле называется ленивым (lazy field), и обязано также иметь модификаторы static
и final
.
Ленивое поле должно иметь инициализатор. Компилятор и рантайм договариваются с целью запустить инициализатор в точности при первом использовании переменной, а не при инициализации класса, которому принадлежит это поле.
Каждое lazy static final
поле связывается в момент компиляции с элементом константного пула, который представляет его значение. Поскольку элементы константного пула и сами по себе вычисляются лениво, достаточно просто назначить правильно подобранное значение для каждой static lazy final переменной, связанной с этим элементом. (К одному элементу можно привязать более одной ленивой переменной, но вряд ли это является полезной или осмысленной фичей.) Имя атрибута — LazyValue
, и он должен относиться к элементу константного пола, который можно ldc-шнуть в значение, которое конвертируемо в тип ленивого поля. Разрешены только те приведения, которые уже используются в MethodHandle.invoke
.
Таким образом, ленивое статическое поле можно рассматривать как именованный псевдоним на элемент константного пула внутри класса, который объявил это поле. Инструменты вроде компиляторов могут каким-то своим образом попытаться использовать это поле.
Ленивое поле никогда не является константной переменной (в том смысле JLS 4.12.4) и явным образом исключено из участия в константных выражения (в смысле JLS 15.28). Поэтому, оно никогда не захватывает атрибут ConstantValue
, даже если его инициализатор является константным выражением. Вместо этого, ленивое поле захватывает новый вид атрибута классфайла под названием LazyValue
, с которым JVM сверяется при линковке ссылки на это конкретное поле. Формат этого нового атрибута похож на предыдущий, поскольку он также указывает на элемент константного пула, в данном случае — тот, который разрешается в значение поля.
Когда линкуется ленивое статическое поле, обычный процесс исполнения инициализаторов класса не должен исчезать. Вместо этого, любой метод <clinit>
объявляющего класса инициализируется по правилам, определенным в JVMS 5.5. Другими словами, байткод getstatic
для ленивого статического поля выполняет всё ту же линковку, что и для любого статического поля. После инициализации (или в ходе уже-запущенной инициализации текущего треда), JVM разрешает элементы константного пула, связанные с полем, и сохраняет полученные из константного пула значения в это самое поле.
Поскольку lazy static final не могут быть пустыми, им нельзя присваивать никаких значений — даже в том небольшом количестве контекстов, где это работает для пустых финальных переменных.
Во время компиляции все ленивые статические поля инициализируются независимо от неленивых статических полей, вне зависимости от их расположения в исходном коде. Значит, ограничения на расположение статических полей не относятся к ленивым статическим полям. Инициализатор ленивого статического поля может использовать любое статическое поле того же самого класса, вне зависимости от порядка, в котором они встречаются в исходнике. Инициализатор любого нестатического поля или инициализатор класса могут обращаться к ленивому полю, вне зависимости от того, в каком порядке в исходнике они идут относительно друг друга. Обычно делать так — не самая здравая идея, поскольку при этом теряется весь смысл ленивых значений, но возможно, это можно как-то использовать в условных выражениях или на потоке управления. Поэтому к ленивым статическим полям можно относиться скорей как к полям другого класса — в том смысле, что на них можно ссылаться в любом порядке из любой части класса, в котором они объявлены.
Ленивые поля могут быть обнаружены с помощью reflection API, используя два новых метода API в java.lang.reflect.Field
. Новый метод isLazy
возвращает true
тогда и только тогда, когда поле имеет модификатор lazy
. Новый метод isAssigned
возвращает false
тогда и только тогда, когда поле является ленивым и всё еще не проинициализировано на момент запуска isAssigned
. (Оно может вернуть true чуть ли не на следующем вызове в этом же самом треде, в зависимости от наличия гонок). Не существует никаких способов узнать, проинициализировано ли поле, кроме как с помощью isAssigned
.
(Вызов isAssigned
нужен только для того, чтобы помочь с редкими проблемами, связанными с разрешением циклических зависимостей. Возможно, мы можем обойтись и без реализации этого метода. Тем не менее, люди, которые пишут код с ленивыми переменными, время от времени хотят аккуратно узнать, установлено ли в такую переменную значение или ещё нет, примерно тем же способом, как пользователи мьютексов иногда хотят узнать у мьютекса, заблокирован он или нет, но не хотят по-настоящему попасть под блокировку)
Есть одно необычное ограничение на ленивые финальные поля: они никогда не должны инициализироваться в свои дефолтные значения. То есть ленивое поле ссылки не должно инициализироваться в null
, а числовые типы не должны иметь нулевое значение. Ленивое булево значение может быть инициализировано всего одним значением — true
, поскольку false
является его значением по умолчанию. Если инициализатор ленивого статического поля возвращает своё значение по умолчанию, линковка этого поля упадёт с соответствующей ошибкой.
Это ограничение введено для того. чтобы позволить реализациям JVM резервировать значения по умолчанию как внутреннее сторожевое значение, отмечающее состояние неинициализированного поля. Значение по умолчанию уже задано в изначальном значении любого поля, установлено в момент подготовки (это описано в JLS 5.4.2). Так что это значение естественным образом уже существует в начале жизненного цикла любого поля, и поэтому является логичным выбором для использования в качестве сторожевого значения, отслеживающего состояние этого поля. Используя эти правила, из ленивого статического поля никогда нельзя получить изначальное значение по умолчанию. Для этого JVM может, например, реализовать ленивое поле как неизменяемую ссылку на соответствующий элемент константного пула.
Ограничения на значения по умолчанию можно обойти, оборачивая значения (которые, возможно, равны дефолтным) в боксы или контейнеры какого-то удобного вида. Нулевое число можно обернуть в ненулевую ссылку на Integer. Непримитивные типы можно обернуть в Optional, который становится empty в случае попадания на null.
Чтобы поддержать свободу в способах реализации фичи, требования на метод isAssigned
специально занижены. Если JVM может доказать, что ленивая статическая переменная может быть проинициализирована без наблюдаемых внешних эффектов, она может сделать эту инициализацию в любое время. В этом случае, isAssigned
будет возвращать true
даже если getfield
никогда не вызывалось. На isAssigned
накладывается лишь то требование, что если уж оно вернуло false
, то никакие из побочные эффекты от инициализации переменной не должны наблюдаться в текущем треде. А если он вернул true
, то тогда текущей тред может в будущем наблюдать побочные эффекты инициализации. Такой контракт позволяет компилятором подменить ldc
на getstatic
для собственных полей, что позволяет JVM не заниматься отслеживанием детализированных состояний финальных переменных, имеющих общие или вырожденные элементы в константном пуле.
Несколько тредов могут прийти в состояние гонки за инициализацией ленивого финального поля. Как это уже происходит с CONSTANT_Dynamic
, JVM выбирает произвольного победителя этой гонки и предоставляет значение этого победителя во все участвующие в гонке треды, и записывает его для всех последующих попыток получить значение. Чтобы обойти гонки, конкретные реализации JVM могут попробовать использовать CAS операции, если платформа их поддерживает — победитель гонки увидит предыдущее значение по умолчанию, а побеждённые увидят недефолтное значение, выигравшее в гонке.
Таким образом, существующие правила единственного присваивания финальных переменных продолжают работать и теперь захватывают все сложности ленивых вычислений.
Та же самая логика применима к безопасной публикации с помощью финальных полей — она одинакова как для ленивых, так и для неленивых полей.
Заметьте, что класс может преобразовать статическое поле в ленивое статическое без нарушения бинарной совместимости. Клиентская инструкция getstatic
идентична в обоих случаях. Когда объявление переменной меняется на ленивое, getstatic
линкуется другим способом.
Альтернативные решения
Можно использовать вложенные классы как контейнеры для ленивых переменных.
Можно определить что-то вроде библиотечного API для управления ленивыми значениями или (в более общем смысле) любыми монотонными данными.
Отрефакторить то, что собирались сделать ленивыми статическими переменными так, чтобы они превратились в нульарные статические методы и их тела публиковались с помощью ldc CONSTANT_Dynamic констант, каким-то способом.
(Замечание. Приведённые выше обходные пути не предоставляют бинарно-совместимого способа эволюционно отвязать существующие статические константы от их завязки на <clinit>
)
Если говорить о предоставлении большей функциональности, можно разрешить ленивым полям быть нестатичными или нефинальными, сохраняя текущие соответствия и аналогии между поведением статических и нестатических полей. Константный пул не сможет быть хранилищем для нестатических полей, но он всё ещё может держать bootstrap-методы (в зависимости от текущего экземпляра). Frozen arrays (если их реализуют) могут получить ленивый вариант. Такие исследования являются хорошей основой для будущих проектов, построенных на основе этого документа. И кстати, такие возможности делают ещё более осмысленным наше решение запретить значения по умолчанию.
Ленивые переменные должны инициализироваться с помощью собственных инициализирующих выражений. Иногда это кажется очень неприятным ограничением, которое отбрасывает нас назад во времена изобретения пустых финальных переменных. Вспомните, что эти пустые финальные переменные могут инициализироваться произвольными блоками кода, включая логику try-finally, и они могут инициализироваться группами, а не одновременно. В будущем, можно будет попытаться применить те же возможности и к ленивым финальным переменным. Возможно, одна или больше ленивых переменных может быть связана с приватным блоком инициализирующего кода, задача которого заключается в том, чтобы назначить каждую переменную ровно один раз, как это происходит с инициализатором класса или конструктором объекта. Архитектура такой фичи может стать более ясна после появления деконструкторов, поскольку решаемые ими задачи в каком-то смысле пересекаются.
Минутка рекламы. Совсем скоро пройдёт конференция Joker 2018, на которой будет множество видных специалистов по Java и JVM. Посмотреть полный список спикеров и докладов можно на официальном сайте.
Автор
Джон Роуз — инженер и архитектор JVM в Oracle. Ведущий инженер Da Vinci Machine Project (часть OpenJDK). Ведущий инженер JSR 292 (Supporting Dynamically Typed Languages on the Java Platform), занимается спецификацией динамических вызовов и связанных вопросов, таких как профилирование типов и улучшенные компиляторные оптимизации. Раньше работал над inner classes, делал изначальный порт HotSpot на SPARC, Unsafe API, а также разрабатывал множество динамических, параллельных и гибридных языков, включая Common Lisp, Scheme («esh»), динамические биндинги для C++.
Переводчик
Олег Чирухин — на момент написания этого текста работает комьюнити-менеджером в компании JUG.ru Group, занимается популяризацией Java-платформы. До перехода в JRG принимал участие в разработке банковских и государственных информационных систем, экосистемы самописных языков программирования, онлайн-игр. Текущие исследовательские интересы включают виртуальные машины, компиляторы и языки программирования.