Projects — Phase 06¶
Three projects. Together they take about 30–40 focused hours. Each one is designed to force a specific senior-level competency into your fingers: profiling a service you didn’t write, defending microbenchmark numbers with JMH, and staring an OOM in the face until it explains itself. Ship all three publicly on GitHub — they are portfolio-grade.
Acceptance criteria for the whole phase: you have real artifacts (flame graphs, JMH result tables, heap dumps, GC logs) checked into each repo, and a README.md that walks a reviewer through the story. study partners can smell copy-paste; make yours the real thing.
Project A: Profile the Slow Service (12–16 h)¶
You’ll be handed a deliberately slow HTTP service (build one, or fork a small Spring Boot demo you don’t already know cold) and asked to make it faster by measurement, not guesswork. This is the exact study scenario for principal engineer roles.
The service¶
Build a Spring Boot 3.2+ service with the following intentional pathologies. Keep it small (one controller, ~200 LOC) so the exercise is finding the bugs, not reading the codebase.
A
/reportendpoint that fetches user data from an in-memoryHashMap, formats it viaString.formatin a loop, and returns a JSON response.Populated with 100k users at startup.
Deliberately slow paths:
String concatenation with
+inside a loop building each row.A synchronized block around an unnecessary read.
A regex compiled inside the hot path instead of a static
Pattern.A
stream().collect(Collectors.toList())where a rawforwould allocate half as much.
The exercise¶
Load-test the baseline with
k6orwrkat 200 concurrent users, 60 s. Record p50/p95/p99, throughput, CPU %, allocation rate.Attach Async Profiler in CPU mode for 30 s under load. Save the flame graph as
flame-cpu-baseline.html.Attach Async Profiler in
-e allocmode. Saveflame-alloc-baseline.html.Start a 60 s JFR recording (
jcmd JFR.start settings=profile duration=60s). Savebaseline.jfr. Open in JMC, screenshot the Automated Analysis page.From the three artifacts, identify the top 3 bottlenecks. For each, write one paragraph: what it is, how the artifact showed it, expected fix, expected magnitude of improvement.
Apply the three fixes.
Re-run all four measurements. Save
flame-cpu-after.html,flame-alloc-after.html,after.jfr.Publish a
WRITEUP.mdin the repo: before/after latency table, side-by-side flame-graph screenshots, one paragraph per fix. This is your artifact.
Acceptance criteria¶
p99 latency reduced by at least 3× from baseline.
Allocation rate reduced by at least 2× (visible in
-prof gcand Async Profiler alloc flame graph).Every claim in
WRITEUP.mdhas a screenshot or table backing it. No unsupported numbers.Both
.jfrfiles committed to the repo (or a release attachment).One-command reproduction:
./gradlew bootRun+ a documentedk6 run load.jsinvocation.
Why this project matters¶
study partners at senior levels routinely ask “tell me about a time you profiled and improved a service.” If your answer includes flame graphs, JFR recordings, and before/after numbers, you skip a level. If your answer is “we increased the heap”, you fail the round.
Project B: The Five JMH Benchmarks (8–12 h)¶
Write, run, and interpret five JMH benchmarks. This is where you build defensive credibility — the ability to disprove wrong performance claims, including your own.
The five benchmarks¶
All under one JMH submodule. Each with @Fork(value=2), @Warmup(5, time=1), @Measurement(10, time=1), appropriate @BenchmarkMode.
String concat vs StringBuilder across
@Param({"5", "50", "500", "5000"})sizes. Expected: they tie at 5, StringBuilder wins by orders of magnitude at 5000. This is your DCE-defeating warm-up problem.ArrayListvsLinkedListiteration. Iterate a pre-filled list of 100kIntegers, sum the elements. Two variants each: index-basedforloop and enhanced-for. Expected:ArrayListwins across the board, and index-based onLinkedListis catastrophically slow (get(i)is O(n)).HashMapvsTreeMaplookup. Pre-fill each with 10k keys. Benchmarkget()of a random hit key. Expected: HashMap ~2–3× faster than TreeMap for random access; report both averages and 99th percentile viaSampleTime.synchronizedvsReentrantLockunder contention. A shared counter incremented by N threads (use@Threads(8)and@State(Scope.Benchmark)). Comparesynchronizedblock,ReentrantLock,AtomicLong,LongAdder. Expected:LongAdderdemolishes the others under contention;synchronizedandReentrantLockare close on Java 21+.Virtual thread vs platform thread task overhead. Benchmark the cost of
executor.submit(() -> {})(do-nothing task,Blackhole.consumeinside) fornewFixedThreadPool(200),newVirtualThreadPerTaskExecutor(), andnewCachedThreadPool(). Expected: virtual thread creation is ~1–2 μs and doesn’t degrade with load; platform pool submit is a queue push (~100 ns) but capped at 200 concurrent tasks.
Acceptance criteria¶
Every benchmark uses
Blackhole.consume(...)or returns a value. Novoidbenchmarks with unused locals.-prof gcreported for each benchmark. Allocation rate (gc.alloc.rate.norm, bytes/op) documented per variant.Each benchmark’s results published as a Markdown table in
RESULTS.md, withScore ± Error.For each benchmark, one paragraph interpreting the result: is the difference statistically significant (do error bars overlap), and does the direction match your prior hypothesis? If it doesn’t, that’s more interesting — dig in and explain.
JDK version, JMH version, and hardware documented at the top of
RESULTS.md.
Why this project matters¶
After this, you can walk into any team’s codebase, spot a “we cache this for performance” comment, and settle the argument in under an hour with a proper JMH benchmark. That skill is rare and highly leveraged.
Project C: Three OOMs You Caused on Purpose (6–8 h)¶
Deliberately cause and then fix three different kinds of OutOfMemoryError in a small, controlled repo. Ship the heap dumps and GC logs alongside the code. study partners love this exercise — it proves you understand the JVM’s memory categories deeply enough to engineer their failure.
The three OOMs¶
OOM 1 — Java heap space. Write a program that adds byte[] entries to a List<byte[]> in a loop, never releasing references. Run with -Xmx256m -XX:+HeapDumpOnOutOfMemoryError -XX:HeapDumpPath=./dumps. Capture: the stack trace, the .hprof file, screenshot of MAT’s dominator tree showing the List as the retainer.
OOM 2 — Metaspace. Write a program that dynamically creates classes in a loop using ByteBuddy or a URLClassLoader with unique class names, never releasing the loader. Run with -XX:MaxMetaspaceSize=64m -XX:+HeapDumpOnOutOfMemoryError. Capture: the stack trace, jcmd GC.class_histogram output at 50 % Metaspace showing the class count growing, then the final trace when Metaspace fills.
OOM 3 — Direct buffer memory. Write a program that allocates ByteBuffer.allocateDirect(10 * 1024 * 1024) in a loop into a list, never releasing. Run with -XX:MaxDirectMemorySize=128m. Capture: the stack trace showing OutOfMemoryError: Direct buffer memory and NMT summary showing Internal / Direct Buffer category filling.
The three fixes¶
For each OOM, implement the fix and verify. Document what the fix is and why it works.
OOM 1 fix. Replace the unbounded
Listwith a Caffeine cache:Caffeine.newBuilder().maximumWeight(200_000_000).weigher((k, v) -> v.length).build(). Verify: heap stays below-Xmx256mindefinitely.OOM 2 fix. Close/dereference the class loader when done. Verify:
GC.class_histogramshows class count stable across iterations. (Bonus: understand why the classloader must be unreachable — a static field holding a reference to any of its classes pins the whole loader.)OOM 3 fix. Explicitly release direct buffers, either by
Cleaner, by reusing a pool via Netty’sPooledByteBufAllocator, or by not using direct buffers at all. Verify: NMT summary shows Internal/Direct stable.
Acceptance criteria¶
./run.sh oom1,./run.sh oom2,./run.sh oom3reliably reproduce each OOM within 30 s.Each
.hprof(or its equivalent for Metaspace/Direct — heap dumps aren’t the diagnostic for those) is committed or attached as a release../run.sh fix1|fix2|fix3variants run for at least 5 minutes without OOM.WRITEUP.mddocuments each OOM’s exact error message, the region that ran out, the tool used to observe the growth, and why the fix works.
Why this project matters¶
Most engineers only ever see the OOMs their own code accidentally causes. Deliberately engineering three different species trains a kind of memory-anatomy reflex: you see the error message and instantly know which region, which tools, which categories of fix. That reflex is what a staff engineer brings to an incident.
Delivery Checklist¶
All three repos public on GitHub with clear
README.md.Artifacts committed or attached: flame graphs, JFR recordings, JMH
RESULTS.md, heap dumps.Every performance claim in every writeup has a screenshot or table backing it.
JDK 21+ used throughout. Note the exact JDK vendor + version.
Linked from your main portfolio index (Phase 12).
Return to README.md · Previous: 06_common_production_pathologies.md