Tracking down an OpenJDK Memory Alignment bug on FreeBSD

Published by Harald OpenJDK FreeBSD jemalloc alignment

For applications that need it the Java Virtual Machine (JVM) allows for allocating off-heap or native memory segments. These are allocated via the java.lang.foreign.Arena interface in the java.base module. In contrast to the memory automatically managed by the JVM, these segments are managed by the application itself, and therefore not subject to garbage collection.

The java.lang.foreign.Arena.allocate() API, takes both a size in bytes, and an alignment constraint for the returned memory as arguments. The alignment constraint is to ensure that the starting address of the allocated memory segment is properly aligned for the data it will contain. The actual alignment of the returned address should be at least as large as the requested constraint, and be a value that is a power of two.

package java.lang.foreign;

public interface Arena extends SegmentAllocator, AutoCloseable {
    @Override
    MemorySegment allocate(long byteSize, long byteAlignment);
}

To test that this is actually the case, there are test cases included in the OpenJDK source tree that will excercise these API's. They will do small allocations with alignment constraints from 2 bytes to 1 MB, and check if the returned address is aligned as expected. There's also a separate test that writes a value to the allocated memory, and reads it back to ensure both that the memory access and storing the value works as expected.

For the longest time, these tests would sporadically fail on FreeBSD. Not every time, but often enough that it was clear something was not as right as it should be. They would often pass, however, when runing them again to try to replicate the issue.

What was really weird was that it would only fail when requesting an alignment of exactly 16 bytes. Never on any other alignment. This sent me down a rabbit hole where I would learn not only about how the JVM handles native (off-heap) allocations, but also how various implementations of the C standard library allocates memory, and how an ambiguity in the C standard was the cause of all this.

JVM SegmentFactories

The first place to look was the code in the JVM powering this feature. It's a few layers deep, but eventually we get to a function called allocateNativeInternal in the internal SegmentFactories class. There we find the following (slightly edited) Java code:

  if (byteAlignment > MAX_MALLOC_ALIGN) {
      allocationSize = alignedSize + byteAlignment - MAX_MALLOC_ALIGN;
      allocationBase = allocateMemoryWrapper(allocationSize);
      result = Utils.alignUp(allocationBase, byteAlignment);
  } else {
      allocationSize = alignedSize;
      allocationBase = allocateMemoryWrapper(allocationSize);
      result = allocationBase;
  }

In short, if the requested byteAlignment is greater than the constant MAX_MALLOC_ALIGN, we will adjust the size of the memory to allocate, and make sure that the returned start address is properly aligned within this segment. Otherwise we simply allocate the requested size and don't do any special processing of the result.

For 64 bit platforms (where this problem was observed) MAX_MALLOC_ALIGN was defined as 16:

    // The maximum alignment supported by malloc - typically 16 bytes on
    // 64-bit platforms and 8 bytes on 32-bit platforms.
    private static final long MAX_MALLOC_ALIGN = Unsafe.ADDRESS_SIZE == 4 ? 8 : 16;

Going deeper: libc and malloc

The comment above is a bit vague, but in short it boils down to typical implementations of malloc(3) returning addresses that are aligned to 16 byte boundaries by default on 64bit architectures. This is due to section 7.22.3 in the C standard (C11 which is the version of the C standard that OpenJDK uses,) which reads:

The pointer returned if the allocation succeeds is suitably aligned so that it may be assigned to a pointer to any type of object with a fundamental alignment requirement and then used to access such an object or an array of such objects in the space allocated (...)

In a bit clearer terms this means that the memory returned by malloc has an alignment that is usable to store any native datatype in C.

However this can be interpreted in two ways – the "strong" reading where every allocation must have an alignment suitable for every data type, and the "weak" reading where alignment must be suitable for any type that can fit into the allocated memory.

The maximum alignment is defined by _Alignof(max_align_t), which is 16 bytes on a 64 bit system.

Where it gets murky, introducing jemalloc

The Glibc implementation of malloc(3) used by Linux, as well as the OS X and OpenBSD libc implementations all follow the "strong" interpretation above, and return memory aligned on 16 byte boundaries for all allocations. However, FreeBSD uses jemalloc, which has the following to say about alignment:

Allocation requests that are no more than half the quantum (8 or 16, depending on architecture) are rounded up to the nearest power of two that is at least sizeof(double). All other object size classes are multiples of the quantum (...)

Here the "quantum" corresponds to _Alignof(max_align_t) from the C standard, or MAX_MALLOC_ALIGN defined by the SegmentFactories code above. The double datatype however, is only 8 bytes. So for allocations of memory sizes less than 8 bytes (half the quentum of 16 bytes), the returned memory will be aligned at 8 byte (sizeof(double)) boundaries.

Back to the test case

Combining all this information it becomes easier to see why the test case was failing. Let's look at the test code:

    public void testActualByteAlignment(long align) {
        if (align > (1L << 10)) {
            return;
        }
        try (Arena arena = Arena.ofConfined()) {
            var segment = arena.allocate(4, align);
            assertTrue(segment.maxByteAlignment() >= align);
            // Power of two?
            assertEquals(Long.bitCount(segment.maxByteAlignment()), 1);
            assertEquals(segment.asSlice(1).maxByteAlignment(), 1);
        }
    }

The important part is the line var segment = arena.allocate(4, align);. This tries to allocate a memory segment with a size of four bytes, and with the given alignment passed in to the test function. Then the test asserts that the actual alignment of the segment is at least as large as the requested alignment.

Combined with the implementation of the allocateNativeInternal above, we see that if the requested alignment is larger than 16 bytes extra padding is added to the size of the memory we want to allocate. This so that we have room to offset the actual data pointer to comply with the requested alignment. Otherwise no adjustment is made, and the function simply return the memory as provided by malloc().

So when requesting a size of four bytes with an alignment of 16, no adjustment is made, and jemalloc will return an address aligned on an eight-byte boundary. As 8 byte aligned memory will also be 16 byte aligned in about half the cases, this caused the test to only fail some times.

This issue would not show up on other platforms supported by OpenJDK because the default malloc implementations on these platforms would return a 16 byte aligned address in this case.

The fix

The fix for this ended up being quite trivial:

  if (byteAlignment > MAX_MALLOC_ALIGN) {
      allocationSize = alignedSize + byteAlignment - MAX_MALLOC_ALIGN;
      allocationBase = allocateMemoryWrapper(allocationSize);
      result = Utils.alignUp(allocationBase, byteAlignment);
  } else {
      // always allocate at least 'byteAlignment' bytes, so that malloc is guaranteed to
      // return a pointer aligned to that alignment, for cases where byteAlignment > alignedSize
      allocationSize = Math.max(alignedSize, byteAlignment);
      allocationBase = allocateMemoryWrapper(allocationSize);
      result = allocationBase;
  }

The only change is that we make sure to adjust allocationSize to be at least as big as byteAlignment in cases where the size of the allocation is smaller.

Getting to this solution was far from trivial though, and involved several people from both the OpenJDK and BSD communities sharing their wisdom and suggestions. Several alternatives were tested along the way, some seemingly fixing the issue by making the tests pass, only to be found to cause other issues that was not tested for. (New tests were added to address those issues as well.)

For posterity, here's the Github pull request for those who may be interested. I think this is a great example of the strength of a well run free software project. A community coming together to cooperate on solving an issue. A big thanks to everyone involved. I sure learned a lot from it!