Перейти к основному содержимому

API доступа к внешней памяти в Java 14

· 8 мин. чтения

1. Обзор

Объекты Java находятся в куче. Однако это может иногда приводить к таким проблемам, как неэффективное использование памяти, низкая производительность и проблемы со сборкой мусора . Собственная память может быть более эффективной в этих случаях, но ее использование традиционно было очень сложным и подвержено ошибкам.

Java 14 представляет API доступа к внешней памяти для более безопасного и эффективного доступа к собственной памяти.

В этом руководстве мы рассмотрим этот API.

2. Мотивация

Эффективное использование памяти всегда было сложной задачей. В основном это связано с такими факторами, как неадекватное понимание памяти, ее организации и сложных методов адресации памяти.

Например, неправильно реализованный кеш памяти может привести к частой сборке мусора. Это резко ухудшит производительность приложения.

До введения API доступа к внешней памяти в Java было два основных способа доступа к собственной памяти в Java. Это классы java.nio.ByteBuffer и sun.misc.Unsafe .

Давайте кратко рассмотрим преимущества и недостатки этих API.

2.1. API байтового буфера

ByteBuffer API позволяет создавать байтовые буферы вне кучи напрямую . К этим буферам можно получить прямой доступ из программы на Java. Однако есть некоторые ограничения: ****

  • Размер буфера не может быть больше двух гигабайт
  • Сборщик мусора отвечает за освобождение памяти.

Кроме того, неправильное использование ByteBuffer может вызвать утечку памяти и ошибки OutOfMemory . Это связано с тем, что неиспользуемая ссылка на память может помешать сборщику мусора освободить память.

2.2. Небезопасный API

Unsafe API чрезвычайно эффективен благодаря своей модели адресации. Однако, как следует из названия, этот API небезопасен и имеет несколько недостатков:

  • Это часто позволяет программам Java вызывать сбой JVM из-за незаконного использования памяти.
  • Это нестандартный Java API

2.3. Необходимость в новом API

Таким образом, доступ к чужой памяти ставит перед нами дилемму. Должны ли мы использовать безопасный, но ограниченный путь ( ByteBuffer )? Или мы должны рискнуть использовать неподдерживаемый и опасный API Unsafe ?

Новый API доступа к внешней памяти направлен на решение этих проблем.

3. API внешней памяти

API доступа к внешней памяти предоставляет поддерживаемый, безопасный и эффективный API для доступа как к куче, так и к собственной памяти. Он построен на трех основных абстракциях:

  • MemorySegment — моделирует непрерывную область памяти.
  • MemoryAddress — местоположение в сегменте памяти
  • MemoryLayout - способ определить макет сегмента памяти не зависящим от языка способом.

Давайте обсудим это подробно.

3.1. сегмент памяти

Сегмент памяти представляет собой непрерывную область памяти. Это может быть куча или память вне кучи. И есть несколько способов получить сегмент памяти.

Сегмент памяти, поддерживаемый собственной памятью, называется сегментом собственной памяти. Он создается с использованием одного из перегруженных методов allocateNative .

Создадим собственный сегмент памяти размером 200 байт:

MemorySegment memorySegment = MemorySegment.allocateNative(200);

Сегмент памяти также может поддерживаться существующим массивом Java, выделенным в куче. Например, мы можем создать сегмент памяти массива из массива long :

MemorySegment memorySegment = MemorySegment.ofArray(new long[100]);

Кроме того, сегмент памяти может поддерживаться существующим Java ByteBuffer . Это известно как сегмент буферной памяти :

MemorySegment memorySegment = MemorySegment.ofByteBuffer(ByteBuffer.allocateDirect(200));

В качестве альтернативы мы можем использовать файл с отображением памяти. Это известно как отображенный сегмент памяти. Давайте определим 200-байтовый сегмент памяти, используя путь к файлу с доступом для чтения и записи:

MemorySegment memorySegment = MemorySegment.mapFromPath(
Path.of("/tmp/memory.txt"), 200, FileChannel.MapMode.READ_WRITE);

Сегмент памяти привязан к определенному потоку . Таким образом, если какой-либо другой поток требует доступа к сегменту памяти, он должен получить доступ с помощью метода Acquire .

Кроме того, сегмент памяти имеет пространственные и временные границы с точки зрения доступа к памяти:

  • Пространственная граница — сегмент памяти имеет нижний и верхний пределы
  • Временная граница — управляет созданием, использованием и закрытием сегмента памяти.

Вместе пространственные и временные проверки обеспечивают безопасность JVM.

3.2. Адрес памяти

MemoryAddress — это смещение в пределах сегмента памяти . Обычно его получают с помощью метода baseAddress :

MemoryAddress address = MemorySegment.allocateNative(100).baseAddress();

Адрес памяти используется для выполнения таких операций, как извлечение данных из памяти в базовом сегменте памяти.

3.3. ПамятьМакет

Класс MemoryLayout позволяет нам описать содержимое сегмента памяти. В частности, он позволяет нам определить, как память разбивается на элементы, где указывается размер каждого элемента.

Это немного похоже на описание структуры памяти как конкретного типа, но без предоставления класса Java. Это похоже на то, как такие языки, как C++, отображают свои структуры в памяти.

Давайте возьмем пример декартовой координатной точки, определенной с координатами x и y :

int numberOfPoints = 10;
MemoryLayout pointLayout = MemoryLayout.ofStruct(
MemoryLayout.ofValueBits(32, ByteOrder.BIG_ENDIAN).withName("x"),
MemoryLayout.ofValueBits(32, ByteOrder.BIG_ENDIAN).withName("y")
);
SequenceLayout pointsLayout =
MemoryLayout.ofSequence(numberOfPoints, pointLayout);

Здесь мы определили макет, состоящий из двух 32-битных значений с именами x и y . Этот макет можно использовать с SequenceLayout , чтобы сделать что-то похожее на массив, в данном случае с 10 индексами.

4. Использование родной памяти

4.1. Ручки Памяти

Класс MemoryHandles позволяет нам создавать VarHandles. VarHandle разрешает доступ к сегменту памяти.

Давайте попробуем это:

long value = 10;
MemoryAddress memoryAddress = MemorySegment.allocateNative(8).baseAddress();
VarHandle varHandle = MemoryHandles.varHandle(long.class, ByteOrder.nativeOrder());
varHandle.set(memoryAddress, value);

assertThat(varHandle.get(memoryAddress), is(value));

В приведенном выше примере мы создаем MemorySegment из восьми байтов. Нам нужно восемь байтов для представления длинного числа в памяти. Затем мы используем VarHandle для его сохранения и извлечения.

4.2. Использование дескрипторов памяти со смещением

Мы также можем использовать смещение вместе с MemoryAddress для доступа к сегменту памяти. Это похоже на использование индекса для получения элемента из массива:

VarHandle varHandle = MemoryHandles.varHandle(int.class, ByteOrder.nativeOrder());
try (MemorySegment memorySegment = MemorySegment.allocateNative(100)) {
MemoryAddress base = memorySegment.baseAddress();
for(int i=0; i<25; i++) {
varHandle.set(base.addOffset((i*4)), i);
}
for(int i=0; i<25; i++) {
assertThat(varHandle.get(base.addOffset((i*4))), is(i));
}
}

В приведенном выше примере мы сохраняем целые числа от 0 до 24 в сегменте памяти.

Сначала мы создаем MemorySegment размером 100 байт. Это связано с тем, что в Java каждое целое число занимает 4 байта. Следовательно, для хранения 25 целочисленных значений нам потребуется 100 байт (4*25).

Чтобы получить доступ к каждому индексу, мы устанавливаем varHandle так , чтобы он указывал на правильное смещение, используя addOffset для базового адреса.

4.3. Макеты памяти

Класс MemoryLayouts определяет различные полезные константы макета .

Например, в предыдущем примере мы создали SequenceLayout :

SequenceLayout sequenceLayout = MemoryLayout.ofSequence(25, 
MemoryLayout.ofValueBits(64, ByteOrder.nativeOrder()));

Это можно выразить проще, используя константу JAVA_LONG :

SequenceLayout sequenceLayout = MemoryLayout.ofSequence(25, MemoryLayouts.JAVA_LONG);

4.4. ValueLayout

ValueLayout моделирует структуру памяти для основных типов данных, таких как целые и плавающие типы. Каждый макет значения имеет размер и порядок байтов. Мы можем создать ValueLayout , используя метод ofValueBits :

ValueLayout valueLayout = MemoryLayout.ofValueBits(32, ByteOrder.nativeOrder());

4.5. SequenceLayout

SequenceLayout обозначает повторение данного макета. Другими словами, это можно представить как последовательность элементов, аналогичную массиву с определенной компоновкой элементов.

Например, мы можем создать макет последовательности для 25 элементов по 64 бита каждый:

SequenceLayout sequenceLayout = MemoryLayout.ofSequence(25, 
MemoryLayout.ofValueBits(64, ByteOrder.nativeOrder()));

4.6. Групповой макет

GroupLayout может комбинировать несколько макетов членов . Макеты элементов могут быть похожими типами или комбинацией разных типов.

Существует два возможных способа определения макета группы. Например, когда макеты элементов организованы один за другим, он определяется как структура. С другой стороны, если макеты элементов выложены с одного и того же начального смещения, то это называется объединением .

Давайте создадим GroupLayout типа struct с целым числом и длинной :

GroupLayout groupLayout = MemoryLayout.ofStruct(MemoryLayouts.JAVA_INT, MemoryLayouts.JAVA_LONG);

Мы также можем создать GroupLayout типа union , используя метод ofUnion :

GroupLayout groupLayout = MemoryLayout.ofUnion(MemoryLayouts.JAVA_INT, MemoryLayouts.JAVA_LONG);

Первая из них представляет собой структуру, которая содержит по одному элементу каждого типа. И второе — это структура, которая может содержать тот или иной тип.

Групповой макет позволяет нам создать сложный макет памяти, состоящий из нескольких элементов. Например:

MemoryLayout memoryLayout1 = MemoryLayout.ofValueBits(32, ByteOrder.nativeOrder());
MemoryLayout memoryLayout2 = MemoryLayout.ofStruct(MemoryLayouts.JAVA_LONG, MemoryLayouts.PAD_64);
MemoryLayout.ofStruct(memoryLayout1, memoryLayout2);

5. Нарезка сегмента памяти

Мы можем разрезать сегмент памяти на несколько меньших блоков. Это избавляет нас от необходимости выделять несколько блоков, если мы хотим хранить значения с разными макетами.

Давайте попробуем использовать asSlice :

MemoryAddress memoryAddress = MemorySegment.allocateNative(12).baseAddress();
MemoryAddress memoryAddress1 = memoryAddress.segment().asSlice(0,4).baseAddress();
MemoryAddress memoryAddress2 = memoryAddress.segment().asSlice(4,4).baseAddress();
MemoryAddress memoryAddress3 = memoryAddress.segment().asSlice(8,4).baseAddress();

VarHandle intHandle = MemoryHandles.varHandle(int.class, ByteOrder.nativeOrder());
intHandle.set(memoryAddress1, Integer.MIN_VALUE);
intHandle.set(memoryAddress2, 0);
intHandle.set(memoryAddress3, Integer.MAX_VALUE);

assertThat(intHandle.get(memoryAddress1), is(Integer.MIN_VALUE));
assertThat(intHandle.get(memoryAddress2), is(0));
assertThat(intHandle.get(memoryAddress3), is(Integer.MAX_VALUE));

6. Заключение

В этой статье мы узнали о новом API доступа к внешней памяти в Java 14.

Во-первых, мы рассмотрели потребность во внешнем доступе к памяти и ограничения API до Java 14. Затем мы увидели, что API доступа к внешней памяти является безопасной абстракцией для доступа как к куче, так и к памяти без кучи.

Наконец, мы изучили использование API для чтения и записи данных как из кучи, так и из нее.

Как всегда, исходный код примеров доступен на GitHub .