Skip to content

GH-163: Add arrow-memory-ffm module based on the FFM API - #1334

Open
fb64 wants to merge 7 commits into
apache:mainfrom
fb64:memory-ffm
Open

fb64 wants to merge 7 commits into
apache:mainfrom
fb64:memory-ffm

Conversation

@fb64

@fb64 fb64 commented Oct 5, 2026

Copy link
Copy Markdown
Contributor

What's Changed

MemoryUtil relies on sun.misc.Unsafe whichever allocation manager is used, and on reflection that requires --add-opens=java.base/java.nio=ALL-UNNAMED. Unsafe memory access is deprecated for removal (JEP 471) and has printed a warning since JDK 24 (JEP 498). This PR adds an opt-in alternative built on the FFM API, which is final since JDK 22 (JEP 454).

  • Refactoring, no behavior change: the Unsafe code in MemoryUtil moves to UnsafeMemoryAccessor, behind a new MemoryUtilAccessor interface. Unsafe stays the default.
  • New arrow-memory-ffm module (JDK 22+): FfmMemoryAccessor uses MemorySegment/Arena instead of Unsafe and reflection, and FfmAllocationManager allocates one Arena per buffer. The module is only built with a JDK 22+ launcher. It's added to the BOM and the docs.
  • Opt-in: -Darrow.allocation.manager.type=FFM switches both the allocator and the accessor, so Unsafe isn't used at all. -Darrow.memory.accessor.type=FFM switches only the accessor.

With FFM, --add-opens is no longer needed, and a dedicated test run checks that. MemorySegment.reinterpret is a restricted method, so pass --enable-native-access=org.apache.arrow.memory.ffm (or ALL-UNNAMED on the classpath) to avoid a JVM warning.

I assume this request was created with the help of AI agent (Claude).

Closes #163.

fb64 added 2 commits October 5, 2026 19:02
- Move the sun.misc.Unsafe code from MemoryUtil into UnsafeMemoryAccessor
- MemoryUtil delegates every low-level operation to a MemoryUtilAccessor
- No behavior change: UnsafeMemoryAccessor is the only accessor
- New opt-in module (JDK 22+, java.lang.foreign per JEP 454), only part
  of the Maven reactor when building with a JDK 22+ launcher
- FfmMemoryAccessor implements MemoryUtilAccessor with MemorySegment and
  Arena instead of sun.misc.Unsafe and reflection
- FfmAllocationManager allocates one Arena per buffer, mirroring
  UnsafeAllocationManager, with a DefaultAllocationManagerFactory for
  CheckAllocator's classpath scan
- arrow.memory.accessor.type=FFM selects the FFM accessor and fails with
  an actionable message if arrow-memory-ffm is missing; unknown values
  warn and fall back to Unsafe
- arrow.allocation.manager.type=FFM selects FfmAllocationManager and, when
  arrow.memory.accessor.type is unset, the FFM accessor too, falling back
  to Unsafe with a warning if the module is missing
- Isolated Surefire executions cover each property combination, with and
  without add-opens
- Add the module to the BOM and to the install and overview docs
@fb64 fb64 changed the title Memory FFM Module GH-163: Add arrow-memory-ffm module based on the FFM API Oct 5, 2026
MEMORY_ACCESSOR_TYPE_PROPERTY_NAME);
return accessor;
} catch (RuntimeException e) {
// Unlike an explicit arrow.memory.accessor.type=FFM request, this preference is only

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The fallback to Unsafe only catches RuntimeException, and loadFfmAccessor() only converts ReflectiveOperationException . Suppose -Darrow.allocation.manager.type=FFM is set on JDK 21 or earlier with the arrow-memory-ffm jar on the classpath. Loading FfmMemoryAccessor would throw UnsupportedClassVersionError, since the module is compiled for release 22. An ExceptionInInitializerError from its static init would do the same. Both are Errors, so they skip this fallback and MemoryUtil.<clinit> fails. The class then stays unusable for the life of the JVM, which is the situation the comment in this catch block says the fallback avoids.

Could we catch RuntimeException | LinkageError here? A test that forces the FFM path on a pre-22 JDK would also help.

Comment thread bom/pom.xml
</dependency>
<dependency>
<groupId>org.apache.arrow</groupId>
<artifactId>arrow-memory-ffm</artifactId>

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The BOM lists arrow-memory-ffm unconditionally, but memory/pom.xml only builds the memory-ffm module under the arrow-memory-ffm profile, which is active on [22,). If a release or deploy is built on JDK 17 or 21, the published BOM will reference an artifact that was never built. Anyone who imports the BOM and adds arrow-memory-ffm would then get an unresolvable dependency.

It's not a big deal, but as arrow-java release is not cut with JDK 22+ (for now), I wanted to mention that.

@fb64 fb64 Oct 6, 2026 •

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Good point, indeed. So what are the options:

  • Build the release job on JDK 25 and target Java 17 for other modules maven.compiler.release=17
  • Remove the arrow-memory-ffm from the BOM until releases are built on JDK 22+.

Let me know which one fit the best for now ...

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Maybe the later is easier and makes more sense for now.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

OK, but that also means the new module won't be built or published to Maven Central either, since the release jars are built on JDK 17 (the Binaries job in rc.yml). Is there any plan to move it on JDK 25 (with the compiler compatibility) ?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I can also configure java 25 in this PR to https://github.com/apache/arrow-java/blob/main/.github/workflows/rc.yml#L398

      - name: Set up Java
        uses: actions/setup-java@v6
        with:
          java-version: '25'
          distribution: 'temurin'


FfmAllocationManager(BufferAllocator accountingAllocator, long requestedSize) {
super(accountingAllocator);
this.arena = Arena.ofShared();

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Nit: each allocation creates its own Arena.ofShared() here, and release0() calls arena.close(). Closing a shared arena requires a handshake will all threads. Workloads that allocate and free many small buffers, such as vector resizing or per-batch Flight allocations, will likely be much slower than with the Unsafe and Netty managers. This doesn't need to block the PR. It might be worth documenting as a known limitation, or following up with a different arena strategy. Do we have any benchmark numbers?

@fb64 fb64 Oct 6, 2026 •

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks, nice catch @jbonofre, you were right! Benchmark and results: https://gist.github.com/fb64/334d2eeb14e1e4c5adbff6e38e9f3b7e

Using one shared Arena per buffer makes allocation about 100x slower than Unsafe, and it gets worse with more JVM threads (4 KiB: Unsafe 89 ns vs FFM 8.8 µs, and 58.8 µs with 256 idle threads).

I also prototyped a version that allocates with malloc/free downcalls and keeps the same FFM accessor: it matches Unsafe on every workload, including vector growth and batch building. So memory access isn't the issue, only the per-buffer arena.

This doesn't weaken safety: the FFM accessor already accesses memory through new global segments built from the buffer's raw address, not through the arena's segment, so closing the arena never protected against use-after-free. Arrow's protection comes from ArrowBuf's reference count checks, which don't change. Unlike Arena.allocate, malloc also doesn't zero the memory, which matches the Unsafe and Netty managers.

I propose to update this PR with a malloc/free implementation based on the foreign linker instead of one shared Arena per buffer. Does that sound good?

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@fb64 yes, agree for a malloc/free impl in this PR. Happy to help and review 😄

@fb64 fb64 Oct 6, 2026 •

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

✅ I also updated the benchmark results in the gist after multiple run on my laptop (Mac book pro M1)

fb64 added 3 commits October 6, 2026 10:08
[fix] fall back to unsafe on ffm linkage errors

- An inferred FFM accessor (arrow.allocation.manager.type=FFM) now also
  falls back to Unsafe on LinkageError: UnsupportedClassVersionError on
  JDK 21 or earlier, or a failing FfmMemoryAccessor static initializer
- resolveAccessor takes its inputs as parameters, so tests can simulate
  these errors without a pre-22 JDK
- Addresses review comment r4192588927
[fix] init ffm allocation factory before empty buffer

- Declare FfmAllocationManager.FACTORY before EMPTY: creating EMPTY can
  initialize BaseAllocator, whose default config reads FACTORY back while
  the class is still initializing, and got null
- With only arrow-memory-ffm on the classpath, accessing FACTORY first
  failed with an NPE in BaseAllocator's static initializer
- Add an isolated Surefire execution, since the test needs a fresh JVM
[fix] allocate ffm buffers with malloc and free

- Replace one shared Arena per buffer with malloc/free downcalls through
  the foreign linker, in FfmAllocationManager and FfmMemoryAccessor
- Closing a shared Arena handshakes with every JVM thread: allocating and
  releasing 4 KiB drops from 8.8 us to 97 ns (Unsafe: 90 ns), and from
  58.8 us to 99 ns with 256 idle threads
- Memory is no longer zeroed on allocation, like the Unsafe and Netty
  managers
- FfmMemoryAccessor.freeMemory now frees any malloc address, which removes
  the address-to-arena map and its silent no-op for foreign addresses
- A failed malloc throws OutOfMemoryError, as Arena.allocate and Unsafe do
- Addresses review comment r4192653585

private FfmMemoryAccessor() {}

private static MemorySegment segment(long address, long byteSize) {

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Every scalar get/put goes through this helper, which creates a new MemorySegment via reinterpret. getByteBufferAddress also builds a duplicate buffer and a segment per call. These are hot paths for vectors.

Just curious if you have JMH numbers agains the Unsafe accessor? I would like to know whether escape analysis removes the allocations in practice.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Good question! I added accessor benchmarks to the gist, run with -prof gc: https://gist.github.com/fb64/334d2eeb14e1e4c5adbff6e38e9f3b7e (section "Accessor reads and writes")

Escape analysis does remove them: with default settings the FFM accessor allocates 0 B per value and matches Unsafe on reads and writes.
As a control, with -XX:-DoEscapeAnalysis each access allocates 80 B (the two segments) and FFM gets 4 to 5x slower, so the benchmark does catch them when they exist.

For getByteBufferAddress, escape analysis removes the allocations too, but copying into a direct ByteBuffer is still about 1.4 ns slower than Unsafe (5.6 ns vs 4.2 ns for 64 bytes). That cost is paid once per copy, whatever its size, not once per value.

Instead of relying on C2 optimization, two improvements are possible:

  • Reads and writes: we can use a single global segment as a view over the whole native address space, created once, and access native addresses directly through it. I tried it CF this gist section: with -XX:-DoEscapeAnalysis, reads and writes no longer allocate and match Unsafe and nothing changes with default settings. I can push it as a fixup.
  • getByteBufferAddress: we can drop the duplicate() with MemorySegment.ofBuffer(buf).address() - buf.position(), which returns the same address and cuts the lookup from 2.1 ns to 1.3 ns in a quick test. The segment created by ofBuffer can't be avoided with the public API, so this path would still rely on escape analysis. Can also push it as a fix-up

I think it'd be a good idea. Let me know what you think.

Comment on lines +125 to +145
@Override
public void copyToMemory(byte[] src, long srcIndex, long destAddress, long bytes) {
MemorySegment.copy(
src,
checkedInt(srcIndex),
segment(destAddress, bytes),
ValueLayout.JAVA_BYTE,
0,
checkedInt(bytes));
}

@Override
public void copyFromMemory(long srcAddress, byte[] dest, long destIndex, long bytes) {
MemorySegment.copy(
segment(srcAddress, bytes),
ValueLayout.JAVA_BYTE,
0,
dest,
checkedInt(destIndex),
checkedInt(bytes));
}

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

checkedInt throws IllegalArgumentException when bytes or the index exceeds Integer.MAX_VALUE, while the Unsafe accessor takes long. Since the other side is a byte[], this probably can't happen in practice.

Could you add a short comment saying so, so the difference is deliberate?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yes, this is deliberate, due to MemorySegment.copy taking an int index and length on the array side, since Java arrays are int-indexed, so a valid call always fits. Every call site in Arrow (ArrowBuf, the JDBC ClobConsumer, the decimal vectors) passes ints or checks the array bounds first, so checkedInt can only fail on a caller bug, where the Unsafe accessor would copy outside the array without any check...
I'll add a comment on checkedInt

* @throws OutOfMemoryError if {@code malloc} cannot allocate them, like {@code
* sun.misc.Unsafe#allocateMemory}
*/
static long allocate(long bytes) {

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

A negative bytes is passed to malloc as a huge size_t, so it returns NULL and we throw OutOfMemoryError. Unsafe.allocateMemory throws IllegalArgumentException here.

Could we add an explicit bytes < 0 check so callers see the same exception type?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Good catch, done: NativeMemory.allocate now throws IllegalArgumentException for a negative size, like Unsafe.allocateMemory, with a test. Before, the OutOfMemoryError was even treated as fatal by JUnit and killed the test JVM 😅

@jbonofre jbonofre added the enhancement PRs that add or improve features. label Oct 7, 2026
@apache apache deleted a comment from github-actions Bot Oct 7, 2026
@github-actions github-actions Bot added this to the 20.0.0 milestone Oct 7, 2026
fb64 added 2 commits October 7, 2026 16:57
[docs] explain int checks in byte array copies

- Note why checkedInt narrows byte[] indexes and lengths to int:
  MemorySegment.copy takes ints there, so a valid call always fits
- Addresses review comment r4207204760
[fix] reject negative ffm allocation sizes

- NativeMemory.allocate throws IllegalArgumentException for a negative
  size, like Unsafe.allocateMemory, instead of passing it to malloc as a
  huge size_t and throwing OutOfMemoryError
- Addresses review comment r4207224636
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

enhancement PRs that add or improve features.

Projects

None yet

Development

Successfully merging this pull request may close these issues.

[Java] Implement arrow-memory-ffm

2 participants