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

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).