Davide Angelocola

Announcing zstd-ffm

26 July 2026

I needed Zstandard compression for vortex-java, a pure-Java implementation of the Vortex columnar format I’ve been building. The obvious choice was zstd-jni — mature, tracks upstream zstd closely, ships to Maven Central and Android, and runs in production everywhere. The main problem is that it can’t work with MemorySegment, and that’s the cornerstone of vortex-java: mmap a big file and decode what is needed while minimizing copies. And also a bit of curiosity about what a Zstd binding would look like starting from JDK 25 instead of JDK 8. The result is zstd-ffm.


Why not just use zstd-jni

There’s nothing wrong with zstd-jni — it’s “the excellent zstd-jni,” to quote zstd-ffm’s own README1. It’s production-ready, tracks the zstd release branch, and compiles down to Java 8 bytecode2. It runs anywhere, from an old Android app to a JDK 25 server.

zstd-ffm makes the opposite trade. It doesn’t run on anything older than JDK 25 — no fallback path, no JNI escape hatch, java.lang.foreign isn’t there to call. Targeting Java 8, 11, 17, or 21? zstd-jni is still the only option, full stop. In exchange for that reach, zstd-ffm gets three things a JNI binding built for Java 8 compatibility structurally cannot have.

Point one: bindings are written in Java

The Foreign Function & Memory API went final in JDK 22, stable in the first LTS to carry it, JDK 25. It replaces the JNI shim with typed Java: a MethodHandle bound straight to the C symbol. No .c glue file, no generated header. It is really easy to bind C functions and C structs: it can be done mechanically with jextract and AI, in some cases even manually.

Depending on the library and requirements, it is possible to expose a faithful Java API, 1:1 with the C API, or a more sophisticated Java layer on top of it. In any case, the minimal code to write is something like:

// size_t ZSTD_compress(void* dst, size_t dstCap, const void* src, size_t srcSize, int level)
static final MethodHandle COMPRESS =
        NativeLibrary.lookup("ZSTD_compress",
                FunctionDescriptor.of(JAVA_LONG, ADDRESS, JAVA_LONG, ADDRESS, JAVA_LONG, JAVA_INT));

That’s the entire binding for one function: the comment is the C prototype, the FunctionDescriptor is the same prototype in Java. NativeLibrary.lookup itself is just as short:

private static final Linker LINKER = Linker.nativeLinker();
private static final SymbolLookup LIB = SymbolLookup.libraryLookup("zstd.so", Arena.ofAuto());

static MethodHandle lookup(String name, FunctionDescriptor fd) {
    return LINKER.downcallHandle(
            LIB.find(name).orElseThrow(() -> new UnsatisfiedLinkError("Symbol not found: " + name)),
            fd);
}

No JNI means no per-platform shared object, no hand-written C++ glue — just the native libzstd itself, cross-compiled with Zig for six platforms, including a Windows .dll built on a Linux runner3.

The bigger difference isn’t fewer moving parts — it’s what a mistake costs. A MemorySegment carries its own size and thread confinement, so an out-of-range access throws instead of corrupting the heap. An Arena owns the lifetime of everything allocated in it, so touching a segment after its arena closes throws too, not a segfault three frames away. JNI’s raw pointer has none of that: a wrong offset or a stale handle just corrupts memory or crashes the JVM, with nothing in the type system to catch it first. That’s also why a downcall is a restricted, permissioned operation at all.

Benchmarked against zstd-jni’s own zero-copy ByteBuffer path — both sides linking the identical zstd 1.5.7 — the honest read is parity on the size that matters most: decompressing 200 KiB is a +0.9% tie once codec throughput dominates call overhead. The edge that does show up is neither large nor uniform: +9.8% compressing 1.2 KiB, +22.9% decompressing 1.2 KiB, +9.4% compressing 200 KiB. Compression doesn’t converge the way decompression does, at least at these two sizes. Sizes below roughly 2 KiB, where per-call overhead dominates, are the unusual case for a compression library, not the typical one4.

Point two: module support

Running any of the code above from the classpath on JDK 25 requires the flag:

java --enable-native-access=ALL-UNNAMED Main

The native-access flag from point one has a blast-radius problem: ALL-UNNAMED grants native access to everything on the classpath, not just zstd-ffm. Every dependency gets the same grant, whether it touches native memory or not. The module path fixes that — the grant can name a single module instead of blanket-approving the whole classpath. zstd-ffm ships a real module-info.java declaring exactly that module:

module io.github.dfa1.zstd {
    exports io.github.dfa1.zstd;
}

Put your app on the module path alongside it, and the flag names only the module doing the native call:

java --module-path app.jar:zstd-platform.jar \
     --enable-native-access=io.github.dfa1.zstd \
     -m myapp/com.example.Main

The payoff shows up at review time, not runtime: a security reviewer can grep a deployment manifest for one module name instead of auditing every jar on the classpath. One package exported; the MethodHandles that touch native memory never leave the module5. zstd-jni has no equivalent. It ships as a plain jar with no module-info.java, so on the module path it resolves as an automatic module — no export control at all. That’s not a knock on zstd-jni: JNI never had a restricted operation for a module system to gate. The distinction only exists because FFM created something worth scoping.

Point three: domain primitives, with value types on the horizon

The public API takes no naked int or long for a size, a compression level, or a window log — each is a validated record:

public record ZstdByteSize(long value) {
    public ZstdByteSize {
        if (value < 0) {
            throw new IllegalArgumentException("size " + value + " must not be negative");
        }
    }
    // ofKiB, ofMiB, fromFrameContentSize, fromUnsignedFrameHeaderField ...
}

The other two follow the same shape, but validate against bounds queried from the linked libzstd itself, not a fixed constant — a level valid for one build isn’t hardcoded as valid for every build:

public record ZstdCompressionLevel(int value) {
    public ZstdCompressionLevel {
        if (value < MIN_ACCEPTED || value > MAX_ACCEPTED) {
            throw new IllegalArgumentException("level " + value + " outside [" + MIN_ACCEPTED + ", " + MAX_ACCEPTED + "]");
        }
    }
    // DEFAULT, FASTEST, MAX ...
}
public record ZstdWindowLog(int value) {
    public ZstdWindowLog {
        if (value != 0 && (value < MIN_ACCEPTED || value > MAX_ACCEPTED)) {
            throw new IllegalArgumentException(
                    "windowLog " + value + " must be 0 or in [" + MIN_ACCEPTED + ", " + MAX_ACCEPTED + "]");
        }
    }
    // AUTO ...
}

zstd-ffm’s v0.12 changelog frames the domain-primitive sweep — ZstdByteSize, ZstdCompressionLevel, ZstdWindowLog, ZstdMagicVariant, ZstdVersion — as a direct application of the case made in Your Compiler Is Already Part of Your Security Team6.

These are ordinary records today — identity classes, one heap allocation apiece. Cheap at an API boundary, not free in a per-chunk hot loop. That’s the trade-off Rethink Domain Primitives with Valhalla measured directly: a wrapper record costs roughly 4× the bare primitive it replaces. Project Valhalla’s value class7 removes that cost, flattening the same fields into the array slot or register instead of the heap — still a JDK 27 preview, not something Maven Central can depend on yet. The migration from here is mechanical: the types are already final, immutable, and validate once at construction. Nothing about their design has to change to stop paying for identity once the JVM stops charging for it.

What’s next

zstd-ffm is at v0.12, pre-1.0. The introduction of domain primitives in this release was itself preparation for 1.0: replacing naked int/long at the API boundary means breaking changes, cheaper to make now than after a 1.0 tag asks for stability. vortex-java 8 is the first real consumer.

zstd-ffm is on Maven Central, BSD 3-Clause licensed, JDK 25+ only. It’s for early adopters, not a drop-in swap for a zstd-jni integration that needs to keep running on older JVMs. If that’s you, the repository has a quickstart, dictionary compression, and a zero-copy MemorySegment API to start from. Issues and pull requests are welcome.


  1. zstd-ffm README — “an FFM-based alternative to the excellent zstd-jni for early adopters on JDK 25+.” 

  2. zstd-jni build.sbt targets --release 8, and the project publishes an Android .aar alongside the JVM jar — reach that a JNI binding can offer and an FFM one, tied to JDK 25+, currently cannot. 

  3. ADR 0002 — Zig as the native C compiler, zstd-ffm. 

  4. Golden-corpus JMH run: 3 forks × 3 warmup × 5 measurement iterations with -prof gc, error bars at 99.9% confidence intervals — publication-grade for the cut quoted here, per zstd-ffm’s own methodology notes. Measured on one machine only, an Apple M5 laptop (JDK 25, zstd-jni 1.5.7-11, both sides linking zstd 1.5.7); call-overhead deltas like these are sensitive to the CPU and memory subsystem, and there’s no server-class run yet to confirm the margins hold. Full tables in zstd-ffm’s docs/benchmarks.md

  5. ADR 0011 — JPMS module descriptor, zstd-ffm, accepted 2026-06-27. 

  6. CHANGELOG.md, zstd-ffm v0.12, 2026-07-26. 

  7. JEP 401: Value Classes and Objects — preview, current EA builds target JDK 27. 

  8. CHANGELOG.md, vortex-java v0.10.0, 2026-06-26: “The vortex.zstd encoding now compresses and decompresses through io.github.dfa1.zstd:zstd … instead of io.airlift:aircompressor-v3.”