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

JEP draft: Frozen Arrays (Preview)

Замороженные массивы, Preview (предварительная версия)

ОтветственныйJohn Rose
ТипFeature
ОбластьJDK
СтатусDraft
Создан2021/02/03 00:59
Обновлён2021/02/08 09:46
Задача8261007

Summary

Вводится новая разновидность встроенных типов массивов Java: немодифицируемые (поверхностно неизменяемые) массивы.

Замороженными массивами можно безопасно делиться без согласования и без риска неожиданного изменения. Заморозка эффективнее защитного копирования: среда выполнения часто может убрать копирование при оптимизации.

Это Preview-возможность языка и VM.

Goals

Этот JEP предлагает небольшие изменения в языке программирования Java и в виртуальной машине Java, где сейчас все массивы Java изменяемы. Пользователи смогут заморозить любой входной массив и получить так называемый «замороженный массив». Его тип, длина и содержимое совпадают с входным массивом, но его элементам никогда нельзя присвоить новые значения. Замороженные массивы поддерживают некоторые оптимизации и приёмы безопасности (например, свёртку констант и защитное копирование), которые обычные изменяемые массивы поддерживать не могут.

Non-Goals

Ни одно из следующих возможных расширений для поддержки неизменяемых массивов не входит в рамки этого JEP, хотя они могут стать предметом дальнейшей работы:

  • Поддержка в языке для прямого объявления переменных, которые являются замороженными массивами, для выражений создания массивов, которые сразу создают замороженные массивы, или для методов с переменным числом аргументов, которые получают аргументы в виде замороженных массивов. (Но см. примечания в разделе «Дальнейшая работа».)

  • Разновидность замороженных массивов без Identity (идентичность объекта), экземпляры которой не только массивы, но фактически primitive objects.

  • Сохраняя Identity у замороженных массивов, мы могли бы позаимствовать у primitive objects идею ссылок, на которых нельзя синхронизироваться. (Похоже, это была бы благонамеренная нерегулярность из тех, что лишь создают проблемы.)

  • Добавление массивам Java новых (нестатических) вспомогательных методов.

  • Возможность принудительно «переиспользовать» созданный пользователем изменяемый массив как замороженный, приказав ему «заморозить себя». Хотя JVM иногда может делать это незаметно, вызов «freeze» для изменяемого массива всегда будет возвращать другой массив.

  • Транзакционные, и/или ограничивающие, и/или срезающие операции над массивами, которые позволяют одной или нескольким позициям элементов переходить между изменяемым и замороженным состоянием или предоставляют ограниченные представления более крупных, и/или изменяемых, и/или larval-массивов, и/или массивов, общих для всей программы.

  • Доработка существующих API (например, Core Reflection), чтобы они использовали замороженные массивы там, где сейчас используют изменяемые. (Но см. примечания в разделе «Дальнейшая работа».)

  • Прямая поддержка глубоко вложенных неизменяемых структур данных на основе массивов.

  • Прямая поддержка в формате class-файла для создания и инициализации замороженных массивов. (Например, механизмы, с помощью которых condy или indy могли бы брать «данные только для чтения» прямо из class-файла, как с сегментами .rodata в других системах.)

  • Добавление аналогичной поддержки для типов List (помимо List.of и т. п.), например поддержки объявлений с инициализацией списком, константных или шаблонных выражений или varargs.

  • Новые типы или реализации массивов, а также виртуализированные представления массивов Java, которые иногда условно называют «Arrays 2.0».

  • Операции заморозки для типов Java, отличных от массивов.

  • Изменения массивов, чтобы они выглядели более «value-based» или несовместимыми с Identity, например методы equals, hashCode и toString, как у списков.

Последующие работы могут доработать существующие API, чтобы они использовали преимущества замороженных массивов, или ввести новые возможности языка и API на основе замороженных массивов.

Motivation

Программистам на Java нравятся компактные и интуитивные нотации для работы с массивами Java, определённые в Java Language Specification. (Мы можем называть их «встроенными массивами Java», когда могут подразумеваться другие виды массивов, например массивы вне кучи или объектные типы массивов, которые предоставляют некоторые библиотеки.) Встроенные массивы можно читать и записывать с помощью привычной нотации с квадратными скобками a[i]. Поскольку массивы Java встроены в язык, они часто работают лучше других видов линейных коллекций (например, ArrayList или ByteBuffer) и по занимаемой памяти, и по скорости.

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

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

  • Метод, который получает несколько аргументов в массиве (скажем, через механизм «varargs»), не может закэшировать этот массив, поскольку вызывающий код может переиспользовать массив аргументов для будущего вызова с другими аргументами.

  • Общую переменную-массив (например, глобальную константу static или массив, хранящийся в общем объекте) в общем случае нельзя открыто предоставлять для совместного использования, поскольку неисправный клиент может затереть элемент массива, создав состояние гонки, и другие потоки увидят непредсказуемую смесь старых и новых значений элементов.

  • Обходной путь для трёх предыдущих проблем — возвращать или делать так называемую «защитную копию», когда это нужно. Но защитную копию легко случайно пропустить, а присущие ей затраты подталкивают программистов «убрать её при оптимизации», иногда ошибочно.

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

  • Более тонкая проблема: метод, получающий массив аргументов (через «varargs» или нет), не может рассчитывать на то, что эти аргументы останутся неизменными даже во время его собственного выполнения, поскольку на этот массив могут влиять побочные эффекты одновременно из других потоков. Известно, что это приводит к так называемым ошибкам «TOCTTOU», когда метод доверяет аргументам, которые изменились после проверки.

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

Все эти проблемы происходят из того, что изменяемость элементов — неотъемлемое свойство всех массивов. Их можно решить, если дать способ создавать массивы, элементы которых нельзя изменять.

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

Проблемы усугубляются тем, что массивы Java встроены в язык, а также их возрастом. (В отличие от List, не было такого времени, когда встроенные массивы не были частью Java.) Поэтому существующие API, особенно старые (например, Core Reflection), используют типы массивов, даже когда это влечёт одну или несколько из описанных выше опасностей. Проблему не сдвинуть, пока не будут переписаны затронутые API или пока не изменится поведение массивов. Этот JEP делает второе.

Description

Описанные ниже возможности являются Preview-возможностями и включаются флагами компиляции и выполнения --enable-preview.

Что такое замороженный массив?

Для любого типа массива T[] (включая типы примитивных массивов, например int[]) экземпляры этого типа могут находиться в замороженном состоянии; такие экземпляры являются замороженными массивами. До этого JEP, напротив, все массивы не заморожены; их можно называть «модифицируемыми» (или «изменяемыми»). Любая ненулевая ссылка типа массива указывает либо на модифицируемый, либо на замороженный массив.

Любая попытка записать элемент замороженного массива вызывает исключение класса ArrayStoreException (или его подкласса).

Поскольку система типов (и языка, и VM) не даёт способа различить переменные, которые всегда ссылаются на изменяемые массивы, всегда ссылаются на замороженные массивы или могут ссылаться на массивы обоих видов, в общем случае любая запись в массив (например, a[i]=x или байт-коды вроде iastore или aastore) будет включать динамическую проверку («замороженности»). Эта проверка похожа на существующие проверки ссылок на null (которые могут выбросить NullPointerException) или подтипов массивов ссылок (которые могут выбросить ArrayStoreException).

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

Откуда берутся замороженные массивы?

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

Отдельный JEP, JDK-8261099, предлагает низкоуровневый небезопасный метод JVM, который фиксирует ранее изменяемый массив «на месте», после чего массив становится замороженным. Такой метод нужно использовать очень осторожно, но, возможно, именно он будет исходным источником всех замороженных массивов, описанных в этом JEP. В безопасной пользовательской модели, предлагаемой этим JEP, все фабрики замороженных массивов выглядят так, будто создают новые массивы, замороженные с самого начала («замороженные от рождения»).

Поскольку замороженные массивы «заморожены от рождения», их фабричным методам нужно передавать все элементы, которые будут храниться в новом замороженном массиве.

Поэтому будет создан один или несколько низкоуровневых статических фабричных методов, которые выделяют массивы в замороженном состоянии. На вход такому методу подаётся массив (замороженный или изменяемый), содержащий элементы, которые нужно сохранить в новом замороженном массиве, и, возможно, начальный и конечный индексы во входном массиве (ср. Arrays.copyOfRange).

В качестве пробного примера простейшего возможного API можно создать метод System.arrayfreeze по аналогии с System.arraycopy. Вместо аргументов назначения он будет возвращать замороженный массив, содержащий ровно указанные исходные аргументы:

T[] src = ...;
T[] dest = System.arrayfreeze(src, 0, src.length);

Внутри этот метод будет использовать небезопасный примитив для заморозки массива на месте, недоступный обычным пользователям. Метод будет выглядеть примерно так:

Object arrayfreeze(Object src, int beg, int end) {
  Object dest = Arrays.newInstance(src.getClass().getComponentType(), end-beg);
  arraycopy(src, beg, dest, 0, dest.length);
  UNSAFE.freeze(dest);  // privileged and unsafe
  return dest;
}

Обратите внимание: в этой пользовательской модели заморозка изменяемого массива всегда возвращает другой массив. Отдельный экземпляр массива не может переходить между изменяемым и замороженным состоянием. Исключение из этого правила — код, использующий привилегированный низкоуровневый метод «заморозки на месте», описанный в JDK-8261099.

Изменяемый массив всегда можно создать из массива, который может быть замороженным, с помощью операции clone. То есть clone, применённый к операнду-массиву, никогда не возвращает замороженный массив.

Все эти детали могут измениться.

Мы не собираемся добавлять новые инструкции байт-кода.

Как использовать замороженный массив?

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

  • Для любого массива будет допустим синтаксис a.freeze() со значением, очень близким к a.clone(). (Единственное различие — в изменяемости соответствующих возвращаемых значений.)

  • В классе java.util.Arrays к методам copyOf и copyOfRange добавятся аналогичные методы freeze и freezeRange.

  • Низкоуровневый статический метод System.isFrozenArray (и/или java.util.Arrays.isFrozen), возвращающий boolean, будет проверять, заморожен массив или модифицируем.

  • Для доступа к фабричным методам можно создать фабрику method handle MethodHandles.frozenArrayConstructor по аналогии с arrayConstructor. Возвращаемый method handle будет принимать не целочисленную длину, а целый массив содержимого для заморозки. Если превратить его в метод с varargs, он мог бы давать доступ к скрытым быстрым путям для отдельных длин и поэтому был бы полезен в стратегии трансляции на основе invokedynamic.

  • Варианты комбинаторов method handle для asCollector и asVarargsCollector тоже можно было бы адаптировать для заморозки.

Модель производительности заморозки и клонирования

Спецификация каждой фабрики замороженных массивов будет содержать оговорку, что JVM всегда вправе повторно использовать любой ранее созданный замороженный массив в качестве результата. То есть в ответ на запрос замороженного массива со значениями x[i] JVM, если она может найти уже существующий замороженный массив a, элементы которого совпадают (в том же порядке) с x[i], может выполнить запрос, вернув ссылку на a, а не на вновь созданный замороженный массив.

Оптимизация повторного использования, скорее всего, будет выполняться (и может даже быть гарантирована) в следующих случаях:

  • Длина запрошенного массива равна нулю. (Можно использовать закэшированный замороженный массив нулевой длины с нужным типом элементов, найденный, например, через таблицу ClassValue.)

  • Входной массив a фабричного метода уже заморожен, и запрошенная длина совпадает с длиной входного массива. В этом случае a можно вернуть напрямую (операция O(1)). Это можно назвать свойством идемпотентности заморозки.

  • Таким образом, цепочку копирований вида a.freeze().freeze() можно вычислять так, как если бы это было a.freeze().

  • Если в цепочке копирований вида a.clone().freeze() VM может доказать, что промежуточный операнд (для freeze) не используется нигде, кроме запроса на заморозку, цепочку можно вычислять так, как если бы это было a.freeze().

  • Локально ограниченный (не выходящий за пределы области) массив вида a.clone(), если он ни разу не изменяется, можно считать созданным с помощью a.freeze() (а если a уже заморожен, то просто a). Таким образом, у конечных пользователей массивов, копируемых для защиты, производительность кода может вырасти, если такие преобразования выполняются «под капотом».

  • Локально ограниченный (не выходящий за пределы области) массив вида new a[N], если он изменяется только до последующей операции freeze, можно считать созданным операцией arrayfreeze над временным буфером; этот буфер тот же поток мог бы позже использовать повторно. Кроме того, если JVM предоставляет N-арный фабричный метод нужного типа, его можно вызвать напрямую. Таким образом, код конечных пользователей вновь созданных замороженных массивов работает так, как будто создаётся только итоговый замороженный массив, а не временный. Такую оптимизацию будет проще выполнять, если мы добавим прямую запись для создания инициализированных замороженных массивов и она будет транслироваться с помощью invokedynamic, у которого есть хорошие шансы найти лучший фабричный метод в конкретной среде выполнения.

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

  • храните и передавайте массивы в замороженном виде, когда это возможно

  • пусть клонированием занимается кто-то другой, если без него не обойтись

Доверенный код, чувствительный к производительности, может использовать ограниченный небезопасный примитивный метод (из JDK-8261099), чтобы создавать новые массивы и замораживать их на месте без дополнительного копирования. Использовать этот небезопасный примитив не следует, если нет особой причины, по которой JIT не может оптимизировать код более общими методами, описанными в этом JEP. У пользователей небезопасного примитива за пределами java.base код, скорее всего, сломается, когда изменится внутренняя реализация замороженных массивов.

Изменения языка и стратегия трансляции

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

(Заметим, что это принципиально новая динамическая проверка и новое исключение для примитивных массивов и массивов Object, которые раньше при записи не могли выбросить ArrayStoreException.)

Синтаксис a.freeze() требует специального разрешения в Java Language Specification, аналогично clone.

Крайне нежелательно жёстко встраивать freeze в Java VM так же, как был встроен clone, с помощью специальных исключений, дающих clone особый режим доступа и тип для верификатора. Поэтому мы рассмотрим внедрение метода freeze более масштабируемым способом, не затрагивающим спецификацию VM. Например, a.freeze() можно определить как «синтаксический сахар» для вызова статического метода, такого как java.lang.ArrayMethods.freeze(a). Этот способ открывает дальнейшие возможности подключения дополнительных методов java.util.Arrays без изменения как языка, так и VM.

Сериализация

Сериализация будет доработана так, чтобы передавать признак заморозки массивов. Делать это не хочется, но это можно довольно просто сделать поверх существующего кода, и, по-видимому, так можно предотвратить ошибки безопасности, связанные с сериализацией, которые могли бы возникнуть, если бы структуры данных, полагающиеся на замороженные массивы, из-за трюка с десериализацией внезапно стали внутренне изменяемыми в элементах этих массивов. Возражение на этот довод: правильно написанные методы readObject уже выполняют clone, и их изменили бы так, чтобы они вызывали freeze. Ответ на это возражение: для конечных пользователей будет меньше неожиданностей, если при прохождении массива через цикл сериализации и десериализации сохраняется каждый бит его структуры — не только тип, длина и элементы, но и бит заморозки.

JNI и Unsafe

Операции JNI, позволяющие изменять массивы, должны как минимум выдавать предупреждение, если их применяют к замороженным массивам. (Возможно, это могло бы быть предупреждение плюс отсутствие действия.) Коду, который с помощью Unsafe записывает новые значения в массивы, в общем случае придётся проверять бит isFrozen.

Никакого влияния на систему типов

Ни одна система типов — ни система типов языка, ни дескрипторы методов и полей, ни верификатор байт-кода, ни динамически проверяемые типы (через instanceof), ни рефлексивная система Class — не будет различать замороженные и изменяемые массивы. Для экземпляров будет доступна только динамическая проверка isFrozen.

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

Модель памяти Java

В JMM можно добавить утверждение, что элементы замороженного массива в отношении безопасной публикации действуют так же, как поля final. По сути замороженный массив ведёт себя как объект, все поля которого final и к полям которого обращаются по индексу (aaload, iaload и т. д.), а не по имени (getfield).

В JMM есть операция «freeze» для объектов с final-полями, которая происходит в конце вызова конструктора; эта операция «freeze» будет применяться и при создании замороженного массива.

(Привилегированный низкоуровневый метод «заморозки на месте» также является операцией «freeze» с точки зрения JMM; упоминать этот внутренний метод в JMM не нужно, если только мы не найдём способ безопасно открыть его в публичной модели для пользователей.)

Пример

class Node {
  private final String label;
  private final Node[] children;  //always frozen
  public Node[] children() { return children; }
  public String label() { return label; }
  public Node(String l, Node... cn) {
    cn = cn.freeze(); // O(1) if already frozen
    label = l;
    children = cn;
  }
}
class NodeUser {
  Node defPair(Node a, Node b) {
    Node[] ab = { a, b };
    ab = ab.freeze();  // JIT might optimize this locally
    return new Node("pair", ab);
  }
  void use(Node node) {
    Node[] frozenNodes = node.children();
    Node[] mutableNodes frozenNodes.clone();
    Arrays.sort(mutableNodes);
    doStuff(mutableNodes);
    doStuff(frozenNodes);  // still safe to use these
  }
}

Alternatives

Неизменяемые списки, создаваемые List.of, — недавний обходной путь для аналогичного набора проблем изменяемых списков. Программистам рекомендуется по возможности использовать такие списки вместо встроенных массивов Java. Однако это не всегда возможно: существующий API может требовать встроенных массивов, программистам могут быть нужны скорость и компактность встроенных массивов, или они могут просто упорно предпочитать встроенную в Java запись выражений для работы со встроенными массивами.

Мы могли бы дождаться более масштабной работы по виртуализации («Arrays 2.0»), после которой добавить замороженные массивы было бы так же просто, как было добавить List.of. (Что на самом деле оказалось не так просто, как можно было бы подумать.) Но это наверняка на много лет отложило бы конкретные преимущества замороженных массивов в сегодняшнем языке и API.

Мы могли бы дождаться примитивных классов и затем перенести их концепции, чтобы построить улучшенный «замороженный массив без identity», который ведёт себя как примитивный объект. Однако это не универсальное решение проблем, которые решают в этом JEP замороженные массивы с identity. В частности, модель производительности «замороженных массивов без identity», по-видимому, страдала бы от неожиданного требования, чтобы оператор == (и инструкции acmp) выполнял работу O(N) вместо O(1), чтобы доказать, что два замороженных массива различны.

Мы могли бы попробовать встроить какую-то операцию «lock», которая переводит часть массива или весь массив на месте в замороженное состояние — навсегда или до соответствующей операции «unlock», — или построить какое-то «неизменяемое представление» изменяемого базового массива, как в Collection API. Такие механизмы сложнее и дороже, чем данное предложение, поэтому их труднее использовать правильно, и как альтернатива защитному копированию они менее привлекательны. Если разрешить массиву менять состояние изменяемости, появляется новый вид побочного канала с новыми видами сбоев при конкурентном выполнении, поэтому такое решение, похоже, плохо подходит для многих описанных выше проблем.

Risks and Assumptions

Дополнительные проверки требуют аккуратной работы в оптимизаторе, аналогичной устранению проверок на null. Они могут вызвать проблемы с производительностью, хотя, разумеется, не по сравнению с обычным clone.

Если модель для пользователя окажется недостаточно простой, возникнут неприятные неожиданности, производительность будет недостаточно высокой или доработанные API будет трудно внедрить, то распространение может оказаться слабым.

Доработка API, возвращающих массивы, так, чтобы они возвращали замороженные массивы, будет деликатной операцией из-за реальных случаев использования (возможно, 0,0001 % от всех), в которых кто-то решил, что будет остроумно изменять возвращённый массив.

Этот JEP предполагает, что мы можем позволить себе добавить (в статусе Preview) несколько фабрик и других элементов API для массивов, в частности запись a.freeze(), как описано выше. В качестве альтернативы мы могли бы сначала реализовать все эти элементы API как статические методы в jdk.internal.FrozenArrays для внутреннего использования в java.base, а затем отдельным шагом выпустить их как видимые пользователям элементы API.

Dependencies and Future Work

Зависимостей от существующих работ нет.

Часть инженерной работы над проверками записи в массив для проекта Valhalla может быть применена к проверкам изменяемости при записи, которые требует этот JEP.

Если мы решим запретить синхронизацию на замороженных массивах, может пригодиться часть работы над примитивными классами (снова Valhalla).

Очень желательно добавить способ, которым методы с varargs могли бы запрашивать замороженные входные массивы. Это дало бы дополнительные преимущества и для расхода памяти, и для безопасности. Внутри метода не понадобились бы аннотации вида @SafeVarargs. Снаружи метода клиенты могли бы по возможности передавать замороженные массивы вместо изменяемых.

Если мы сделаем замороженные varargs, стоит рассмотреть использование родственного синтаксиса (контекстного ключевого слова?) для объявления инициализированных массивов и для выражений создания массивов:

int sumInts(__Frozen int... ints) {
  // bytecode does ints = ints.freeze()
  int sum = 0;
  for (int i : ints)  sum += i;
  return sum;
}
void foo() {
  sum();  // passes frozen zero-length array?
  sum(1,2,3);  // passes frozen length-3 array?
}
void bar() {
  __Frozen int[] stuff = { 1,2,3 };
  assert stuff.isFrozen();
  sum(stuff);
  sum(new __Frozen int[]{ 4,5,6 });
}

Мы захотим доработать многие API в java.base. Примеры элементов API, которые могли бы выиграть от замороженных массивов:

  • рефлексивные методы, возвращающие массивы, в Class, jlr.Executable и т. д.
  • Enum.values
  • Throwable.getStackTrace
  • MethodType.parameterArray, DynamicCallSiteDesc.bootstrapArgs и т. д.
  • методы, возвращающие массивы byte или char в качестве «результата» или «полезной нагрузки»
  • метод Collection.toArray и различные связанные с ним
  • методы, принимающие массивы в качестве аргументов, например Runtime.exec

Во многих из этих случаев обратная совместимость с некоторыми существующими клиентами, вероятно, может вынудить нас по-прежнему возвращать изменяемые массивы. Иногда это можно исправить: существующую точку API объявить устаревшей в пользу новой, которая возвращает «настоящий» frozen-массив. В документации существующей точки API можно указать, что она эквивалентна вызову clone для результата новой точки API, Например:

public interface Collection<T> {
  /** Returns a frozen array containing all of the elements in this collection,
   * as if by {@code this.toArray().freeze()}.
   */
  default Object[] freezeArray() {
    return toArray().freeze();
  }
}

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

Другие возможные последующие функции см. выше в разделе «Что не является целью».