Core Java Fundamentals: JVM, Compilation, Memory, and Types
A staff-engineer guide to JVM architecture, Java compilation, memory model, and type behavior for backend engineers.
Core Java Fundamentals
This guide covers the runtime behavior and language foundation every backend engineer needs to understand when building production services. We go beyond syntax trivia into the JVM internals that determine latency, throughput, and reliability under load.
1. The Java Platform: JDK, JRE, JVM
At the center of Java is the JVM — the abstraction layer that decouples your code from the operating system. Understanding the three layers of the platform is essential for deployment, debugging, and performance tuning.
Production note: In containerized environments, you typically use a JDK-based image for building and a JRE-based (or jlink-customized) image for runtime. The jlink tool can create a minimal runtime image containing only the modules your application needs — dramatically reducing container size.
Key distinctions
| Layer | What it includes | When you need it |
|---|---|---|
| JDK | javac, jar, javadoc, jlink, full JRE | Development, CI/CD builds |
| JRE | JVM + core libraries + deployment tools | Running compiled applications |
| JVM | Bytecode interpreter, JIT compiler, GC, memory manager | Always — it is the runtime itself |
Check your deployment: run java -version to see which JVM you're using. In production, prefer HotSpot (the standard Oracle/OpenJDK JVM) or GraalVM for ahead-of-time compilation use cases.
2. Compilation Pipeline
Java's compilation model is a two-stage process: source code is compiled to bytecode ahead of time, then the JVM further compiles hot paths to native code at runtime. This hybrid approach gives you portability with near-native performance.
The pipeline in detail
- Source compilation (
javac): Parses Java source, performs type checking, and emits.classfiles containing bytecode. This stage does no optimization — it is purely a syntactic and type-level transformation. - Class loading: The JVM loads classes lazily (on first reference). This is why startup time is proportional to the number of classes used, not the number on the classpath.
- Bytecode verification: Ensures bytecode is well-formed — no stack overflow/underflow, no illegal type casts, no unsafe jumps. This is a critical security layer.
- Interpretation: Freshly loaded methods start in interpreted mode. The JVM gathers profiling data (method call counts, branch patterns) during this phase.
- JIT compilation: The JVM first interprets bytecode. Frequently executed ("hot") methods are compiled into native machine code by the JIT compiler. With HotSpot's default tiered compilation, methods are first compiled by C1 for fast execution and profiling, then recompiled by C2 for maximum optimization once sufficient runtime profiling data has been collected. The exact thresholds are JVM-version dependent and are based on both invocation counts and loop execution counts.
Cold start impact: In serverless or autoscaling environments, the interpreted startup phase adds latency to initial requests. Strategies to mitigate this include:
- Warmup loops during application initialization
- GraalVM native-image for near-instant startup
- Azul Prime's ReadyNow technology to cache JIT-compiled code
Tiered compilation explained
| Tier | Compiler | Behavior | When |
|---|---|---|---|
| 0 | Interpreter | No compilation | Startup, cold methods |
| 1 | C1 (simple) | Basic optimizations, profiling | Mildly hot methods |
| 2 | C1 (limited) | More profiling | Warming up |
| 3 | C1 (full) | Full profiling, counters | Nearing threshold |
| 4 | C2 | Aggressive optimizations, PGO | Very hot methods |
3. Bytecode and Execution Model
Bytecode is a stack-based intermediate representation. Each method invocation creates a stack frame containing:
How a simple expression executes
Consider the expression int c = a + b; after compilation to bytecode:
// Java source
int a = 10;
int b = 20;
int c = a + b;// Corresponding bytecode (mnemonic)
ICONST_10 // Push int constant 10 onto operand stack
ISTORE_1 // Pop top value → local variable 1 (a)
BIPUSH 20 // Push int constant 20 onto stack
ISTORE_2 // Pop top value → local variable 2 (b)
ILOAD_1 // Push local variable 1 (a) onto stack
ILOAD_2 // Push local variable 2 (b) onto stack
IADD // Pop two ints, add them, push result
ISTORE_3 // Pop result → local variable 3 (c)The JVM is defined by bytecode, not by source language. Any language that compiles to valid .class files — Scala, Kotlin, Clojure, Groovy, JRuby — can run on the JVM, enjoying the same JIT, GC, and tooling ecosystem.
4. JVM Memory Model
The JVM divides memory into several distinct regions, each with different performance characteristics and GC behavior.
Region characteristics
| Region | Shared? | Content | GC | Size control |
|---|---|---|---|---|
| Eden | Yes | New object allocations | Minor GC | -Xmn, -XX:NewRatio |
| Survivor (S0/S1) | Yes | Objects surviving minor GC | Minor GC | -XX:SurvivorRatio |
| Old Generation | Yes | Long-lived objects | Major/Full GC | -Xms, -Xmx |
| Metaspace | Yes | Class metadata, constant pool | Class unloading | -XX:MaxMetaspaceSize |
| Stack | No (per-thread) | Local variables, frames | Deallocated on method exit | -Xss (default 1MB) |
| Code Cache | Yes | JIT-compiled native code | Sweep of stale code | -XX:ReservedCodeCacheSize |
| Direct Buffers | Yes | Off-heap I/O buffers | Native GC via Cleaner | -XX:MaxDirectMemorySize |
Key production insight: Heap allocations cost GC pressure. Stack allocations are nearly free (just a stack-pointer bump). In high-throughput services, prefer stack-allocated primitives and avoid creating intermediate objects in hot paths.
Common memory configuration flags
# Heap sizing (always set both -Xms and -Xmx to avoid resize pauses)
-Xms4g -Xmx4g
# Young generation sizing
-Xmn2g
-XX:NewRatio=2 # Old : Young = 2 : 1
-XX:SurvivorRatio=8 # Eden : Survivor = 8 : 1 (default)
# Metaspace
-XX:MaxMetaspaceSize=256m
# Stack size per thread
-Xss512k
# Direct memory (for NIO)
-XX:MaxDirectMemorySize=512m5. Garbage Collection
Garbage collection is the automatic reclamation of heap memory. The JVM uses a generational hypothesis: most objects die young.
GC algorithms compared
| GC Algorithm | Flag | Pause type | Throughput | Latency | Best for |
|---|---|---|---|---|---|
| Serial | -XX:+UseSerialGC | Stop-the-world (all) | Low | Poor | Single-threaded, small heaps |
| Parallel | -XX:+UseParallelGC | Stop-the-world (all) | High | Moderate | Batch processing, high-throughput |
| G1 | -XX:+UseG1GC | Regional, incremental | Moderate | Predictable | Default since Java 9, most services |
| ZGC | -XX:+UseZGC | Concurrent (sub-ms) | Moderate | Ultra-low | Large heaps, low-latency services |
| Shenandoah | -XX:+UseShenandoahGC | Concurrent (sub-ms) | Moderate | Ultra-low | Large heaps, low-latency services |
G1 GC has been the default garbage collector since Java 9 and is a good general-purpose choice for most server-side applications. For applications with very large heaps (tens to hundreds of GB) or very low latency requirements (e.g., sub-10 ms or sub-millisecond pause targets), consider ZGC or Shenandoah. ZGC was introduced as an experimental feature in Java 11 and became production-ready in Java 15.
GC tuning by workload type
Batch / data processing (high throughput):
-XX:+UseParallelGC
-XX:ParallelGCThreads=8
-Xms8g -Xmx8gLow-latency web services (sub-10ms p99):
-XX:+UseZGC
-XX:ZAllocationSpikeTolerance=2.0
-Xms8g -Xmx8g
-XX:ConcGCThreads=4General purpose services (default recommended):
-XX:+UseG1GC
-XX:MaxGCPauseMillis=100
-XX:G1HeapRegionSize=4m
-Xms4g -Xmx4gPractical guidance
- Monitor GC logs:
-Xlog:gc*:file=gc.log:tags,time,uptime,level(Java 9+ unified logging) - Watch for promotion failures:
-XX:+PrintTenuringDistributionhelps tune survivor space sizes - Avoid large young gen: Pauses scale with young gen size. 2-4 GB is typical for most services
- Use
jstatlive:jstat -gcutil <pid> 1sshows GC utilization per second
6. Class Loading and Initialization
Class loading is a three-phase process that happens lazily — the JVM only loads a class when it is first referenced.
Class loader hierarchy
The JVM uses a delegation model for class loaders:
When initialization happens
A class is initialized (static blocks execute) only when:
- A
newinstance is created - A
staticmethod is invoked - A
staticfield is accessed Class.forName()is called- It is the initial class of a JVM launch
Production risk: Heavy static initialization (database connections, thread pools in static blocks) delays application startup and can cause cold-start timeouts in containerized environments. Use lazy initialization or dependency injection to defer expensive setup.
public class ExpensiveInit {
// BAD: runs during class loading, delays startup
static {
DatabasePool.init("jdbc:...", 50);
}
// GOOD: initialized on first use
private static class LazyPool {
static final DatabasePool INSTANCE = DatabasePool.init("jdbc:...", 50);
}
public static DatabasePool pool() {
return LazyPool.INSTANCE;
}
}7. Primitive vs Reference Types
Java's type system is split into two worlds. Understanding the distinction is critical for writing high-performance backend code.
Memory and performance comparison
| Aspect | Primitive | Reference (wrapper) |
|---|---|---|
| Storage | Direct value on stack or inline in object | Pointer (8 bytes) + heap object (16+ bytes) |
| Null | Cannot be null | Can be null |
| Identity | == compares values | == compares references; use .equals() |
| Generics | Not allowed directly | Required (List<Integer> not List<int>) |
| Collections | Not supported | Supported |
| Memory overhead | 1–8 bytes | 16–24 bytes (object header + value + padding) |
| Array memory | int[1M] = 4 MB | Integer[1M] = ~20 MB (objects + array of refs) |
Autoboxing pitfalls
// Hidden allocation: each addition creates a new Long object
Long sum = 0L;
for (long i = 0; i < 1_000_000; i++) {
sum += i; // ← unbox + add + box = 1M allocations!
}
// Better: use primitive in loop, box only at the end
long sum = 0L;
for (long i = 0; i < 1_000_000; i++) {
sum += i;
}
Long result = sum; // single allocationJMH benchmark insight: A hot loop using Long instead of long can be 10-50x slower due to allocation, GC pressure, and cache misses. Always reach for primitives in performance-sensitive code.
Equality pitfalls with wrappers
Integer a = 127;
Integer b = 127;
System.out.println(a == b); // true (cached: -128 to 127)
Integer c = 200;
Integer d = 200;
System.out.println(c == d); // false (not cached!)
// Always use .equals() or unbox
System.out.println(c.equals(d)); // true
System.out.println((int)c == (int)d); // true8. equals(), hashCode(), and Object Contracts
The equals() and hashCode() methods form the backbone of all hash-based collections. Getting them wrong is one of the most common sources of production bugs.
The contract
| Rule | Description |
|---|---|
| Reflexive | x.equals(x) must be true |
| Symmetric | x.equals(y) ⇔ y.equals(x) |
| Transitive | x.equals(y) && y.equals(z) ⇒ x.equals(z) |
| Consistent | Multiple invocations return same result (if no mutation) |
| hashCode contract | If x.equals(y), then x.hashCode() == y.hashCode() |
| hashCode consistency | hashCode() must return the same value across invocations |
public final class UserId {
private final String value;
public UserId(String value) {
this.value = Objects.requireNonNull(value);
}
@Override
public boolean equals(Object o) {
if (this == o) return true;
if (!(o instanceof UserId other)) return false;
return value.equals(other.value);
}
@Override
public int hashCode() {
return value.hashCode(); // same fields as equals()
}
}Never use mutable fields in equals() or hashCode(). If an object's hash code changes while it is stored in a HashMap or HashSet, the collection becomes corrupted — the object is "lost" in the wrong hash bucket and will never be found.
Production patterns for equals/hashCode
| Pattern | Approach | When to use |
|---|---|---|
Objects.equals() + Objects.hash() | return Objects.equals(field1, field2) | Simple value objects |
record (Java 16+) | record UserId(String value) {} | Immutable data carriers |
AutoValue / Lombok @Data | Annotation-based generation | Teams, consistent conventions |
| Manual | Hand-written with instanceof pattern matching | Fine-grained control, legacy code |
Common bug: mutable fields in hashCode
// BAD: hashCode changes when setName() is called
public class BadEntity {
private String name;
@Override
public int hashCode() { return name.hashCode(); }
}
// Usage
var map = new HashMap<BadEntity, String>();
var e = new BadEntity();
e.setName("Alice");
map.put(e, "value");
e.setName("Bob");
map.get(e); // → null (wrong bucket!)9. final, finally, and finalize
These three keywords sound alike but serve completely different purposes.
final
public final class ImmutableConfig { // cannot be subclassed
private final int maxConnections; // must be set once, never reassigned
public ImmutableConfig(int maxConnections) {
this.maxConnections = maxConnections;
}
public final int getMaxConnections() { // cannot be overridden
return maxConnections;
}
}Effective Java principle: "Minimize mutability." Declare fields final by default. Mark classes final unless designed for inheritance. This makes code easier to reason about and safer in concurrent contexts.
finally
// Prefer try-with-resources (Java 7+)
try (Connection conn = dataSource.getConnection();
PreparedStatement stmt = conn.prepareStatement(sql)) {
return stmt.executeQuery();
}
// Resources are auto-closed, no finally needed
// Use finally only for non-Closeable cleanup
CountDownLatch latch = new CountDownLatch(1);
try {
processAsync(latch);
} finally {
latch.countDown(); // ensures thread unblocks even on exception
}finalize()
// DEPRECATED — never use in production
@Override
@Deprecated(since = "9")
protected void finalize() throws Throwable {
// Runs unpredictably, if at all
// Use Cleaner or try-with-resources instead
}| Feature | final | finally | finalize() |
|---|---|---|---|
| Category | Keyword (modifier) | Block | Method |
| Purpose | Prevent modification | Guarantee cleanup | Object finalization |
| Use it? | Always (for immutability) | When try-with-resources won't fit | Never |
| Replacement | — | try-with-resources | Cleaner, AutoCloseable |
10. Production Observations
Java fundamentals are not just theory — they shape how backend services behave under real traffic.
Performance decision tree
Key takeaways
- JVM startup and class loading affect deployment times, especially in autoscaling environments
- Heap layout and GC choices determine your latency profile — benchmark with your actual workload
- Primitive vs reference decisions matter in high-throughput code; use
longnotLongin hot loops equals()/hashCode()correctness is one of the most common backend bugs in caches, session stores, and collectionsfinalis a design tool, not an optimization hint — modern JIT compilers are not constrained byfinalfor optimization- Profile before tuning: use
async-profileror JFR (Java Flight Recorder) to identify real bottlenecks before optimizing
Essential JVM flags for production
# Always set these
-Xms4g -Xmx4g # Fixed heap (no resize)
-XX:+UseG1GC # Modern GC
-XX:MaxGCPauseMillis=100 # Pause target
-Xlog:gc*:file=gc.log:tags,time,uptime,level # GC logging
-XX:+ExitOnOutOfMemoryError # Fail fast on OOM
-XX:+HeapDumpOnOutOfMemoryError # Capture heap dump
-XX:HeapDumpPath=/var/log/app/ # Dump location
-Djava.security.egd=file:/dev/urandom # Faster secure randomInterview Questions
- What is the difference between JDK, JRE, and JVM?
- How does Java compile source code to executable form? Explain the role of
javac, bytecode, and JIT compilation. - What are the main JVM memory regions, and how do they differ?
- Why does the JVM use generational garbage collection? Describe the lifecycle of an object from Eden to Old Gen.
- What happens during class loading and initialization? Describe the three phases.
- How do primitive types differ from reference types? When does autoboxing hurt performance?
- Why must
equals()andhashCode()be overridden together? What happens if only one is overridden? - What is the difference between
final,finally, andfinalize()? - How does tiered compilation work? What are the four tiers?
- What GC algorithm would you choose for a low-latency payment service? Why?
- What happens if you use mutable fields in
hashCode()? How would you debug it? - How would you reduce JVM cold start time in a serverless environment?