Chuyển tới nội dung chính

Monitoring & Tối ưu Memory

Bài trước: Thread Memory & Virtual Threads — Bạn đã hiểu cách threads tương tác với memory. Bài này hướng dẫn cách quan sát, chẩn đoán, và tối ưu memory trong thực tế.

JVM Command-Line Tools

JDK cung cấp sẵn các tools để monitor JVM — không cần cài thêm:

jps — List Java Processes

$ jps
12345 MyApplication
12346 Jps
12347 GradleDaemon
$ jps -v  # Hiện JVM arguments
12345 MyApplication -Xmx2g -XX:+UseG1GC

jstat — GC Statistics Real-time

# GC statistics, cập nhật mỗi 1 giây
$ jstat -gcutil 12345 1000

S0 S1 E O M CCS YGC YGCT FGC FGCT GCT
0.00 45.23 67.89 34.56 95.12 92.34 42 0.234 3 0.567 0.801
CộtÝ nghĩa
S0, S1Survivor 0, 1 usage (%)
EEden usage (%)
OOld Gen usage (%)
MMetaspace usage (%)
YGCYoung GC count
YGCTYoung GC total time (seconds)
FGCFull GC count
FGCTFull GC total time (seconds)
Đọc jstat output
  • E tăng nhanh, YGC thường xuyên → bình thường (objects chết trẻ)
  • O tăng dần không giảm → có thể memory leak
  • FGC nhiều → Old Gen đầy thường xuyên → cần điều tra
  • FGCT cao → Full GC quá chậm → xem xét GC collector khác

jmap — Heap Dump

# Tạo heap dump (file chứa toàn bộ Heap snapshot)
$ jmap -dump:format=b,file=heapdump.hprof 12345

# Histogram: top classes chiếm memory
$ jmap -histo 12345 | head -20
num #instances #bytes class name
1: 1234567 98765432 [B (byte arrays)
2: 567890 45631200 java.lang.String
3: 345678 27654240 java.util.HashMap$Node

jstack — Thread Dump

# In stack trace của tất cả threads
$ jstack 12345

"main" #1 prio=5 os_prio=0 tid=0x... nid=0x... runnable
java.lang.Thread.State: RUNNABLE
at com.myapp.Service.process(Service.java:42)
at com.myapp.Main.main(Main.java:15)

"Thread-1" #12 prio=5 os_prio=0 tid=0x... nid=0x... waiting on condition
java.lang.Thread.State: WAITING
at java.lang.Object.wait(Native Method)
at com.myapp.Queue.take(Queue.java:30)
Thread dump cho deadlock detection

jstack tự động phát hiện deadlocks:

Found one Java-level deadlock:
=============================
"Thread-1": waiting to lock monitor 0x... (object 0x..., a java.lang.Object)
which is held by "Thread-2"
"Thread-2": waiting to lock monitor 0x... (object 0x..., a java.lang.Object)
which is held by "Thread-1"

jcmd — Diagnostic Commands (Đa năng)

# GC heap info
$ jcmd 12345 GC.heap_info

# Thread dump dạng JSON (Java 21+)
$ jcmd 12345 Thread.dump_to_file -format=json threads.json

# Force GC (chỉ dùng khi debug)
$ jcmd 12345 GC.run

# List tất cả commands available
$ jcmd 12345 help

Common Memory Issues

1. OutOfMemoryError: Java heap space

Nguyên nhân: Heap đầy, GC không thể giải phóng đủ memory.

Triệu chứng:
- Full GC xảy ra liên tục
- Application chậm dần rồi crash
- jstat: O (Old Gen) usage = 99-100%

Chẩn đoán:

# 1. Kiểm tra heap usage
jstat -gcutil <pid> 1000

# 2. Tạo heap dump
jmap -dump:format=b,file=heap.hprof <pid>

# 3. Mở heap dump bằng Eclipse MAT hoặc VisualVM
# → Tìm Leak Suspects → thấy class/object chiếm nhiều memory nhất

Fix:

  • Tăng heap: -Xmx4g (tạm thời)
  • Tìm và fix memory leak (dài hạn) — xem phần Memory Leak Patterns

2. OutOfMemoryError: Metaspace

Nguyên nhân: Quá nhiều classes loaded — thường do dynamic class generation (Reflection, CGLib proxies):

# Kiểm tra Metaspace
jstat -gcutil <pid> 1000 # Cột M = Metaspace usage

# Fix: Giới hạn Metaspace
java -XX:MaxMetaspaceSize=256m -jar app.jar

3. StackOverflowError

Nguyên nhân: Stack frame quá nhiều (recursion sâu) hoặc stack size quá nhỏ:

# Tăng stack size per thread (mặc định ~512KB-1MB)
java -Xss2m -jar app.jar

# Hoặc fix recursion: thêm base case, chuyển sang iteration

Memory Leak Patterns

Memory leak trong Java = objects vẫn reachable nhưng không còn cần thiết:

Pattern 1: Static Collections giữ reference

// ❌ Memory leak — static collection không bao giờ bị GC
public class EventLog {
private static final List<Event> events = new ArrayList<>();

public static void log(Event event) {
events.add(event); // Chỉ add, không bao giờ remove!
}
}
// events → ArrayList → tất cả Event objects → KHÔNG bao giờ GC
// ✅ Fix: Giới hạn size hoặc dùng bounded collection
private static final List<Event> events = new ArrayList<>();

public static void log(Event event) {
if (events.size() > 10000) {
events.subList(0, 5000).clear(); // Remove oldest half
}
events.add(event);
}

// Hoặc dùng CircularBuffer, Caffeine cache, etc.

Pattern 2: Listeners không unregister

// ❌ Listener giữ reference → object không bị GC
public class UserProfile {
public UserProfile(EventBus bus) {
bus.register(this); // this → bị EventBus giữ reference
}
// Nếu UserProfile không unregister khi không cần → leak
}
// ✅ Fix: Luôn unregister
public class UserProfile implements AutoCloseable {
private final EventBus bus;

public UserProfile(EventBus bus) {
this.bus = bus;
bus.register(this);
}

@Override
public void close() {
bus.unregister(this); // Giải phóng reference
}
}

Pattern 3: Inner Class giữ Outer Reference

// ❌ Non-static inner class giữ implicit reference đến outer class
public class DataProcessor {
private byte[] hugeData = new byte[100_000_000]; // 100MB

public Runnable createTask() {
// Anonymous inner class giữ reference đến DataProcessor.this
return new Runnable() {
@Override
public void run() {
System.out.println("Processing...");
// Không dùng hugeData, nhưng vẫn giữ reference!
}
};
}
}
// ✅ Fix: Dùng static inner class hoặc lambda (nếu không capture outer)
public Runnable createTask() {
return () -> System.out.println("Processing...");
// Lambda không capture this → không giữ reference đến outer
}

Pattern 4: Unclosed Resources

// ❌ Connection không close → pool exhaustion → memory leak
public void query(String sql) {
Connection conn = dataSource.getConnection();
Statement stmt = conn.createStatement();
ResultSet rs = stmt.executeQuery(sql);
// Nếu exception xảy ra → conn không bao giờ close!
}
// ✅ Fix: try-with-resources
public void query(String sql) {
try (Connection conn = dataSource.getConnection();
Statement stmt = conn.createStatement();
ResultSet rs = stmt.executeQuery(sql)) {
// Process results
} // Auto-close tất cả, kể cả khi exception
}

Optimization Techniques

1. Specify Initial Capacity

// ❌ Nhiều lần resize
List<String> list = new ArrayList<>(); // 7+ resizes cho 1000 items
Map<String, Integer> map = new HashMap<>(); // 7+ rehashes cho 1000 entries

// ✅ Set capacity
List<String> list = new ArrayList<>(1000);
Map<String, Integer> map = new HashMap<>(1334); // 1000 / 0.75 + 1

2. Primitive Streams thay Boxed Streams

// ❌ Boxing overhead — mỗi int → Integer object
int sum = numbers.stream()
.map(n -> n * 2) // Stream<Integer>
.reduce(0, Integer::sum);

// ✅ Primitive stream — không boxing
int sum = numbers.stream()
.mapToInt(Integer::intValue) // IntStream
.map(n -> n * 2)
.sum();

// ✅ Tốt nhất: IntStream từ đầu
int sum = IntStream.rangeClosed(1, 1_000_000)
.map(n -> n * 2)
.sum();

3. StringBuilder trong Loops

// ❌ Mỗi += tạo StringBuilder mới → nhiều objects
String result = "";
for (String item : items) {
result += item + ", "; // Tạo StringBuilder + String mới mỗi iteration
}

// ✅ Một StringBuilder cho toàn bộ loop
StringBuilder sb = new StringBuilder(items.size() * 20); // Estimate capacity
for (String item : items) {
sb.append(item).append(", ");
}
String result = sb.toString();

4. Weak/Soft References cho Caches

import java.lang.ref.WeakReference;
import java.lang.ref.SoftReference;

// WeakReference: GC collect ngay khi không có strong reference
WeakReference<BigObject> weak = new WeakReference<>(new BigObject());
// weak.get() có thể null bất cứ lúc nào sau GC

// SoftReference: GC collect khi sắp OOM (giữ lâu hơn weak)
SoftReference<BigObject> soft = new SoftReference<>(new BigObject());
// soft.get() null khi JVM cần memory

// WeakHashMap: keys là weak references → auto-cleanup
Map<Key, Value> cache = new WeakHashMap<>();

5. Avoid Premature Optimization

Nguyên tắc Oracle: "Measure First, Tune Second"
  1. Viết code đúng trước — correctness > performance
  2. Đo hiệu năng — dùng jstat, jmap, profiler
  3. Tìm bottleneck thực sự — không đoán
  4. Optimize vị trí cụ thể — chỉ optimize nơi đo được vấn đề
  5. Đo lại — confirm improvement

"Premature optimization is the root of all evil" — Donald Knuth

GC Tuning Cơ bản

Chọn Heap Size

# Rule of thumb: Xmx = 2-4x live data size
# Live data = heap usage sau Full GC

# Ví dụ: live data = 500MB → Xmx = 1-2GB
java -Xms1g -Xmx2g -jar app.jar

# Best practice: Xms = Xmx (tránh resize overhead)
java -Xms2g -Xmx2g -jar app.jar

Chọn GC Collector

# G1 — default, phù hợp hầu hết apps
java -XX:+UseG1GC -XX:MaxGCPauseMillis=200 -jar app.jar

# ZGC — latency-critical
java -XX:+UseZGC -jar app.jar

# Parallel — batch/throughput
java -XX:+UseParallelGC -jar app.jar

Enable GC Logging

# Java 9+ unified logging
java -Xlog:gc*:file=gc.log:time,level -jar app.jar

# Verbose GC logging
java -Xlog:gc*=debug:file=gc-debug.log:time -jar app.jar

Ví dụ thực tế: Chẩn đoán Memory Issue

Scenario: App chậm dần sau vài giờ chạy
// Bước 1: Monitor bằng jstat
// $ jstat -gcutil <pid> 5000
// Thấy: O (Old Gen) tăng dần từ 30% → 50% → 70% → 90%
// → Old Gen leak!

// Bước 2: Heap dump
// $ jmap -dump:format=b,file=heap.hprof <pid>

// Bước 3: Phân tích bằng Eclipse MAT hoặc VisualVM
// → Tìm thấy: HashMap chứa 500,000 Session objects
// → Root: static SessionManager.sessions không bao giờ remove expired

// Bước 4: Fix
public class SessionManager {
// ❌ Trước: không remove expired sessions
private static final Map<String, Session> sessions = new HashMap<>();

// ✅ Fix: scheduled cleanup
private static final Map<String, Session> sessions = new ConcurrentHashMap<>();

static {
// Remove expired sessions mỗi 5 phút
ScheduledExecutorService scheduler = Executors.newSingleThreadScheduledExecutor();
scheduler.scheduleAtFixedRate(() -> {
sessions.entrySet().removeIf(e -> e.getValue().isExpired());
}, 5, 5, TimeUnit.MINUTES);
}
}

Lỗi thường gặp

Lỗi thường gặp

Lỗi 1: Tăng Xmx khi có memory leak

# ❌ Chỉ delay vấn đề, không fix
java -Xmx8g -jar app.jar # OOM sau 8 giờ thay vì 2 giờ

# ✅ Tìm và fix root cause
# Dùng heap dump + MAT → tìm leak → fix code

Lỗi 2: Không set Xms = Xmx

# ❌ JVM phải resize heap nhiều lần khi startup
java -Xms256m -Xmx2g -jar app.jar

# ✅ Set bằng nhau — tránh resize overhead
java -Xms2g -Xmx2g -jar app.jar

Lỗi 3: Optimize quá sớm

// ❌ "Tối ưu" không cần thiết — GC hiện đại xử lý tốt
Object pool = objectPool.borrow(); // Object pooling thủ công
try { use(pool); } finally { objectPool.return(pool); }

// ✅ GC hiện đại rất hiệu quả — object allocation rẻ (~10ns)
Object obj = new Object(); // Đơn giản hơn, JVM optimize

Bài tập

Bài 1: JVM Monitoring [Cơ bản]

Viết một chương trình Java tạo dần objects (mỗi giây thêm 10,000 strings vào List). Chạy chương trình và dùng jstat -gcutil <pid> 1000 để quan sát GC hoạt động. Ghi lại: YGC count, FGC count, Old Gen usage trend.

Xem lời giải
import java.util.ArrayList;
import java.util.List;

public class GCObserver {
public static void main(String[] args) throws Exception {
System.out.println("PID: " + ProcessHandle.current().pid());
System.out.println("Run: jstat -gcutil " + ProcessHandle.current().pid() + " 1000");

List<String> data = new ArrayList<>();
for (int i = 0; i < 1000; i++) {
for (int j = 0; j < 10_000; j++) {
data.add("Item-" + i + "-" + j);
}
System.out.println("Added batch " + i + ", total: " + data.size());
Thread.sleep(1000);
}
}
}

Kỳ vọng: E (Eden) dao động lên xuống (Minor GC), O (Old Gen) tăng dần, YGC tăng đều, FGC xảy ra khi Old Gen gần đầy.

Bài 2: Memory Leak Detection [Trung bình]

Cho code sau, xác định memory leak và fix:

public class EventSystem {
private static final List<EventListener> listeners = new ArrayList<>();

public static void addListener(EventListener listener) {
listeners.add(listener);
}

public static void fireEvent(Event event) {
for (EventListener listener : listeners) {
listener.onEvent(event);
}
}
}

// Trong một servlet/controller:
public void handleRequest(Request req) {
EventSystem.addListener(event -> {
log(event); // Lambda capture 'this' nếu log() là instance method
});
// Mỗi request thêm 1 listener, không bao giờ remove!
}
Xem lời giải

Leak: Mỗi request thêm listener vào static list → không bao giờ remove → memory tăng mãi.

Fix options:

// Fix 1: removeListener sau khi dùng xong
public void handleRequest(Request req) {
EventListener listener = event -> log(event);
EventSystem.addListener(listener);
try {
processRequest(req);
} finally {
EventSystem.removeListener(listener);
}
}

// Fix 2: Dùng WeakReference cho listeners
private static final List<WeakReference<EventListener>> listeners = new ArrayList<>();

// Fix 3: Scope listener — không dùng static global system

Bài 3: Optimization Challenge [Thách thức]

Đoạn code sau xử lý 1 triệu records. Optimize nó để giảm memory usage và tăng speed. Đo trước và sau.

public List<String> processRecords(List<Map<String, String>> records) {
List<String> results = new ArrayList<>();
for (Map<String, String> record : records) {
String name = record.get("name");
String city = record.get("city");
String result = "Name: " + name + ", City: " + city;
results.add(result);
}
return results;
}
Gợi ý

Optimization points:

  1. new ArrayList<>(records.size()) — pre-allocate
  2. StringBuilder cho string concatenation
  3. String.intern() cho city nếu ít giá trị unique
  4. Stream API với parallel nếu list lớn

Tóm tắt

ToolMục đích
jpsList Java processes
jstatGC statistics real-time
jmapHeap dump, histogram
jstackThread dump, deadlock detection
jcmdDiagnostic commands tổng hợp
Memory IssueNguyên nhânFix
OOM: HeapHeap đầy / memory leakFix leak hoặc tăng -Xmx
OOM: MetaspaceQuá nhiều classesGiới hạn -XX:MaxMetaspaceSize
StackOverflowRecursion sâuBase case / tăng -Xss
Memory LeakObjects reachable nhưng không cầnClose resources, remove listeners, limit caches

Đọc thêm