Skip to content

09 · JVM Internals & Garbage Collection Basics

Java code doesn't run directly on the CPU — it runs on the Java Virtual Machine (JVM), which loads compiled bytecode, manages memory, and translates it into native instructions. Understanding the JVM's structure helps explain both performance and the automatic memory management that makes Java easier (if less predictable) than manual memory management.

JVM architecture overview

Component Role
Class loader Loads .class files into memory, links and initializes classes
Heap Stores all objects and arrays; shared across all threads
Stack Per-thread; stores method call frames, local variables, partial results
Metaspace Stores class metadata (replaces the old "permanent generation")
Execution engine Interprets bytecode, or compiles it to native code via the JIT compiler
Garbage collector Reclaims heap memory occupied by objects no longer reachable

Every thread gets its own stack (so recursion in one thread can't corrupt another thread's local variables), but all threads share the same heap — which is exactly why concurrent access to heap-allocated objects needs the coordination covered in Level 3, Module 1.

Class loading, briefly

Before any of your code runs, the class loader subsystem finds a class's bytecode (from the classpath, a JAR, wherever) and takes it through three phases: loading (reading the .class bytes into memory), linking (verifying the bytecode is well-formed, allocating storage for static fields), and initialization (running static initializers and static field assignments, top to bottom in source order).

public class LoadingDemo {
    static {
        System.out.println("Static initializer running");
    }

    static int counter = initCounter();

    static int initCounter() {
        System.out.println("Initializing counter");
        return 0;
    }

    public static void main(String[] args) {
        System.out.println("main() running");
    }
}
// Output:
// Static initializer running
// Initializing counter
// main() running

A class is only loaded and initialized the first time it's actually referenced (a new, a static method call, accessing a static field) — this "lazy initialization" is why a class you never use adds essentially no startup cost.

The generational heap

Most objects die young — a temporary String built inside a loop, a local list used and discarded within one method call. The HotSpot JVM exploits this observation by splitting the heap into generations:

  • Young generation — new objects are allocated here first (in an area called "Eden"). Minor GCs run frequently but are cheap, since most objects in Eden are already garbage by the time a collection runs.
  • Old generation (tenured) — objects that survive several young-generation collections get "promoted" here. Old-gen collections are less frequent but more expensive, since they scan a much larger region.
Heap
 ├── Young Generation
 │     ├── Eden          (new objects allocated here)
 │     ├── Survivor 0
 │     └── Survivor 1
 └── Old Generation       (long-lived, promoted objects)

Common GC algorithms

Collector Flag Characteristics
Serial GC -XX:+UseSerialGC Single-threaded; simplest, best for small heaps/apps
Parallel GC -XX:+UseParallelGC Multi-threaded stop-the-world; maximizes throughput
G1 (Garbage-First) -XX:+UseG1GC Default since Java 9; balances throughput and pause times
ZGC -XX:+UseZGC Ultra-low pause times (sub-millisecond), scales to huge heaps

G1 is the JVM's default collector today and a reasonable choice for most applications; ZGC and Shenandoah trade some throughput for dramatically lower pause times, useful for latency-sensitive services.

Heap sizing flags

java -Xms256m -Xmx1024m -XX:+UseG1GC -jar myapp.jar
  • -Xms — initial heap size (256 MB here).
  • -Xmx — maximum heap size (1024 MB here) — if the JVM can't reclaim enough memory to satisfy an allocation once it's hit this ceiling, it throws OutOfMemoryError.
  • -XX:+UseG1GC — selects the G1 collector explicitly (already the default on modern JVMs, but useful to know how to force it).

Observing allocation and GC pressure

public class GcPressureDemo {
    public static void main(String[] args) {
        long start = System.currentTimeMillis();

        // Allocate a large number of short-lived objects to pressure the young generation
        for (int i = 0; i < 5_000_000; i++) {
            String s = "object-" + i;   // each concatenation allocates a new String
            if (s.length() > 1000) {    // never true -- keeps the compiler from optimizing it away
                System.out.println(s);
            }
        }

        long elapsed = System.currentTimeMillis() - start;
        System.out.println("Allocated 5,000,000 short-lived strings in " + elapsed + " ms");
    }
}

Run it with GC logging enabled to watch collections happen in real time:

java -Xlog:gc GcPressureDemo
[0.012s][info][gc] Using G1
[0.045s][info][gc] GC(0) Pause Young (Normal) (G1 Evacuation Pause) 12M->3M(64M) 4.112ms
[0.089s][info][gc] GC(1) Pause Young (Normal) (G1 Evacuation Pause) 15M->3M(64M) 3.887ms
...
Allocated 5,000,000 short-lived strings in 612 ms

(-Xlog:gc is the modern unified logging flag; older material may reference -verbose:gc, which still works but is less detailed.)

Inspecting a running JVM with jcmd

jcmd is a general-purpose diagnostic tool bundled with the JDK — no code changes needed:

jcmd <pid> GC.heap_info      # summary of heap regions and usage
jcmd <pid> GC.run            # request a full garbage collection
jcmd <pid> VM.flags          # show the JVM flags currently in effect

How It Actually Works

The heap is generationally split because most objects die young (the "weak generational hypothesis"): the young generation (Eden + two Survivor spaces) is collected frequently with a fast copying collector — live objects are copied between Survivor spaces and get their age incremented in the object header each survived cycle, promoted to the old generation once they cross a tenuring threshold. This generational split is why minor GCs are typically sub-millisecond to a few milliseconds while full/major GCs (scanning the much larger old generation) can pause for much longer.

G1 (the default collector since Java 9) divides the heap into fixed-size regions instead of contiguous generations, tracks "remembered sets" (which regions hold references into which other regions) to avoid scanning the whole heap for roots, and picks the regions with the most garbage to collect first ("garbage-first") to hit a target pause-time goal rather than a fixed heap-size trigger. ZGC/Shenandoah go further, doing almost all marking and relocation concurrently with application threads, using colored pointers or load barriers so a thread reading a reference to a moved object gets redirected on the fly instead of the world stopping.

Object headers matter beyond GC: the mark word also encodes lock state (unlocked/biased/thin/fat) and identity hash code, which is why calling System.identityHashCode() on an object can force the JVM to materialize a hash into the header it might otherwise never need to compute.

Exercise

Write a program that builds a List<byte[]>, repeatedly adding a new 1 MB byte[] array in a loop, printing the iteration count every 50 iterations. Run it with -Xmx200m -Xlog:gc and observe both the GC log output and, once the list grows too large for the heap, the resulting OutOfMemoryError: Java heap space — then explain in a comment why holding a reference to every array in the list (instead of discarding each one) is what prevents the garbage collector from reclaiming them.