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.
Each .class file contains:
- Constant Pool — literals, class/method references
- Method Bytecode — the actual instructions
- Field Info — field names, types, access modifiers
- Metadata — annotations, source file info
Multiple .class files can be bundled into a .jar (Java Archive) for distribution.
Java Packaging Formats
After compilation, Java bytecode can be packaged in many ways depending on the target deployment environment.
| Format | Extension | What It Is | Used 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
Build Tool Mapping
| Build Tool | JAR | WAR | EAR | Fat JAR | Native | Docker |
|---|---|---|---|---|---|---|
| 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
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.
Class Loading
The JVM loads classes lazily — only when they are first referenced. A hierarchy of class loaders handles this:
The Three Phases of Loading
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.
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.
Bytecode Execution
The JVM is a stack-based virtual machine. Unlike register-based architectures (x86, ARM), all operations happen via an operand stack.
The bytecode interpreter executes these instructions one by one. This is inherently slow — which is why the JIT compiler exists.
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
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
| Optimization | What it does |
|---|---|
| Inlining | Pastes method body at call site, avoiding call overhead |
| Escape Analysis | Objects that don't escape a method get allocated on the stack (no GC needed) |
| Loop Unrolling | Reduces loop overhead by duplicating loop body |
| Dead Code Elimination | Removes code that has no effect on the result |
| Devirtualization | If only one class implements an interface, skips virtual dispatch |
| Speculative Optimization | Assumes 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.
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.
Old Generation
Objects that survived many Minor GCs get promoted here. Collected less frequently (Major GC / Full GC) — more expensive.
Object Lifecycle
GC Algorithms
| Collector | Focus | Pause 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 |
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.
Arrays have an additional 4-byte length field between the class pointer and the array data.
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 — 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.
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.
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.
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.
Thread Types in a Typical Java Application
| Thread Type | Purpose | Managed 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
How Virtual Threads Change Everything
With Virtual Threads (Java 21+), you write blocking-style code but the JVM handles it efficiently:
| Model | Threads for 10K connections | Memory | Code 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.
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
| Bytecode | Used For | Dispatch | Speed |
|---|---|---|---|
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:
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.
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:
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.
Compact Strings (Java 9+)
Before Java 9, all strings used char[] (2 bytes per character, UTF-16). Since most strings are ASCII/Latin-1:
| Version | Internal Storage | Memory 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) |
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).
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
Both the CPU and the JIT compiler can reorder instructions for performance. The JMM defines happens-before rules that guarantee visibility.
Happens-Before Rules
| Rule | Guarantee |
|---|---|
| Program order | Each action in a thread happens-before the next action in that thread |
| Monitor lock | Unlock happens-before subsequent lock of the same monitor |
| Volatile | Write to volatile happens-before subsequent read of same volatile |
| Thread start | thread.start() happens-before any action in that thread |
| Thread join | All actions in a thread happen-before join() returns |
| Final fields | Writing a final field in constructor happens-before reading it (if this didn't escape) |
volatile Deep Dive
The Broken Double-Checked Locking
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?
- No GC pauses — large buffers won't trigger Full GC
- Zero-copy I/O — OS can DMA directly to/from the buffer (no heap copy)
- Large data sets — caches, memory-mapped files, columnar storage
Methods of Off-Heap Access
| API | Since | Safety | Use 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 |
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.
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
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.
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
| Location | Has Safepoint? |
|---|---|
| Method entry/exit | Yes |
| 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.wait | Yes |
The Dangerous Counted Loop
Use -XX:+SafepointTimeout -XX:SafepointTimeoutDelay=2000 or JFR's SafepointBegin event to diagnose slow safepoints.
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.
| Heap Size | Compressed Oops | Reference Size | Impact |
|---|---|---|---|
| < ~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.
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
Why Microbenchmarks Lie
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.
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 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:
ConcurrentHashMap Internals
Fork/Join Framework
Introduced in Java 7 for divide-and-conquer parallelism. Also the engine behind parallelStream().
Work-Stealing Algorithm
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.
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.
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
Java Module System (JPMS)
Introduced in Java 9 (Project Jigsaw). Modules add a layer of encapsulation above packages.
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).
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
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.
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 Category | Examples |
|---|---|
| GC | Pause durations, promotion rates, allocation rates |
| JIT | Compilations, deoptimizations, code cache usage |
| Threading | Contention, park/unpark, thread starts |
| I/O | File read/write, socket read/write with durations |
| Safepoints | Time-to-safepoint, safepoint duration |
| Allocation | Object allocation in/outside TLAB |
| Exceptions | Exception construction (with stack traces) |
| Class loading | Classes loaded/unloaded, classloader stats |
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.
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
Real-World Uses
| System | SPI Interface | What Gets Loaded |
|---|---|---|
| JDBC | java.sql.Driver | MySQL, PostgreSQL, Oracle drivers |
| SLF4J | SLF4JServiceProvider | Logback, Log4j2 bindings |
| Servlet | ServletContainerInitializer | Spring, Jersey bootstrapping |
| Java Modules | provides...with | Module-aware SPI (replaces META-INF) |
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
| Retention | Available At | Example |
|---|---|---|
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
| Tool | What It Generates | Approach |
|---|---|---|
| Lombok | Getters, setters, builders, equals/hashCode | AST manipulation (modifies compile tree directly) |
| MapStruct | Type-safe object mapping code | Standard annotation processing (generates source files) |
| Dagger 2 | Dependency injection wiring | Generates DI factories at compile time (no reflection at runtime) |
| Micronaut | Bean definitions, AOP proxies | Compile-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.
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
The Solution: Value Classes
Impact: Huge for numerical computing, financial systems, game engines, and any code that works with large collections of small objects (Complex, Color, LocalDate, etc.).
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 Point | Panama Solution |
|---|---|
| Write C header + C implementation for every method | Call 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 JVM | Restricted 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
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.
Virtual Thread Architecture
What Triggers Unmounting?
| Operation | Unmounts? | Notes |
|---|---|---|
Thread.sleep() | Yes | Virtual thread yields, carrier freed |
Socket.read()/write() | Yes | Reimplemented with non-blocking I/O internally |
BlockingQueue.take() | Yes | Parks virtual thread |
ReentrantLock.lock() | Yes | Unmounts while waiting |
synchronized block | No — PINS! | Carrier thread blocked (avoid in hot paths) |
| Native method (JNI) | No — PINS! | Can't unmount inside native code |
Pinning: The Gotcha
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.
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
| Step | What it does |
|---|---|
| Static Analysis | Starts from main(), traces all reachable code. Unreachable code is excluded. |
| AOT Compilation | Compiles all reachable bytecode to native machine code (x86, ARM). |
| Heap Snapshotting | Runs static initializers at build time. Pre-built objects are embedded in the binary. |
| Substrate VM | Embeds a minimal runtime for GC, threading, and exception handling. |
| Dead Code Elimination | Strips unused classes, methods, and JDK modules. |
| Linking | Produces a single platform-specific executable. |
What's Not Available by Default
Because native builds use a closed-world assumption, these features require explicit configuration:
- Reflection
- Dynamic class loading
- JMX / JVMTI agents
- Serialization
- Dynamic proxies
JVM vs Native Build
| Aspect | Traditional JVM | Native Build |
|---|---|---|
| Class loading | At runtime (lazy) | All resolved at build time |
| Execution | Interpreted → JIT compiled | AOT compiled native code |
| Startup | Seconds | Milliseconds |
| Peak throughput | Higher | ~10-30% lower |
| Memory | Higher | 50-80% lower |
| Build time | Fast | Very slow |
| Reflection | Full support | Needs config |
| Dynamic loading | Supported | Not 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
The JVM trades startup cost for runtime intelligence (profiling + JIT). Native builds trade runtime adaptability for instant startup and lower resource usage.