Case study: a 7.6 GB allocation hiding in one line
This is jvmlens doing the one thing it is for: turning a recording into the single line worth fixing, with the receipt to prove the fix worked — and the proof that it changed nothing else. It’s the human-perf-engineer companion to jvmlens + your AI agent.
The target is a real engine, not a benchmark toy: gotmpl4j,
a pure-Java implementation of Go’s text/template. The hot path is table rendering — the kind of
template every Helm chart and Hugo site leans on. This case study is one chapter of a longer
profile-driven tuning effort — the full sequence of jvmlens-found wins is on the
gotmpl4j performance page.
1. Profile — what jvmlens surfaced
A JMH run of the table-render benchmark, recorded with JFR and summarized inline by jvmlens’s JMH
profiler (no separate analyze step), with JMH’s GC profiler on for the exact bytes/op:
$ java -cp benchmarks.jar:jvmlens-jmh.jar org.openjdk.jmh.Main \
TableBenchmark.gotmpl4jRender -p n=1000 -f 4 -wi 3 -i 6 \
-prof gc \
-prof "org.alexmond.jvmlens.jmh.JvmlensProfiler:appPackage=org.alexmond.gotmpl4j"
jvmlens put one method at the top of both the CPU and the allocation tables:
| Method | CPU | Allocation |
|---|---|---|
|
43% |
71% (7.6 GB) |
And — this is the part that matters — it attributed the allocation down to the byte, then to the line:
-
byte[]4.6 GB ·String2.4 GB -
one source line:
mantissa.substring(0, dot) + mantissa.substring(dot + 1)
Three throwaway String objects allocated per call, just to reposition a decimal point. No
guessing, no reading the whole method — the recording named the line.
2. Fix — delete the intermediate allocations
The substrings and the concat were the whole cost. The rewrite repositions the decimal point without ever materialising the pieces:
-
parse the mantissa digits into one small
char[], -
trim trailing zeros by index (no copies),
-
emit into a single pre-sized
StringBuilder, -
read the exponent in place with
Integer.parseInt(s, from, to, radix)— no substring.
One small buffer instead of three short-lived String s per call.
3. Prove — JMH, before and after
| Metric | Before | After |
|---|---|---|
JMH |
1.04 MB/op |
0.77 MB/op (−26%) |
|
7.6 GB |
3.6 GB (−52%) |
|
43% |
25% |
The honest top-line is the −26% allocation per operation across the whole benchmark — that is the end-to-end win, measured, not a single method’s share.
|
A footnote on reading the numbers honestly. The fix extracts a jvmlens now makes this legible directly: the diff prints an "Allocation by type (rollup)" block
that sums extracted helpers ( |
4. Guard — byte-identical, or it doesn’t ship
A faster float formatter that prints different output is a bug, not an optimization. So the win is gated on correctness:
-
gotmpl4j’s conformance suites — rendered through the real Go engine for ground truth — stay green.
-
an 18-case edge spot-check matches Go’s
fmt %vexactly: scientific-notation thresholds, trailing-zero stripping, negatives, very large and very small exponents.
The conformance suite is the regression guard — the next refactor that drifts a byte fails the build.
5. …and it holds over time
The same commit is independently visible on a long-running deployment — two instruments, one change, the same conclusion:
-
load-test latency p50/p90/p99, flat at ~0.20 ms, halved to ~0.10 ms at the optimization commit;
-
the test suite ~−20% off its stable ~18 s baseline.
This is the agent’s history= + jvmlens trend view of the same win that JMH measured in §3.
6. The loop
That is the whole cycle, and it is the point:
profile → top lever → fix → prove → guard → document
jvmlens did the first step and the fourth: it named the line, and it produced the receipt. The recording is the difference between "string formatting is probably slow somewhere" and "this one line allocates 7.6 GB — here it is."
Reproduce it
Everything here is checked in:
-
The benchmark:
TableBenchmark.gotmpl4jRenderin gotmpl4j's JMH module. -
The change: gotmpl4j PR #77.
-
Run it yourself (the §1 command). For the before/after inside one JMH session, keep the baseline recording and diff the next run against it:
# baseline (before the fix): keep the recording + its measured bytes/op $ ... -prof gc -prof "...JvmlensProfiler:appPackage=org.alexmond.gotmpl4j;keep=/tmp/before.jfr" # after the fix: print the diff + the measured A/B verdict vs the baseline $ ... -prof gc -prof "...JvmlensProfiler:appPackage=org.alexmond.gotmpl4j;baseline=/tmp/before.jfr"
You don’t have to trust the table. Run the recording and read the same top line.
Field-validated on real projects
This loop isn’t a demo — it’s how two shipped projects are actually tuned, each with a profiling section that documents the jvmlens-found wins and the methodology:
-
gotmpl4j — Performance: the full Optimization history this case study is drawn from — accessor caching, the
floatStringrewrite above, per-thread scratch buffers, theprintfregex pre-compile (−67% alloc), and the O(n²) lexer fix — each found with jvmlens and proved with a multi-fork JMHgc.alloc.rate.normA/B. -
jhelm — Performance: a profile-driven Helm renderer measured against 540+ real-world charts, tuned with jvmlens’s live-attach capture and before→after diff (the redistribution-hedge and per-op-vs-throughput cautions in Usage came straight from that workload).