Module 24 - JVM Internals: GC, JIT, ClassLoaders
Table of contents
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)
- Ask parent to load the class
- If parent returns null, try this loader’s own search path
- 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 |