Skip to content

Performance regression for ByteArrayMemory on Java 25 #224

Description

@andreas-karlsson

If running the multithreaded BenchmarkSievePrimes bench on JDK 25, one can witness a radical performance breakdown. On my local machine it runs 220 times slower than on JDK 21. The reason is primarily that it calls isAccessModeSupported on every atomic read/write. On JDK 25 this throws a NoSuchMethodError internally before returning false. AFAIK there is no supported way of doing atomics on byte[] in JDK 25 and in this scenario ByteArrayMemory should fallback to the slow lock path just like ByteBufferMemory.

After fixing this, there still won't be a performant memory on JDK 25. I propose the Redline NativeMemory (or a hardened version thereof) is promoted to a neutral supported module. It still needs its own module if runtime is to remain on JDK 11. My own experimentation has shown that the native mapped memory can also give a good performance boost when using the bytecode compilers.

Activity

  1. andreaTP commented on Oct 6, 2026

    @andreaTP
    Contributor

    AFAIK there is no supported way of doing atomics on byte[] in JDK 25

    Maybe someone among: @forax @headius @dmlloyd have an hint here 🙏

  2. headius commented on Oct 6, 2026

    @headius
    Contributor

    Yeah that doesn't seem right. The access mode shouldn't change across accesses and should be established on the first call at most.

  3. andreaTP commented on Oct 9, 2026

    @andreaTP
    Contributor

    @andreas-karlsson I have let an agent do the heavy comparisons and benchmarks:

    Follow-up on the JDK 23+ atomics regression (#224): I prototyped the two backing stores the JDK
    recommends instead of byte[] views and measured them against the current memories.

    What was tested. Two full Memory implementations: direct ByteBuffer pages with
    byteBufferViewVarHandle atomics (Java 9+), and heap long[] pages through MemorySegment
    (Java 22+). Both keep volatile/CAS lock-free on JDK 21 and 25, verified. Compared against
    ByteArrayMemory and ByteBufferMemory on the threaded sieve and on five real libraries
    (quickjs4j, sqlite4j, jq4j, pglite4j, prism), JMH, Temurin 21.0.11 and 25.0.3. All workloads
    returned identical results across memory types.

    Sieve, 4 threads, compiled, ops/s (higher is better):

    Memory JDK 21 JDK 25
    ByteArrayMemory (main) 47.6 14.4
    ByteBufferMemory 13.8 14.7
    direct ByteBuffer 41.5 44.8
    long[] MemorySegment n/a 40.3

    Real workloads, JDK 25, time per op (lower is better); last column is how much slower (+) or faster (-) direct is than byte[]:

    Workload ByteArrayMemory ByteBufferMemory direct ByteBuffer direct vs byte[]
    quickjs4j, 1 ms script 165 ms 186 ms 187 ms +13%
    quickjs4j, 1+2 32.6 us 41.1 us 40.0 us +22%
    sqlite4j, insert 1000 rows 12.7 ms 14.4 ms 14.2 ms +12%
    sqlite4j, select 10k rows 61.6 ms 63.8 ms 65.5 ms +6%
    sqlite4j, open connection 6.5 ms 6.7 ms 6.6 ms +2%
    prism, parse 130 KB 115 ms 117 ms 119 ms +4%
    pglite4j, SELECT 1 1.23 ms 1.35 ms 1.26 ms +2%
    pglite4j, create instance (167 MB) 71 ms 70 ms 69 ms -3%
    jq4j, filter 100 KB JSON (shared memory) 477 ms 206 ms 191 ms -60%

    JDK 21 shows the same shape with more noise: direct is 3 to 35% slower than byte[] on plain
    workloads and equal on the sieve.

    Takeaways.

    • byte[] stays fastest for plain access, by 2 to 15% on JDK 25 across every library. That is the
      common case.
    • Direct buffers win only where atomics are involved: 3x on the sieve on JDK 25, and 2.3x on jq4j
      even single-threaded (its pthread build takes a lock on every allocation).
    • Off-heap has a cost beyond speed. Opening one 32 MB sqlite connection per op on JDK 21 produced
      iterations of 1.6 s and 4.3 s instead of 10 ms: direct pages are reclaimed only by GC, so
      Bits.reserveMemory hit the direct-memory limit and ran System.gc() with sleep backoff. Pages
      also count against MaxDirectMemorySize and grow RSS past -Xmx, and there is no deterministic
      free on Java 11.
    • Separately: on JDK 25 any atomic op on a non-shared ByteArrayMemory traps as out-of-bounds
      (monitor(addr) dereferences the null waitStates map and the NPE is rethrown as a bounds trap).
      Still present on main; ByteBufferMemory.lock already handles the non-shared case.

    Caveat: the benchmark machine was thermally throttled (1.7 to 2.6 GHz), so compare within a JDK
    column, not across JDKs.

  4. headius commented on Oct 9, 2026

    @headius
    Contributor

    Interesting results! I would guess that the atomic operations are cheaper against direct memory because the JVM does not need to be at a safe point. Heap data structures need to be pinned in memory for the duration of the atomic swap, while direct memory is already pinned by definition.

    I'm not sure I understand your results, though. Is one of these a VarHandle + byte[] implementation? If the VarHandle is slower than any other on-heap variant, that should probably be reported to folks on the compiler team. Would be interesting to look at the assembly it's emitting.

  5. headius commented on Oct 9, 2026

    @headius
    Contributor

    Never mind, now I see ByteArrayMemory is the VarHandle version. And if I'm reading right it is the fastest overall, ignoring small gains from atomic operations against DirectByteBuffer?

  6. andreas-karlsson commented on Oct 10, 2026

    @andreas-karlsson
    ContributorAuthor

    @andreaTP Thanks a lot for the thorough investigation, and for testing against real libraries.

    I'll open a PR for the non-shared locking bug (monitor() dereferencing the null waitStates). While looking at it I noticed a related issue in the same fallback: the lock is chosen by the exact start address of the access, so overlapping atomics of different sizes, e.g. an i32 at 0 and an i16 at 2, take different locks and aren't atomic relative to each other. Since wasm atomics are naturally aligned and at most 8 bytes, locking on the aligned 8-byte word should fix that. I will open a PR for that as well.

    Some context from our production use of Endive. We have a highly concurrent server workload, but it also requires sharing memory, so we can't pool single-threaded instances, and hence we use a lot of atomics.

    For that workload I built our own mmapped memory, similar to Redline's NativeMemory. Initial results look very promising, roughly 2.5–2.8× the throughput of ByteArrayMemory on JDK 21 (2.5× single-threaded, 2.8× at 10 threads, Apple M1 Max). I think the interesting part I can contribute to your numbers is how big a role function size plays for JVM inlining. A rough breakdown of the performance increase:

    • mmapped memory with FFM access on JDK 25: about the same as ByteArrayMemory on JDK 21
    • bypassing the FFM access path for plain loads/stores (one explicit bounds check, then raw Unsafe access): +45%. I'm not suggesting Unsafe for Endive, but it shows how much the FFM access path costs in large generated methods.
    • raising C2's inlining limits (mainly -XX:-ClipInlining and raised -XX:MaxTrivialSize): about +35–40%.
    • shrinking the memory accessors and atomics so they fit within those limits: another +35%

    Since QuickJS, SQLite and Prism have very large functions too, it would be interesting to see whether -XX:-ClipInlining -XX:MaxTrivialSize=40 changes your numbers, possibly for all memory types.

    To be clear, we're not blocked by any of this, since we can keep using our own memory implementation, but I'd be happy to help move things forward. A few thoughts:

    • Your results suggest picking the memory based on whether it's shared: byte[] is fastest for plain access, and atomics only matter for shared memories. So ByteArrayMemory for non-shared memories and a native memory for shared ones.
    • For the shared case I think a native mmapped memory is the best option, given the GC, MaxDirectMemorySize and RSS issues with direct buffers you describe. It also commits pages lazily and can be freed explicitly. With the hardening in Fix Redline native memory growth, atomics, wait/notify and traps #232, Redline's NativeMemory looks close to that already, so once it's production-ready I don't see a reason not to also use it with the bytecode compilers.
    • Getting the same speed without JVM flags would take careful function-size work in the compiler, so that memory helpers stay within C2's default inlining limits. That may never be worth the complexity, but if anyone goes in that direction, we'd first need a better benchmark suite to guide it (Improved performance benchmarking #225).
  7. headius commented on Oct 10, 2026

    @headius
    Contributor

    Since QuickJS, SQLite and Prism have very large functions too, it would be interesting to see whether -XX:-ClipInlining -XX:MaxTrivialSize=40 changes your numbers, possibly for all memory types.

    I am familiar with these flags and intrigued that you are seeing good performance gains using them. I'll try to test that against prism as well.

    We currently deploy prism using the native AOT compiled version from cranelift, and serialized access to the parser runtime (parallel loading is not very common in Ruby apps) but on platforms without AOT support we must fall back on the WASM runtime. Until there's an effective mechanism for splitting large functions, this remains slower than we would like.

  8. andreas-karlsson commented on Oct 10, 2026

    @andreas-karlsson
    ContributorAuthor

    @headius Nice. You might need to adjust the limit. I reached the best performance after also adjusting the the memory access functions so they resulted in smaller bytecode fitting below the 40 byte limit.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions