1. Heap, GC Algorithms & Reading a GC Log
The JVM heap is split into generations on the assumption most objects die young: a small young generation (further split into Eden and two Survivor spaces) where new objects are allocated and collected frequently and cheaply, and a larger old generation for objects that survive several young collections, collected less often but at higher cost per collection. Java 21's default collector, G1, is a good default for most Spring Boot services — it targets a configurable maximum pause time rather than optimizing purely for throughput.
java -Xlog:gc*:file=gc.log:time,uptime,level,tags -jar app.jar
[12.451s][info][gc] GC(14) Pause Young (Normal) (G1 Evacuation Pause)
252M->38M(512M) 18.2ms
That single line says a lot: before this collection the heap held 252MB of live objects, after it only 38MB remained live (meaning ~214MB of garbage was reclaimed), out of a current total heap size of 512MB, and the whole pause — during which application threads mostly stop — took 18.2ms. A healthy service shows young collections like this fairly frequently but briefly; the two patterns actually worth worrying about are collections growing steadily longer over time (a possible memory leak, since a genuinely leaking heap has more and more live data to scan each time) and old-generation ("Mixed" or "Full") collections happening frequently, which are far more expensive and usually mean the heap is undersized for the actual working set.
-Xmx deliberately in a container, don't leave it to the default heuristic
The JVM's default heap sizing is based on available memory, and inside a Kubernetes Pod with a memory limit (Week 12), an oversized default heap plus normal JVM overhead can get the whole Pod OOMKilled by the kernel before the JVM's own GC ever gets a chance to react. Set -XX:MaxRAMPercentage deliberately, well below 100% of the Pod's memory limit, to leave headroom for thread stacks, metaspace, and off-heap memory.
2. Thread Dumps, Heap Dumps & Profiling a Live Service
When a service feels stuck — requests hanging, CPU pegged, or just "slow" with no obvious cause in the logs — a thread dump is the fastest way to see exactly what every thread is doing at that instant, without adding any instrumentation ahead of time.
jcmd <pid> Thread.print > threads.txt
# or, if actuator's threaddump endpoint is exposed (Week 12):
curl localhost:8080/actuator/threaddump
"http-nio-8080-exec-7" #47 daemon prio=5 tid=0x... nid=0x... waiting on condition
java.lang.Thread.State: WAITING (parking)
at java.base/jdk.internal.misc.Unsafe.park(Native Method)
at java.base/java.util.concurrent.locks.LockSupport.park(LockSupport.java:211)
at com.zaxxer.hikari.pool.HikariPool.getConnection(HikariPool.java:XXX)
at com.codeverse.week23.TaskService.findTask(TaskService.java:42)
A single thread waiting is normal. Twenty request-handling threads all
WAITING at the exact same HikariPool.getConnection line is a
specific, actionable finding — it says the connection pool (Week 11) is exhausted,
not that "the service is slow" in some vague sense. Comparing two thread dumps taken a
few seconds apart is even more telling: threads stuck at the identical stack trace in
both dumps are genuinely blocked, not just caught mid-execution by coincidence.
A heap dump answers a different question — not what threads are doing, but what's actually consuming memory:
jcmd <pid> GC.heap_dump heap.hprof
Opened in a tool like Eclipse MAT or VisualVM, a heap dump's "dominator tree" shows exactly which objects are retaining the most memory and, critically, why they're still reachable — a static collection that keeps growing without ever evicting entries is one of the most common real memory leaks in a long-running Spring Boot service, and a heap dump is what turns "memory keeps climbing" into "this specific cache, growing unbounded, is the cause."
A single thread dump is a snapshot — a thread caught mid-execution at a particular line looks identical to one genuinely stuck there. Taking three or four dumps a few seconds apart and comparing them is what actually distinguishes "busy" from "stuck": a thread at a different stack trace each time is working normally; a thread frozen at the same trace across every dump is the real problem.
3. Java 21 Virtual Threads
Week 14 framed the choice between Spring MVC's thread-per-request model and WebFlux's reactive model as a fundamental tradeoff: blocking, easy-to-read code that doesn't scale to very high concurrency, versus non-blocking, harder-to-read code that does. Virtual threads, stable since Java 21, change one side of that tradeoff directly. A virtual thread is a JVM-managed thread that's extremely cheap to create (thousands can exist where a few hundred platform threads would exhaust memory) and, critically, unmounts from its underlying OS thread automatically while blocked on I/O — the OS thread is freed for other work during the wait, then a virtual thread (not necessarily the same OS thread) resumes it when the I/O completes.
spring.threads.virtual.enabled=true
With that single property, ordinary Spring MVC request-handling threads become virtual threads — no code changes anywhere. The Week 3 blocking controller and the Week 4 JPA repository call inside it are entirely unchanged:
@GetMapping("/{id}")
TaskResponse getTask(@PathVariable Long id) {
return toResponse(taskRepository.findById(id) // still a blocking call
.orElseThrow(TaskNotFoundException::new));
}
What's different is what happens while that call blocks waiting on the database: instead of tying up one of a small, fixed platform thread pool for the whole wait (the original thread-per-request cost Week 14 described), the virtual thread parks and releases its carrier OS thread, which is immediately free to run other virtual threads. The result is thread-per-request's simple, blocking programming model with much closer to WebFlux's concurrency characteristics — without rewriting a single controller or repository as reactive code.
synchronized still pins
Virtual threads solve the cost of waiting on I/O — they add nothing to raw CPU-bound throughput, which is bounded by real processor cores regardless of thread type. They also don't unmount from their carrier while inside a synchronized block prior to newer JDK updates that specifically address this ("thread pinning") — a virtual thread blocked on I/O inside a heavily-synchronized legacy code path can still tie up a platform thread the way Week 14 originally described, so this isn't a universal free upgrade for every codebase.
4. Hands-on Exercise
Diagnose a real connection pool exhaustion and measure virtual threads under load
Reproduce a real thread-dump-diagnosable problem, then compare platform threads against virtual threads under concurrent load.
Requirements:
- Deliberately shrink your HikariCP pool to 2 connections and fire 20 concurrent slow requests at an endpoint that queries the database; capture a thread dump mid-load and identify the threads stuck at
HikariPool.getConnection. - Enable GC logging on a running instance under moderate load, and identify at least one young collection entry in the log, explaining what its numbers mean.
- Capture a heap dump and open it in VisualVM or Eclipse MAT; identify the largest object in the dominator tree and explain why it's retained.
- Run a load test (a simple concurrent loop is fine) against the same blocking endpoint with
spring.threads.virtual.enabledfirstfalsethentrue, and compare throughput and thread count under high concurrency.
Thread.ofVirtual().start(...) prints a distinctly different thread name pattern than a platform thread in a stack trace — a quick way to confirm virtual threads are genuinely being used during your load test, rather than assuming the property took effect.
5. Knowledge Check
Three quick questions. Expand each to check your answer.
Q1
What's the difference between young generation collections growing steadily longer over time versus staying roughly constant?
What's the difference between young generation collections growing steadily longer over time versus staying roughly constant?
Roughly constant young collection times indicate a healthy, steady amount of live data surviving each collection — the normal pattern for objects that die young. Collections that steadily grow longer over the service's lifetime suggest the amount of live data being scanned on each collection keeps increasing, which is a strong sign of a memory leak — objects that should have become garbage are still reachable and accumulating.
Q2
Why is one thread dump insufficient to distinguish a genuinely stuck thread from one that's simply busy?
Why is one thread dump insufficient to distinguish a genuinely stuck thread from one that's simply busy?
A single thread dump is only a snapshot at one instant — a thread doing normal, ongoing work can be caught at any line, including one that looks alarming in isolation. Only by comparing multiple dumps taken seconds apart can you tell the difference: a thread at a different stack trace each time is progressing normally, while one frozen at the identical trace across every dump is genuinely blocked.
Q3
What specifically do virtual threads improve, and what do they leave unchanged?
What specifically do virtual threads improve, and what do they leave unchanged?
Virtual threads dramatically reduce the cost of a thread that's blocked waiting on I/O, by unmounting from the underlying OS thread during the wait instead of tying it up — this lets simple, blocking, thread-per-request code scale to far higher concurrency than platform threads allow. They don't speed up CPU-bound work at all, since that's bounded by real processor cores, and older JVM versions' synchronized blocks can still pin a virtual thread to its carrier, preventing the unmounting benefit in that specific code path.