← Knowledge Base

Java Internals

A deep dive into how the JVM works — from source code to native execution, memory management, garbage collection, and native builds.

↓
Section 01

Compilation

Java source code is not compiled directly to machine code. Instead, javac compiles it to bytecode — a platform-independent intermediate representation that the JVM can execute on any OS.

Hello.java
→
javac
→
Hello.class (bytecode)

Each .class file contains:

Multiple .class files can be bundled into a .jar (Java Archive) for distribution.

Section 01.5

Java Packaging Formats

After compilation, Java bytecode can be packaged in many ways depending on the target deployment environment.

FormatExtensionWhat It IsUsed For
JAR .jar Java Archive — a ZIP file containing .class files, resources, and a META-INF/MANIFEST.MF Libraries, standalone apps, CLI tools
Executable JAR .jar JAR with Main-Class in manifest — runnable via java -jar app.jar Spring Boot apps, self-contained services
Fat/Uber JAR .jar JAR with all dependencies bundled inside (classes or nested JARs) Single-file deployment (Spring Boot, Shadow plugin)
WAR .war Web Application Archive — contains servlets, JSPs, WEB-INF/, and web.xml Deployed to servlet containers (Tomcat, Jetty, WildFly)
EAR .ear Enterprise Application Archive — bundles multiple JARs and WARs together with application.xml Full Java EE / Jakarta EE app servers (WildFly, WebSphere)
RAR .rar Resource Adapter Archive — implements JCA (Java Connector Architecture) Connecting to legacy systems (EIS, mainframes, MQs)
JMOD .jmod Module archive (Java 9+) — contains classes, native libs, configs, legal notices JDK distribution modules, used by jlink to build custom JREs
jlink Image (directory) Custom minimal JRE + your modules — only includes required platform modules Trimmed runtime images (e.g., 30 MB instead of 300 MB)
Native Image .exe / binary GraalVM AOT-compiled standalone executable — no JVM needed Serverless, CLIs, containers, fast startup
jpackage .msi .dmg .deb .rpm Platform-specific installer wrapping a jlink image + your app (Java 14+) Desktop distribution to end users (no JDK install required)
Docker Image OCI image Container image with JRE/JDK + your JAR/WAR. Often based on Eclipse Temurin or distroless Cloud deployment, Kubernetes, microservices

Internal Structure

JAR: WAR: EAR: ├── META-INF/ ├── META-INF/ ├── META-INF/ │ └── MANIFEST.MF │ └── MANIFEST.MF │ └── application.xml ├── com/ ├── WEB-INF/ ├── lib/ │ └── myapp/ │ ├── web.xml │ └── shared.jar │ ├── Main.class │ ├── classes/ ├── myapp.war │ └── Utils.class │ │ └── com/myapp/... ├── ejb-module.jar └── resources/ │ └── lib/ └── rar-module.rar └── config.properties │ └── dependency.jar ├── index.html └── css/js/images...

Build Tool Mapping

Build ToolJARWAREARFat JARNativeDocker
Maven maven-jar-plugin maven-war-plugin maven-ear-plugin maven-shade-plugin native-maven-plugin jib-maven-plugin
Gradle jar task war plugin ear plugin shadow plugin graalvm native jib / bootBuildImage
Spring Boot Executable JAR (embedded Tomcat) or WAR — Default (nested JARs) spring-native bootBuildImage (Buildpacks)

Evolution Over Time

JAR (1996)
→
WAR/EAR (1999)
→
Fat JAR (2010s)
→
Docker + jlink (2017)
→
Native Image (2019+)

The trend is clear: from deploy-to-app-server (WAR/EAR) to self-contained executables (fat JAR, native image, containers). Modern Java apps are increasingly deployed as standalone units rather than components within a shared server.

Section 02

Class Loading

The JVM loads classes lazily — only when they are first referenced. A hierarchy of class loaders handles this:

Bootstrap ClassLoader → core Java (java.lang, java.util) ↓ Platform ClassLoader → platform modules (java.sql, java.xml) ↓ Application ClassLoader → your code (classpath)

The Three Phases of Loading

Loading → Linking → Initialization
// 1. LOADING // Reads .class bytes into memory // 2. LINKING // a) Verification — checks bytecode is valid & safe // b) Preparation — allocates static fields, sets defaults (0, null) // c) Resolution — resolves symbolic refs to memory addresses // 3. INITIALIZATION // Runs static {} blocks and static field assignments
Section 03

JVM Memory Layout

The JVM divides memory into several distinct areas, each serving a specific purpose.

Stack (per thread)

Local variables, method call frames, operand stack for temporary values. Each thread gets its own stack.

Heap (shared)

All objects and arrays live here. Shared across all threads. Managed by the garbage collector.

Metaspace (off-heap)

Class metadata, method bytecode, constant pools. Stored outside the main heap in native memory.

Code Cache

Stores native machine code generated by the JIT compiler. Grows as more methods get compiled.

Additionally, each thread has its own PC (Program Counter) Register that tracks the current bytecode instruction being executed.

Section 04

Bytecode Execution

The JVM is a stack-based virtual machine. Unlike register-based architectures (x86, ARM), all operations happen via an operand stack.

Java source
int a = 3; int b = 4; int c = a + b;
Bytecode instructions
iconst_3 // push 3 onto operand stack istore_1 // pop → store in local variable 1 (a) iconst_4 // push 4 onto operand stack istore_2 // pop → store in local variable 2 (b) iload_1 // push a (3) onto stack iload_2 // push b (4) onto stack iadd // pop both, add, push result (7) istore_3 // pop → store in local variable 3 (c)

The bytecode interpreter executes these instructions one by one. This is inherently slow — which is why the JIT compiler exists.

Section 05

JIT Compilation

The JVM monitors which methods run frequently. When a method becomes "hot" (called thousands of times), the JIT compiler converts it to optimized native machine code.

Tiered Compilation

Cold
Interpreted
→
Warm
C1 Compiled
→
Hot
C2 Compiled

C1 (Client compiler) — compiles quickly with basic optimizations. Gets you fast startup.

C2 (Server compiler) — compiles slowly with aggressive optimizations. Gets you peak throughput.

JIT Optimizations

OptimizationWhat it does
InliningPastes method body at call site, avoiding call overhead
Escape AnalysisObjects that don't escape a method get allocated on the stack (no GC needed)
Loop UnrollingReduces loop overhead by duplicating loop body
Dead Code EliminationRemoves code that has no effect on the result
DevirtualizationIf only one class implements an interface, skips virtual dispatch
Speculative OptimizationAssumes based on profiling data; deoptimizes if wrong

This is why Java gets faster over time — the JIT learns your application's actual behavior and optimizes for it.

Section 06

Garbage Collection

The JVM automatically manages heap memory. The core insight: most objects die young (generational hypothesis).

Heap Generations

Young Generation

New objects are allocated here. Collected frequently (Minor GC) — fast because most objects are already dead.

Eden (new allocations)
Survivor S0
Survivor S1

Old Generation

Objects that survived many Minor GCs get promoted here. Collected less frequently (Major GC / Full GC) — more expensive.

Tenured Space

Object Lifecycle

new Object()
→
Eden
→
Survivor (S0/S1)
→
Old Gen

GC Algorithms

CollectorFocusPause Time
G1 Balanced (default since Java 9) Medium
ZGC Ultra-low latency, large heaps < 1ms
Shenandoah Low latency, concurrent Low
Parallel Maximum throughput High
Serial Single-threaded, small apps High
Section 07

Object Layout in Memory

Every Java object stored on the heap has a specific internal structure. Even an empty new Object() takes 16 bytes on a 64-bit JVM.

Mark Word GC age, lock state, identity hash code, biased locking info 8 bytes
Class Pointer Points to class metadata in Metaspace (compressed with -XX:+UseCompressedOops) 4 bytes
Instance Fields Your actual data — primitives stored inline, references stored as pointers variable
Padding Alignment to 8-byte boundary for memory access efficiency 0-7 bytes

Arrays have an additional 4-byte length field between the class pointer and the array data.

Section 08

Threading Model

Java threads traditionally map 1:1 to OS (kernel) threads. Each thread gets its own stack, program counter, and OS-level scheduling.

Lock Escalation

When you use synchronized, the JVM uses the object's Mark Word for locking. The lock escalates through stages:

Biased Lock
→
Thin Lock (CAS spin)
→
Fat Lock (OS mutex)

Biased — optimistic, assumes only one thread accesses the object. Zero overhead.
Thin — uses Compare-And-Swap (CAS) spin loop. Fast for short contention.
Fat — falls back to OS-level mutex. Expensive but fair.

Virtual Threads (Java 21+)

Lightweight threads managed by the JVM, not the OS. Millions can run concurrently. Mounted/unmounted on a small pool of carrier (OS) threads. Ideal for I/O-bound workloads.

Section 09

I/O Models & Thread Types

How Java handles I/O (disk, network, database) has evolved dramatically. Understanding this is key to building high-performance applications.

Blocking I/O (java.io / Traditional)

The original Java I/O model. Each I/O operation blocks the calling thread until it completes.

Blocking I/O — Thread-per-connection
// Each client gets its own thread while (true) { Socket client = serverSocket.accept(); // BLOCKS until connection new Thread(() -> { InputStream in = client.getInputStream(); in.read(buffer); // BLOCKS until data arrives // process... }).start(); }
Thread-1: [===BLOCKED (waiting for data)===]---[process]---[===BLOCKED===] Thread-2: [===BLOCKED (waiting for data)===]---[process]---[===BLOCKED===] Thread-3: [===BLOCKED (waiting for data)===]---[process]---[===BLOCKED===] ... Thread-N: [===BLOCKED===] ← each connection = 1 OS thread (~1MB stack each)

Problem: 10,000 connections = 10,000 threads = ~10GB of stack memory, massive context switching overhead. This is the C10K problem.

Non-Blocking I/O (java.nio / NIO)

Introduced in Java 1.4. A single thread can manage many connections using a Selector that monitors multiple channels.

NIO — Selector-based multiplexing
Selector selector = Selector.open(); channel.register(selector, SelectionKey.OP_READ); while (true) { selector.select(); // waits until ANY channel is ready Set<SelectionKey> keys = selector.selectedKeys(); for (SelectionKey key : keys) { if (key.isReadable()) { // read data (non-blocking) } } }
Selector (1 thread) | ┌────────────┼────────────┐ | | | Channel-1 Channel-2 Channel-3 (conn A) (conn B) (conn C) → OS notifies selector which channels have data ready → 1 thread handles thousands of connections

Under the hood, this maps to OS-level primitives: epoll (Linux), kqueue (macOS), IOCP (Windows).

Asynchronous I/O (NIO.2 / java.nio.channels.AsynchronousChannel)

Introduced in Java 7. True async — you submit an I/O operation and get a callback or Future when it completes. The OS does the work.

Async I/O — Callback-based
channel.read(buffer, null, new CompletionHandler<>() { public void completed(Integer result, Object att) { // called when read is done — on a different thread } public void failed(Throwable exc, Object att) { // called on error } });

Thread Types in a Typical Java Application

Thread TypePurposeManaged By
Main Thread Runs main(), the entry point of the application JVM
Worker / Application Threads Handle business logic, request processing Thread pool (e.g., ExecutorService)
I/O Threads Handle network reads/writes, selector loops NIO framework (Netty, Undertow)
Event Loop Threads Single-threaded loop processing events (NIO selector) Framework (Netty EventLoop)
GC Threads Garbage collection (parallel/concurrent) JVM
JIT Compiler Threads Background compilation of hot methods JVM (C1/C2 compilers)
Finalizer Thread Runs finalize() on objects before GC JVM
Daemon Threads Background tasks; JVM exits when only daemons remain User / Framework
Virtual Threads (Java 21+) Lightweight, JVM-scheduled threads for I/O-bound work JVM runtime

I/O Threads vs Worker Threads

In frameworks like Netty, Spring WebFlux, and Vert.x, there's a critical separation:

I/O Threads (Event Loop)

  • ● Small fixed pool (usually = CPU cores)
  • ● Run the NIO selector loop
  • ● Handle socket read/write/accept
  • ● NEVER block these — blocks all connections on that loop
  • ● Decode/encode, dispatch to workers

Worker Threads

  • ● Larger pool (configurable)
  • ● Execute business logic
  • ● Can do blocking operations (DB, file I/O)
  • ● Managed by ExecutorService or framework pool
  • ● Blocking here only affects that one task
Request arrives | ┌─────────▼──────────┐ | I/O Thread | (event loop, non-blocking) | - accept socket | | - read bytes | | - decode request | └─────────┬──────────┘ | dispatch ┌─────────▼──────────┐ | Worker Thread | (from thread pool) | - business logic | | - DB query | | - compute result | └─────────┬──────────┘ | result ┌─────────▼──────────┐ | I/O Thread | | - encode response | | - write to socket | └────────────────────┘

How Virtual Threads Change Everything

With Virtual Threads (Java 21+), you write blocking-style code but the JVM handles it efficiently:

Virtual Threads — simple blocking code, massive concurrency
try (var executor = Executors.newVirtualThreadPerTaskExecutor()) { for (int i = 0; i < 100_000; i++) { executor.submit(() -> { String result = httpClient.send(request); // looks blocking processResult(result); // but JVM unmounts thread }); // during I/O wait } }
Virtual Thread during I/O: [running on Carrier-1]──▶ hits I/O ──▶ [UNMOUNTED from Carrier-1] | Carrier-1 is FREE | I/O completes to run other virtual | threads! ▼ [MOUNTED back on Carrier-2]──▶ continues
ModelThreads for 10K connectionsMemoryCode Style
Blocking I/O 10,000 OS threads ~10 GB Simple (blocking)
NIO (Netty/WebFlux) ~8-16 I/O threads ~50 MB Complex (reactive/callbacks)
Virtual Threads 10,000 virtual + ~16 carrier ~50 MB Simple (blocking style)

Virtual threads give you the simplicity of blocking code with the efficiency of non-blocking I/O. This is why they're considered a game-changer for Java.

Section 10

Method Dispatch

When Java calls a method, the JVM must figure out which actual code to run. This varies by call type and has real performance implications.

Types of Invocation

BytecodeUsed ForDispatchSpeed
invokestatic Static methods Direct (resolved at compile time) Fastest
invokespecial Constructors, super, private methods Direct Fast
invokevirtual Instance methods on classes Virtual (vtable lookup) Medium
invokeinterface Methods via interface reference Interface (itable scan) Slower
invokedynamic Lambdas, string concat (Java 9+), method handles Bootstrap method resolves call site once Adaptive

vtable (Virtual Method Table)

Every class has a vtable — an array of method pointers. When calling invokevirtual, the JVM:

class Animal vtable: [ eat() → Animal.eat, speak() → Animal.speak ] class Dog extends Animal vtable: [ eat() → Animal.eat, speak() → Dog.speak ] ↑ overridden animal.speak() → look up object's actual class → vtable[1] → Dog.speak()

The vtable offset is constant for a given method, so dispatch is just an array index lookup — O(1).

itable (Interface Table)

Interfaces are harder because a class can implement many interfaces in any order. The JVM must search the itable to find the right interface, then index into it.

class Dog implements Animal, Pet, Serializable itable: [ Animal → [eat:offset0, speak:offset1], Pet → [name:offset0, cuddle:offset1], Serializable → [...] ] pet.cuddle() → scan itable for Pet → index into Pet's table → Dog.cuddle()

This scan makes invokeinterface slightly slower than invokevirtual. The JIT often optimizes this away via inline caching.

invokedynamic

Added in Java 7 (for JVM languages), heavily used since Java 8+ for lambdas. Instead of hardcoding the dispatch, a bootstrap method runs once to link the call site:

Lambda under the hood
// You write: Runnable r = () -> System.out.println("hello"); // Compiler generates: // 1. A private static method: lambda$main$0() // 2. invokedynamic call to LambdaMetafactory.metafactory() // 3. Bootstrap method creates a lightweight class at runtime // 4. Subsequent calls go directly to the generated class (cached)
Section 11

String Internals

Strings are the most allocated objects in most Java apps. The JVM has multiple optimizations for them.

String Pool (Intern Pool)

The JVM maintains a hash table of unique strings in native memory. String literals are automatically interned.

String pool behavior
String a = "hello"; // goes to string pool String b = "hello"; // reuses same reference from pool a == b // true (same object) String c = new String("hello"); // creates NEW object on heap a == c // false (different objects) a == c.intern() // true (intern returns pool reference)
String Pool (native memory, hash table) ┌─────────────────────────────────────┐ │ "hello" → 0x7f001000 │ │ "world" → 0x7f001040 │ │ "java" → 0x7f001080 │ └─────────────────────────────────────┘ Heap ┌─────────────────────────────────────┐ │ new String("hello") → 0x7f002000 │ ← separate object! └─────────────────────────────────────┘

Compact Strings (Java 9+)

Before Java 9, all strings used char[] (2 bytes per character, UTF-16). Since most strings are ASCII/Latin-1:

VersionInternal StorageMemory per char
Java 8 and earlier char[] (always UTF-16) 2 bytes
Java 9+ byte[] + coder flag 1 byte (Latin-1) or 2 bytes (UTF-16)
Inside java.lang.String (Java 9+)
public final class String { private final byte[] value; // actual character data private final byte coder; // 0 = LATIN1, 1 = UTF16 private int hash; // cached hashCode (0 = not computed) }

String Concatenation (Java 9+)

Before Java 9, "a" + b + "c" compiled to StringBuilder chains. Since Java 9, it uses invokedynamic + StringConcatFactory, allowing the JVM to pick the best strategy at runtime (up to ~2.5x faster).

Section 12

Java Memory Model (JMM)

The JMM defines when a write by one thread becomes visible to another thread. Without it, modern hardware and compilers would make multithreaded code unpredictable.

The Problem

CPU-1 cache: x = 1 CPU-2 cache: x = 0 ↑ wrote ↑ stale! └─────── Main Memory: x = ??? ───────┘ Without rules, CPU-2 may NEVER see CPU-1's write.

Both the CPU and the JIT compiler can reorder instructions for performance. The JMM defines happens-before rules that guarantee visibility.

Happens-Before Rules

RuleGuarantee
Program orderEach action in a thread happens-before the next action in that thread
Monitor lockUnlock happens-before subsequent lock of the same monitor
VolatileWrite to volatile happens-before subsequent read of same volatile
Thread startthread.start() happens-before any action in that thread
Thread joinAll actions in a thread happen-before join() returns
Final fieldsWriting a final field in constructor happens-before reading it (if this didn't escape)

volatile Deep Dive

What volatile actually does
volatile boolean ready = false; int data = 0; // Thread 1: data = 42; // (1) regular write ready = true; // (2) volatile write — STORE BARRIER // flushes ALL prior writes to main memory // Thread 2: if (ready) { // (3) volatile read — LOAD BARRIER // invalidates cache, reads fresh from memory print(data); // (4) guaranteed to see 42 }
volatile write: [StoreStore barrier] → write → [StoreLoad barrier] volatile read: [LoadLoad barrier] → read → [LoadStore barrier] These barriers prevent CPU and compiler reordering across the volatile access.

The Broken Double-Checked Locking

Why this broke before volatile fix
// BROKEN without volatile (pre-Java 5 semantics) class Singleton { static Singleton instance; // NOT volatile! static Singleton get() { if (instance == null) { // check 1 (no lock) synchronized (Singleton.class) { if (instance == null) { // check 2 (locked) instance = new Singleton(); // CPU can reorder: assign reference BEFORE // constructor finishes! Another thread sees // a non-null but PARTIALLY CONSTRUCTED object. } } } return instance; } } // FIX: declare "volatile static Singleton instance" // The StoreStore barrier ensures constructor completes // before the reference is published.
Section 13

Direct / Off-Heap Memory

Not all memory in a Java app lives on the GC-managed heap. Off-heap memory is allocated in native (OS) memory, outside the GC's reach.

Why Go Off-Heap?

Methods of Off-Heap Access

APISinceSafetyUse Case
ByteBuffer.allocateDirect() Java 1.4 Safe NIO channels, network I/O
MappedByteBuffer (mmap) Java 1.4 Safe Memory-mapped files (databases, large files)
sun.misc.Unsafe Internal Unsafe Raw pointer arithmetic, CAS, used by frameworks
Foreign Memory API (Panama) Java 22+ Restricted Safe replacement for Unsafe, native interop
JVM Process Memory Map: ┌──────────────────────────────────┐ │ Java Heap (GC managed) │ -Xmx │ ┌──────────┐ ┌────────────┐ │ │ │ Young Gen │ │ Old Gen │ │ │ └──────────┘ └────────────┘ │ ├──────────────────────────────────┤ │ Metaspace (class metadata) │ -XX:MaxMetaspaceSize ├──────────────────────────────────┤ │ Code Cache (JIT compiled) │ -XX:ReservedCodeCacheSize ├──────────────────────────────────┤ │ Thread Stacks (1MB × N) │ -Xss per thread ├──────────────────────────────────┤ │ Direct ByteBuffers │ -XX:MaxDirectMemorySize ├──────────────────────────────────┤ │ Native Allocations │ malloc / Unsafe / Panama │ (JNI, native libs, etc.) │ └──────────────────────────────────┘

A common OOM scenario: heap looks fine, but direct memory or native allocations exhausted the process's address space. Tools like jcmd Native Memory Tracking (NMT) help debug this.

Section 14

TLAB (Thread-Local Allocation Buffer)

Object allocation in Java is extremely fast — often just a pointer bump. TLABs are the reason why.

The Problem Without TLAB

The heap is shared across all threads. Without TLABs, every new Object() would need a lock or CAS to safely advance the allocation pointer. With thousands of allocations per second per thread, this would be a massive bottleneck.

How TLABs Work

Eden Space: ┌────────────────────────────────────────────────────────┐ │ ┌──────────┐ ┌──────────┐ ┌──────────┐ ┌───────┐ │ │ │ Thread-1 │ │ Thread-2 │ │ Thread-3 │ │ free │ │ │ │ TLAB │ │ TLAB │ │ TLAB │ │ │ │ │ │ [#### ] │ │ [## ] │ │ [##### ] │ │ │ │ │ │ ↑ ptr │ │ ↑ ptr │ │ ↑ ptr │ │ │ │ │ └──────────┘ └──────────┘ └──────────┘ └───────┘ │ └────────────────────────────────────────────────────────┘ Each thread allocates within its OWN buffer — no locking needed!
Allocation fast path (pseudo-assembly)
// new Object() compiles down to roughly: if (tlab.ptr + objectSize <= tlab.end) { obj = tlab.ptr; // just a pointer tlab.ptr += objectSize; // bump it forward return obj; // ~10 nanoseconds! } else { // TLAB exhausted — get a new one (rare, requires sync) allocateNewTLAB(); }

This is why Java object allocation is often faster than C's malloc() — malloc uses free lists and coalescing, while TLAB allocation is just a pointer increment.

Section 15

Safepoints

A safepoint is a point in execution where a thread's state is fully known to the JVM. The GC (and other VM operations) can only inspect/modify threads at safepoints.

Why They Exist

The GC needs to know which references are on the stack, in registers, etc. It can't safely scan a thread in the middle of arbitrary code. So the JVM places safepoint polls at specific locations.

Where Safepoints Are Placed

LocationHas Safepoint?
Method entry/exitYes
Loop back-edges (non-counted loops)Yes
Counted loops (for i=0; i<n)No (JIT optimizes away!)
JNI calls (native code)Yes (on return)
Thread.sleep / Object.waitYes

The Dangerous Counted Loop

This loop can delay GC for seconds
// Counted loop — JIT removes safepoint polls for speed for (int i = 0; i < 2_000_000_000; i++) { sum += array[i % array.length]; // NO safepoint here! GC must wait for this loop to finish. // ALL other threads also stuck waiting at their safepoints. } // Fix: use long instead of int, or add Thread.onSpinWait() for (long i = 0; i < 2_000_000_000L; i++) { // non-counted → has safepoints sum += array[(int)(i % array.length)]; }
Time-to-Safepoint (TTSP): GC: "I need all threads at safepoints!" Thread-1: [at safepoint] ✓ waiting... Thread-2: [at safepoint] ✓ waiting... Thread-3: [in counted loop, NO safepoint...............] ✓ finally! ↑ ALL threads paused this long This is TTSP latency

Use -XX:+SafepointTimeout -XX:SafepointTimeoutDelay=2000 or JFR's SafepointBegin event to diagnose slow safepoints.

Section 16

Compressed Oops

"Oop" stands for Ordinary Object Pointer. On a 64-bit JVM, every reference is 8 bytes — doubling memory usage compared to 32-bit. Compressed Oops fixes this.

How It Works

Since objects are aligned to 8-byte boundaries, the last 3 bits of any address are always 000. The JVM stores only the upper bits, effectively using 32-bit pointers to address 235 = 32 GB of heap.

Real address: 0x00000007_F8001000 (64-bit) Compressed (>> 3): 0x0000_0000_FF000200 (32-bit stored) Decompress (<< 3): 0x00000007_F8001000 (original restored) 32-bit pointer × 8 alignment = 32 GB addressable heap
Heap SizeCompressed OopsReference SizeImpact
< ~32 GB Enabled (default) 4 bytes ~30-40% less memory for pointer-heavy apps
> ~32 GB Disabled 8 bytes Sudden memory jump — 36 GB heap can use MORE than 32 GB

Practical advice: Heaps of 32-33 GB are a dead zone. Either stay under 32 GB (compressed) or go well above (e.g. 40 GB+) to actually gain usable space.

Section 17

Warmup & Benchmarking

Java performance is non-deterministic at startup. A method's speed changes over its lifetime as the JVM learns and optimizes.

The Warmup Curve

Throughput ↑ │ ┌──────────────── C2 optimized (peak) │ ┌────┘ │ ┌────┘ │ ┌────┘ C1 compiled │ ┌────┘ │ ┌────┘ │─┘ Interpreted (cold) └──────────────────────────────────────→ Time | warmup period | steady state |

Why Microbenchmarks Lie

Everything wrong with naive benchmarking
// DON'T do this: long start = System.nanoTime(); for (int i = 0; i < 1_000_000; i++) { doSomething(); // JIT may ELIMINATE this entirely (dead code) } long elapsed = System.nanoTime() - start; // Problems: // 1. No warmup — measuring interpreted + C1 + C2 mixed // 2. Dead code elimination — JIT removes code with no side effects // 3. On-Stack Replacement (OSR) — JIT compiles mid-loop (weird perf) // 4. No GC control — GC pauses included in timing // 5. Loop unrolling — JIT optimizes the loop itself

Use JMH (Java Microbenchmark Harness) instead. It handles warmup iterations, blackholes (prevent dead code elimination), fork (separate JVM per benchmark), and proper statistics.

On-Stack Replacement (OSR)

When the JIT detects a hot loop while it's already running, it compiles the method and replaces the frame on the stack mid-execution. This transitions from interpreted to compiled code without waiting for the next method call.

Section 18

java.util.concurrent Internals

The j.u.c package is the backbone of all Java concurrency. Understanding its primitives explains how everything else works.

CAS (Compare-And-Swap)

The fundamental building block. A single atomic CPU instruction that says: "set value to Y only if it's currently X".

CAS operation (maps to CPU cmpxchg instruction)
// Pseudocode for what the CPU does atomically: boolean compareAndSwap(int* address, int expected, int newValue) { if (*address == expected) { *address = newValue; return true; // success } return false; // someone else changed it — retry } // Used in AtomicInteger.incrementAndGet(): do { current = get(); next = current + 1; } while (!compareAndSwap(current, next)); // spin until success

CAS is lock-free (no OS mutex), but can spin under high contention. For counters, LongAdder (striped CAS) is faster than AtomicLong under contention.

AQS (AbstractQueuedSynchronizer)

The framework behind ReentrantLock, Semaphore, CountDownLatch, and ReentrantReadWriteLock. It provides:

AQS Internal Structure: state: int (meaning depends on subclass) - ReentrantLock: 0 = unlocked, N = locked N times - Semaphore: number of permits remaining - CountDownLatch: count remaining Wait Queue (CLH queue): HEAD ←→ [Thread-2 | WAITING] ←→ [Thread-3 | WAITING] ←→ TAIL 1. Thread tries CAS on state → success? acquired! 2. Failed? → enqueue in CLH queue → park (OS-level suspend) 3. Release? → update state → unpark head of queue

ConcurrentHashMap Internals

ConcurrentHashMap (Java 8+)
// No more Segment-based locking (Java 7 approach) // Java 8+ uses per-bucket CAS + synchronized on tree/list head // Structure: Node[] table; // array of buckets // put() logic: // 1. Hash key → bucket index // 2. Bucket empty? → CAS a new node in (no lock!) // 3. Bucket has nodes? → synchronized(head node) { // walk linked list or red-black tree // insert/update // } // 4. List > 8 nodes? → convert to red-black tree (O(n) → O(log n)) // 5. Concurrent resize: threads HELP with transfer (cooperative)
Section 19

Fork/Join Framework

Introduced in Java 7 for divide-and-conquer parallelism. Also the engine behind parallelStream().

Work-Stealing Algorithm

Thread-1's deque: [Task-A] [Task-B] [Task-C] ← pushes/pops from THIS end Thread-2's deque: [Task-D] Thread-3's deque: (empty) → STEALS from Thread-1's OTHER end → [Task-C] - Each thread has a double-ended queue (deque) of tasks - Works on its own tasks from the HEAD (LIFO — small tasks first) - When idle, STEALS from another thread's TAIL (FIFO — large tasks) - Large tasks at tail → stolen work has more sub-tasks → good load balancing
Fork/Join in action
class SumTask extends RecursiveTask<Long> { protected Long compute() { if (array.length <= THRESHOLD) { return sequentialSum(); // small enough — compute directly } SumTask left = new SumTask(firstHalf); SumTask right = new SumTask(secondHalf); left.fork(); // submit to deque (may be stolen) Long rightResult = right.compute(); // compute in current thread Long leftResult = left.join(); // wait for forked result return leftResult + rightResult; } }

parallelStream() uses the common ForkJoinPool (ForkJoinPool.commonPool()). Its default parallelism = Runtime.availableProcessors() - 1. Blocking operations inside parallelStream can starve other parallel streams in the same JVM.

Section 20

Structured Concurrency (Java 21+)

Traditional threading has a problem: subtasks can outlive their parent, leak resources, or fail silently. Structured concurrency enforces a parent-child relationship.

Structured concurrency — tasks can't leak
try (var scope = new StructuredTaskScope.ShutdownOnFailure()) { Subtask<User> user = scope.fork(() -> fetchUser(id)); Subtask<Order> order = scope.fork(() -> fetchOrder(id)); scope.join(); // wait for ALL subtasks scope.throwIfFailed(); // propagate first failure // Both succeeded — safe to use results return new Response(user.get(), order.get()); } // scope closes → any incomplete subtasks are CANCELLED

Unstructured (Traditional)

  • ✗ Subtasks can outlive parent
  • ✗ Failure in one task — others keep running
  • ✗ Hard to cancel all related work
  • ✗ Thread dumps show flat list

Structured

  • ✓ Subtasks bounded by scope lifetime
  • ✓ Failure cancels siblings automatically
  • ✓ Clean cancellation propagation
  • ✓ Thread dumps show task hierarchy
Section 21

Java Module System (JPMS)

Introduced in Java 9 (Project Jigsaw). Modules add a layer of encapsulation above packages.

module-info.java
module com.myapp.core { requires java.sql; // depends on java.sql module requires transitive java.logging; // dependents also get java.logging exports com.myapp.core.api; // only this package is visible opens com.myapp.core.internal to // allow reflection only for com.google.gson; // specific modules }
Before Modules (Java 8): classpath: everything sees everything └── any class can access any public class → accidental coupling With Modules (Java 9+): ┌─────────────────┐ ┌─────────────────┐ │ java.base │ │ java.sql │ │ (always avail) │←────│ requires base │ │ exports: │ │ exports: │ │ java.lang │ │ java.sql │ │ java.util │ │ javax.sql │ │ java.io │ └─────────────────┘ └─────────────────┘ ↑ ┌───────┴─────────┐ │ com.myapp.core │ │ requires sql │ │ exports: api │ │ hides: internal │ └─────────────────┘

Key benefit: internal packages (like sun.misc.Unsafe) are now truly hidden. The JDK itself was split into ~70 modules, so tools like jlink can create custom minimal JREs (e.g., 30 MB instead of 300 MB).

Section 22

Custom Class Loaders

The JVM's class loading architecture is pluggable. This enables app server isolation, hot-reload, and plugin systems.

How App Servers Isolate Web Apps

Bootstrap ClassLoader (JDK core) ↓ Platform ClassLoader (JDK modules) ↓ App ClassLoader (server code: Tomcat, Jetty) ↓ ↓ WebApp-1 ClassLoader WebApp-2 ClassLoader (loads WEB-INF/lib) (loads WEB-INF/lib) ┌─────────────────┐ ┌─────────────────┐ │ Guava 31.0 │ │ Guava 28.0 │ ← different versions! │ MyServlet.class │ │ OtherServlet │ └─────────────────┘ └─────────────────┘ Same class name, different classloaders = DIFFERENT classes to the JVM

Hot Reload

Frameworks like Spring DevTools and JRebel create a new classloader when code changes, loading the updated classes while keeping the base classloader intact. This is faster than a full restart.

Minimal custom ClassLoader
class PluginLoader extends ClassLoader { protected Class<?> findClass(String name) { byte[] bytes = loadBytesFromPlugin(name); // custom source return defineClass(name, bytes, 0, bytes.length); } } // Two instances of PluginLoader can load the same class name // but the JVM treats them as COMPLETELY DIFFERENT types // (class identity = class name + classloader)
Section 23

JFR & JMX

Java Flight Recorder (JFR)

A low-overhead profiling framework built into the JVM. Records events continuously with ~1% overhead. Think of it as a "black box recorder" for your JVM.

Event CategoryExamples
GCPause durations, promotion rates, allocation rates
JITCompilations, deoptimizations, code cache usage
ThreadingContention, park/unpark, thread starts
I/OFile read/write, socket read/write with durations
SafepointsTime-to-safepoint, safepoint duration
AllocationObject allocation in/outside TLAB
ExceptionsException construction (with stack traces)
Class loadingClasses loaded/unloaded, classloader stats
Using JFR
# Start recording with JVM java -XX:StartFlightRecording=duration=60s,filename=recording.jfr MyApp # Attach to running JVM jcmd <pid> JFR.start duration=60s filename=recording.jfr # Analyze with JDK Mission Control (JMC) or programmatically

JMX (Java Management Extensions)

A live monitoring and management API. Exposes MBeans (managed beans) for heap usage, thread counts, GC stats, and custom app metrics.

Your App ←→ MBeanServer ←→ JMX Connector ←→ Monitoring Tool (JConsole, VisualVM, Prometheus JMX Exporter, Grafana, Datadog) Built-in MBeans: ├── java.lang:type=Memory → heap/non-heap usage ├── java.lang:type=GarbageCollector → GC count, time ├── java.lang:type=Threading → thread count, deadlocks ├── java.lang:type=OperatingSystem → CPU, memory └── java.lang:type=Runtime → uptime, classpath, VM args
Section 24

SPI & ServiceLoader

The Service Provider Interface pattern lets you write code against an interface and discover implementations at runtime — without hardcoding them.

How It Works

1. Define an interface: com.db.Driver 2. Implementations register themselves: META-INF/services/com.db.Driver └── contains: "com.mysql.MySQLDriver" "org.postgresql.PGDriver" 3. Discover at runtime: ServiceLoader<Driver> drivers = ServiceLoader.load(Driver.class); for (Driver d : drivers) { // iterates all registered implementations }

Real-World Uses

SystemSPI InterfaceWhat Gets Loaded
JDBCjava.sql.DriverMySQL, PostgreSQL, Oracle drivers
SLF4JSLF4JServiceProviderLogback, Log4j2 bindings
ServletServletContainerInitializerSpring, Jersey bootstrapping
Java Modulesprovides...withModule-aware SPI (replaces META-INF)
Section 25

Annotation Processing

Annotations can be processed at compile time to generate code, validate constraints, or produce metadata — before your app even runs.

When Annotations Run

.java + annotations
→
javac (rounds of processing)
→
generated .java files
→
.class output
RetentionAvailable AtExample
SOURCE Compile time only (discarded after) @Override, @SuppressWarnings, Lombok
CLASS In .class file but not at runtime ByteBuddy, some static analysis tools
RUNTIME Available via reflection at runtime @Autowired, @Entity, @GET

Compile-Time Code Generators

ToolWhat It GeneratesApproach
LombokGetters, setters, builders, equals/hashCodeAST manipulation (modifies compile tree directly)
MapStructType-safe object mapping codeStandard annotation processing (generates source files)
Dagger 2Dependency injection wiringGenerates DI factories at compile time (no reflection at runtime)
MicronautBean definitions, AOP proxiesCompile-time DI — no reflection needed (native-build friendly)

Key insight: Compile-time processing trades build speed for runtime speed. Frameworks like Micronaut and Dagger generate everything ahead-of-time, making them ideal for native builds where reflection is limited.

Section 26

Project Valhalla (Value Types)

Valhalla's goal: eliminate the overhead of object headers for small, identity-less types. Codes like a class, performs like a primitive.

The Problem

Current: Point (x=3, y=4) as an object ┌──────────────┐ │ Mark Word 8B │ ← not needed for a simple pair of ints! │ Class Ptr 4B │ ← overhead │ x: int 4B │ ← your data (only 8B of actual payload) │ y: int 4B │ ← │ padding 4B │ └──────────────┘ Total: 24 bytes for 8 bytes of data (67% overhead!) Array of 1M Points: 1M objects, 1M allocations, 1M GC-tracked references [ref][ref][ref]... → each ref points to a scattered heap object (cache misses)

The Solution: Value Classes

With Valhalla: value class Point ┌──────────┐ │ x: int 4B │ ← NO header! Just the data. │ y: int 4B │ └──────────┘ Total: 8 bytes (0% overhead) Array of 1M Points: flat, contiguous memory [x,y][x,y][x,y]... → cache-friendly, like a struct array in C - No object identity (no synchronization, no == for identity) - No null (unless declared as nullable) - Can be flattened into arrays and fields - GC doesn't need to track individual value objects
Value class syntax (preview)
value class Point { int x; int y; Point(int x, int y) { this.x = x; this.y = y; } } // What you CAN'T do with value classes: // - synchronized(point) — no identity, no monitor // - point1 == point2 — compares by VALUE, not identity // - point = null — not allowed (unless Point?)

Impact: Huge for numerical computing, financial systems, game engines, and any code that works with large collections of small objects (Complex, Color, LocalDate, etc.).

Section 27

Project Panama

Panama replaces JNI with a safe, efficient way to call native code and access native memory from Java.

The Problem with JNI

JNI Pain PointPanama Solution
Write C header + C implementation for every methodCall native functions directly from Java (no C glue code)
Manual memory management (easy to leak or corrupt)Arena-based lifecycle management (auto-close)
No safety — JNI can crash the JVMRestricted methods with opt-in, bounds-checked access
Complex build (javah, .so/.dll, library path)Pure Java — jextract generates bindings from .h files

Foreign Function & Memory API

Calling C's strlen() from Java (no JNI)
// 1. Look up the native function Linker linker = Linker.nativeLinker(); SymbolLookup stdlib = linker.defaultLookup(); MethodHandle strlen = linker.downcallHandle( stdlib.find("strlen").get(), FunctionDescriptor.of(JAVA_LONG, ADDRESS) ); // 2. Allocate native memory safely try (Arena arena = Arena.ofConfined()) { MemorySegment str = arena.allocateFrom("Hello Panama!"); long len = (long) strlen.invoke(str); // len = 13 } // arena closes → native memory freed automatically
jextract workflow: mylib.h (C header) → jextract → MyLib.java (generated bindings) ↓ Your code calls MyLib.function() ↓ Linker invokes native function (no JNI, no C glue code)
Section 28

Project Loom — Deep Dive

Loom adds continuations to the JVM — the low-level primitive that makes virtual threads possible.

What Is a Continuation?

A continuation is a pauseable, resumable computation. It captures the entire call stack and can yield control, then resume exactly where it left off.

Continuation execution: run() → methodA() → methodB() → yield() ← stack frozen, stored on heap (carrier thread is FREE) ... later ... resume() → methodB() continues → returns → methodA() continues → done ↑ stack restored from heap

Virtual Thread Architecture

Virtual Threads (millions) ┌────┐ ┌────┐ ┌────┐ ┌────┐ ┌────┐ ┌────┐ │ VT1│ │ VT2│ │ VT3│ │ VT4│ │ VT5│ │ VT6│ ... └──┬─┘ └──┬─┘ └──┬─┘ └──┬─┘ └──┬─┘ └──┬─┘ │ │ │ │ │ │ ▼ ▼ ▼ ▼ ▼ ▼ ┌──────────────────────────────────────────┐ │ ForkJoinPool (scheduler) │ │ work-stealing, FIFO scheduling │ └──────┬──────────┬──────────┬─────────────┘ ▼ ▼ ▼ ┌──────┐ ┌──────┐ ┌──────┐ │ CT-1 │ │ CT-2 │ │ CT-3 │ Carrier Threads │ (OS) │ │ (OS) │ │ (OS) │ (= CPU cores) └──────┘ └──────┘ └──────┘

What Triggers Unmounting?

OperationUnmounts?Notes
Thread.sleep()YesVirtual thread yields, carrier freed
Socket.read()/write()YesReimplemented with non-blocking I/O internally
BlockingQueue.take()YesParks virtual thread
ReentrantLock.lock()YesUnmounts while waiting
synchronized blockNo — PINS!Carrier thread blocked (avoid in hot paths)
Native method (JNI)No — PINS!Can't unmount inside native code

Pinning: The Gotcha

Pinning — when virtual threads lose their advantage
// BAD: synchronized pins the carrier thread synchronized (lock) { socket.read(buffer); // carrier BLOCKED (pinned), can't run other VTs } // GOOD: ReentrantLock unmounts properly lock.lock(); try { socket.read(buffer); // virtual thread unmounts, carrier freed! } finally { lock.unlock(); } // Detect pinning: -Djdk.tracePinnedThreads=full

Stack Storage

Unlike OS threads (fixed ~1MB stack), virtual thread stacks are stored on the heap as stack chunk objects. They start small (~1KB) and grow as needed. This is why you can have millions — their memory footprint is proportional to actual stack depth, not a fixed reservation.

Section 29

Native Build (GraalVM)

A native build compiles Java ahead-of-time (AOT) into a standalone binary. No JVM required at runtime.

What Happens During a Native Build

.java
→
javac
→
.class
→
native-image
→
binary
StepWhat it does
Static AnalysisStarts from main(), traces all reachable code. Unreachable code is excluded.
AOT CompilationCompiles all reachable bytecode to native machine code (x86, ARM).
Heap SnapshottingRuns static initializers at build time. Pre-built objects are embedded in the binary.
Substrate VMEmbeds a minimal runtime for GC, threading, and exception handling.
Dead Code EliminationStrips unused classes, methods, and JDK modules.
LinkingProduces a single platform-specific executable.

What's Not Available by Default

Because native builds use a closed-world assumption, these features require explicit configuration:

Section 30

JVM vs Native Build

AspectTraditional JVMNative Build
Class loadingAt runtime (lazy)All resolved at build time
ExecutionInterpreted → JIT compiledAOT compiled native code
StartupSecondsMilliseconds
Peak throughputHigher~10-30% lower
MemoryHigher50-80% lower
Build timeFastVery slow
ReflectionFull supportNeeds config
Dynamic loadingSupportedNot possible

JVM Wins When

  • Long-running applications
  • Peak throughput matters most
  • Heavy use of reflection/proxies
  • Dynamic plugins or class loading
  • Fast development iteration

Native Wins When

  • Serverless / Lambda (cold start)
  • CLI tools
  • Microservices at scale
  • Container-constrained environments
  • Low memory footprint is critical

The Big Picture

Traditional JVM .java → javac → .class → ClassLoader → Interpreter → JIT → Native Code ↑ GC manages heap Native Build .java → javac → .class → static analysis → AOT compile → single binary (build time) (no JVM needed)

The JVM trades startup cost for runtime intelligence (profiling + JIT). Native builds trade runtime adaptability for instant startup and lower resource usage.