1. 概要

Javaオブジェクトはヒープ上にあります。 ただし、これにより、非効率的なメモリ使用量、パフォーマンスの低下、ガベージコレクションの問題などの問題が発生する場合があります。 このような場合、ネイティブメモリの方が効率的ですが、これを使用することは従来から非常に難しく、エラーが発生しやすくなっています。

Java14は外部メモリアクセスAPIを導入しますより安全かつ効率的にネイティブメモリにアクセスします。

このチュートリアルでは、このAPIについて説明します。

2. 動機

メモリの効率的な使用は、常に困難な作業でした。 これは主に、メモリ、その構成、および複雑なメモリアドレス指定技術の不十分な理解などの要因によるものです。

たとえば、不適切に実装されたメモリキャッシュは、頻繁なガベージコレクションを引き起こす可能性があります。 これにより、アプリケーションのパフォーマンスが大幅に低下します。

Javaで外部メモリアクセスAPIが導入される前は、Javaでネイティブメモリにアクセスする主な方法は2つありました。 これらは、java.nio.ByteBufferおよびsun.misc.Unsafeクラスです。

これらのAPIの長所と短所を簡単に見てみましょう。

2.1. ByteBuffer API

ByteBuffer API を使用すると、直接のオフヒープバイトバッファーを作成できます。 これらのバッファには、Javaプログラムから直接アクセスできます。 ただし、いくつかの制限があります。

  • バッファサイズは2ギガバイトを超えることはできません
  • ガベージコレクタは、メモリの割り当て解除を担当します

さらに、 ByteBuffer を誤って使用すると、メモリリークやOutOfMemoryエラーが発生する可能性があります。 これは、未使用のメモリ参照により、ガベージコレクタがメモリの割り当てを解除できなくなる可能性があるためです。

2.2. 安全でないAPI

Unsafe API は、そのアドレス指定モデルにより非常に効率的です。 ただし、名前が示すように、このAPIは安全ではなく、いくつかの欠点があります。

  • 多くの場合、は、不正なメモリ使用量が原因でJavaプログラムがJVMをクラッシュさせることを許可します
  • 非標準のJavaAPIです

2.3. 新しいAPIの必要性

要約すると、外部メモリへのアクセスは私たちにとってジレンマを引き起こします。 安全ですが制限されたパス( ByteBuffer )を使用する必要がありますか? または、サポートされていない危険な安全でない APIを使用するリスクを冒す必要がありますか?

新しい外部メモリアクセスAPIは、これらの問題を解決することを目的としています。

3. 外部メモリAPI

外部メモリアクセスAPIは、ヒープメモリとネイティブメモリの両方にアクセスするための、サポートされた安全で効率的なAPIを提供します。 これは、3つの主要な抽象化に基づいて構築されています。

  • MemorySegment –メモリの連続領域をモデル化します
  • MemoryAddress –メモリセグメント内の場所
  • MemoryLayout –言語に依存しない方法でメモリセグメントのレイアウトを定義する方法

これらについて詳しく説明しましょう。

3.1. MemorySegment

メモリセグメント連続したメモリ領域です。 これは、ヒープメモリまたはオフヒープメモリのいずれかです。 また、メモリセグメントを取得する方法はいくつかあります。

ネイティブメモリに裏打ちされたメモリセグメントは、ネイティブメモリセグメントと呼ばれます。これは、オーバーロードされたalllocateNativeメソッドの1つを使用して作成されます。

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

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

ここでは、xyという名前の2つの32ビット値で構成されるレイアウトを定義しました。 このレイアウトを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));

上記の例では、8バイトのMemorySegmentを作成します。 メモリ内のlong数を表すには8バイトが必要です。 次に、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までの整数をメモリセグメントに格納しています。

最初に、100バイトのMemorySegmentを作成します。 これは、Javaでは、各整数が4バイトを消費するためです。 したがって、25個の整数値を格納するには、100バイト(4 * 25)が必要です。

各インデックスにアクセスするには、ベースアドレスで addOffset を使用して、varHandleが右オフセットを指すように設定します。

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は、整数型や浮動型などの基本的なデータ型のメモリレイアウトをモデル化します。各値のレイアウトには、サイズとバイトオーダーがあります。  ofValueBits メソッドを使用して、ValueLayoutを作成できます。

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

4.5. SequenceLayout

SequenceLayoutは、特定のレイアウトの繰り返しを示します。つまり、これは、定義された要素レイアウトを持つ配列に類似した要素のシーケンスと考えることができます。

たとえば、それぞれ64ビットの25要素のシーケンスレイアウトを作成できます。

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

4.6. GroupLayout

GroupLayoutは複数のメンバーレイアウトを組み合わせることができます。 メンバーのレイアウトは、類似したタイプまたは異なるタイプの組み合わせのいずれかです。

グループレイアウトを定義するには、2つの方法があります。 たとえば、メンバーレイアウトが次々に編成される場合、それは structとして定義されます。一方、メンバーレイアウトが同じ開始オフセットからレイアウトされる場合、それは[[ X212X]ユニオン。

integerlongを使用してstructタイプのGroupLayoutを作成しましょう。

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

ofUnion メソッドを使用して、unionタイプのGroupLayoutを作成することもできます。

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

これらの最初のものは、各タイプの1つを含む構造です。 そして、2つ目は、いずれかのタイプを含むことができる構造です。

グループレイアウトを使用すると、複数の要素で構成される複雑なメモリレイアウトを作成できます。 例えば:

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. 結論

この記事では、Java14の新しい外部メモリアクセスAPIについて学習しました。

最初に、外部メモリアクセスの必要性とJava14より前のAPIの制限について検討しました。 次に、外部メモリアクセスAPIが、ヒープメモリと非ヒープメモリの両方にアクセスするための安全な抽象化である方法を確認しました。

最後に、ヒープの内外でデータを読み書きするためのAPIの使用について検討しました。

いつものように、例のソースコードはGitHubから入手できます。