JEP 393: Foreign-Memory Access API (Third Incubator)
Foreign-Memory Access API, третья версия Incubator (инкубационный модуль)
| Ответственный | Maurizio Cimadamore |
| Тип | Feature |
| Область | JDK |
| Статус | Closed / Delivered |
| Выпуск | 16 |
| Компонент | core-libs |
| Обсуждение | panama dash dev at openjdk dot java dot net |
| Трудоёмкость | M |
| Длительность | M |
| Связан с | JEP 383: Foreign-Memory Access API (Second Incubator) |
| JEP 370: Foreign-Memory Access API (Incubator) | |
| JEP 412: Foreign Function & Memory API (Incubator) | |
| JEP 389: Foreign Linker API (Incubator) | |
| Рецензенты | Jorn Vernee, Paul Sandoz |
| Создан | 2020/09/21 09:58 |
| Обновлён | 2022/03/02 17:09 |
| Задача | 8253415 |
Аннотация
Добавить API, с помощью которого Java-программы смогут безопасно и эффективно обращаться к внешней памяти за пределами кучи Java.
История
Foreign-Memory Access API впервые был предложен в JEP 370 и в конце 2019 года запланирован в Java 14 как API в статусе Incubator. Затем он повторно вышел в статусе Incubator по JEP 383, который в середине 2020 года был запланирован в Java 15. Этот JEP предлагает внести доработки на основе отзывов и ещё раз выпустить API в статусе Incubator в Java 16. Обновление API включает следующие изменения:
- более чёткое разделение ролей между интерфейсами
MemorySegmentиMemoryAddress; - новый интерфейс
MemoryAccessс общими статическими методами доступа к памяти, чтобы в простых случаях APIVarHandleтребовался как можно реже; - поддержка разделяемых сегментов;
- возможность регистрировать сегменты в
Cleaner.
Цели
-
Универсальность. Единый 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. Однако возможности прямых буферов ограничены. Например, нельзя создать прямой буфер больше двух гигабайт, потому что APIByteBufferиспользует индексацию на основеint. Кроме того, работать с прямыми буферами бывает неудобно, поскольку освобождение связанной с ними памяти возложено на сборщик мусора: связанную память можно освободить только после того, как сборщик мусора сочтёт прямой буфер недостижимым. За годы было подано много запросов на улучшение, чтобы преодолеть эти и другие ограничения (например, 4496703, 6558368, 4837564 и 5029431). Многие из этих ограничений связаны с тем, что APIByteBufferпроектировался не только для доступа к памяти вне кучи, но и для обмена большими объёмами данных между производителем и потребителем в таких областях, как кодирование и декодирование кодировок и частичные операции ввода-вывода. -
Другой распространённый способ, которым разработчики могут обращаться к внешней памяти из Java-кода, — API
Unsafe.Unsafeпредоставляет много операций доступа к памяти (например,Unsafe::getIntиputInt), которые благодаря относительно общей модели адресации работают как для доступа к куче, так и для доступа вне кучи. Доступ к памяти черезUnsafeчрезвычайно эффективен: все операции доступа к памяти определены как интринсики HotSpot JVM, поэтому JIT-компилятор HotSpot регулярно их оптимизирует. Однако APIUnsafeпо определению небезопасен: он позволяет обращаться к любому адресу памяти (например,Unsafe::getIntпринимает адрес типаlong). Это значит, что Java-программа может обрушить JVM, обратившись к уже освобождённой памяти. Вдобавок APIUnsafeне является поддерживаемым 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 есть пара координат доступа:
- координата типа
MemorySegment— сегмент, память которого нужно разыменовать; - координата типа
long— смещение от базового адреса сегмента, по которому выполняется разыменование.
Var handle доступа к памяти получают с помощью фабричных методов класса MemoryHandles. Например, чтобы задать элементы сегмента нативной памяти, можно использовать var handle доступа к памяти так:
VarHandle intHandle = MemoryHandles.varHandle(int.class,
ByteOrder.nativeOrder());
try (MemorySegment segment = MemorySegment.allocateNative(100)) {
for (int i = 0; i < 25; i++) {
intHandle.set(segment, i * 4, i);
}
}
Более сложные идиомы доступа можно выразить, комбинируя простые var handle доступа к памяти с помощью одного или нескольких комбинаторных методов класса MemoryHandles. С их помощью клиент может, например, отобразить тип var handle доступа к памяти, используя пару method handle для проекции и вложения. Клиент также может переупорядочить координаты заданного var handle доступа к памяти, отбросить одну или несколько координат и вставить новые.
Чтобы API было проще использовать, класс MemoryAccess предоставляет ряд статических методов доступа, с помощью которых можно разыменовывать сегменты памяти, не создавая var handle доступа к памяти. Например, есть метод, записывающий значение int в сегмент по заданному смещению, и с ним пример выше можно упростить так:
try (MemorySegment segment = MemorySegment.allocateNative(100)) {
for (int i = 0; i < 25; i++) {
MemoryAccess.setIntAtOffset(segment, i * 4, i);
}
}
Разметка памяти
Чтобы сделать API выразительнее и уменьшить потребность в явных числовых вычислениях, как в примерах выше, можно программно описать содержимое MemorySegment с помощью MemoryLayout. Например, разметку сегмента нативной памяти из примеров выше можно описать так:
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)) {
for (int i = 0; i < intArrayLayout.elementCount().getAsLong(); i++) {
indexedElementHandle.set(segment, (long) i, i);
}
}
В этом примере объект разметки управляет созданием var handle доступа к памяти через создание пути в разметке, с помощью которого из сложного выражения разметки выбирается вложенная разметка. Объект разметки также управляет выделением сегмента нативной памяти, которое основано на сведениях о размере и выравнивании, полученных из разметки. Константа цикла из предыдущих примеров (25) заменена числом элементов разметки-последовательности.
Непроверяемые сегменты
Операции разыменования возможны только для сегментов памяти. Поскольку у сегмента памяти есть пространственные и временные границы, среда выполнения всегда может гарантировать, что память, связанная с данным сегментом, разыменовывается безопасно. Но бывают ситуации, когда у клиента есть только адрес памяти; например, так часто бывает при взаимодействии с нативным кодом. Кроме того, адрес памяти можно построить из значений long (через фабрику MemoryAddress::ofLong). В таких случаях среда выполнения никак не может узнать пространственные и временные границы, связанные с адресом памяти, поэтому API запрещает разыменовывать адреса памяти.
Чтобы разыменовать адрес памяти, у клиента есть два варианта. Если известно, что адрес находится внутри сегмента памяти, клиент может выполнить операцию перебазирования (MemoryAddress::segmentOffset). Эта операция заново интерпретирует смещение адреса относительно базового адреса сегмента и получает новое смещение, которое можно применить к существующему сегменту, — после этого его можно безопасно разыменовать.
Если же такого сегмента нет, клиент может небезопасно создать его с помощью специальной фабрики MemoryAddress::asSegmentRestricted. По сути, эта фабрика привязывает пространственные и временные границы к адресу, который иначе не проверяется, чтобы разрешить операции разыменования. Как следует из названия, эта операция небезопасна по самой своей природе, поэтому использовать её нужно осторожно. По этой причине Foreign Memory Access API разрешает вызывать эту фабрику, только если системному свойству JDK foreign.restricted задано значение, отличное от deny. Возможные значения этого свойства:
deny— выбрасывает исключение времени выполнения при каждом ограниченном вызове (это значение по умолчанию);permit— разрешает ограниченные вызовы;warn— какpermit, но дополнительно выводит однострочное предупреждение при каждом ограниченном вызове;debug— какpermit, но дополнительно выводит дамп стека для каждого ограниченного вызова.
В будущем мы планируем интегрировать доступ к ограниченным операциям с системой модулей. Некоторые модули могли бы каким-либо образом объявлять, что им требуется ограниченный нативный доступ. При запуске приложения, которое зависит от таких модулей, пользователю, возможно, придётся выдать этим модулям разрешения на выполнение ограниченных нативных операций, иначе среда выполнения откажется запускать приложение.
Привязка к потоку
Помимо пространственных и временных границ, у сегментов есть привязка к потоку. То есть сегмент принадлежит потоку, который его создал, и никакой другой поток не может обращаться к содержимому сегмента или выполнять над ним определённые операции (например, close). Привязка к потоку накладывает ограничения, но она необходима, чтобы гарантировать оптимальную производительность доступа к памяти даже в многопоточной среде.
Foreign-Memory Access API предоставляет несколько способов ослабить барьеры привязки к потоку. Во-первых, потоки могут совместно использовать сегменты, выполняя явные операции передачи, при которых поток отказывается от владения сегментом и передаёт его другому потоку. Рассмотрим следующий код:
MemorySegment segmentA = MemorySegment.allocateNative(10); // confined to thread A
...
var segmentB = segmentA.withOwnerThread(threadB); // now confined to thread B
Такой шаблон доступа также называют последовательной привязкой (serial confinement). Он может быть полезен в сценариях «производитель/потребитель», где к сегменту в каждый момент времени должен обращаться только один поток. Обратите внимание: чтобы операция передачи была безопасной, API уничтожает исходный сегмент (как если бы был вызван close, но без освобождения нижележащей памяти) и возвращает новый сегмент с правильным владельцем. Реализация также гарантирует, что к моменту, когда второй поток обращается к сегменту, все записи первого потока сброшены в память.
Если последовательной привязки недостаточно, клиенты могут при желании снять владение потоком, то есть превратить привязанный сегмент в разделяемый, к которому несколько потоков могут одновременно обращаться — и который могут одновременно закрывать. Как и раньше, при переводе сегмента в разделяемый исходный сегмент уничтожается и возвращается новый сегмент без потока-владельца:
MemorySegment segmentA = MemorySegment.allocateNative(10); // confined by thread A
...
var sharedSegment = segmentA.withOwnerThread(null); // now a shared segment
Разделяемый сегмент особенно полезен, когда нескольким потокам нужно параллельно работать с содержимым сегмента (например, с помощью фреймворка 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, sequenceElement());
int sum = StreamSupport.stream(MemorySegment.spliterator(segment.withOwnerThread(null),
seq_bulk),
true)
.mapToInt(slice -> {
int res = 0;
for (int i = 0; i < 100 ; i++) {
res += MemoryAccess.getIntAtIndex(slice, i);
}
return res;
}).sum();
Метод MemorySegment::spliterator принимает сегмент и последовательную раскладку (sequence layout) и возвращает экземпляр spliterator, который разбивает сегмент на фрагменты, соответствующие элементам заданной последовательной раскладки. Здесь мы хотим просуммировать элементы массива, содержащего миллион элементов. Параллельное суммирование, при котором каждое вычисление обрабатывает ровно один элемент, было бы неэффективным, поэтому мы используем API раскладок, чтобы получить блочную последовательную раскладку. Блочная раскладка — это последовательная раскладка того же размера, что и исходная, но элементы в ней сгруппированы по 100 элементов, поэтому её удобнее обрабатывать параллельно.
Получив spliterator, мы можем построить с его помощью параллельный поток (stream) и параллельно просуммировать содержимое сегмента. Поскольку сегмент, с которым работает spliterator, разделяемый, к нему могут одновременно обращаться несколько потоков. API spliterator гарантирует упорядоченный доступ: он создаёт срезы исходного сегмента и передаёт каждый срез потоку для выполнения нужного вычисления, тем самым гарантируя, что никакие два потока никогда не смогут одновременно работать с одной и той же областью памяти.
Разделяемые сегменты также могут пригодиться для последовательной привязки в случаях, когда поток, передающий сегмент, не знает, какой другой поток продолжит работу с сегментом, например:
// thread A
MemorySegment segment = MemorySegment.allocateNative(10); // confined by thread A
// do some work
segment = segment.withOwnerThread(null);
// thread B
segment.withOwnerThread(Thread.currentThread()); // now confined by thread B
// do some more work
Несколько потоков могут соперничать за захват разделяемого сегмента, но API гарантирует, что стать владельцем разделяемого сегмента удастся только одному из них.
Неявное освобождение памяти
Сегменты памяти поддерживают детерминированное освобождение, но их также можно зарегистрировать в Cleaner, чтобы ресурсы памяти, связанные с сегментом, были освобождены, когда сборщик мусора определит, что сегмент больше не достижим:
MemorySegment segment = MemorySegment.allocateNative(100);
Cleaner cleaner = Cleaner.create();
segment.registerCleaner(cleaner);
// do some work
segment = null; // Cleaner might reclaim the segment memory now
Регистрация сегмента в cleaner не мешает клиентам явно вызывать MemorySegment::close. API гарантирует, что действие очистки сегмента будет вызвано не более одного раза — либо явно (клиентским кодом), либо неявно (через cleaner). Поскольку к недостижимому сегменту (по определению) не может обратиться ни один поток, cleaner всегда может освободить все ресурсы памяти, связанные с недостижимым сегментом, независимо от того, привязан ли этот сегмент к потоку или является разделяемым.
Альтернативы
Продолжать использовать существующие 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), более общим и эффективным способом.