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

JEP 383: Foreign-Memory Access API (Second Incubator)

Foreign-Memory Access API, вторая версия Incubator (инкубационный модуль)

ОтветственныйMaurizio Cimadamore
ТипFeature
ОбластьJDK
СтатусClosed / Delivered
Выпуск15
Компонентcore-libs
Обсуждениеpanama dash dev at openjdk dot java dot net
Связан сJEP 370: Foreign-Memory Access API (Incubator)
JEP 393: Foreign-Memory Access API (Third Incubator)
РецензентыPaul Sandoz
ОдобренMark Reinhold
Создан2020/04/10 15:41
Обновлён2021/08/28 00:14
Задача8242499

Аннотация

Ввести API, позволяющий Java-программам безопасно и эффективно обращаться к внешней памяти за пределами кучи Java.

История

Foreign-Memory Access API был предложен в JEP 370 и в конце 2019 года запланирован в Java 14 как API в статусе Incubator. Этот JEP предлагает внести доработки с учётом отзывов и повторно выпустить API в статусе Incubator в Java 15. В рамках этого обновления API внесены следующие изменения:

  • Богатый API комбинаторов VarHandle для настройки var handle доступа к памяти;
  • Целенаправленная поддержка параллельной обработки сегмента памяти через интерфейс Spliterator;
  • Расширенная поддержка отображаемых сегментов памяти (например, MappedMemorySegment::force);
  • Безопасные элементы API для поддержки последовательной привязки к потоку (например, для передачи владения от одного потока другому); и
  • Небезопасные элементы API для работы с адресами, полученными, например, из нативных вызовов, и их разыменования, а также для обёртывания таких адресов в синтетические сегменты памяти.

Цели

  • Универсальность: один API должен уметь работать с разными видами внешней памяти (например, с нативной памятью, энергонезависимой памятью, управляемой памятью кучи и т. д.).
  • Безопасность: API не должен позволять нарушить безопасность JVM, независимо от вида памяти, с которой выполняется работа.
  • Детерминированность: операции освобождения внешней памяти должны быть явными в исходном коде.
  • Удобство использования: для программ, которым нужен доступ к внешней памяти, API должен стать убедительной альтернативой унаследованным API Java, таким как sun.misc.Unsafe.

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

  • Цель не в том, чтобы заново реализовать унаследованные API Java, такие как sun.misc.Unsafe, поверх Foreign-Memory Access API.

Мотивация

Многие Java-программы обращаются к внешней памяти, например Ignite, mapDB, memcached, Lucene и API ByteBuf из Netty. Благодаря этому они могут:

  • избегать затрат и непредсказуемости, связанных со сборкой мусора (особенно при поддержке больших кэшей);
  • разделять память между несколькими процессами; и
  • сериализовать и десериализовать содержимое памяти, отображая файлы в память (например, через mmap).

К сожалению, API Java не предлагает удовлетворительного решения для доступа к внешней памяти:

  • API ByteBuffer, появившийся в Java 1.4, позволяет создавать прямые байтовые буферы, которые размещаются вне кучи и поэтому позволяют пользователям работать с памятью вне кучи непосредственно из Java. Однако возможности прямых буферов ограничены. Например, нельзя создать прямой буфер больше двух гигабайт, поскольку API ByteBuffer использует схему индексации на основе int. Кроме того, работать с прямыми буферами бывает неудобно, поскольку освобождение связанной с ними памяти возлагается на сборщик мусора: связанная память может быть освобождена только после того, как сборщик мусора сочтёт прямой буфер недостижимым. За эти годы было подано множество запросов на улучшение, чтобы преодолеть эти и другие ограничения (например, 4496703, 6558368, 4837564 и 5029431). Многие из этих ограничений вытекают из того, что API ByteBuffer проектировался не только для доступа к памяти вне кучи, но и для обмена большими объёмами данных между производителем и потребителем в таких областях, как кодирование и декодирование наборов символов и частичные операции ввода-вывода.

  • Ещё один распространённый способ, которым разработчики могут обращаться к внешней памяти из кода на Java, — API Unsafe. Unsafe предоставляет множество операций доступа к памяти (например, Unsafe::getInt и putInt), которые работают как для доступа к куче, так и для доступа вне кучи благодаря относительно общей модели адресации. Доступ к памяти через Unsafe чрезвычайно эффективен: все операции доступа к памяти определены как интринсики HotSpot JVM, поэтому JIT-компилятор HotSpot регулярно их оптимизирует. Однако API Unsafe по определению небезопасен — он позволяет обращаться к любому адресу памяти (например, Unsafe::getInt принимает адрес типа long). Это значит, что Java-программа может обрушить JVM, обратившись к уже освобождённой области памяти. Вдобавок API Unsafe не является поддерживаемым API Java, и его использование всегда настоятельно не рекомендовалось.

  • Использовать JNI для доступа к внешней памяти можно, но присущие этому решению издержки делают его редко применимым на практике. Весь процесс разработки получается сложным, поскольку JNI требует от разработчика писать и сопровождать фрагменты кода на C. Кроме того, JNI по своей природе медленный, поскольку при каждом обращении требуется переход из Java в нативный код.

В итоге, когда речь заходит о доступе к внешней памяти, разработчики стоят перед дилеммой: выбрать безопасный, но ограниченный (и, возможно, менее эффективный) путь, например API ByteBuffer, или отказаться от гарантий безопасности и принять опасный и неподдерживаемый API Unsafe?

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

Описание

Foreign-Memory Access API поставляется как Incubator-модуль с именем jdk.incubator.foreign, в пакете с тем же именем; он вводит три основные абстракции: MemorySegment, MemoryAddress и MemoryLayout:

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

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

try (MemorySegment segment = MemorySegment.allocateNative(100)) {
   ...
}

Будет создан сегмент памяти, связанный с буфером нативной памяти размером 100 байт.

Сегменты памяти пространственно ограничены, то есть у них есть нижняя и верхняя границы. Любая попытка использовать сегмент для доступа к памяти за этими границами приведёт к исключению. Как видно по использованию конструкции try с ресурсами, сегменты памяти также ограничены во времени: их нужно создать, использовать, а затем закрыть, когда они больше не нужны. Закрытие сегмента всегда является явной операцией и может повлечь дополнительные побочные эффекты, например освобождение связанной с сегментом памяти. Любая попытка обратиться к уже закрытому сегменту памяти приведёт к исключению. Вместе пространственные и временные границы гарантируют безопасность Foreign-Memory Access API и, следовательно, то, что его использование не может обрушить JVM.

Разыменование памяти, связанной с сегментом, выполняется с помощью var handle — абстракции для доступа к данным, появившейся в Java 9. В частности, сегмент разыменовывается с помощью var handle доступа к памяти. У var handle такого вида есть координата доступа типа MemoryAddress, которая служит адресом, по которому выполняется разыменование.

Var handle доступа к памяти получают с помощью фабричных методов класса MemoryHandles. Например, чтобы задать элементы нативного сегмента памяти, мы могли бы использовать var handle доступа к памяти так:

VarHandle intHandle = MemoryHandles.varHandle(int.class,
        ByteOrder.nativeOrder());

try (MemorySegment segment = MemorySegment.allocateNative(100)) {
    MemoryAddress base = segment.baseAddress();
    for (int i = 0; i < 25; i++) {
        intHandle.set(base.addOffset(i * 4), i);
    }
}

Var handle доступа к памяти могут получать дополнительные координаты доступа типа long для поддержки более сложных схем адресации, например многомерной адресации в сегменте памяти, который сам по себе плоский. Такие var handle доступа к памяти обычно получают, вызывая методы-комбинаторы, определённые в классе MemoryHandles. Например, более прямой способ задать элементы нативного сегмента памяти — индексированный var handle доступа к памяти, построенный следующим образом:

VarHandle intHandle = MemoryHandles.varHandle(int.class, 
        ByteOrder.nativeOrder());
VarHandle indexedElementHandle = MemoryHandles.withStride(intHandle, 4);

try (MemorySegment segment = MemorySegment.allocateNative(100)) {
    MemoryAddress base = segment.baseAddress();
    for (int i = 0; i < 25; i++) {
        indexedElementHandle.set(base, (long) i, i);
    }
}

Чтобы повысить выразительность API и уменьшить потребность в явных числовых вычислениях, подобных приведённым в примерах выше, MemoryLayout можно использовать для программного описания содержимого MemorySegment. Например, раскладку нативного сегмента памяти из примеров выше можно описать так:

SequenceLayout intArrayLayout
    = MemoryLayout.ofSequence(25,
        MemoryLayout.ofValueBits(32,
            ByteOrder.nativeOrder()));

Так создаётся последовательная раскладка памяти, в которой раскладка заданного элемента (32-битного значения) повторяется 25 раз. Имея раскладку памяти, мы можем избавиться от всех ручных числовых вычислений в коде, а также упростить создание нужных var handle доступа к памяти, как показано в следующем примере:

SequenceLayout intArrayLayout
    = MemoryLayout.ofSequence(25,
        MemoryLayout.ofValueBits(32,
            ByteOrder.nativeOrder()));

VarHandle indexedElementHandle
    = intArrayLayout.varHandle(int.class,
        PathElement.sequenceElement());

try (MemorySegment segment = MemorySegment.allocateNative(intArrayLayout)) {
    MemoryAddress base = segment.baseAddress();
    for (int i = 0; i < intArrayLayout.elementCount().getAsLong(); i++) {
        indexedElementHandle.set(base, (long) i, i);
    }
}

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

Проверяемые и непроверяемые адреса

Операции разыменования возможны только для проверяемых адресов памяти. Проверяемые адреса типичны для API — например, адрес, полученный из сегмента памяти в коде выше (segment.baseAddress()). Однако если адрес памяти непроверяемый и с ним не связан ни один сегмент, то безопасно разыменовать его нельзя, поскольку среда выполнения никак не может узнать пространственные и временные границы, связанные с этим адресом. Примеры непроверяемых адресов:

  • адрес NULL (MemoryAddress::NULL)
  • адрес, построенный из значения long (с помощью фабрики MemoryAddress::ofLong)

Чтобы разыменовать непроверяемый адрес, у клиента есть два варианта. Если известно, что адрес попадает в сегмент памяти, который у клиента уже есть, клиент может выполнить так называемую операцию перебазирования (MemoryAddress::rebase), при которой смещение непроверяемого адреса интерпретируется заново относительно базового адреса сегмента, и в результате получается новый экземпляр адреса, который можно безопасно разыменовать. Если же такого сегмента нет, клиент может создать его небезопасным способом с помощью специальной фабрики MemorySegment::ofNativeRestricted. Эта фабрика по сути присоединяет пространственные и временные границы к адресу, который иначе был бы непроверяемым, чтобы сделать возможными операции разыменования.

Однако, как следует из названия, эта операция небезопасна по самой своей природе и должна использоваться с осторожностью. Поэтому Foreign Memory Access API разрешает вызывать эту фабрику, только когда свойство JDK foreign.restricted имеет значение, отличное от deny. Возможные значения этого свойства:

  • deny — при каждом ограниченном вызове выбрасывает исключение времени выполнения. Это значение по умолчанию;
  • permit — разрешает ограниченные вызовы;
  • warn — как permit, но дополнительно выводит однострочное предупреждение при каждом ограниченном вызове.
  • debug — как permit, но дополнительно выводит дамп стека, соответствующего любому данному ограниченному.

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

Привязка к потоку

Помимо пространственных и временных границ, у сегментов есть привязка к потоку. То есть сегментом владеет поток, который его создал, и никакой другой поток не может обращаться к содержимому сегмента или выполнять над ним некоторые операции (например, close). Привязка к потоку накладывает ограничения, но она необходима, чтобы гарантировать оптимальную производительность доступа к памяти даже в многопоточной среде. Если снять ограничения привязки к потоку, несколько потоков смогут одновременно обращаться к одному и тому же сегменту и закрывать его — что может нарушить гарантии безопасности, которые даёт Foreign Memory Access API, если не ввести какую-либо очень дорогую форму блокировки для предотвращения гонок между доступом и закрытием.

Foreign Memory Access API предоставляет два способа ослабить барьеры привязки к потоку. Во-первых, потоки могут совместно использовать сегменты, выполняя явные операции передачи: поток отказывается от владения данным сегментом и передаёт его другому потоку. Рассмотрим следующий код:

MemorySegment segmentA = MemorySegment.allocateNative(10); // confined by thread A
...
var segmentB = segmentA.withOwnerThread(threadB); // confined by thread B

Такой шаблон доступа также называют последовательной привязкой (serial confinement); он может быть полезен в сценариях «производитель/потребитель», где к сегменту одновременно должен обращаться только один поток. Обратите внимание: чтобы операция передачи была безопасной, API уничтожает исходный сегмент (как если бы был вызван close, но без освобождения нижележащей памяти) и возвращает новый сегмент с правильным владельцем. Реализация также гарантирует, что к моменту, когда второй поток обращается к сегменту, все записи первого потока сброшены в память.

Во-вторых, содержимое сегмента памяти всё же можно обрабатывать параллельно (например, с помощью фреймворка вроде Fork/Join) — получив из сегмента памяти экземпляр Spliterator. Например, чтобы параллельно просуммировать все 32-битные значения сегмента памяти, можно использовать следующий код:

SequenceLayout seq = MemoryLayout.ofSequence(1_000_000, MemoryLayouts.JAVA_INT);
SequenceLayout seq_bulk = seq.reshape(-1, 100);
VarHandle intHandle = seq.varHandle(int.class, PathElement.sequenceElement());    

int sum = StreamSupport.stream(MemorySegment.spliterator(segment, seq_bulk), true)
                .mapToInt(slice -> {
					int res = 0;
        			MemoryAddress base = slice.baseAddress();
        			for (int i = 0; i < 100 ; i++) {
            			res += (int)intHandle.get(base, (long)i);
        			}
        			return res;
                }).sum();

MemorySegment::spliterator принимает сегмент и последовательную раскладку (sequence layout) и возвращает экземпляр сплитератора, который делит сегмент на части, соответствующие элементам переданной последовательной раскладки. Здесь мы хотим просуммировать элементы массива, содержащего миллион элементов; параллельное суммирование, в котором каждое вычисление обрабатывает ровно один элемент, было бы неэффективным, поэтому мы с помощью API раскладок получаем укрупнённую последовательную раскладку. Укрупнённая раскладка — это последовательная раскладка того же размера, что и исходные раскладки, но с элементами, сгруппированными по 100 штук, — что должно сделать её более пригодной для параллельной обработки.

Получив сплитератор, мы можем построить из него параллельный поток данных (stream) и параллельно просуммировать содержимое сегмента. Хотя здесь к сегменту одновременно обращаются несколько потоков, доступ происходит упорядоченно: из исходного сегмента создаётся срез, который передаётся потоку для выполнения вычислений. Среда выполнения доступа к внешней памяти знает, обращается ли поток в данный момент к срезу сегмента через сплитератор, и поэтому может обеспечить безопасность, не позволяя закрыть сегмент, пока над этим сегментом выполняется параллельная обработка.

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

Продолжать использовать существующие API, такие как java.nio.ByteBuffer или sun.misc.Unsafe, или, что хуже, JNI.

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

Создать API для доступа к внешней памяти, который был бы одновременно безопасным и эффективным, — непростая задача. Поскольку пространственные и временные проверки, описанные в предыдущих разделах, должны выполняться при каждом обращении, крайне важно, чтобы JIT-компиляторы могли устранять эти проверки, например вынося их за пределы горячих циклов. Реализации JIT, вероятно, потребуют доработки, чтобы использование API было столь же эффективным и поддающимся оптимизации, как использование существующих API, таких как ByteBuffer и Unsafe.

Зависимости

API, описанный в этом JEP, вероятно, поможет в разработке поддержки взаимодействия с нативным кодом, которая является целью проекта Panama. Этот API также можно использовать для более общего и эффективного доступа к энергонезависимой памяти, который уже возможен с помощью JEP 352 (Non-Volatile Mapped Byte Buffers).