«1. Обзор

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

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

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

2. Мотивация

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

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

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

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

2.1. ByteBuffer API

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

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

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

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

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

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

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

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

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

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

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

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

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

3.1. MemorySegment

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

Сегмент памяти, поддерживаемый собственной памятью, называется сегментом собственной памяти. Он создается с помощью одного из перегруженных методов 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

Класс 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

Класс 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));

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

4.2. Использование MemoryHandles со смещением

Мы также можем использовать смещение в сочетании с 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

Класс 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 может комбинировать несколько макетов членов. Макеты элементов могут быть похожими типами или комбинацией разных типов.

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

Давайте создадим 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.