This document focuses on thread safety when using Java’s Foreign Function & Memory (FFM) API
(java.lang.foreign) to call native libraries.
FFM gives you memory safety primitives (bounded segments, explicit lifetimes), but it does not magically make your code thread-safe. You still need to define and enforce concurrency rules around:
- your Java wrapper’s state and lifecycle,
- FFM lifetimes / confinement rules, and
- the native library’s own thread-safety guarantees.
Any wrapper that owns a native resource has the same hard rule as sockets/files:
Never let an operation race with
close()on the same underlying native handle.
If close() frees a native context while another thread is in a downcall using it, you can get use-after-free
behavior in native code.
Key facts you should design around:
Arena.ofConfined()produces segments that are thread-confined (access from other threads is illegal).Arena.ofShared()produces segments that are shareable across threads, but you still need normal synchronization if multiple threads read/write the same memory.- Closing an arena invalidates segments allocated from it; FFM will throw if you use them after close.
Practical consequence:
Prefer per-operation confined arenas for scratch memory. Avoid storing confined segments in fields of objects that might be used from multiple threads.
Native libraries vary widely:
- Some are thread-safe only if you use one context per thread/connection.
- Some require external locking around a context.
- Some have global mutable state and are not thread-safe at all.
Your wrapper must reflect that reality—either by:
- enforcing single in-flight usage (locking), or
- providing per-thread/per-session contexts, or
- documenting “not thread-safe”.
Inside each method:
- create
try (Arena a = Arena.ofConfined()) { ... } - allocate temporary buffers/outputs
- call native
- copy results into Java arrays/DTOs/
String - return Java values (no segment leakage)
This avoids:
- memory leaks (arena closes deterministically)
- use-after-close (segments die with the scope)
- cross-thread segment access (scratch segments never escape)
You’ll see this pattern throughout this repo.
When your native resource is stateful (socket/session/opaque handle), define a rule like:
One native context supports at most one in-flight operation at a time.
Then enforce it with a lock so that operations and close() are mutually exclusive.
If you need parallelism:
- don’t try to make one native context concurrent
- create N independent sessions/contexts and distribute work across them (pool)
This is often the cleanest way to stay correct with libraries that are not re-entrant per context.
Native calls can block and may pin a virtual thread to its carrier while inside the native frame. This is not a correctness problem, but it can be a scalability concern.
A pragmatic strategy is to offer two modes:
- “simple” mode (virtual threads) when concurrency is low/moderate
- “isolated” mode (platform thread executor per session) to confine any pinning impact
See docs/concurrency/jdk25-concurrency.md for broader concurrency/performance notes.
SvcNativeWrapper allocates output buffers with Arena.ofConfined() inside each method:
simple-ffm-demo/demo-app/src/main/java/es/omarall/ffm/wrapper/SvcNativeWrapper.java
This is the recommended baseline: scratch segments never escape the method, so you don’t accidentally share them across threads.
The demo C library is intentionally simple and stateless:
simple-ffm-demo/native-lib/src/main/native/svc.c
However, the context (svc_ctx*) is still freed in svc_destroy. If one thread calls close() while another is
mid-operation, you risk native use-after-free.
In this repo, SvcNativeWrapper prevents that by serializing operations and close() with a lock. The result is:
- safe concurrent callers (no
close()race with an operation) - no parallelism on the same native context (everything is executed one-at-a-time)
If you need throughput, scale out by creating multiple wrapper instances (or use a pooled/session approach like
mymodbus).
System.load(...) is a one-time VM-level action. The demo wrapper guards it with a static flag.
In production systems, prefer loading during application bootstrap (composition root) to avoid any lazy-load races.
ModbusNative is the native-backend abstraction:
mymodbus/src/main/java/es/omarall/mymodbus/nativeport/ModbusNative.java
This enables:
- a real adapter (FFM+jextract) for production/integration testing
- a fake adapter for unit tests/benchmarks
ModbusSession enforces one in-flight request per connection and serializes operations with a fair lock:
mymodbus/src/main/java/es/omarall/mymodbus/session/ModbusSession.java
Crucially, close() also takes the same lock. That means:
- no operation can run concurrently with
nativeApi.close() - the native context cannot be freed while in use
This is the core “thread safety boundary” in the module.
PooledModbusClient provides fan-out reads by distributing partitions across multiple ModbusSession instances:
mymodbus/src/main/java/es/omarall/mymodbus/session/PooledModbusClient.java
This is a canonical approach for native dependencies:
- each session has its own connection and native context
- each session is internally serialized
- parallelism comes from multiple independent contexts
The jextract-based adapter allocates native buffers per call (confined), then copies results into Java arrays:
mymodbus/src/main/java-jextract/es/omarall/mymodbus/nativeport/LibModbusJextract.java
This aligns with the “per-operation scratch” pattern and avoids leaking segments to callers.
Use this as a quick pre-merge checklist:
- Public APIs expose only Java types (no
MemorySegment/Arenain signatures). - Each operation uses confined scratch allocations and copies results out.
- Any stateful native context has an explicit concurrency contract:
- not thread-safe (documented), or
- single in-flight (lock), or
- per-thread/per-session contexts.
close()cannot race with operations on the same context (shared lock or equivalent).- Error mapping captures native error state immediately (and on the same thread as the failing call).
- Concurrency scaling uses pooling instead of sharing one native context.