Compare Java and Clojure performance with disciplined JVM measurements: latency, throughput, allocation, warmup, garbage collection, and behavior-equivalence evidence before drawing migration conclusions.
Performance comparison during a Java-to-Clojure migration should start with evidence, not language loyalty. Clojure runs on the JVM, so the same runtime realities matter: warmup, garbage collection, allocation rate, thread contention, I/O wait, and production-shaped inputs.
The useful question is not “Is Clojure faster than Java?” It is “Did this migrated slice preserve behavior, meet its latency and throughput budget, and make the performance trade-offs visible?”
| Metric | What it tells you |
|---|---|
| Latency | How long one request, job, or operation takes. Track p50, p95, and p99, not only averages. |
| Throughput | How many completed operations the system handles per second or minute under load. |
| Allocation rate | How much memory the path allocates while doing work. This often explains garbage collection pressure. |
| Garbage collection | Whether pauses, promotion, or heap growth changed after migration. |
| CPU profile | Where time is spent when the path is CPU-bound rather than waiting on I/O. |
| Error rate | Whether adapter conversion, nil handling, or numeric behavior introduced failures. |
For Java engineers, the key shift is that Clojure performance is still JVM performance. You still need representative inputs, warmed-up code, controlled runs, and production observability.
Before optimizing Clojure, preserve the Java behavior and performance baseline.
| Baseline item | Why it matters |
|---|---|
| Representative fixtures | Small examples can hide allocation and branch behavior. |
| Production input size | Sequence pipelines that are fine for 20 records may matter for 2 million records. |
| JVM version and flags | Java and Clojure results are meaningless if they run under different JVM settings. |
| Warmup policy | JVM JIT compilation changes behavior between cold and steady-state runs. |
| External effects | Database, network, and queue timing can hide actual code-path cost. |
If the path calls a database, message broker, or HTTP service, separate application-code measurements from external wait time. Otherwise the benchmark mostly measures the dependency.
Suppose a Java service computes an order summary from in-memory order values.
1public Summary summarize(List<Order> orders) {
2 BigDecimal total = BigDecimal.ZERO;
3 int rejected = 0;
4
5 for (Order order : orders) {
6 if (order.isRejected()) {
7 rejected++;
8 } else {
9 total = total.add(order.getAmount());
10 }
11 }
12
13 return new Summary(total, rejected);
14}
A direct Clojure migration should be measured as behavior, not just syntax.
1(defn summarize [orders]
2 (reduce (fn [summary order]
3 (if (:order/rejected? order)
4 (update summary :summary/rejected inc)
5 (update summary :summary/total + (:order/amount order))))
6 {:summary/total 0M
7 :summary/rejected 0}
8 orders))
This version is readable and fixture-friendly. It may allocate more than a hand-written Java loop. That is not automatically a problem. It becomes a problem only when measurement shows the migrated path misses its budget.
| Result | Likely interpretation |
|---|---|
| Same latency, lower code complexity | Keep the Clojure version and document the boundary. |
| Slightly slower, no user impact | Prefer reviewability unless the path is strategically hot. |
| Slower with high allocation | Inspect sequence realization, intermediate maps, and persistent updates. |
| Faster under contention | Immutability or simpler state coordination may be helping. |
| Faster locally, slower in production | Re-check input size, warmup, GC, I/O, and adapter conversion. |
Do not celebrate microbenchmarks that do not match the migrated production path. Do not reject Clojure because one naive rewrite allocated more than expected.
Use this checklist before claiming a migration improved or hurt performance:
Performance work is engineering work. The comparison should produce a decision the team can defend in code review and operations review.
| Mistake | Better move |
|---|---|
| Timing one REPL expression once | Use repeatable runs and production-shaped inputs. |
| Comparing Java arrays to Clojure maps without acknowledging representation changes | Measure the actual migration contract and note conversion cost. |
| Ignoring warmup | Separate cold-start behavior from steady-state behavior. |
| Treating averages as enough | Track percentiles, especially p95 and p99. |
| Optimizing before profiling | Find the bottleneck before adding type hints, transients, or primitive arrays. |
The REPL is useful for exploration, but it is not a substitute for a performance plan.