When Jank's Native C++ Interop Beats JVM Clojure and When It Doesn't

When Jank's Native C++ Interop Beats JVM Clojure and When It Doesn't

pr0h0•
jankclojurecjitinterop
AI Usage (91%)

The Native Interop Claim Worth Testing

The pitch that keeps circulating around jank is "Clojure, but native, with real C++ interop." That sentence does two jobs at once, and one of them is a trap. This post works out when jank's native C++ interop actually beats JVM Clojure and when it doesn't, by separating the parts of the "native is faster" story that hold up from the parts that fall apart once you write down what you're measuring.

The interesting claim isn't that jank is faster than Clojure. It's narrower and much easier to defend: crossing into C++ costs something qualitatively different when the boundary gets compiled away instead of marshalled. That's a claim about a boundary, not a runtime, and you can test it.

My position up front: jank wins when the C++ boundary is wide and hot — many calls, large buffers, templates, RAII lifetimes. JVM Clojure wins almost everywhere else. And for the narrow-boundary case, the honest 2025 comparison isn't JNI anymore, it's the FFM API from JEP 454, which changes the calculus. Most "native is faster" write-ups skip that.

JVM Clojure vs. Jank: Two Runtimes, Two Trade-off Sets

What JVM Clojure Gives You for Free

Clojure on the JVM inherits a stack that took twenty years to build: a Maven-shaped dependency universe, deps.edn and the Clojure CLI, nREPL and a REPL workflow people plan their day around, and JFR plus async-profiler when something is slow. HotSpot's C2 compiler does profile-guided inlining and speculative optimization on code that has actually run — which is exactly the workload shape of a long-lived server.

You also get a garbage collector you can pick by name — G1, ZGC, Shenandoah — and a stable interop ABI. Java's interop surface is javac output: name-mangled but documented, reflection-visible, identical across vendors.

One correction to the usual narrative: most Clojure "Java interop" never touches JNI. (Math/sqrt 2.0) is an intrinsic. (.toUpperCase s) is a normal virtual call on java.lang.String. JNI only shows up when you call a native method or load your own library. Java interop is cheap precisely because it isn't FFI.

What Jank Swaps the JVM For: LLVM and the C++ ABI

jank is a Clojure dialect that compiles through LLVM and treats C++ as a first-class interop target rather than a foreign one. The project's stated goal is that you write Clojure and call C++ without hand-writing a binding layer for each type.

What you give up is the JVM's stability contract, and what you take on is the C++ ABI. That trade is real and it isn't free. Anything linking against C++ inherits libstdc++/libc++ version coupling, name mangling, and _GLIBCXX_USE_CXX11_ABI-style switches. You can see it in the artifact:

$ readelf -d ./bench-native | grep NEEDED
 0x0000000000000001 (NEEDED)  Shared library: [libstdc++.so.6]
 0x0000000000000001 (NEEDED)  Shared library: [libc.so.6]

Not a knock on jank specifically — every native language pays it — but it's the thing that bites in year two, when the host application upgrades its toolchain and your binary doesn't.

The C++ Interop Boundary in Practice

The Same C++ Call Under JNI and Under Jank

Say you have a C++ engine that evaluates a std::vector<double>. Under JNI, one call looks roughly like this:

// native-lib.c — C-visible symbol, one per signature you support
#include <jni.h>
#include "engine.h"

JNIEXPORT jlong JNICALL Java_bench_Engine_open(JNIEnv *env, jclass cls) {
  return (jlong)(intptr_t)engine_open();
}

JNIEXPORT jdouble JNICALL Java_bench_Engine_eval(JNIEnv *env, jclass cls,
                                                 jlong h, jdoubleArray xs) {
  jsize n = (*env)->GetArrayLength(env, xs);
  // no JVM calls allowed between Get and Release
  jdouble *buf = (*env)->GetPrimitiveArrayCritical(env, xs, NULL);
  double out = engine_eval((engine_t *)(intptr_t)h, buf, n);
  (*env)->ReleasePrimitiveArrayCritical(env, xs, buf, JNI_ABORT);
  return out;
}

Three things get paid for here, and only one of them is runtime cost. There's the symbol-per-signature tax (write it again for float, again for int64_t). There's the pinning constraint — the JNI spec is explicit that between GetPrimitiveArrayCritical and its release you may not call back into the VM or block. And there's the ownership question: the jlong handle is an untracked pointer, and nothing stops a caller from using it after engine_close.

Under jank, the same call is written without a shim, because the C++ type is the type. Exact spelling of the interop forms is version-dependent, so treat this as shape rather than something to paste — I haven't compiled it against a current release:

;; ILLUSTRATIVE — check the interop docs for your jank version
;; before trusting the spelling here.
(def v (cpp/std.vector<double> [1.0 2.0 3.0]))
(println (cpp/std.size v))

That's a genuinely smaller amount of code, and more to the point, it's code a compiler can inline and constant-fold. The real win isn't "native is faster" — it's that the boundary is visible to the optimizer.

What Still Has to Cross the Boundary in Any FFI Design

Every interop design answers the same four questions, and jank doesn't exempt you from them:

  • Representation. Clojure's persistent vector isn't std::vector. Somewhere a copy or a view has to be chosen, and someone has to own the lifetime.
  • Strings. java.lang.String, std::string, std::string_view — three different ownership stories. string_view outliving its buffer is the classic FFI bug.
  • Errors. JNI forbids a C++ exception from unwinding into the VM; you must translate. Under jank, a C++ exception can propagate natively — which means your Clojure code now has to reason about C++ exception types.
  • Concurrency. The JVM's memory model and C++'s memory model are not the same document. std::atomic defaults to sequentially consistent, which isn't what the JVM gives you.
⚠️

The most common way to lose a week in any FFI is a string_view or raw pointer that outlives the buffer it points into. Decide ownership at the boundary in writing before you write the binding.

JIT Startup, Warm-Up, and Benchmark Hygiene for Both Runtimes

Separating Build Time, Cold Start, Warm-Up, and Steady State

"Native" doesn't automatically mean fast startup. jank uses LLVM-based JIT compilation, so work happens before your main produces useful output — the same category of cost the JVM has, just a different profile. Clojure CLI startup, for comparison, is dominated by JVM boot plus loading clojure.core, which lands in the hundreds-of-milliseconds-to-about-a-second range, not milliseconds. Both numbers are order-of-magnitude useful and neither counts as a result until you measure it.

Four numbers, always separate:

MeasurementWhat it answersHow to get it
Build timeDoes a one-line change cost 3 s or 90 s?Clean rebuild, wall clock
Cold startTime to first correct output, no warm-uphyperfine --warmup 0 --runs 20
Warm-up curveWhen does throughput stop improving?Same workload, N iterations, plot per-iteration timings
Steady stateThe number people actually quotehyperfine --warmup 20 --runs 100
## same workload, both runtimes; keep the JVM flags identical between runs
hyperfine --warmup 20 --runs 100 \
  'java -cp target/bench.jar clojure.main -m bench.core' \
  './bench-native'
💪

Pin the machine, pin the JVM flags, and pin the jank/LLVM version in the same file you record the numbers in. A timing without those three is an anecdote.

I deliberately haven't put jank-vs-Clojure throughput numbers in this post, because I haven't run them under controlled conditions and a fabricated table is worse than no table. If you see a comparison that doesn't state build mode (JIT vs AOT), warm-up count, and hardware, it's measuring the author's enthusiasm.

Build Tooling Is the Real Tax When You Adopt Jank

This is where the two ecosystems diverge more than at runtime. Clojure's inner loop is deps.edn plus clojure -M, and the dependency cache is incremental by default.

jank sits on CMake and LLVM. That means LLVM development headers (hundreds of megabytes, not kilobytes), a specific LLVM major version, and rebuild times that scale with how much of the runtime you touched. If your CI builds jank from source, your CI now has an LLVM toolchain in it. If it pulls a prebuilt release, you've pinned LLVM anyway — you just didn't choose the pin.

I haven't verified the current build steps against the latest release, so check the repo rather than trusting a blog post (this one included) for the exact incantation.

The honest framing: you're not adopting a language, you're adopting a native toolchain. That's reasonable. It just isn't a drop-in replacement for clj -M.

Where Jank's Native C++ Interop Genuinely Wins

  • Embedding in a C++ host. If the host is a game engine, audio pipeline, or exchange gateway that can't ship a JVM, jank's model fits. You compile to a library and link it in. The JVM mostly answers this scenario with "run a sidecar process and talk over IPC," which is a different architecture.
  • Wide, hot boundaries. Ten million calls per second across a boundary is a design error under JNI once you account for local-reference management — the spec only guarantees a small base capacity per native frame, and you expand it with PushLocalFrame/EnsureLocalCapacity or you leak. It isn't a design error when the call inlines.
  • Templates and overloads. std::vector<T> can't cross JNI generically. You write one shim per instantiation or you give up. In jank, the type parameter is just a type.
  • Short-lived processes with real work. No JVM boot, no heap sizing, no -Xmx tuning for a 200 ms job.

Where Native Interop Doesn't Win, and What to Reach For Instead

  • Web services and CRUD. Clojure on the JVM, no hesitation. Library breadth and hiring pool aren't soft factors; they're the reason the project ships.
  • Teams that need a native call occasionally. Use the FFM API. JDK 22 finalized it; you get a memory-segment model with deterministic deallocation, no hand-written JNI shim, and no C++ toolchain. Catch is that it binds C ABI surfaces, so C++ templates still need an extern "C" wrapper — which is exactly the tax jank removes.
  • Startup-critical CLIs on the JVM. GraalVM native-image, with the usual caveats about reflection config and build time, beats switching runtimes entirely.
📝

"jank removes the wrapper layer" and "jank removes the need to think about ownership" are different claims. Only the first one is true.

Evaluation Checklist Before Committing to Jank

QuestionIf the answer is no
Is your C++ boundary wide and hot, not one call per request?JVM Clojure + FFM
Can you pin an LLVM version across dev and CI?Do not adopt yet
Do you have a concrete ownership story for string_view and raw pointers?Write it down first
Is your team ready for C++ ABI breakage at toolchain upgrades?Stay on the JVM
Have you measured cold start and steady state separately?You do not have data
Can you build the runtime from source in CI?Prebuilt only, accept the pin

Verdict: When to Choose Jank and When to Stay on the JVM

jank isn't "Clojure that's faster." It's a bet that a meaningful set of programs want Clojure's programming model and C++'s library surface in one address space, and that the glue between them is worth deleting. For that shape — embedded runtime, wide hot boundary, templates JNI can't express — the bet holds, and the ergonomic gap is wide enough that I'd pay the toolchain cost.

For everything else, the JVM's answer got better while nobody was looking. JEP 454 removed most of the reason to hand-write JNI, and the debugging and dependency story has no native equivalent. Reach for jank when the boundary is the problem. Don't reach for it because "native" sounds faster — that's the claim that doesn't survive contact with a benchmark harness.

Further Reading

Share this post

More posts

Comments