
When Jank's Native C++ Interop Beats JVM Clojure and When It Doesn't
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_viewoutliving 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::atomicdefaults 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:
| Measurement | What it answers | How to get it |
|---|---|---|
| Build time | Does a one-line change cost 3 s or 90 s? | Clean rebuild, wall clock |
| Cold start | Time to first correct output, no warm-up | hyperfine --warmup 0 --runs 20 |
| Warm-up curve | When does throughput stop improving? | Same workload, N iterations, plot per-iteration timings |
| Steady state | The number people actually quote | hyperfine --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/EnsureLocalCapacityor 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
-Xmxtuning 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
| Question | If 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
- jank project site — design goals and interop documentation
- jank source repository — build steps and current release state
- Clojure: Java Interop reference — official semantics, including reflection and type hints
- JNI specification (JDK 21) — local-reference capacity, critical array access rules, exception constraints
- JEP 454: Foreign Function & Memory API — finalized in JDK 22
- GraalVM Native Image — the JVM's own answer to startup cost
- hyperfine — the benchmark harness used in the commands above


