Definition
- Heap – the shared memory area where all objects created with
newlive; managed by the garbage collector and split into Young and Old generations. - Stack – per-thread memory holding method frames, local variables, and return addresses; grows and shrinks as methods are called and return.
- Metaspace – stores class metadata (class structure, method definitions); replaced PermGen in Java 8 and grows automatically in native memory.
- Code cache – stores native machine code produced by the JIT compiler for hot methods.
- JIT compiler – converts frequently executed bytecode into optimized native code at runtime using tiered compilation (C1 and C2).
- Garbage collector – automatically reclaims memory from objects no longer reachable, using generational collection.
Think of the JVM as the National Bank of India head office building. The heap is the giant records vault where all customer files (objects) are stored, and a cleanup crew (garbage collector) regularly removes files no one references. Each bank teller (thread) has their own small desk (stack) for the one customer they are serving. The rulebook room (metaspace) stores what a Savings Account or Trade even is. And a fast-lane counter (code cache) pre-prepares forms for the most frequent transactions so they do not have to be filled from scratch each time.
Understanding the Concepts
What are the JVM memory areas?
The JVM divides memory into several distinct regions, each serving a specific purpose. Understanding them is essential for debugging production issues like OutOfMemoryError, StackOverflowError, and high GC pause times.
package com.bank.jvm;
public class MemoryAreasDemo {
public static void main(String[] args) {
// Heap: objects created with new
Object account = new Object(); // lives on the heap
// Stack: local variables and method frames
int balance = 50000; // primitive on the stack
// Metaspace: class metadata loaded when this class first runs
System.out.println("Heap max: " + Runtime.getRuntime().maxMemory() / (1024 * 1024) + " MB");
System.out.println("Heap used: " + (Runtime.getRuntime().totalMemory() - Runtime.getRuntime().freeMemory()) / (1024 * 1024) + " MB");
}
}
Heap max: 256 MB
Heap used: 5 MB
How does the heap work with generational collection?
The heap is split into generations because most objects die young. The Young Generation has an Eden space plus two Survivor spaces (S0 and S1). New objects are allocated in Eden. When Eden fills up, a Minor GC runs, copying surviving objects into a Survivor space. Objects that survive several minor collections are promoted to the Old Generation, which holds long-lived objects.
package com.bank.jvm;
import java.math.BigDecimal;
import java.util.ArrayList;
import java.util.List;
public class GenerationalDemo {
public static void main(String[] args) {
// Short-lived objects: created in Eden, collected in Minor GC
for (int i = 0; i < 1_000_000; i++) {
BigDecimal fee = new BigDecimal("10.50").multiply(BigDecimal.valueOf(i));
// fee is unreachable after this iteration -> collected in Minor GC
}
// Long-lived object: survives many GC cycles -> promoted to Old Gen
List<String> accountCache = new ArrayList<>();
for (int i = 0; i < 10_000; i++) {
accountCache.add("ACC" + i);
}
System.out.println("Cache size: " + accountCache.size());
}
}
java -Xlog:gc* -Xmx128m com.bank.jvm.GenerationalDemo
[GC (Allocation Failure) Young: 33792K->4096K Pause: 5ms]
[GC (Allocation Failure) Young: 37888K->4096K Pause: 4ms]
Cache size: 10000
How does the stack work?
Each thread has its own stack. When a method is called, a new frame is pushed onto the stack holding local variables, the operand stack, and the return address. When the method returns, the frame is popped. Primitives and object references live on the stack, but the objects they point to live on the heap.
package com.bank.jvm;
public class StackDemo {
static void transfer(String from, String to, int amount) {
// Frame for transfer() pushed onto stack
debit(from, amount);
credit(to, amount);
// Frame for transfer() popped when returning
}
static void debit(String account, int amount) {
System.out.println("Debit " + amount + " from " + account);
}
static void credit(String account, int amount) {
System.out.println("Credit " + amount + " to " + account);
}
public static void main(String[] args) {
transfer("ACC1001", "ACC2001", 5000);
}
}
Debit 5000 from ACC1001
Credit 5000 to ACC2001
What causes StackOverflowError?
Infinite or excessively deep recursion pushes more frames than the stack can hold, causing StackOverflowError. The default stack size is around 512 KB to 1 MB per thread. You can increase it with -Xss, but the real fix is usually to convert recursion to iteration.
package com.bank.jvm;
public class StackOverflowDemo {
static int calculateCompoundInterest(int months) {
if (months == 0) return 0;
return calculateCompoundInterest(months - 1) + 100; // no base case for negative
}
public static void main(String[] args) {
try {
calculateCompoundInterest(100_000); // too deep
} catch (StackOverflowError e) {
System.out.println("StackOverflowError: recursion too deep");
}
}
}
StackOverflowError: recursion too deep
How does the JIT compiler make Java fast?
The JVM first interprets bytecode. When it detects hot methods that run thousands of times, it compiles them to native code using tiered compilation. The C1 compiler does a quick compile with basic optimizations. The C2 compiler does heavy optimization for the hottest methods, including method inlining, escape analysis, and dead code elimination.
package com.bank.jvm;
import java.math.BigDecimal;
public class JitDemo {
static BigDecimal calculateFee(BigDecimal amount, BigDecimal rate) {
return amount.multiply(rate); // hot method, will be JIT-compiled
}
public static void main(String[] args) {
BigDecimal rate = new BigDecimal("0.015");
// Warm up: JIT compiles after ~10,000 invocations
for (int i = 0; i < 100_000; i++) {
calculateFee(BigDecimal.valueOf(i), rate);
}
// Now the method is JIT-compiled, running at near-native speed
long start = System.nanoTime();
for (int i = 0; i < 1_000_000; i++) {
calculateFee(BigDecimal.valueOf(i), rate);
}
long elapsed = System.nanoTime() - start;
System.out.println("1M fee calcs in " + (elapsed / 1_000_000) + " ms");
}
}
java -XX:+PrintCompilation com.bank.jvm.JitDemo
1 3 com.bank.jvm.JitDemo::calculateFee (C1)
2 4 com.bank.jvm.JitDemo::calculateFee (C2)
1M fee calcs in 45 ms
How do you analyze thread dumps?
jstack prints every thread's state. You look for BLOCKED or WAITING threads to find deadlocks, and for threads stuck in the same method across multiple dumps to find contention.
# Capture thread dump of a running process
jstack $(pgrep -f BankingApp) > thread-dump.txt
# Look for deadlocks
grep -A 5 "deadlock" thread-dump.txt
# Look for blocked threads
grep -B 2 "BLOCKED" thread-dump.txt
"transfer-pool-1" #15 BLOCKED on com.bank.service.AccountLock@1a2b3c
at com.bank.service.TransferService.debit(TransferService.java:45)
- waiting to lock <0x00000007c0010a58> (com.bank.service.AccountLock)
- locked <0x00000007c0010a60> (com.bank.service.AccountLock)
"transfer-pool-2" #16 BLOCKED on com.bank.service.AccountLock@1a2b3d
at com.bank.service.TransferService.credit(TransferService.java:52)
- waiting to lock <0x00000007c0010a60> (com.bank.service.AccountLock)
- locked <0x00000007c0010a58> (com.bank.service.AccountLock)
Found 1 deadlock.
How do you analyze heap dumps?
jmap -dump:live,format=b,file=heap.hprof captures a heap dump. Open it in Eclipse MAT to find memory leaks by looking at the dominator tree and retained sizes. The most common banking leak is accidentally retaining every processed transaction in a growing collection.
# Capture heap dump
jmap -dump:live,format=b,file=heap.hprof $(pgrep -f BankingApp)
# Or trigger on OOM automatically
java -XX:+HeapDumpOnOutOfMemoryError \
-XX:HeapDumpPath=/var/dumps/ \
-Xmx512m \
-jar banking-app.jar
Eclipse MAT dominator tree:
java.util.ArrayList @ 0x7c001000 retained: 380 MB
com.bank.model.Transaction[250000] @ 0x7c002000
Each Transaction holds BigDecimal amount, String description, Date timestamp
Leak suspect: ArrayList of Transactions growing without bound in
com.bank.batch.EndOfDayProcessor.processAll()
Line 78: transactions.add(tx); // never cleared
How do you choose the right garbage collector?
G1GC is the default from Java 9 onward and gives predictable pause targets. ZGC provides sub-millisecond pauses for very large heaps. Shenandoah offers similar low-pause characteristics. The choice depends on your latency requirements.
| Collector | Pause Target | Best For |
|-----------|-------------|----------|
| G1GC | ~200ms | General purpose, default |
| ZGC | <1ms | Large heaps, low latency |
| Shenandoah | <10ms | Low latency, RedHat |
| Parallel GC | Throughput | Batch processing |
# G1GC with pause target (default from Java 9)
java -XX:+UseG1GC -XX:MaxGCPauseMillis=200 -jar banking-app.jar
# ZGC for sub-millisecond pauses (Java 15+)
java -XX:+UseZGC -Xmx8g -jar banking-app.jar
# Enable GC logging
java -Xlog:gc*:file=gc.log:time,uptime,level,tags -jar banking-app.jar
What should a senior engineer know about containers?
The single most important production habit is controlling the heap size in containers. Without -XX:MaxRAMPercentage, the JVM may see the host's total memory instead of the container limit, allocate too much heap, and get killed by the container runtime.
# WRONG: JVM sees 64 GB host memory, allocates 16 GB heap
# Container limit is 2 GB -> OOMKilled
docker run -m 2g banking-app
# CORRECT: JVM uses 75% of container limit = 1.5 GB heap
docker run -m 2g -e JAVA_OPTS="-XX:MaxRAMPercentage=75.0" banking-app
// Verify the JVM sees the right limits
public class ContainerCheck {
public static void main(String[] args) {
long maxHeap = Runtime.getRuntime().maxMemory();
System.out.println("Max heap: " + (maxHeap / (1024 * 1024)) + " MB");
System.out.println("Available processors: " + Runtime.getRuntime().availableProcessors());
}
}
What are common mistakes in JVM tuning?
The most common mistake is setting a fixed heap size (-Xmx) that does not account for native memory (metaspace, thread stacks, code cache, NIO buffers). The total JVM footprint is always larger than the heap.
# WRONG: -Xmx2g in a 2g container leaves no room for native memory
# Total = heap + metaspace + stacks + code cache + native buffers > 2g -> OOMKilled
docker run -m 2g -e JAVA_OPTS="-Xmx2g" banking-app
# CORRECT: leave 25% headroom for native memory
docker run -m 2g -e JAVA_OPTS="-XX:MaxRAMPercentage=75.0" banking-app
# heap = 1.5g, leaving 500 MB for native memory
Visual Overview
┌──────────────────────────────────────────────────────────┐ │ JVM Memory Layout │ │ │ │ ┌─────────────────────────────────────────────────────┐ │ │ │ HEAP │ │ │ │ ┌───────────────────────┐ ┌────────────────────┐ │ │ │ │ │ Young Generation │ │ Old Generation │ │ │ │ │ │ ┌─────┐ ┌────┐ ┌────┐│ │ Long-lived │ │ │ │ │ │ │Eden │ │ S0 │ │ S1 ││ │ objects │ │ │ │ │ │ │new │ │surv│ │surv││ │ (promoted from │ │ │ │ │ │ │objs │ │ │ │ ││ │ Young Gen) │ │ │ │ │ │ └─────┘ └────┘ └────┘│ └────────────────────┘ │ │ │ │ └───────────────────────┘ │ │ │ └─────────────────────────────────────────────────────┘ │ │ │ │ ┌──────────────┐ ┌────────────┐ ┌──────────────────┐ │ │ │ Thread Stacks │ │ Metaspace │ │ Code Cache │ │ │ │ (per thread) │ │ (native) │ │ (JIT output) │ │ │ │ frames, locals│ │ class defs │ │ native code │ │ │ └──────────────┘ └────────────┘ └──────────────────┘ │ └──────────────────────────────────────────────────────────┘
Object lifecycle:
new Object() ──> Eden ──Minor GC──> Survivor
│
survives N cycles
│
▼
Old Generation
│
no references left
│
▼
Full GC collects
Key Takeaway
The JVM divides memory into several areas, and understanding them is essential for debugging production issues. The heap is where all objects live and is managed by the garbage collector, split into a Young Generation for short-lived objects and an Old Generation for long-lived ones. Minor GC cleans the Young Generation cheaply, while Full GC cleans the Old Generation with longer pauses. Each thread has its own stack for method frames, metaspace holds class metadata, and the code cache holds native code from the JIT compiler.
The JIT compiler is what makes Java fast in production. The JVM interprets bytecode at first, and when it sees a hot method running thousands of times it compiles it to native code using tiered compilation. The C1 compiler does quick optimization and the C2 compiler does heavy optimization including inlining and escape analysis. A tight fee-calculation loop over millions of trades runs close to native speed after warm-up.
When a service misbehaves in production, I reach for specific tools. I take a thread dump with jstack to find blocked or deadlocked threads. I capture a heap dump with jmap and open it in Eclipse MAT to find memory leaks. I enable GC logging to see pause times and collection frequency. For deeper profiling I use Java Flight Recorder because of its low overhead.
The single most important production habit in the banking systems at National Bank of India is controlling the heap size in containers using the MaxRAMPercentage flag, so the JVM respects the container's memory limit rather than seeing the entire host. Getting that wrong is a very common cause of containers being killed.
Remember one sentence: the heap stores objects and the GC cleans them generationally, the JIT compiles hot bytecode to native speed, and jstack, jmap, and GC logs are the three tools that diagnose production issues.
---