1. Overview

Java objects reside on the heap. However, this can occasionally lead to problems such as inefficient memory usage, low performance, and garbage collection issues. Native memory can be more efficient in these cases, but using it has been traditionally very difficult and error-prone.

Java 14 introduces the foreign memory access API to access native memory more securely and efficiently.

In this tutorial, we'll look at this API.

2. Motivation

Efficient use of memory has always been a challenging task. This is mainly due to the factors such as inadequate understanding of the memory, its organization, and complex memory addressing techniques.

For instance, an improperly implemented memory cache could cause frequent garbage collection. This would degrade application performance drastically.

Before the introduction of the foreign memory access API in Java, there were two main ways to access native memory in Java. These are java.nio.ByteBuffer and sun.misc.Unsafe classes.

Let's have a quick look at the advantages and disadvantages of these APIs.

2.1. ByteBuffer API

The ByteBuffer API allows the creation of direct, off-heap byte buffers. These buffers can be directly accessed from a Java program. However, there are some limitations:

  • The buffer size can't be more than two gigabytes
  • The garbage collector is responsible for memory deallocation

Furthermore, incorrect use of a ByteBuffer can cause a memory leak and OutOfMemory errors. This is because an unused memory reference can prevent the garbage collector from deallocating the memory.

2.2. Unsafe API

The Unsafe API is extremely efficient due to its addressing model. However, as the name suggests, this API is unsafe and has several drawbacks:

  • It often allows the Java programs to crash the JVM due to illegal memory usage
  • It's a non-standard Java API

2.3. The Need for a New API

In summary, accessing a foreign memory poses a dilemma for us. Should we use a safe but limited path (ByteBuffer)? Or should we risk using the unsupported and dangerous Unsafe API?

The new foreign memory access API aims to resolve these issues.

3. Foreign Memory API

The foreign memory access API provides a supported, safe, and efficient API to access both heap and native memory. It's built upon three main abstractions:

  • MemorySegment – models a contiguous region of memory
  • MemoryAddress – a location in a memory segment
  • MemoryLayout – a way to define the layout of a memory segment in a language-neutral fashion

Let's discuss these in detail.

3.1. MemorySegment

A memory segment is a contiguous region of memory. This can be either heap or off-heap memory. And, there are several ways to obtain a memory segment.

A memory segment backed by native memory is known as a native memory segment. It's created using one of the overloaded allocateNative methods.

Let's create a native memory segment of 200 bytes:

MemorySegment memorySegment = MemorySegment.allocateNative(200);

A memory segment can also be backed by an existing heap-allocated Java array. For example, we can  create an array memory segment from an array of long:

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

Additionally, a memory segment can be backed by an existing Java ByteBuffer. This is known as a buffer memory segment:

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

Alternatively, we can use a memory-mapped file. This is known as a mapped memory segment. Let's define a 200-byte memory segment using a file path with read-write access:

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

A memory segment is attached to a specific thread. So, if any other thread requires access to the memory segment, it must gain access using the acquire method.

Also, a memory segment has spatial and temporal boundaries in terms of memory access:

  • Spatial boundary — the memory segment has lower and upper limits
  • Temporal boundary — governs creating, using, and closing a memory segment

Together, spatial and temporal checks ensure the safety of the JVM.

3.2. MemoryAddress

A MemoryAddress is an offset within a memory segment. It's commonly obtained using the baseAddress method:

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

A memory address is used to perform operations such as retrieving data from memory on the underlying memory segment.

3.3. MemoryLayout

The MemoryLayout class lets us describe the contents of a memory segment. Specifically, it lets us define how the memory is broken up into elements, where the size of each element is provided.

This is a bit like describing the memory layout as a concrete type, but without providing a Java class. It's similar to how languages like C++ map their structures to memory.

Let's take an example of a cartesian coordinate point defined with the coordinates x and 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);

Here, we've defined a layout made of two 32-bit values named x and y. This layout can be used with a SequenceLayout to make something similar to an array, in this case with 10 indices.

4. Using Native Memory

4.1. MemoryHandles

The MemoryHandles class lets us construct VarHandles. A VarHandle allows access to a memory segment.

Let's try this out:

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

In the above example, we create a MemorySegment of eight bytes. We need eight bytes to represent a long number in memory. Then, we use a VarHandle to store and retrieve it.

4.2. Using MemoryHandles with Offset

We can also use an offset in conjunction with a MemoryAddress to access a memory segment. This is similar to using an index to get an item from an array:

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

In the above example, we are storing the integers 0 to 24 in a memory segment.

At first, we create a MemorySegment of 100 bytes. This is because, in Java, each integer consumes 4 bytes. Therefore, to store 25 integer values, we need 100 bytes (4*25).

To access each index, we set the varHandle to point to the right offset using addOffset on the base address.

4.3. MemoryLayouts

The MemoryLayouts class defines various useful layout constants.

For instance, in an earlier example, we created a SequenceLayout:

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

This can be expressed more simply using the JAVA_LONG constant:

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

4.4. ValueLayout

A ValueLayout models a memory layout for basic data types such as integer and floating types. Each value layout has a size and a byte order. We can create a ValueLayout using the ofValueBits method:

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

4.5. SequenceLayout

A SequenceLayout denotes the repetition of a given layout. In other words, this can be thought of as a sequence of elements similar to an array with the defined element layout.

For example, we can create a sequence layout for 25 elements of 64 bits each:

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

4.6. GroupLayout

A GroupLayout can combine multiple member layouts. The member layouts can be either similar types or a combination of different types.

There are two possible ways to define a group layout. For instance, when the member layouts are organized one after another, it is defined as a struct. On the other hand, if the member layouts are laid out from the same starting offset, then it is called a union.

Let's create a GroupLayout of struct type with an integer and a long:

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

We can also create a GroupLayout of union type using ofUnion method:

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

The first of these is a structure which contains one of each type. And, the second is a structure that can contain one type or the other.

A group layout allows us to create a complex memory layout consisting of multiple elements. For example:

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

5. Slicing a Memory Segment

We can slice a memory segment into multiple smaller blocks. This avoids our having to allocate multiple blocks if we want to store values with different layouts.

Let's try using 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. Conclusion

In this article, we learned about the new foreign memory access API in Java 14.

First, we looked at the need for foreign memory access and the limitations of the pre-Java 14 APIs. Then, we saw how the foreign memory access API is a safe abstraction for accessing both heap and non-heap memory.

Finally, we explored the use of the API to read and write data both on and off the heap.

As always, the source code of the examples is available over on GitHub.