- Stack: each thread gets its own stack, made up of frames, one per active method call. A frame holds that method's local variables, parameters, and (for primitives) their actual values, plus references to heap objects. Frames are pushed on method entry and popped on return — strictly LIFO, and automatically reclaimed the instant the method returns, no GC involved. A stack has a fixed maximum size; exceeding it (typically via runaway/infinite recursion) throws
StackOverflowError. - Heap: a single memory area shared by all threads, where every object and array is allocated (
new SomeClass(),new int[10]), regardless of which thread or method created it. Objects live on the heap until the garbage collector determines they're unreachable and reclaims them — there's no deterministic "this method ended, free it now" moment like the stack has.
void method() {
int x = 5; // x itself lives on this call's stack frame
Point p = new Point(1,2); // the Point object lives on the heap;
// `p` (the reference/pointer to it) lives on the stack
}
Practical implications: stack access is very fast (simple pointer bump, great cache locality, no synchronization needed since it's thread-private) but limited in size and lifetime; heap access is slightly slower and subject to GC pauses, but objects can outlive the method that created them and be shared across threads.
Running out of heap space throws OutOfMemoryError: Java heap space; running out of stack space (typically deep/infinite recursion) throws StackOverflowError — a common early interview distinction.
Most production JVM collectors (Parallel GC, G1, and others) are built around the generational hypothesis: empirically, the vast majority of objects become garbage very shortly after being allocated (think: loop-local temporaries, short-lived request objects), while a small minority survive much longer. Splitting the heap by object age lets the collector focus effort where garbage actually accumulates.
Young Generation: where new objects are allocated. Subdivided further:
- Eden space: where nearly all new objects are born.
- Two Survivor spaces (S0/S1): objects that survive an Eden collection are copied into one survivor space; on the next collection they're copied to the other, alternating — this "copying collector" approach is cheap because it only touches live objects, not garbage.
A minor GC collects only the young generation — fast and frequent, because the young gen is small and most objects there are already dead.
Old (Tenured) Generation: objects that survive enough minor GCs (their "age" counter crosses a threshold) are promoted ("tenured") into the old generation, on the assumption they're likely to be long-lived. The old generation is larger and collected far less often, via a major/full GC — typically more expensive since it scans a much bigger, longer-lived object graph.
Heap
├── Young Generation
│ ├── Eden (most objects allocated here)
│ ├── Survivor 0
│ └── Survivor 1
└── Old Generation (long-lived, promoted objects)
There's also Metaspace (off-heap since Java 8, replacing PermGen) storing class metadata, separate from both generations.
This generational design is why minor GCs are usually fast, sub-millisecond-to-a-few-ms pauses, while a full GC (especially with older collectors like Parallel GC) can cause noticeably longer "stop-the-world" pauses — a major reason modern low-latency collectors like G1 and ZGC were built to minimize or largely eliminate full-heap pause times.
Related Resources
Beyond ordinary ("strong") references, java.lang.ref provides three reference types with progressively weaker guarantees about keeping an object alive — useful for caching and cleanup scenarios where you want the GC to be able to reclaim memory under pressure.
-
Strong reference: an ordinary reference (
Object o = new Object();). As long as any strong reference chain reaches an object, the GC will never collect it — this is the default and what most code uses. -
SoftReference<T>: the GC is allowed to clear it, but is guaranteed not to until the JVM is actually running low on memory (specifically, before throwingOutOfMemoryError). Ideal for memory-sensitive caches — data you'd like to keep around for reuse, but that's fine to discard and recompute if memory gets tight.
SoftReference<byte[]> cache = new SoftReference<>(loadLargeData());
byte[] data = cache.get(); // null if it was reclaimed under memory pressure
-
WeakReference<T>: the GC clears it at the next collection cycle if no strong references exist, regardless of memory pressure. Used where you need to associate data with an object without preventing that object from being collected — the canonical example isWeakHashMap, whose keys are weakly referenced, so entries automatically disappear once their key is otherwise unreachable (useful for caches/registries keyed by objects with independent lifecycles). -
PhantomReference<T>:get()always returnsnull— it can never be used to access the object. It exists purely to be notified, via aReferenceQueue, after the object has already been finalized and is about to be reclaimed, enabling precise post-mortem cleanup scheduling (e.g., releasing native/off-heap memory tied to the object) more reliably than the deprecatedfinalize().
Strength ordering (strongest to weakest): Strong > Soft > Weak > Phantom — each is collected more eagerly than the last.
Related Resources
Garbage collection reclaims objects that are unreachable from any GC root — it does nothing to help if your code still holds a reference to an object it no longer actually needs. A "Java memory leak" is really an unintentional reachability leak: something keeps pointing at an object long after it's logically garbage, so it silently accumulates and eventually causes OutOfMemoryError.
Common real-world causes:
- Unbounded caches: a
HashMapused as a cache that only ever grows, with no eviction policy or size cap — every entry ever added stays reachable forever.
static Map<String, byte[]> cache = new HashMap<>(); // never removes entries — grows forever
Fix: use a bounded cache (e.g., an LRU-evicting cache, or SoftReference values, or a library like Caffeine).
-
Unregistered listeners/callbacks: registering a listener on a long-lived object (e.g., a UI component or event bus) and never removing it — the long-lived publisher keeps the listener (and everything it references) alive indefinitely.
-
ThreadLocalvalues not cleaned up in thread pools: since pooled threads are reused, a value set viaThreadLocal.set()and neverremove()-d persists on that thread indefinitely, referenced by the thread's internalThreadLocalMaplong after the task that set it has finished. -
Non-static inner classes: each instance implicitly holds a reference to its enclosing outer instance — if you hand out a long-lived inner-class instance (e.g., store it in a static collection), it transitively keeps the entire outer object alive too, often unexpectedly.
-
Static fields holding large collections:
staticfields live for the entire JVM lifetime (tied to the class, not any instance) — accumulating data in a static collection without ever clearing it is a straightforward, common leak.
Diagnosis: heap dumps (via jmap/VisualVM/Eclipse MAT) showing an ever-growing count of a particular object type, and GC logs showing the old generation steadily climbing even after full GCs, are the classic symptoms pointing at a reachability leak rather than a genuine allocation-rate problem.
The JVM supports several pluggable garbage collectors, each tuned for a different goal along the classic throughput vs. latency (pause time) trade-off:
-
Serial GC (
-XX:+UseSerialGC): a single thread performs the entire collection, and all application threads pause during it ("stop-the-world"). Simple and low-overhead, appropriate for small heaps or single-CPU environments where multi-threaded collection wouldn't pay off. -
Parallel GC (
-XX:+UseParallelGC): uses multiple threads to perform collection faster, but still stop-the-world for the application during a collection. Optimizes for maximum throughput (total useful work done over time), accepting occasionally longer individual pauses. Was the JVM's default collector for a long time (pre-Java 9). -
G1 (Garbage-First) GC (
-XX:+UseG1GC, default since Java 9): divides the heap into many small, equally-sized regions rather than one contiguous young/old split, and tracks how much garbage each region holds. It prioritizes collecting the regions with the most reclaimable garbage first ("garbage first"), aiming to hit a configurable target pause time (-XX:MaxGCPauseMillis) while still maintaining good throughput — a balanced default for most general-purpose server applications. -
ZGC (
-XX:+UseZGC) and Shenandoah: modern, low-latency collectors designed for sub-millisecond pause times, even on very large heaps (multi-GB to TB scale), by doing almost all collection work concurrently with running application threads rather than stopping them. They trade some CPU overhead/throughput for that strong latency guarantee — ideal for latency-sensitive services where a multi-hundred-millisecond GC pause is unacceptable.
Choosing: G1 is a solid default for most applications; Parallel GC still suits pure batch/throughput-oriented workloads where occasional longer pauses are fine; ZGC/Shenandoah are the right call when consistent low latency matters more than raw throughput, especially on large heaps.