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

JEP draft: low-level control of field initialization

низкоуровневое управление инициализацией полей

ОтветственныйJohn Rose
ТипFeature
ОбластьJDK
СтатусDraft
Создан2022/11/16 18:55
Обновлён2024/02/27 18:04
Задача8297156

Иногда требуется настраивать инициализацию полей класса и управлять ею. Во многих случаях чтение неинициализированного поля (RBFW = read-before-first-write, чтение до первой записи) является ошибкой программы, но такие ошибки (опять же во многих случаях) диагностируются не полностью.

Статический анализ, предписанный JLS, может выявлять и выявляет многие ошибки RBFW в исходном коде. Но все такие механизмы неполны, и сделать их полностью корректными нельзя без новых сложных ограничений на динамическое поведение «машины Тьюринга». Нестатическое поле можно увидеть в неинициализированном состоянии, если this выходит за пределы конструктора до того, как тот установит значение поля; статическое поле можно увидеть в неинициализированном состоянии, если лексически предшествующее статическое действие вызывает метод, который читает это поле. Конкурентность добавляет сложности, а динамическое связывание через границы компиляции добавляет ещё больше.

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

Вот существующие механизмы:

  • JLS предписывает статическое отслеживание «определённого присваивания» (definite assignment) для final-полей.
  • JLS также предписывает более ограниченное частное правило для всех статических полей.
  • JVMS предписывает, что если поле может быть прочитано до первой записи, в нём всегда присутствует значение по умолчанию, зависящее от типа.
  • JVMS предписывает, что ссылка по умолчанию (null) сама сообщает об ошибке (исключением NPE) при её использовании, за исключением очень ограниченного набора операций.
  • JVMS предписывает, что первое чтение статического поля запускает процесс инициализации класса (через <clinit>), который может выполнить инициализирующую запись, предшествующую чтению.

Эти правила в совокупности выявляют многие, но не все ошибки RBFW, с разной степенью точности. (Ошибки NPE, как известно, трудно проследить до их первопричины.) По крайней мере для Java, чтобы завершить доказательство того, что все ошибки RBFW будут выявлены, необходима динамическая проверка. Подробнее о необходимости динамических проверок в Java см. слайды 39–46 в докладе Rose на JVMLS17 на тему «Проверка безопасности в Java никогда не бывает завершена».

Если переменной можно присвоить значение по умолчанию её типа (тем самым подтвердив значение по умолчанию, изначально хранящееся в ней), то для динамического обнаружения ошибок RBFW JVM должна где-то хранить дополнительное состояние, чтобы отличать исходное состояние по умолчанию от любого аналогичного состояния, полученного явной записью значения по умолчанию. В результате появился бы запрос, который всегда может сказать, что в поле ещё ни разу не выполнялась запись.

Иногда при условиях RBFW исключение не выбрасывается, а выполняется некоторое исправление. Так происходит с классическими статическими полями, которые инициализируются автоматически. Так же обстоит дело и с более тонкими ленивыми статическими полями. В этих случаях существует второй вид ошибки RBFW, при котором вычисление исправляющего действия (например, вычисление выражения-инициализатора поля) циклически зависит от значения поля, так что исправление рекурсивно запрашивает само себя; по сути, это переполнение стека. Было бы полезно, чтобы JVM записывала для таких полей дополнительное состояние, указывающее, что исправление выполняется. (Точнее, исправление началось, но, возможно, не завершилось; описанное ранее состояние указывает, когда исправление завершено.) Чтение поля, при котором обнаружено это второе условие и проверено, что записи ещё не было, должно сообщать о том, что исправление не удалось.

В рамках текущих спецификаций может быть полезно отслеживать состояния инициализации переменных. Параметр JVM вроде -Xcheck:fieldinit мог бы заставлять JVM прозрачно выделять биты отслеживания и/или сигнальные значения и изменять «микрокод» чтения и записи полей для дополнительного учёта. Чтобы остаться в соответствии со спецификацией, JVM допускала бы условия RBFW (поскольку на самом деле это не ошибки), но каким-то образом сообщала бы о них пользователю как о возможных проблемах.

Спецификации (и JLS, и JVMS) также можно расширить, чтобы язык мог запрашивать особую обработку условий RBFW.

  • В случае ленивых статических полей (JDK-8209964) RBFW для ленивого статического final-поля исправляется не только выполнением <clinit>, но и выполнением инициализатора этого поля.
  • Для статического поля, настроенного как ленивое статическое поле, но без инициализатора, можно было бы указать, что оно выбрасывает известное исключение (например, IllegalStateException). Это имеет смысл как для final-, так и для не-final-полей.
  • При отслеживании состояния для каждого экземпляра поведение нестатических полей можно настраивать с теми же новыми степенями свободы.
  • При отслеживании состояния для каждого элемента массива и специальных фабриках массивов аналогичным образом можно настраивать даже поведение элементов массивов.

Некоторые из описанных выше механизмов могли бы заменить аннотацию @Stable в кодовой базе JDK. Эта аннотация указывает JIT-компилятору сворачивать в константы значения, отличные от значений по умолчанию, в помеченных полях и массивах. Использование @Stable — это хождение по канату для экспертов, не подкреплённое реальным отслеживанием состояния, и поэтому само по себе не может быть стандартизировано.

Некоторые из описанных выше механизмов могли бы помочь в реализации слабо исключающих null типов полей, таких как String!, предложенных в Valhalla и в других местах. Они «слабые» в том смысле, что при стирании превращаются в типы без декораций (такие как String), но их можно сделать надёжнее, усилив контейнеры этих типов. Например, поле типа String! объявлялось бы в JVM как Ljava/lang/String;, но также было бы помечено для обнаружения RBFW. Его также можно было бы пометить для дополнительного обнаружения — записей null.

В некоторых сценариях эти возможности хорошо сочетаются с настраиваемой логикой чтения и записи для обнаружения исключительных значений. Например, если пометить поле String! так, чтобы и getfield, и putfield отвергали null, получится разумная стратегия трансляции как для final-, так и для не-final-полей этого исключающего null типа.

Для некоторых сценариев с не-final-переменными следует предусмотреть способ мягко запросить, доступна ли переменная для чтения, не получая исключения. Опять же, для изменяемых переменных может быть полезен переход обратно в несвязанное состояние. Эти операции можно было бы закодировать с помощью перегрузок байт-кодов getfield и putfield.

Такие механизмы стали бы лучшей основой для ленивых значений Scala и аналогичных возможностей в других языках, работающих на JVM.

Следующий уровень после управления инициализацией — управление всеми изменениями состояния. По сути, это некий изощрённый способ позволить пользователю JVM настраивать «микрокод» getfield и родственных инструкций, так чтобы значение, перемещаемое между стеком и полем, преобразовывалось некоторой парой проекции и вложения (возможно, связанной с ограничением типа: здесь есть связь с реифицированными обобщёнными типами). Или, может быть, значение, перемещаемое между стеком и полем, или какое-то другое состояние проверяется или нормализуется по некоторому критерию корректности.

В приведённых ранее сценариях одним из критериев корректности значения является исключение null и/или исключение RBFW. Но возможны, по-видимому, и другие.

Одна интересная возможность — ограничение доступа к полю (field-confinement), как в докладе Rose на JVMLS17, слайд 56, «Making the best of the Object header». В этом сценарии чтение или запись поля всегда проверяли бы условие корректности, гарантирующее доступ без гонок; в частности, чтение или запись выбрасывали бы исключение, если объект ещё не заблокирован текущим потоком. В качестве альтернативы можно было бы исправлять проблему ожиданием. Если посмотреть с определённой стороны, именно это и делают методы synchronized: они исправляют любую недостающую синхронизацию; нет причин, по которым мы не могли бы применить то же правило к чтению и записи полей. Возможность выбрасывать исключение (а не исправлять ситуацию) при отсутствии синхронизации была бы новой для Java, но такая проверка с быстрым отказом — обычное дело в дизайне Java API. Возможно, вариант вроде ensure-synchronized, применяемый как модификатор и к методам, и к полям, стал бы удобным способом получить такое поведение с быстрым отказом. Реализация такого механизма для полей могла бы естественным образом использовать программируемые операции get/put в JVM.

Но этот следующий уровень — добавление логики в операции get и put на уровне языка — опасно приближает нас к разговору о каких-то «свойствах в Java», который вряд ли закончился бы хорошо. Тем не менее у JVM могут быть обоснованные сценарии использования программируемых действий get/put над полями, даже если JLS не берёт на себя обязательство ввести в язык свойства как таковые.

Заметки о реализации

Дополнительные состояния переменных можно представить в JVM с помощью побочных битов, сигнальных значений или того и другого. JVM может отслеживать записи, когда записываемое значение является значением типа по умолчанию, и в этом случае атомарно устанавливать побочный бит вместо записи значения по умолчанию (если побочный бит действительно был сброшен). Тогда проверка RBFW просто ищет значение по умолчанию, и если оно обнаружено, то проверяется также побочный бит, чтобы узнать, было ли поле явно обнулено. Порядок операций важен, чтобы избежать гонок. После установки побочный бит никогда не сбрасывается, и любая запись изменяет либо побочный бит, либо поле, поэтому в этой схеме нет новых состояний гонки.

Более простой способ различать состояния, иногда применимый, — чтобы JVM изменяла чтение и запись значения по умолчанию так, чтобы читалось или записывалось ненулевое сигнальное значение, невидимое пользователю. Побочный бит не нужен, но это не работает для таких типов, как int, в которых недостаточно битов для представления требуемого дополнительного сигнального значения.

Опять же, для отслеживания условия «исправление началось» можно использовать второй побочный бит или второе сигнальное значение. Такие схемы хорошо изучены; см., например, Scala SIP-20, версия V4.

Сделать это для массивов сложнее, поскольку раскладка массивов в памяти несколько менее абстрактна, чем раскладка объектов. К элементам массива мог бы применяться механизм сигнальных значений. В противном случае дополнительные биты отслеживания нужно где-то разместить. Это можно сделать без изменения раскладки, если заголовок объекта массива указывает на раздутую блокировку, а та, в свою очередь, указывает на побочный массив битов отслеживания. Либо саму раскладку массива можно сделать полиморфной и разместить побочные биты вперемешку рядом с элементами массива. Есть способы сделать это без большого перерасхода памяти, разбив массив на блоки размером порядка строк кэша.

Если ссылочная переменная помечена так, чтобы JVM отвергала ошибки RBFW, и если поле также помечено так, чтобы также отвергались записи null, то отсюда следует, что null может храниться в этом поле, только если оно ещё не инициализировано. В этом случае не нужны ни бит отслеживания, ни специальное сигнальное значение; обычный указатель null служит сигнальным значением для отвергаемого состояния. Такое сочетание условий даёт дешёвый способ реализовать поля и элементы массивов String!.