Garbage Collection x JVM Made Easy: What Developers Actually Need To Know
Demystifying Java Garbage Collection: how GC works, why it matters, and how choosing the right collector boosts API performance
i’ve been interested in diving deeper into the topic of garbage collection and better understanding what’s happening behind-the-scenes of the JVM, for quite some time!
and i finally did spend the time to do so 🎉
so i wanted to spread what I’ve learned and do my best in making it easy-to-digest for newbies and people who think this is some sophisticated and hard thing to learn - as i did 😅
p.s. i am working on something that will soon help you with interview prep and general learning of technical concepts! subscribe and be one of the first to try it out when it’s out! 👇
so, writing code in java?
your memory doesn’t have to be a headache. here's the core of garbage collection (GC) explained in a friendly, beginner-focused way.
what is GC and why it matters
GC is automatic memory management in the JVM - it finds and removes objects your app no longer uses so you don’t run out of memory
it prevents common bugs like memory leaks and fragmentation that plague manual languages like C/C++.
how does GC work? (mark → sweep → optionally compact)
mark: starts from roots👇 (live entry points), traverses references, and marks reachable objects
sweep: deletes any unmarked (unreachable) objects, freeing space
compact: optionally rearranges live objects to reduce fragmentation
‘compact’ means the JVM moves all the live objects together (e.g., sliding them toward the beginning of the heap), closing the gaps and creating one large chunk of free space . It also updates all pointers so they still reference the moved objects correctly
but what are roots??
GC roots are where marking begins - objects reachable from these are considered “alive”:
local variables and method parameters on the stack:
active threads, including their thread-local data
static fields in loaded classes:
native (JNI) references 👇 , when passing objects into native code - these include local and global refs that bridge Java with C/C++
class loader metadata and constant pools
anything reachable (directly or through references) from these stays alive; unreachable stuff gets swept.
ffs.. now what does native (JNI) code mean??
native code means code written in another language, like C or C++, that the JVM can call directly via the Java Native Interface (JNI)
✔️ it’s used when you need platform-specific features, performance boosts, or must reuse legacy libraries
example: writing a native
method in Java that calls a C function to print "hello from native code"
why native references matter for GC
when you pass Java objects to native code and use global JNI references, you’re telling the JVM "keep this object alive"
if you forget to call
DeleteGlobalRef
, that reference acts like a root - so the object is never collected → memory leakalways pair creation with deletion in native code to let GC do its job
why is this useful in practice?
1st, knowing GC roots helps you spot memory leaks—like a static list or JNI ref holding onto objects forever
2nd, local variables vanish when methods return—the stacks pop, so their roots disappear
finally (but not really 😅), native code users must clear references or objects will live forever in memory
Garbage Collection generations and why most objects die young
the JVM splits heap into young and old generations:
young gen: where new objects go (eden + survivors); cleaned frequently with fast minor GCs
old gen: holds long-lived objects; cleaned rarely with full GCs.
this works because most objects are short-lived
choosing the right GC: impact on tail-latency
your choice of garbage collection algorithm can significantly affect tail-latencies (i.e. the slowest API calls users experience):
G1 GC (default since Java 9) balances throughput and pause times with region-based marking and evacuation
ZGC and Shenandoah are concurrent collectors that aim for ultra-low pauses (~sub-millisecond), which helps minimize interference with API requests
for real-world impact, Netflix found G1 caused spikes in tail-latency (timeouts, retries) in their grpc-based services. switching to ZGC significantly cut tail-latency errors from ~2000/s to ~100/s—dramatically improving API reliability under load
why this matters ‼️
occasional GC pauses can delay requests - users hit timeouts or re-trigger calls.
low-pause GCs reduce those delays, improving user experience and reducing cascading load
test your service under production-like traffic with different collectors to spot which one minimizes high-percentile latency (e.g., 99th, 99.9th)
quick tips for developers 🛠️
avoid unnecessary
System.gc()
calls—they can trigger full GC pausesrelease references when done (
list.clear()
, setting fields to null)watch for leaks from static lists, caches, thread-locals, or JNI
when low latency matters, consider using ZGC or Shenandoah over G1
monitor gc using VisualVM or jvm logging (
-XX:+PrintGCDetails
,-XX:+PrintGCApplicationStoppedTime
) to inspect pause durations
tl;dr and recap
GC = automatic memory cleanup: mark, sweep, compact
roots = locals, threads, statics, JNI refs, metadata
native (JNI) code = C/C++ modules Java can call
forgetting to delete global JNI refs = memory leak
most objects die quickly → young-gen gets cleaned fast
different collectors affect tail-latency: ZGC/Shenandoah lower API request spikes vs G1
choose the right GC, test under load, and avoid holding memory unnecessarily.
for those of you who wanna dive deeper:
watching this is a MUST:
also, haven’t watched it myself, but will do as Taner Ilyazov recommended it:
have a wonderful next week guys!
let’s crush it 🚀