View source on GitHub

Module 24 - JVM Internals: GC, JIT, ClassLoaders

Table of contents
  1. Module 24 - JVM Internals: GC, JIT, ClassLoaders
    1. ClassLoaders
      1. Hierarchy (Java 9+)
      2. Delegation model (parent-first)
      3. Key API
      4. Custom ClassLoader
      5. URLClassLoader
      6. Class identity
    2. Memory & Garbage Collection
      1. Heap layout
      2. GC algorithms (Java 11–21)
      3. JVM flags
      4. Runtime memory API
      5. GC monitoring
      6. Reference types
      7. Cleaner (Java 9+) - replacement for finalize()
    3. JIT Compiler
      1. Compilation tiers (HotSpot Tiered Compilation)
      2. Key optimisations
      3. Warmup effect
      4. Dead code elimination - the benchmark trap
      5. JMH (Java Microbenchmark Harness)
      6. Compilation API
    4. Performance Patterns
      1. Auto-boxing overhead
      2. String building
      3. Object pooling
    5. Summary

ClassLoaders

Hierarchy (Java 9+)

Bootstrap ClassLoader       ← loads java.lang, java.util, JDK core
    └── Platform ClassLoader ← loads other java.* modules
            └── Application ClassLoader ← loads your classpath

getClassLoader() returns null for bootstrap-loaded classes (e.g. String.class).

Delegation model (parent-first)

  1. Ask parent to load the class
  2. If parent returns null, try this loader’s own search path
  3. If still not found → ClassNotFoundException

This prevents user code from shadowing java.lang.String.

Key API

ClassLoader.getSystemClassLoader()                // application loader
ClassLoader.getPlatformClassLoader()              // platform loader
clazz.getClassLoader()                            // loader that defined the class
Thread.currentThread().getContextClassLoader()    // framework hook
loader.loadClass("pkg.Name")                      // parent-first delegation
Class.forName("pkg.Name")                         // load + initialise static blocks
Class.forName("pkg.Name", false, loader)          // load WITHOUT initialising

Custom ClassLoader

public class ByteArrayClassLoader extends ClassLoader {
    public Class<?> define(String binaryName, byte[] bytecode) {
        return defineClass(binaryName, bytecode, 0, bytecode.length);
    }
}

Override findClass() to control where classes are loaded from (network, DB, generated bytecode).

URLClassLoader

URLClassLoader loader = new URLClassLoader(
    new URL[]{ pluginJar.toUri().toURL() },
    ClassLoader.getSystemClassLoader()
);
Class<?> plugin = loader.loadClass("com.example.Plugin");

Used for plugin systems and hot-reload.

Class identity

Two classes are the same only if they have the same binary name and were loaded by the same ClassLoader instance. Casting across loaders throws ClassCastException.


Memory & Garbage Collection

Heap layout

Heap
├── Young Generation
│   ├── Eden           ← new objects allocated here
│   ├── Survivor S0    ← survived at least 1 minor GC
│   └── Survivor S1
└── Old (Tenured)      ← long-lived objects
Non-Heap
├── Metaspace          ← class metadata (replaced PermGen, Java 8+)
├── Code Cache         ← JIT-compiled native code
└── Thread Stacks      ← one per thread

GC algorithms (Java 11–21)

Collector Pauses Use case
Serial GC Stop-the-world Small heaps, embedded
Parallel GC Stop-the-world, parallel Maximum throughput
G1 GC (default) Short, predictable Balanced latency/throughput
ZGC Sub-millisecond (Java 15+) Large heaps, low latency
Shenandoah Concurrent compaction Low pause, any heap size

JVM flags

-Xms512m -Xmx2g        # initial and max heap
-Xss512k               # thread stack size
-XX:+UseG1GC           # select G1 (default from Java 9)
-XX:+UseZGC            # select ZGC
-Xlog:gc*              # GC logging (Java 9+ unified logging)
-XX:+PrintGCDetails    # verbose GC output (older syntax)

Runtime memory API

Runtime rt = Runtime.getRuntime();
rt.totalMemory()    // current heap size (may grow up to -Xmx)
rt.maxMemory()      // -Xmx value
rt.freeMemory()     // free space in current heap
// used = totalMemory() - freeMemory()

// Management API
MemoryMXBean mem = ManagementFactory.getMemoryMXBean();
MemoryUsage heap = mem.getHeapMemoryUsage();
heap.getUsed(); heap.getCommitted(); heap.getMax();

GC monitoring

for (GarbageCollectorMXBean gc : ManagementFactory.getGarbageCollectorMXBeans()) {
    gc.getName();               // "G1 Young Generation", "G1 Old Generation"
    gc.getCollectionCount();    // total GC events
    gc.getCollectionTime();     // total pause time (ms)
}

Reference types

Type Cleared when Use case
Strong Never (while reachable) Normal variables
SoftReference<T> Before OutOfMemoryError Memory-sensitive caches
WeakReference<T> Next GC cycle WeakHashMap keys, canonicalization
PhantomReference<T> After finalisation (get() always null) Resource cleanup
SoftReference<byte[]> cache  = new SoftReference<>(largeArray);
WeakReference<Object> weak   = new WeakReference<>(obj);
PhantomReference<Object> ph  = new PhantomReference<>(obj, refQueue);

byte[] arr = cache.get();   // null if GC'd
Object o   = weak.get();    // null if GC'd

Cleaner (Java 9+) - replacement for finalize()

private static final Cleaner CLEANER = Cleaner.create();

// State must NOT reference the owning object (would prevent GC)
record State(String name) implements Runnable {
    @Override public void run() { /* release native resource */ }
}

State state = new State("myResource");
Cleaner.Cleanable cleanable = CLEANER.register(this, state);

// When this object becomes phantom-reachable, State.run() is called
// Call cleanable.clean() explicitly for deterministic release (AutoCloseable)

finalize() is deprecated since Java 9 and removed in Java 18.


JIT Compiler

Compilation tiers (HotSpot Tiered Compilation)

Tier Compiler Trigger Optimisations
0 Interpreter Always None
1–3 C1 (client) ~1,500 invocations Fast compile, limited
4 C2 (server) ~10,000–15,000 invocations Aggressive, profile-guided

Key optimisations

Inlining - replaces a method call with the method body at the call site. The single most impactful optimisation; enables all others.

// Before inlining:
int result = Math.abs(x);

// After inlining (conceptually):
int result = x < 0 ? -x : x;

Escape analysis - if an object never leaves the method (doesn’t escape to heap), allocate it on the stack or replace it with scalar variables:

void compute() {
    Point p = new Point(x, y);   // may never be heap-allocated
    return p.x * p.y;            // scalars used directly
}

Devirtualisation - if a virtual call site is always called with the same concrete type (monomorphic), replace the virtual dispatch with a direct call.

Loop unrolling / vectorisation - duplicate loop body to reduce branch count; use SIMD instructions where available.

Warmup effect

Round 1 (cold): 45,000 ns  ← interpreter
Round 2:        42,000 ns
...
Round 5 (C1):    8,000 ns  ← C1 compiled
...
Round 10 (C2):   1,200 ns  ← C2 fully optimised

Consequence: never benchmark cold code. Always warm up for 1,000–10,000+ iterations before measuring.

Dead code elimination - the benchmark trap

// JIT may eliminate this entirely if result is never used!
for (int i = 0; i < N; i++) expensiveOp(i);

// Prevent elimination: consume the result
long sink = 0;
for (int i = 0; i < N; i++) sink += expensiveOp(i);
if (sink < 0) throw new AssertionError();  // ensure sink is used

JMH (Java Microbenchmark Harness)

The correct way to benchmark Java code. Handles warmup, dead code elimination, OS scheduling noise, and JIT tricks automatically:

@BenchmarkMode(Mode.AverageTime)
@OutputTimeUnit(TimeUnit.NANOSECONDS)
@State(Scope.Thread)
public class MyBenchmark {
    @Benchmark
    public int measureSort(MyState state) {
        return Arrays.sort(state.data.clone());  // return value prevents DCE
    }
}

Compilation API

CompilationMXBean comp = ManagementFactory.getCompilationMXBean();
comp.getName();                       // "HotSpot 64-Bit Tiered Compilers"
comp.getTotalCompilationTime();       // ms spent in JIT
comp.isCompilationTimeMonitoringSupported();

Performance Patterns

Auto-boxing overhead

// Bad - autoboxes every element
List<Integer> list = new ArrayList<>();
long sum = 0;
for (Integer i : list) sum += i;   // unbox on every iteration

// Good - no boxing
long sum = 0;
for (int i = 0; i < n; i++) sum += i;
// Or: IntStream.range(0, n).sum()

String building

// Bad - O(n²) allocations
String s = "";
for (int i = 0; i < n; i++) s += i;

// Good - O(n)
StringBuilder sb = new StringBuilder(n * 3);  // pre-size if known
for (int i = 0; i < n; i++) sb.append(i);
return sb.toString();

Object pooling

// Only for genuinely expensive-to-create objects (connections, buffers)
T obj = pool.acquire();
try { use(obj); }
finally { pool.release(obj); }

Summary

Concept Key point
ClassLoader hierarchy Bootstrap → Platform → Application; parent-first delegation
Class identity Same name + same ClassLoader = same class
Custom loader Override findClass(); call defineClass() with bytecode
GC generations Young (Eden + Survivors) → Old; Metaspace for class metadata
G1 GC Default since Java 9; region-based; predictable short pauses
ZGC / Shenandoah Sub-millisecond pauses; Java 15+ production
Reference strength Strong → Soft (pre-OOM) → Weak (next GC) → Phantom (post-finalise)
Cleaner Java 9+ replacement for finalize(); deterministic with close()
JIT tiers C1 (~1.5k calls) → C2 (~15k calls); inlining enables everything else
Warmup Benchmark only after JIT has compiled the hot path
Dead code trap Return/consume results; use JMH for serious benchmarking