Mini-challenge: Debug memory leak với heap dump và JFR
Bài thực hành khép lại Module 12 — đọc app có leak, dùng jstat phát hiện trend, jcmd dump heap, MAT phân tích retained, fix root cause với data structure phù hợp. Workflow production-grade từ symptom đến fix.
Mini-challenge khép lại Module 12. Bạn nhận được codebase một service đang leak memory trong production. Symptom: heap leo từ 200MB lúc start lên 3GB sau 1 giờ, 80% time GC, p99 latency tăng từ 50ms lên 2s.
Bạn sẽ:
- Reproduce leak local với load tester.
- Capture diagnostic (jstat, JFR, heap dump).
- Phân tích heap dump với MAT-style approach.
- Identify root cause.
- Fix với data structure phù hợp.
- Verify fix với heap dump diff.
Đây là task điển hình senior Java engineer trong on-call: từ alert đến fix trong 1-2 giờ.
🎯 Đề bài
Setup
Service EventTracker ghi nhận event user clicks vào button trên website. Mỗi event có ID, timestamp, userId, button name. Service expose 2 endpoint:
POST /track— nhận event, lưu vào memory + DB.GET /stats/{userId}— trả về số click 24h gần nhất của user.
Code dùng "in-memory cache" để tránh hit DB cho mọi /stats call. Cache sử dụng HashMap<String, List<Event>> mapping userId → danh sách event.
Sau 1 giờ load test (1000 user × 100 click/user/giờ = 100k event/giờ), heap đầy.
Yêu cầu
-
Reproduce: chạy
EventTrackerApp.mainvới JVM args set heap nhỏ (-Xmx512m -XX:+HeapDumpOnOutOfMemoryError -Xlog:gc*=info:file=gc.log). ChạyLoadGenerator.main5 phút. Quan sát heap. -
Diagnose: dùng tools:
jstat -gcutil <pid> 1000— confirm Old growing.jcmd <pid> JFR.start name=leak duration=120s filename=/tmp/leak.jfr— capture allocation profile.jcmd <pid> GC.heap_dump /tmp/leak.hprof— heap dump.jcmd <pid> GC.class_histogram | head -20— top class theo size.
-
Analyze: từ histogram + heap dump, identify:
- Class nào chiếm nhiều heap nhất.
- Tại sao instance không bị GC (giữ bởi reference nào).
- Pattern leak (cache không bound? listener leak? thread local leak?).
-
Fix: refactor
EventCachevới:- Bound size (max N user trong cache).
- TTL (event > 24h tự evict — đúng business semantic "click 24h gần nhất").
- Thread-safe (concurrent access).
-
Verify: rerun load test, capture heap dump sau 5 phút và sau 30 phút, diff. Heap stable, no growth.
📦 Concept dùng trong bài
| Concept | Bài | Dùng ở đây |
|---|---|---|
| Heap layout (young/old) | 12.4 | Đọc jstat thấy Old growing |
| OOM heap space | 12.4 | Symptom chính |
| GC log | 12.5 | Phân tích pause time + reclaim ratio |
| jstat realtime | 12.6 | Confirm leak trend |
| JFR allocation profile | 12.6 | Tìm hot allocation site |
| Heap dump + MAT | 12.6 | Identify class retain memory |
| Path to GC roots | 12.6 | Trace chain reference giữ object |
| Concurrent collection | 10.4 | Fix với ConcurrentHashMap |
| Caffeine cache (3rd-party) | bonus | Fix với bounded cache |
▶️ Starter code
EventTrackerApp.java:
import java.time.Instant;
import java.util.*;
import java.util.concurrent.atomic.AtomicLong;
public class EventTrackerApp {
public record Event(String id, String userId, String button, Instant timestamp) { }
// BUG: leak here
public static class EventCache {
private final Map<String, List<Event>> cache = new HashMap<>();
public void add(Event e) {
cache.computeIfAbsent(e.userId(), k -> new ArrayList<>()).add(e);
}
public List<Event> getEventsLast24h(String userId) {
List<Event> all = cache.getOrDefault(userId, List.of());
Instant cutoff = Instant.now().minusSeconds(86400);
return all.stream()
.filter(e -> e.timestamp().isAfter(cutoff))
.toList();
}
public int size() {
return cache.values().stream().mapToInt(List::size).sum();
}
}
public static class EventTracker {
private final EventCache cache = new EventCache();
private final AtomicLong idGen = new AtomicLong();
public void track(String userId, String button) {
Event e = new Event(
String.valueOf(idGen.incrementAndGet()),
userId, button, Instant.now());
cache.add(e);
// Imagine DB persist here
}
public int statsLast24h(String userId) {
return cache.getEventsLast24h(userId).size();
}
public int totalEvents() {
return cache.size();
}
}
public static void main(String[] args) throws InterruptedException {
EventTracker tracker = new EventTracker();
// Print cache size every 5s
Thread monitor = new Thread(() -> {
while (true) {
try { Thread.sleep(5000); } catch (InterruptedException e) { return; }
Runtime r = Runtime.getRuntime();
long used = (r.totalMemory() - r.freeMemory()) / 1024 / 1024;
long max = r.maxMemory() / 1024 / 1024;
System.out.printf("Heap %d/%d MB - cache total events: %d%n",
used, max, tracker.totalEvents());
}
});
monitor.setDaemon(true);
monitor.start();
// Server thread - simulate accept track requests
// Read userId from stdin or expose via simple HTTP server
// For challenge, run via LoadGenerator below
System.out.println("EventTracker started. PID: " + ProcessHandle.current().pid());
System.out.println("Run LoadGenerator in another terminal to load this.");
// Block - keep JVM alive
Thread.currentThread().join();
}
}
LoadGenerator.java:
import java.util.concurrent.*;
import java.util.concurrent.atomic.AtomicLong;
public class LoadGenerator {
public static void main(String[] args) throws Exception {
// Connect to EventTracker via shared static field for simplicity
// (In real life, would HTTP POST. Here in-process for clarity.)
EventTrackerApp.EventTracker tracker = new EventTrackerApp.EventTracker();
ExecutorService exec = Executors.newFixedThreadPool(50);
AtomicLong totalSent = new AtomicLong();
// 1000 distinct user, each fires 1 event every 100ms
for (int u = 0; u < 1000; u++) {
final int userId = u;
exec.submit(() -> {
while (true) {
tracker.track("user-" + userId,
"button-" + (userId % 5));
totalSent.incrementAndGet();
try { Thread.sleep(100); } catch (InterruptedException e) { return; }
}
});
}
// Print stats every 10s
while (true) {
Thread.sleep(10000);
System.out.println("Total events sent: " + totalSent.get());
}
}
}
Compile và chạy:
javac EventTrackerApp.java LoadGenerator.java
# Terminal 1: chạy app với heap nhỏ + JFR setup
java -Xms256m -Xmx512m \
-XX:+HeapDumpOnOutOfMemoryError \
-XX:HeapDumpPath=/tmp/heap.hprof \
-Xlog:gc*=info:file=/tmp/gc.log \
EventTrackerApp
# Terminal 2: chạy load (note: PoC - same process, run main directly)
java LoadGenerator
# Terminal 3: monitor
jps -l # tim PID EventTrackerApp
jstat -gcutil <PID> 1000 # realtime GC
jcmd <PID> GC.heap_info # heap stats
jcmd <PID> GC.class_histogram | head -20
Note: starter code dùng cùng JVM cho App và LoadGenerator để đơn giản. Production thực tế tách 2 process qua HTTP.
Dành 30-40 phút làm.
💡 Gợi ý — workflow diagnose
Bước 1: Confirm leak qua jstat
Chạy jstat -gcutil <PID> 1000 trong 2-3 phút:
S0 S1 E O M YGC YGCT FGC FGCT
0.00 88.31 35.12 20.20 93.92 5 0.123 0 0.000
0.00 90.50 60.85 35.20 93.92 12 0.234 0 0.000
0.00 92.50 72.12 55.85 93.92 20 0.456 0 0.000
0.00 94.50 85.12 75.20 93.92 30 0.678 1 0.234
0.00 96.50 90.85 90.85 93.92 40 1.123 2 0.789
Pattern leak rõ ràng:
O%(Old) leo từ 20% → 90% trong 2 phút.FGCxuất hiện và tăng → full GC bắt đầu trigger.YGCtăng nhanh (mỗi 1-2s) → high allocation rate.
Bước 2: Class histogram
jcmd <PID> GC.class_histogram | head -20
num #instances #bytes class name (module)
-------------------------------------------------------
1: 500000 40000000 EventTrackerApp$Event
2: 500000 32000000 java.lang.String
3: 1000 8000000 java.util.ArrayList
4: 1000 128000 java.util.HashMap$Node
...
Top: Event 500k instance, String 500k. ArrayList chỉ 1000 (bằng số user) → mỗi user 1 ArrayList giữ ngày càng nhiều Event.
Bước 3: Heap dump
jcmd <PID> GC.heap_dump /tmp/leak.hprof
Mở MAT (Eclipse Memory Analyzer):
-
Leak Suspects Report auto-run khi mở:
Problem Suspect 1 The class "EventTrackerApp$EventCache", loaded by "jdk.internal.loader.ClassLoaders$AppClassLoader", occupies 380,234,567 bytes (76.05%). The memory is accumulated in one instance of "java.util.HashMap" loaded by "<system class loader>". -
Histogram sort by Retained Heap:
EventCache$1(HashMap) — 380 MB retained.Eventtotal retained — 280 MB.ArrayListretained — 100 MB (chứa Event refs).
-
Path to GC Roots cho instance EventCache:
EventCache@0x... (380 MB retained) cache field of EventTracker@0x... tracker field of LoadGenerator (or main thread local) GC root: thread "main"EventCache giữ qua chain:
main thread local→EventTracker→EventCache.cache(HashMap) →ArrayList per user→Event x N.
Bước 4: Identify root cause
// EventCache
private final Map<String, List<Event>> cache = new HashMap<>();
public void add(Event e) {
cache.computeIfAbsent(e.userId(), k -> new ArrayList<>()).add(e);
// BUG: never remove old event
}
public List<Event> getEventsLast24h(String userId) {
List<Event> all = cache.getOrDefault(userId, List.of());
Instant cutoff = Instant.now().minusSeconds(86400);
return all.stream()
.filter(e -> e.timestamp().isAfter(cutoff))
.toList();
// BUG: filter chi tao List moi return, KHONG xoa event cu khoi cache
}
Tóm tắt:
- Cache thêm mọi event vào ArrayList per user — không bao giờ remove.
getEventsLast24hfilter chỉ tạo list mới, không touch cache.- Cache size grow tuyến tính theo total event sent — leak.
Bước 5: Fix — strategy
Có 3 approach, mỗi cái fix khác nhau:
Approach A — Eager cleanup: trong add, evict event > 24h trước.
public void add(Event e) {
Instant cutoff = Instant.now().minusSeconds(86400);
List<Event> userEvents = cache.computeIfAbsent(e.userId(), k -> new ArrayList<>());
userEvents.removeIf(ev -> ev.timestamp().isBefore(cutoff));
userEvents.add(e);
}
Đơn giản, đúng semantic. Nhược: removeIf O(n) mỗi add — performance issue nếu user có 10000 event/24h.
Approach B — Background cleanup: thread riêng quét cache mỗi 1-5 phút, evict event cũ.
public EventCache() {
ScheduledExecutorService cleaner = Executors.newSingleThreadScheduledExecutor(r -> {
Thread t = new Thread(r, "EventCache-Cleaner");
t.setDaemon(true);
return t;
});
cleaner.scheduleAtFixedRate(this::cleanup, 1, 1, TimeUnit.MINUTES);
}
private void cleanup() {
Instant cutoff = Instant.now().minusSeconds(86400);
cache.values().forEach(list -> list.removeIf(e -> e.timestamp().isBefore(cutoff)));
cache.entrySet().removeIf(entry -> entry.getValue().isEmpty());
}
Cleanup amortized — không impact add. Nhược: tạm thời cache lớn hơn 24h between cleanup. Cần thread-safe (synchronization hoặc ConcurrentHashMap).
Approach C — Caffeine bounded cache với TTL: dùng thư viện chuyên cache.
import com.github.benmanes.caffeine.cache.*;
private final Cache<String, List<Event>> cache = Caffeine.newBuilder()
.maximumSize(10_000) // Max 10k user
.expireAfterWrite(Duration.ofHours(24)) // Auto evict 24h
.build();
Ngắn nhất, robust nhất production. Nhược: dependency thêm.
Quyết định: cho challenge, dùng Approach A (eager cleanup) hoặc Approach B (background cleanup) để giữ pure JDK. Approach C là answer production thật.
Bước 6: Refactor cho thread-safe
EventTracker chạy concurrent (50 thread load). HashMap không thread-safe → cần ConcurrentHashMap. ArrayList per user cũng cần synchronize hoặc dùng thread-safe variant.
private final Map<String, List<Event>> cache = new ConcurrentHashMap<>();
public void add(Event e) {
Instant cutoff = Instant.now().minusSeconds(86400);
cache.compute(e.userId(), (k, list) -> {
if (list == null) list = new ArrayList<>();
synchronized (list) {
list.removeIf(ev -> ev.timestamp().isBefore(cutoff));
list.add(e);
}
return list;
});
}
compute của ConcurrentHashMap là atomic per key. Inner ArrayList vẫn cần sync nếu shared.
Bước 7: Verify
Sau fix, rerun load test:
# Heap dump 1: 5 phút sau start
jcmd <PID> GC.heap_dump /tmp/dump1.hprof
jcmd <PID> GC.class_histogram | head -10
# Heap dump 2: 30 phút sau start
jcmd <PID> GC.heap_dump /tmp/dump2.hprof
jcmd <PID> GC.class_histogram | head -10
# So sanh
diff <(head -10 /tmp/dump1-stat.txt) <(head -10 /tmp/dump2-stat.txt)
Kết quả mong đợi: Event count stable (~24h × 1000 user × 36/giờ = 864k max nếu user fire 36/giờ liên tục — bound by 24h TTL). Heap stable, không growing.
Bonus: JFR allocation hot
jcmd <PID> JFR.start name=alloc duration=60s filename=/tmp/alloc.jfr
# ... wait 60s ...
# Open JMC, Memory Allocation tab
JMC show top allocation method:
EventTracker.track— alloc Event mỗi call (acceptable, ~100k/giờ).EventCache.getEventsLast24h— stream allocation noise.
Optimize stream nếu cần (avoid .stream().filter().toList() cho hot path, dùng manual loop).
✅ Lời giải
Code fix với Approach B (background cleanup) + thread-safe
import java.time.Instant;
import java.util.*;
import java.util.concurrent.*;
import java.util.concurrent.atomic.AtomicLong;
public class EventTrackerApp {
public record Event(String id, String userId, String button, Instant timestamp) { }
public static class EventCache implements AutoCloseable {
private final Map<String, List<Event>> cache = new ConcurrentHashMap<>();
private final ScheduledExecutorService cleaner;
private final long ttlSeconds;
public EventCache(long ttlSeconds) {
this.ttlSeconds = ttlSeconds;
this.cleaner = Executors.newSingleThreadScheduledExecutor(r -> {
Thread t = new Thread(r, "EventCache-Cleaner");
t.setDaemon(true);
return t;
});
cleaner.scheduleAtFixedRate(this::cleanup, 1, 1, TimeUnit.MINUTES);
}
public void add(Event e) {
cache.compute(e.userId(), (k, existing) -> {
List<Event> list = existing != null ? existing : new ArrayList<>();
synchronized (list) {
list.add(e);
}
return list;
});
}
public List<Event> getEventsLast24h(String userId) {
List<Event> userEvents = cache.get(userId);
if (userEvents == null) return List.of();
Instant cutoff = Instant.now().minusSeconds(ttlSeconds);
synchronized (userEvents) {
return userEvents.stream()
.filter(e -> e.timestamp().isAfter(cutoff))
.toList();
}
}
private void cleanup() {
Instant cutoff = Instant.now().minusSeconds(ttlSeconds);
int removed = 0;
for (Iterator<Map.Entry<String, List<Event>>> it = cache.entrySet().iterator(); it.hasNext(); ) {
Map.Entry<String, List<Event>> entry = it.next();
List<Event> list = entry.getValue();
synchronized (list) {
int before = list.size();
list.removeIf(e -> e.timestamp().isBefore(cutoff));
removed += before - list.size();
if (list.isEmpty()) {
it.remove();
}
}
}
System.err.println("[Cleanup] Removed " + removed + " stale events. Cache size: " + size());
}
public int size() {
int total = 0;
for (List<Event> list : cache.values()) {
synchronized (list) {
total += list.size();
}
}
return total;
}
public int userCount() {
return cache.size();
}
@Override
public void close() {
cleaner.shutdown();
}
}
public static class EventTracker {
private final EventCache cache = new EventCache(86400);
private final AtomicLong idGen = new AtomicLong();
public void track(String userId, String button) {
Event e = new Event(
String.valueOf(idGen.incrementAndGet()),
userId, button, Instant.now());
cache.add(e);
}
public int statsLast24h(String userId) {
return cache.getEventsLast24h(userId).size();
}
public int totalEvents() { return cache.size(); }
public int activeUsers() { return cache.userCount(); }
}
public static void main(String[] args) throws InterruptedException {
EventTracker tracker = new EventTracker();
Thread monitor = new Thread(() -> {
while (true) {
try { Thread.sleep(5000); } catch (InterruptedException e) { return; }
Runtime r = Runtime.getRuntime();
long used = (r.totalMemory() - r.freeMemory()) / 1024 / 1024;
long max = r.maxMemory() / 1024 / 1024;
System.out.printf("Heap %d/%d MB | events: %d | users: %d%n",
used, max, tracker.totalEvents(), tracker.activeUsers());
}
});
monitor.setDaemon(true);
monitor.start();
System.out.println("EventTracker started. PID: " + ProcessHandle.current().pid());
Thread.currentThread().join();
}
}
Điểm chính
ConcurrentHashMapthayHashMap: thread-safe cho 50 worker thread track concurrent.computeatomic per key.synchronized (list)quanh ArrayList op:ConcurrentHashMapchỉ guard map structure (put/get key), không guard inner ArrayList. Nhiều thread có cùng userId truy cập cùng list → race nếu không sync.- Background cleaner thread: chạy mỗi 1 phút, evict event > 24h, remove user entry rỗng.
daemon = trueđể không block JVM exit. Iterator.remove()an toàn cho ConcurrentHashMap: ConcurrentHashMap iterator weakly consistent — không throwConcurrentModificationException, supportit.remove().AutoCloseable: shutdown cleaner khi cache dispose. Production cần để tránh leak thread.
Verify
Chạy lại 5 phút:
Heap 234/512 MB | events: 50000 | users: 1000
Heap 256/512 MB | events: 100000 | users: 1000
Heap 278/512 MB | events: 150000 | users: 1000
[Cleanup] Removed 0 stale events. Cache size: 150000
Heap 280/512 MB | events: 200000 | users: 1000
[Cleanup] Removed 0 stale events. Cache size: 200000
...
Sau cleanup đầu tiên (1 phút), khi đã chạy đủ 24h sẽ thấy event removed. Trong 5 phút test ngắn, tất cả event vẫn trong window 24h → cache grow đến cap natural (1000 user × 5 phút × 600 event/phút = 300k event).
Để test cleanup hiệu quả, set TTL ngắn hơn cho test:
private final EventCache cache = new EventCache(60); // TTL 60s for test
Chạy 5 phút:
Heap 200/512 MB | events: 60000 | users: 1000
[Cleanup] Removed 0 stale events. Cache size: 60000
Heap 220/512 MB | events: 120000 | users: 1000
[Cleanup] Removed 60000 stale events. Cache size: 60000
Heap 220/512 MB | events: 120000 | users: 1000
[Cleanup] Removed 60000 stale events. Cache size: 60000
...
Heap stable. Cache grow đến steady state (60s × 1000 user × 10 event/s = 60k), không leak.
So sánh heap dump trước / sau fix
# Truoc fix - 5 phut
jcmd <PID> GC.class_histogram | head -5
# 1: 300000 events EventTrackerApp$Event 24 MB
# 2: 300000 events String 19 MB
# Truoc fix - 30 phut (neu chua OOM)
jcmd <PID> GC.class_histogram | head -5
# 1: 1800000 events EventTrackerApp$Event 144 MB
# 2: 1800000 events String 115 MB
# Growing 6x tuyen tinh -> leak
# Sau fix - 5 phut
jcmd <PID> GC.class_histogram | head -5
# 1: 60000 events EventTrackerApp$Event 4.8 MB
# 2: 60000 events String 3.8 MB
# Sau fix - 30 phut
jcmd <PID> GC.class_histogram | head -5
# 1: 60000 events EventTrackerApp$Event 4.8 MB
# 2: 60000 events String 3.8 MB
# Stable -> no leak
🎓 Mở rộng
Mức 1 — Caffeine library:
<!-- pom.xml -->
<dependency>
<groupId>com.github.ben-manes.caffeine</groupId>
<artifactId>caffeine</artifactId>
<version>3.1.8</version>
</dependency>
import com.github.benmanes.caffeine.cache.*;
private final Cache<String, List<Event>> cache = Caffeine.newBuilder()
.maximumSize(10_000) // Bound user count
.expireAfterWrite(Duration.ofHours(24)) // Auto evict
.recordStats() // Metric for monitoring
.build();
Ngắn hơn 50 dòng code, thread-safe built-in, eviction policy tunable (LFU, LRU, custom). Production standard.
Mức 2 — Off-heap cache với Chronicle Map:
Nếu cache lớn (10GB+), in-heap GC pressure cao. Chronicle Map alloc off-heap:
ChronicleMap<String, List<Event>> cache = ChronicleMap
.of(String.class, List.class)
.averageKey("user-12345")
.averageValueSize(1024)
.entries(1_000_000)
.createPersistedTo(new File("/tmp/event-cache.dat"));
Survive JVM restart, không impact GC. Trade-off: serialization overhead mỗi access.
Mức 3 — Time-series database thay cache:
Cache Event trong app process là anti-pattern cho production scale. Production dùng:
- InfluxDB / Prometheus cho metric / event count.
- Redis cho session-like data với TTL.
- App service stateless, query DB cho
/stats.
Trade-off: latency từ in-memory ~1μs lên DB ~1ms. Acceptable cho stats endpoint không hit mỗi request.
Mức 4 — Continuous JFR monitoring trong production:
// Embedded in app startup
String jfrConfig = "settings=profile,maxsize=200m,maxage=1h,disk=true,filename=/var/log/myapp.jfr";
ManagementFactory.getPlatformMBeanServer().invoke(
new ObjectName("jdk.management.jfr:type=FlightRecorder"),
"newRecording", null, null);
// ... configure with jfrConfig
Hoặc set -XX:StartFlightRecording=... trong JVM args. JFR ghi liên tục 1 giờ. Khi alert fire, dump snapshot:
jcmd <PID> JFR.dump filename=/tmp/incident-$(date +%s).jfr
Capture state lúc incident, phân tích offline với JMC.
✨ Điều bạn vừa làm được
Hoàn thành mini-challenge này, bạn đã:
- Reproduce production issue trong môi trường local — skill đầu tiên cho debug.
- Đọc jstat realtime để confirm pattern leak (Old growing).
- Capture heap dump với jcmd, không restart JVM.
- Phân tích heap dump với class histogram + Path to GC Roots.
- Identify root cause: cache không bound, không TTL.
- Fix với data structure phù hợp: ConcurrentHashMap + background cleanup hoặc Caffeine bounded cache.
- Verify fix với heap dump diff trước/sau.
- Áp dụng production debug workflow end-to-end — kỹ năng phân biệt junior/senior.
Chúc mừng — bạn đã hoàn thành Module 12: JVM Internals!
Bạn giờ hiểu JVM ở mọi tầng:
- Class loader — class load qua loader nào, pattern isolation Tomcat / Spring Boot.
- Bytecode — đọc javap, hiểu invokedynamic, lambda compile thế nào.
- JIT — interpreter → C1 → C2, inlining, escape analysis, deoptimization.
- Memory layout — heap young/old, metaspace, native memory, object header overhead.
- GC — chọn collector phù hợp (Parallel / G1 / ZGC) theo SLA.
- Tools — workflow debug từ symptom đến root cause.
Khoá Java đã đi qua 12 module — cú pháp, OOP, exception, generics, stream, concurrency, I/O, JVM internals. Bạn có nền tảng vững để chuyển sang framework (Spring), memory & performance chuyên sâu (Module 13), hoặc testing & build (Module 15).
Java production-grade engineering không phải về việc viết được nhiều code — mà về việc debug code khi nó đi sai trên production. Bạn vừa thực hành đúng kỹ năng đó.
Bài này có giúp bạn hiểu bản chất không?
Bình luận (0)
Đang tải...