JVM Internals: Hiểu Sâu Để Debug Memory Leak & Tuning Production
JVM Internals: Hiểu Sâu Để Debug Memory Leak & Tuning Production
Bài viết dành cho Senior Java Developer — Từ lý thuyết nền tảng đến xử lý OOM trong hệ thống ngân hàng
Mở đầu: Câu chuyện “chết định kỳ” 2 giờ một lần
Hãy tưởng tượng bạn là kỹ sư trực hệ thống core banking của một ngân hàng lớn. Sau một lần deploy nhẹ nhàng, service xử lý batch reconciliation bắt đầu có triệu chứng kỳ lạ:
- CPU từ 30% nhảy vọt lên 99% mỗi 2 giờ
- Response time từ 50ms lên 8–15 giây
- Service tự recover, rồi lại lặp lại vòng tuần hoàn
- Log duy nhất đáng sợ:
GC overhead limit exceeded→OutOfMemoryError
DevOps đã restart pod mỗi khi sự cố xảy ra. Ticket tồn tại 3 tháng.
Biểu đồ heap usage vẽ nên một bức tranh quen thuộc với ai đã từng đối mặt memory leak:
Heap usage chart:
100% | ╭──╮ ╭──╮
80% | ╭───╯ ╰──────────────╯ ╰──
60% | ╭────╯
40% | ╭────╯
20% |─────╯
T+0h T+0.5h T+1h T+1.5h T+2h T+2.5h T+3h
↑ OOM + restart
Đây là pattern kinh điển của memory leak — heap tăng đều đặn, GC không giải phóng được.
Trước khi đọc tiếp, hãy tự hỏi:
- Tại sao GC không giải phóng được memory nếu hết heap?
- Bạn sẽ bắt đầu debug từ đâu? Cần tool/data gì?
- Loại code nào trong batch job hay gây memory leak nhất?
Nếu chưa có câu trả lời chắc chắn — bài viết này dành cho bạn.
Phần 1: Mental Model — Cấu trúc bộ nhớ JVM mà không phải ai cũng hiểu rõ
1.1 Bản đồ địa hình JVM Memory
Hãy hình dung JVM như một thành phố với các khu vực chức năng riêng biệt:
┌─────────────────────────────────────────────────────────────┐
│ JVM PROCESS │
│ │
│ ┌──────────────── HEAP (GC quản lý) ──────────────────┐ │
│ │ │ │
│ │ ┌─── Young Generation ───┐ ┌─── Old Generation ──┐│ │
│ │ │ ┌──────┐ ┌──┐ ┌──┐ │ │ ││ │
│ │ │ │ Eden │ │S0│ │S1│ │ │ Long-lived objects ││ │
│ │ │ └──────┘ └──┘ └──┘ │ │ (survive 15+ GC) ││ │
│ │ │ (new objects born here)│ │ ││ │
│ │ └────────────────────────┘ └─────────────────────┘│ │
│ └──────────────────────────────────────────────────────┘ │
│ │
│ ┌── Metaspace (non-heap) ──┐ ┌── Thread Stacks ──────┐ │
│ │ Class metadata │ │ Stack frame per method│ │
│ │ Method bytecode │ │ Local variables │ │
│ │ Static variables │ │ (512KB–1MB per thread)│ │
│ └──────────────────────────┘ └───────────────────────┘ │
│ │
│ ┌── Code Cache ──┐ ┌── Direct Memory (off-heap) ──────┐ │
│ │ JIT compiled │ │ ByteBuffer.allocateDirect() │ │
│ │ native code │ │ Netty, Kafka client buffers │ │
│ └────────────────┘ └──────────────────────────────────┘ │
└─────────────────────────────────────────────────────────────┘
Điều quan trọng nhất cần hiểu: Memory leak trong Java KHÔNG phải là “object không được xóa” mà là “object vẫn còn reference dù không dùng nữa”. GC không thể collect object nếu có ít nhất 1 GC root còn trỏ đến nó.
1.2 GC Roots — “Tổ tiên” của mọi object sống
GC roots là điểm bắt đầu của quá trình duyệt memory. Object nào reachable từ GC roots thì SỐNG (không bị collect), còn lại là rác.
Các loại GC roots:
GC Roots:
├── Active thread stacks (local variables đang được dùng trong các method)
├── Static fields (các biến static của class — ví dụ: public static List cache)
├── JNI references (native code đang giữ object)
└── Synchronized objects (đang bị lock)
Ví dụ thực tế: Bạn có một static Map<String, Transaction> để cache kết quả. Map này là GC root. Dù bạn không còn dùng đến các transaction cũ, map vẫn giữ reference → toàn bộ transaction không bao giờ bị GC → memory leak.
1.3 Minor GC vs Major GC: Cuộc chiến không ngừng
Eden đầy → Minor GC (Stop-The-World ngắn, ~1-50ms):
1. Mark: duyệt từ GC roots, đánh dấu object SỐNG
2. Copy: copy object sống sang Survivor space (S0 hoặc S1)
3. Clear: xóa toàn bộ Eden + Survivor cũ
Object sống sót qua 15 Minor GC (tenuring threshold) → promote lên Old Gen
Old Gen đầy → Major GC / Full GC (Stop-The-World DÀI, có thể vài giây)
Đây chính là lý do tại sao memory leak gây “freeze” định kỳ — khi Old Gen đầy, JVM buộc phải chạy Full GC, dừng toàn bộ ứng dụng.
1.4 Ba GC Algorithms phổ biến (Java 8–17)
| GC | Mục tiêu | Đặc điểm | Dùng khi nào |
|---|---|---|---|
| G1GC (default Java 9+) | Cân bằng throughput/latency | Region-based, predictable pause | Heap > 6GB, general purpose |
| ZGC (Java 15+ production) | Low latency < 10ms | Concurrent marking/relocation | Latency-sensitive (banking API) |
| Parallel GC | Throughput tối đa | Multi-thread GC, longer pause | Batch processing, offline jobs |
1.5 ClassLoader — Kẻ giấu mặt gây Metaspace OOM
ClassLoader không chỉ đơn thuần là nạp class. Nó còn là nguồn cơn của một dạng memory leak khác: Metaspace leak.
Bootstrap ClassLoader (core Java: java.lang.*, java.util.*)
↑ parent
Extension ClassLoader (ext/endorsed jars)
↑ parent
Application ClassLoader (classpath của bạn)
↑ parent
Custom ClassLoader (plugin system, hot reload)
Tại sao quan trọng trong banking?
- ClassLoader leak = MetaSpace OOM (class metadata không được unload)
- Xảy ra khi: Custom classloader không được close, JDBC driver load nhiều lần
- Hot-deploy trong Tomcat hay gây MetaSpace leak nếu code không sạch
1.6 JIT Compilation — Tại sao Java nhanh dần theo thời gian?
Lần 1-1000: Bytecode interpreted (chậm, như chạy qua trình thông dịch)
↓
JIT: "Method này được gọi nhiều — hot method" → compile sang native code
↓
Lần 1001+: Execute native code trực tiếp (nhanh như C)
Tiered Compilation (Java 8+):
- Level 0: Interpreted
- Level 1-3: C1 compiler (compile nhanh, optimization ít)
- Level 4: C2 compiler (compile chậm hơn, optimization mạnh)
Đây là lý do JVM cần “warm up” — critical cho banking system cần đo SLA chính xác. Đừng bao giờ đo performance ngay sau khi start.
Phần 2: Production-Grade Implementation — Từ config đến debug thực chiến
2.1 JVM Flags: Config chuẩn cho banking service
# Production JVM flags cho banking service (Spring Boot)
# Mỗi flag đều có lý do — không copy-paste vô tội vạ
java -jar banking-service.jar \
# Heap sizing: Set min = max để tránh heap resize gây pause
# Rule of thumb: 70-75% RAM available cho container
-Xms2g -Xmx2g \
# G1GC: default Java 9+, phù hợp cho service API
# Với ZGC nếu cần latency < 10ms: -XX:+UseZGC
-XX:+UseG1GC \
# Max GC pause target (G1 sẽ cố gắng đạt, không đảm bảo)
# 200ms là reasonable cho banking API (SLA thường 500ms-1s)
-XX:MaxGCPauseMillis=200 \
# G1 initiates concurrent marking khi heap 45% full
# Thấp hơn → GC chạy sớm hơn → ít chance bị Full GC
-XX:InitiatingHeapOccupancyPercent=45 \
# Metaspace: không giới hạn mặc định → có thể leak vô hạn
# Set max để fail fast thay vì ăn hết RAM server
-XX:MaxMetaspaceSize=512m \
# Heap dump tự động khi OOM — QUAN TRỌNG cho post-mortem debug
# Không có flag này = mất cơ hội debug OOM production
-XX:+HeapDumpOnOutOfMemoryError \
-XX:HeapDumpPath=/var/log/banking/heapdump.hprof \
# Log GC để phân tích sau — chi phí thấp, value cao
-Xlog:gc*:file=/var/log/banking/gc.log:time,uptime:filecount=5,filesize=20m \
# Exit ngay khi OOM thay vì limping state
# Kubernetes sẽ restart pod → cleaner behavior
-XX:+ExitOnOutOfMemoryError \
# String deduplication: tiết kiệm 10-30% heap nếu nhiều String trùng
# (thường gặp trong banking: account numbers, transaction codes)
-XX:+UseStringDeduplication
2.2 Detecting & Fixing Memory Leak — Step-by-Step với code thật
Bước 1: Tái hiện leak pattern — đây là code thường thấy trong batch job ngân hàng
@Service
public class ReconciliationBatchJob {
// ❌ ANTI-PATTERN: Cache static không có giới hạn
// Trong batch job chạy 2 giờ, map này tích lũy hàng triệu entry
private static final Map<String, TransactionRecord> processedCache = new HashMap<>();
public void processReconciliation(List<String> transactionIds) {
for (String txId : transactionIds) {
TransactionRecord record = fetchFromDB(txId);
// Mỗi record ~2KB, 1 triệu records = 2GB
// HashMap không bao giờ tự shrink, GC không collect vì map còn reference
processedCache.put(txId, record); // ← MEMORY LEAK
doReconcile(record);
}
}
}
Bước 2: Fix với bounded cache + proper lifecycle
@Service
public class ReconciliationBatchJobFixed {
// ✅ Dùng Caffeine cache với giới hạn size và TTL
// maximumSize: giải phóng entry cũ khi đạt limit (LRU eviction)
// expireAfterWrite: entry tự expire, tránh stale data
private final Cache<String, TransactionRecord> processedCache = Caffeine.newBuilder()
.maximumSize(10_000) // Giới hạn memory rõ ràng
.expireAfterWrite(30, TimeUnit.MINUTES) // Batch job dài tối đa 30 phút
.recordStats() // Cho Micrometer metrics
.build();
// ✅ Hoặc đơn giản hơn: không cache trong batch job
// Batch job nên dùng stateless processing, streaming từ DB
public void processReconciliationStateless(Stream<String> transactionIds) {
transactionIds
.map(this::fetchFromDB) // Fetch từng record, không giữ reference
.forEach(this::doReconcile); // Process và release
// Stream: mỗi element được GC ngay sau khi process xong
}
}
2.3 Heap Dump Analysis — Debug OOM trong production
Khi sự cố xảy ra, bạn cần làm ngay:
# === BƯỚC 1: Thu thập heap dump ===
# Cách 1: Dump từ running process (nếu kịp trước khi OOM)
jmap -dump:format=b,file=heapdump.hprof <PID>
# Cách 2: Trigger qua JMX (nếu đã enable Actuator)
curl -X POST http://localhost:8080/actuator/heapdump -o heapdump.hprof
# Cách 3: Tự động khi OOM (đã config ở JVM flags trên)
# -XX:+HeapDumpOnOutOfMemoryError -XX:HeapDumpPath=/var/log/heapdump.hprof
# === BƯỚC 2: Phân tích với Eclipse MAT ===
# Tải tại: https://eclipse.dev/mat/
# Những gì cần nhìn đầu tiên trong MAT:
# 1. "Leak Suspects Report" → MAT tự suggest vị trí leak
# 2. "Dominator Tree" → Object nào giữ memory nhiều nhất
# 3. "Histogram" → Class nào có nhiều instance nhất
# === BƯỚC 3: Phân tích GC log ===
# Tool: GCEasy (https://gceasy.io) hoặc GCViewer
# Pattern nguy hiểm trong GC log:
# [GC pause (G1 Evacuation Pause) 7943M->7943M(8192M) 2.145s]
# ↑ ↑
# Trước GC = Sau GC = LEAK (GC không giải phóng được)
2.4 Các memory leak patterns thường gặp trong banking
// Pattern 1: EventListener không được unregister
@Component
public class TransactionEventHandler {
// ❌ SAI: Nếu class này được tạo nhiều lần (ví dụ trong loop),
// mỗi instance đăng ký listener, Spring giữ reference → không bị GC
@EventListener
public void handleEvent(TransactionEvent event) { ... }
// ✅ ĐÚNG: @Component singleton → chỉ 1 instance, không leak
}
// Pattern 2: ThreadLocal không được clean (đã thấy trong bài Concurrency)
// Pattern 3: Connection không được close (luôn dùng try-with-resources)
// Pattern 4: Inner class giữ reference đến outer class
public class AccountService {
private final List<AccountChangeListener> listeners = new ArrayList<>();
// ❌ Anonymous inner class giữ implicit reference đến AccountService
public void addListener() {
listeners.add(new AccountChangeListener() { // → AccountService không thể GC
@Override
public void onChange(Account account) {
updateCache(account); // Implicit reference: AccountService.this
}
});
}
// ✅ Static nested class hoặc lambda KHÔNG capture outer class
public void addListenerFixed() {
listeners.add(account -> log.info("Account changed: {}", account.getId()));
}
}
2.5 GC Tuning — Từng bước thực tế
# === Monitoring GC health qua Actuator + Micrometer ===
# Metrics quan trọng cần alert (Prometheus/Grafana):
# jvm_gc_pause_seconds_max > 1s → GC pause quá lâu
# jvm_memory_used_bytes{area=heap} / jvm_memory_max_bytes > 0.85 → Heap pressure
# jvm_gc_memory_promoted_bytes_total tăng đều → Object leak lên Old Gen
# jvm_gc_live_data_size_bytes tăng đều → Memory leak
# === Kiểm tra GC stats nhanh từ command line ===
jstat -gcutil <PID> 1000 10
# Output:
# S0 S1 E O M CCS YGC YGCT FGC FGCT GCT
# 0.00 98.54 70.45 52.31 96.18 93.49 156 2.874 2 0.614 3.488
# ↑ ↑
# Eden 70% full Old Gen 52% (bình thường)
# Nếu Old Gen > 80% và tăng đều → dấu hiệu leak
Phần 3: Trade-offs & Anti-patterns — Những điều không bao giờ được làm
Khi nào chọn GC nào?
| Scenario | GC | Lý do |
|---|---|---|
| Banking API, SLA 500ms, heap 4GB | G1GC -XX:MaxGCPauseMillis=200 | Balanced, predictable |
| Payment gateway, SLA 100ms, heap 8GB | ZGC | Sub-10ms pause, Java 15+ |
| Batch reconciliation overnight | Parallel GC | Max throughput, pause không quan trọng |
| Heap < 2GB | Serial GC / G1 | Serial đủ cho small heap |
Anti-pattern #1: Heap quá nhỏ hoặc quá lớn
# ❌ SAI: Heap = toàn bộ RAM
-Xmx16g # Trên server 16GB RAM
# Hậu quả: OS swap → toàn bộ JVM bị thrash
# Heap dump = 16GB → phân tích mất hàng giờ
# Full GC pause = 30-60 giây
# ✅ ĐÚNG: Để lại buffer cho OS và off-heap
# Container 8GB: -Xmx5g (để lại 3GB cho OS, Metaspace, Direct Memory, thread stacks)
-Xmx5g -Xms5g # Fixed heap size, tránh resize
Anti-pattern #2: Không set MaxMetaspaceSize
# ❌ SAI: Không giới hạn Metaspace (default = unlimited)
# Hậu quả: ClassLoader leak trong Tomcat hot-deploy ăn hết native memory
# ✅ ĐÚNG: Set giới hạn để fail fast
-XX:MaxMetaspaceSize=512m
Anti-pattern #3: Gọi System.gc() trong code
// ❌ SAI — ĐỪNG BAO GIỜ
public void afterBatchComplete() {
largeList.clear();
System.gc(); // Explicit GC call — không bao giờ làm điều này!
}
// ✅ ĐÚNG: Để GC tự quyết định
public void afterBatchComplete() {
largeList.clear();
largeList = null; // Release reference hoàn toàn
}
Anti-pattern #4: String concatenation trong loop
// ❌ SAI — Mỗi + tạo String object mới → Eden bị flood
public String buildReport(List<Transaction> transactions) {
String report = "";
for (Transaction tx : transactions) {
report = report + tx.toString() + "\n"; // N String tạm thời!
}
return report;
}
// ✅ ĐÚNG — StringBuilder
public String buildReport(List<Transaction> transactions) {
StringBuilder sb = new StringBuilder(transactions.size() * 100);
for (Transaction tx : transactions) {
sb.append(tx.toString()).append('\n');
}
return sb.toString();
}
Anti-pattern #5: Finalizer làm chậm GC
// ❌ SAI — Object có finalizer được xử lý đặc biệt
public class LegacyResource {
@Override
protected void finalize() throws Throwable { // ĐỪNG dùng!
cleanup();
}
}
// ✅ ĐÚNG — Implement Closeable, dùng try-with-resources
public class BankingResource implements Closeable {
@Override
public void close() {
cleanup();
}
}
Phần 4: Interview Framework — Những câu hỏi phân biệt Junior vs Senior
Tầng 1 — Surface (Junior có thể trả lời)
Q: Java có garbage collection tự động, tại sao vẫn có memory leak?
Memory leak trong Java xảy ra khi object không còn dùng nữa nhưng vẫn còn reference giữ nó sống — GC không thể collect object còn reachable. Ví dụ: static collection tích lũy object, EventListener không unregister, ThreadLocal không clean. GC chỉ collect unreachable object, không biết object có “cần dùng” hay không.
Tầng 2 — Deep Dive (Phân biệt Mid vs Senior)
Q: Giải thích G1GC hoạt động khác gì so với CMS và tại sao Java chuyển sang G1 làm default?
CMS (Concurrent Mark Sweep) có 2 vấn đề lớn: (1) Heap fragmentation — CMS không compact, sau nhiều GC cycles, Old Gen bị phân mảnh, không còn contiguous space cho large objects → promotion failure → Full GC. (2) Unpredictable pause.
G1GC giải quyết bằng cách chia heap thành nhiều region nhỏ bằng nhau (~1-32MB). Mỗi region có thể là Eden/Survivor/Old dynamically. G1 collect region có nhiều garbage nhất trước (Garbage First), và có thể compact in-place — hết fragmentation.
Trade-off: G1 cần ~10% overhead cho bookkeeping. Với heap <4GB thì Parallel GC vẫn tốt hơn.
Q: Describe cách bạn debug một OOM trong production.
Phase 1 — Stabilize: Restart pod, nhưng TRƯỚC khi restart capture heap dump, thread dump, GC log.
Phase 2 — Diagnose: Mở heap dump với Eclipse MAT, chạy ‘Leak Suspects Report’. Dominator Tree cho biết object nào chiếm >20% heap. Trace retention path về GC root — đó chính là leak path.
Phase 3 — Fix: Tùy nguyên nhân: static collection không bounded → dùng Caffeine bounded cache. EventListener leak → unregister. ThreadLocal leak → try/finally với remove().
Tầng 3 — Design/Architecture (Senior Level)
Q: Thiết kế JVM config và monitoring cho banking service 5000 TPS, SLA 99.9% dưới 500ms, heap 8GB trên Kubernetes.
GC selection: 5000 TPS, SLA 500ms → G1GC với
MaxGCPauseMillis=100. Nếu spiky workload → ZGC để tránh surprise pause.JVM flags: Container 12GB RAM →
-Xms6g -Xmx6g(50%, để room cho overhead).-XX:MaxMetaspaceSize=512m. BậtHeapDumpOnOutOfMemoryError+ upload script.Monitoring: Alert khi
gc_pause_max > 200ms,heap_used/heap_max > 80%,old_gen_growth_rate > X MB/min.Kubernetes:
requests=limitsđể Guaranteed QoS. Liveness probe restart pod khi OOM. PodDisruptionBudget tránh restart all pods cùng lúc.
Phần 5: Checklist thành thạo — Bạn đã thực sự hiểu JVM chưa?
Bạn phải giải thích được 5 điều này cho người khác mà không nhìn tài liệu:
-
1. JVM Memory Areas: Vẽ diagram phân vùng memory (Heap: Young/Old, Metaspace, Stack, Direct Memory), giải thích mỗi vùng chứa gì và ai quản lý.
-
2. GC là gì và memory leak xảy ra như thế nào: Giải thích GC roots, reachability, tại sao GC không collect object “không dùng nữa” nếu còn reference.
-
3. G1GC vs ZGC: Giải thích sự khác nhau cốt lõi (region-based vs colored pointers), khi nào chọn cái nào.
-
4. Debug OOM step-by-step: Nói được quy trình từ alert → capture heap dump → MAT analysis → tìm retention path → fix.
-
5. JVM flags cho production: Giải thích lý do 5 flags:
-Xms=Xmx,-XX:MaxMetaspaceSize,-XX:+HeapDumpOnOutOfMemoryError,-XX:+ExitOnOutOfMemoryError,-Xlog:gc.
Kết luận: JVM không phải “black box”
Nhiều developer coi JVM như một hộp đen kỳ diệu — “cứ viết code, Java tự quản lý memory”. Nhưng khi hệ thống ngân hàng gặp OOM định kỳ mỗi 2 giờ, cái giá của sự thiếu hiểu biết là rất đắt.
Hiểu JVM internals không phải để khoe kiến thức trong phỏng vấn. Nó là kỹ năng sinh tồn cho bất kỳ senior backend engineer nào vận hành hệ thống thực tế.
Bắt đầu từ những điều cơ bản: vẽ bản đồ memory, hiểu GC roots, biết cách đọc heap dump. Rồi từ từ đi sâu vào từng GC algorithm, từng JVM flag. Sau một thời gian, bạn sẽ nhìn vào biểu đồ heap usage và đoán được chính xác vấn đề trước khi OOM xảy ra.
Và khi đó, bạn không còn là “developer viết code” nữa — bạn là kỹ sư làm chủ hệ thống.
📚 Tài liệu tham khảo & đọc thêm
- Garbage Collection Tuning Guide (Oracle)
- Eclipse MAT Documentation
- JVM Anatomy Quarks (Aleksey Shipilëv)
- Jakob Jenkov’s JVM Internals
Nếu thấy bài viết hữu ích, hãy share với đồng đội — biết đâu người trực OOM tiếp theo lại là bạn mất.