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. Однако возможности прямых буферов ограничены. Например, нельзя создать прямой буфер больше двух гигабайт, поскольку 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 такого вида есть координата доступа типа 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).