JEP 370: Foreign-Memory Access API (Incubator)
API доступа к внешней памяти, версия Incubator (инкубационный модуль)
| Ответственный | Maurizio Cimadamore |
| Тип | Feature |
| Область | JDK |
| Статус | Closed / Delivered |
| Выпуск | 14 |
| Компонент | core-libs |
| Обсуждение | panama dash dev at openjdk dot java dot net |
| Связан с | JEP 383: Foreign-Memory Access API (Second Incubator) |
| JEP 393: Foreign-Memory Access API (Third Incubator) | |
| Рецензенты | Brian Goetz, John Rose |
| Одобрен | Mark Reinhold |
| Создан | 2019/07/09 15:55 |
| Обновлён | 2021/08/28 00:20 |
| Задача | 8227446 |
Аннотация
Представить API, с помощью которого программы на Java смогут безопасно и эффективно обращаться к внешней памяти за пределами кучи Java.
Цели
API для работы с внешней памятью должен удовлетворять следующим критериям:
- Универсальность: один и тот же API должен работать с разными видами внешней памяти (например, с нативной памятью, энергонезависимой памятью, управляемой памятью кучи и т. д.).
- Безопасность: API не должен давать возможности нарушить безопасность JVM, с каким бы видом памяти он ни работал.
- Детерминированность: операции освобождения памяти должны быть явно записаны в исходном коде.
Критерии успеха
API для работы с внешней памятью должен стать полноценной альтернативой основным способам, которыми программы на Java сегодня обращаются к внешней памяти, а именно java.nio.ByteBuffer и sun.misc.Unsafe. По производительности новый API должен быть сопоставим с этими существующими API.
Мотивация
Многие существующие библиотеки и программы на Java обращаются к внешней памяти, например Ignite, mapDB, memcached и API ByteBuf из Netty. Благодаря этому они могут
- избегать затрат и непредсказуемости, связанных со сборкой мусора (особенно при поддержке больших кэшей),
- совместно использовать память несколькими процессами и
- сериализовать и десериализовать содержимое памяти, отображая файлы в память (например, через
mmap).
Однако API Java не предлагает удовлетворительного решения для доступа к внешней памяти.
API ByteBuffer, появившийся в Java 1.4, позволяет создавать прямые (direct) байтовые буферы, которые размещаются вне кучи, и работать с памятью вне кучи непосредственно из Java. Однако возможности прямых буферов ограничены. Например, нельзя создать буфер больше двух гигабайт, поскольку API ByteBuffer использует схему индексации на основе int. Кроме того, работать с прямыми буферами бывает неудобно, поскольку освобождение связанной с ними памяти оставлено сборщику мусора: память может быть освобождена только после того, как сборщик мусора сочтёт прямой буфер недостижимым. За эти годы было подано множество запросов на улучшение, чтобы преодолеть эти и другие ограничения (например, 4496703, 6558368, 4837564 и 5029431). Многие из этих ограничений вызваны тем, что API ByteBuffer проектировался не только для доступа к памяти вне кучи, но и для обмена большими объёмами данных между производителем и потребителем, что критически важно, например, для кодирования и декодирования кодировок и частичных операций ввода-вывода.
Ещё один распространённый способ обращаться к внешней памяти из кода на Java — API sun.misc.Unsafe. Unsafe предоставляет множество операций доступа к памяти (например, Unsafe::getInt и putInt), которые работают как с памятью в куче, так и с памятью вне кучи благодаря продуманной и относительно универсальной модели адресации. Доступ к памяти через Unsafe чрезвычайно эффективен: все операции доступа к памяти определены как интринсики JVM, поэтому JIT-компилятор регулярно их оптимизирует. К сожалению, API Unsafe по определению небезопасен — он позволяет обращаться к любому адресу памяти (например, Unsafe::getInt принимает адрес типа long). Из-за этого программа на Java может аварийно завершить JVM, если, например, обратится к уже освобождённому участку памяти. Вдобавок Unsafe API не является поддерживаемым API Java, и его использование всегда настоятельно не рекомендовалось.
Обращаться к памяти можно и через JNI, но присущие этому решению издержки делают его редко применимым на практике. Весь процесс разработки получается сложным, поскольку JNI требует от разработчика писать и поддерживать фрагменты кода на C. Кроме того, JNI по своей природе медленный, поскольку при каждом обращении требуется переход из Java в нативный код.
Таким образом, когда нужно обратиться к внешней памяти, разработчики оказываются перед дилеммой: выбрать безопасный, но ограниченный (и, возможно, менее эффективный) путь (например, ByteBuffer) или отказаться от гарантий безопасности и взять неподдерживаемый и опасный API Unsafe?
Этот JEP представляет поддерживаемый, безопасный и эффективный API доступа к внешней памяти. Получив целевое решение задачи доступа к внешней памяти, разработчики избавятся от ограничений и опасностей существующих API. Кроме того, они получат более высокую производительность, поскольку новый API будет с самого начала проектироваться с учётом оптимизаций JIT.
Описание
API доступа к внешней памяти вводит три основные абстракции: MemorySegment, MemoryAddress и MemoryLayout.
MemorySegment моделирует непрерывную область памяти с заданными пространственными и временными границами. MemoryAddress можно рассматривать как смещение внутри сегмента. Наконец, MemoryLayout — это программное описание содержимого сегмента памяти.
Сегменты памяти можно создавать из разных источников, например из нативных буферов памяти, массивов Java и байтовых буферов (как прямых, так и размещённых в куче). Например, нативный сегмент памяти можно создать так:
try (MemorySegment segment = MemorySegment.allocateNative(100)) {
...
}
Так будет создан сегмент памяти, связанный с нативным буфером памяти размером 100 байт.
Сегменты памяти ограничены в пространстве, то есть у них есть нижняя и верхняя границы. Любая попытка использовать сегмент для доступа к памяти за этими границами приведёт к исключению. Как видно по использованию конструкции try-with-resources, сегменты также ограничены во времени, то есть их создают, используют, а затем закрывают, когда они больше не нужны. Закрытие сегмента — всегда явная операция, и оно может иметь дополнительные побочные эффекты, например освобождение связанной с сегментом памяти. Любая попытка обратиться к уже закрытому сегменту памяти приведёт к исключению. Вместе пространственные и временные проверки безопасности играют ключевую роль в обеспечении безопасности API доступа к памяти и, как следствие, например, в отсутствии аварийных сбоев JVM.
Разыменовать память, связанную с сегментом, можно, получив var handle доступа к памяти (memory-access var handle). У таких специальных var handle есть как минимум одна обязательная координата доступа типа MemoryAddress — адрес, по которому выполняется разыменование. Их получают с помощью фабричных методов класса 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. Например, более прямой способ задать элементы нативного сегмента — индексированный handle доступа к памяти, построенный так:
VarHandle intHandle = MemoryHandles.varHandle(int.class,
ByteOrder.nativeOrder());
VarHandle intElemHandle = MemoryHandles.withStride(intHandle, 4);
try (MemorySegment segment = MemorySegment.allocateNative(100)) {
MemoryAddress base = segment.baseAddress();
for (int i = 0; i < 25; i++) {
intElemHandle.set(base, (long) i, i);
}
}
Фактически это даёт богатую многомерную адресацию буфера памяти, который иначе был бы плоским.
Чтобы сделать API выразительнее и уменьшить потребность в явных числовых вычислениях, как в примерах выше, можно использовать API MemoryLayout для программного описания содержимого сегмента памяти. Например, схему размещения нативного сегмента памяти из примеров выше можно описать так:
SequenceLayout intArrayLayout
= MemoryLayout.ofSequence(25,
MemoryLayout.ofValueBits(32,
ByteOrder.nativeOrder()));
Так создаётся схема размещения памяти типа последовательность (sequence), в которой заданная схема элемента (32-битное значение) повторяется 25 раз. Имея схему размещения памяти, мы можем избавиться от всех ручных числовых вычислений в коде, а также упростить создание нужных var handle доступа к памяти, как показано в следующем примере:
SequenceLayout intArrayLayout
= MemoryLayout.ofSequence(25,
MemoryLayout.ofValueBits(32,
ByteOrder.nativeOrder()));
VarHandle intElemHandle
= 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++) {
intElemHandle.set(base, (long) i, i);
}
}
В этом примере экземпляр схемы размещения управляет созданием var handle доступа к памяти через создание пути в схеме размещения (layout path), с помощью которого из сложного выражения схемы выбирается вложенная схема. Экземпляр схемы размещения также управляет выделением сегмента нативной памяти на основе сведений о размере и выравнивании, полученных из схемы. Константа цикла из предыдущих примеров заменена числом элементов схемы-последовательности.
Изначально API доступа к внешней памяти будет предоставлен как Incubator-модуль с именем jdk.incubator.foreign в одноимённом пакете.
Альтернативы
Продолжать использовать существующие API, такие как ByteBuffer и Unsafe, или, что ещё хуже, JNI.
Риски и допущения
Создать API доступа к внешней памяти, который был бы одновременно безопасным и эффективным, — сложная задача. Поскольку описанные в предыдущих разделах пространственные и временные проверки нужно выполнять при каждом обращении, крайне важно, чтобы JIT-компиляторы умели устранять эти проверки при оптимизации, например вынося их за пределы горячих циклов. Реализации JIT, вероятно, потребуют доработки, чтобы использование API доступа к памяти было таким же эффективным и так же хорошо поддавалось оптимизации, как использование существующих API, таких как ByteBuffer и Unsafe.
Зависимости
API, описанный в этом JEP, вероятно, поможет в разработке поддержки взаимодействия с нативным кодом, которая является целью проекта Panama. Этот API также можно использовать для более универсального и эффективного доступа к энергонезависимой памяти, который уже возможен через JEP 352 (Non-Volatile Mapped Byte Buffers).