Chương 2. JVM, bộ nhớ và Garbage Collection
Ở mức 2–4 năm, tôi không cần bạn thuộc tên từng thuật toán GC. Tôi cần biết khi service của bạn chết vì OutOfMemoryError lúc 2 giờ sáng, bạn làm gì tiếp theo: lấy bằng chứng gì, dùng công cụ nào, đọc kết quả ra sao. Kiến thức về vùng nhớ và GC chỉ có giá trị khi nó giúp bạn chẩn đoán sự cố thật. Chương này đi từ nền tảng tối thiểu đến quy trình xử lý sự cố mà tôi mong ứng viên kể lại được bằng trải nghiệm của chính mình.
Người phỏng vấn muốn kiểm tra
- Bạn mô tả đúng các vùng nhớ: Heap (young/old), Stack, Metaspace, và biết cái gì nằm ở đâu.
- Bạn hiểu GC dựa trên reachability từ GC roots, biết G1 là mặc định và khi nào cân nhắc ZGC.
- Bạn phân biệt các loại OutOfMemoryError và nguyên nhân rò rỉ bộ nhớ điển hình trong ứng dụng Spring.
- Bạn có quy trình rõ ràng khi production bị OOM hoặc CPU 100%, và biết dùng jcmd, jstack, heap dump, JFR.
- Bạn biết cấu hình JVM trong container (Docker/Kubernetes) để không bị kill oan.
2.1 JDK, JRE, JVM, class loading và JIT
Nhắc lại kiến thức
javac biên dịch file .java thành bytecode (.class), còn JVM nạp bytecode đó, vừa thông dịch vừa dùng JIT dịch phần chạy nhiều sang mã máy (xem N1.1). static nghĩa là "thuộc về class": static block chạy một lần khi class được nạp, cha trước con (xem N1.3 và N1.4).
JVM thực thi bytecode; JRE là JVM cộng thư viện chuẩn; JDK là JRE cộng công cụ phát triển (javac, jar, jcmd, jstack, jfr...). Từ Java 11, Oracle không phát hành JRE riêng nữa; nếu muốn runtime gọn cho container, bạn dùng jlink để tạo runtime chỉ gồm các module cần thiết, hoặc dùng image JRE do các nhà phân phối OpenJDK (như Eclipse Temurin) cung cấp.
Class loading gồm ba bước: loading (đọc bytecode), linking (verify, prepare cấp giá trị mặc định cho static field, resolve), initialization (chạy static initializer). Class chỉ được khởi tạo khi dùng lần đầu. Java dùng mô hình parent delegation: Application ClassLoader nhờ Platform ClassLoader (Java 9+, thay cho Extension ClassLoader cũ), Platform nhờ Bootstrap; chỉ khi cha không tìm thấy thì con mới tự nạp. Nhờ vậy không ai có thể tự viết một java.lang.String giả để thay thế class của JDK.
Class loading bằng lời thường
Hãy hình dung JVM như một thư viện: sách (class) không được bày sẵn hết lên bàn lúc mở cửa, mà chỉ được lấy ra khi có người cần đọc. Việc "lấy sách ra và chuẩn bị" gồm ba bước:
- Nạp (loading): ClassLoader tìm file
.class(trong JAR, thư mục classpath), đọc các byte và tạo một objectjava.lang.Classđại diện cho class đó. - Liên kết (linking): kiểm tra bytecode hợp lệ, không phá luật an toàn (verify); cấp chỗ cho static field và gán giá trị mặc định 0,
false,null(prepare); đổi các tên tượng trưng như "java/lang/String" trong bytecode thành tham chiếu thật (resolve, có thể làm lười về sau). - Khởi tạo (initialization): chạy các lệnh gán static field và static block theo đúng thứ tự viết trong code. Đây là lúc dòng
static { ... }mà bạn đã gặp ở N1.3, N1.4 thực sự chạy.
"Dùng lần đầu" có nghĩa cụ thể: tạo object bằng new, gọi static method, đọc/ghi static field (trừ hằng số compile-time), khởi tạo class con, hoặc dùng reflection như Class.forName. Đoạn code dưới đây cho thấy những thao tác nào thực sự kích hoạt bước khởi tạo:
public class LazyInitDemo {
static class Config {
static final int MAX = 10; // compile-time constant: copied into caller, no init
static int counter = log("Config.counter initialized");
static { System.out.println("Config static block"); }
static int log(String s) { System.out.println(s); return 0; }
}
public static void main(String[] args) {
System.out.println("main starts");
System.out.println("MAX = " + Config.MAX); // does NOT initialize Config
Config[] arr = new Config[3]; // array creation does NOT either
System.out.println("array length = " + arr.length);
System.out.println("counter = " + Config.counter); // first real use: init runs here
System.out.println("counter again = " + Config.counter); // no second init
}
}Giải thích: Chương trình in lần lượt main starts, MAX = 10, array length = 3, Config.counter initialized, Config static block, counter = 0, counter again = 0.
Config.MAXlà hằng sốstatic finalkiểu nguyên thủy có giá trị biết lúc biên dịch, nênjavacchép thẳng số 10 vàomain; JVM không cần khởi tạoConfig.- Tạo mảng
Config[]chỉ cần biết kiểu, không chạm vào static field nào, nên cũng không khởi tạo. - Lần đọc
Config.counterđầu tiên mới kích hoạt bước 3: các lệnh static chạy theo thứ tự viết (dòng gáncountertrước, static block sau). Lần đọc thứ hai không chạy lại vì mỗi class chỉ khởi tạo một lần cho mỗi ClassLoader. - Hệ quả thực tế: lỗi trong static block (ví dụ đọc file cấu hình không tồn tại) chỉ lộ ra lúc class được dùng lần đầu, dưới dạng
ExceptionInInitializerError; những lần dùng sau nhậnNoClassDefFoundError, rất dễ đọc nhầm thành "thiếu JAR".
JIT: JVM bắt đầu bằng cách thông dịch bytecode, đồng thời đếm số lần gọi method và vòng lặp. Đoạn code "nóng" được biên dịch sang mã máy bởi C1 (nhanh, tối ưu vừa phải) rồi C2 (chậm hơn nhưng tối ưu mạnh: inline, escape analysis, loại bỏ lock không cần thiết). Đây là lý do ứng dụng Java cần warm-up: vài phút đầu sau khi deploy thường chậm hơn, và benchmark nghiêm túc phải dùng JMH thay vì đo bằng System.currentTimeMillis().
2.2 Các vùng nhớ của JVM
| Vùng nhớ | Chứa gì | Phạm vi | Lỗi khi cạn |
|---|---|---|---|
| Heap | Mọi object và mảng, String pool, static field (qua Class object) | Chung mọi thread | OutOfMemoryError: Java heap space |
| Stack | Frame của mỗi lời gọi: biến cục bộ, tham chiếu, operand | Riêng từng thread | StackOverflowError |
| Metaspace | Metadata của class, method, bytecode | Chung, nằm ở native memory | OutOfMemoryError: Metaspace |
| Khác (native) | Code cache của JIT, direct buffer, thread stack, GC | Native memory | OOM native hoặc bị OS kill |
Heap young/old bằng lời thường
Heap là vùng nhớ chung nơi mọi object được tạo bằng new sinh sống. JVM chia Heap thành hai "khu" dựa trên tuổi của object, giống một thành phố có khu nhà trọ ngắn ngày và khu dân cư lâu năm:
- Young generation (thế hệ trẻ): nơi object mới ra đời. Gồm Eden (chỗ cấp phát mọi object mới) và hai vùng Survivor S0, S1 (chỗ tạm cho object vừa sống sót qua một lần dọn). Mỗi lần sống sót, object được tăng "tuổi" thêm 1.
- Old generation (thế hệ già): nơi object đạt đủ tuổi (mặc định tối đa 15 lần sống sót, JVM có thể hạ ngưỡng này) được chuyển lên, hoặc object quá lớn không vừa Survivor. Ví dụ điển hình: Spring bean, cache, connection pool, những thứ sống suốt đời ứng dụng.
Vì sao phải chia? Trong một web service, DTO của một request, chuỗi JSON tạm, list kết quả trung gian đều chết ngay khi request trả về. Nếu tách riêng khu chứa chúng, GC chỉ cần dọn khu nhỏ này thường xuyên và rẻ, còn khu Old ít thay đổi thì lâu lâu mới dọn một lần. Ví dụ cụ thể:
| Object | Sinh ra ở | Thường kết thúc ở |
|---|---|---|
OrderDto của một request | Eden | Chết ngay ở minor GC kế tiếp |
StringBuilder ghép log | Eden | Chết ngay ở minor GC kế tiếp |
| Entry trong cache Caffeine | Eden | Sống sót nhiều lần, lên Old |
Bean OrderService | Eden lúc khởi động | Lên Old, sống tới khi tắt app |
Heap được chia theo thế hệ (generational) dựa trên quan sát thực nghiệm: đa số object chết rất trẻ (DTO của một request, String tạm). Young generation gồm Eden và hai vùng Survivor; object mới sinh ra ở Eden, sống sót qua vài lần minor GC thì được chuyển (promote) lên Old generation. Dọn young gen nhanh vì chỉ cần copy số ít object còn sống.
Metaspace thay cho PermGen từ Java 8. PermGen có kích thước cố định, dễ gây OutOfMemoryError: PermGen space khi redeploy nhiều lần trên Tomcat. Metaspace nằm ở native memory và mặc định không giới hạn (có thể đặt -XX:MaxMetaspaceSize).
Ghi chú
Nhiều tài liệu cũ ghi "Metaspace lưu static variable". Không chính xác: từ Java 8, giá trị static field nằm trong Heap (gắn với object java.lang.Class), Metaspace chỉ chứa metadata của class. String pool cũng đã chuyển từ PermGen sang Heap từ Java 7.
Mỗi thread có stack riêng (mặc định khoảng 1 MB trên Linux 64-bit, chỉnh bằng -Xss). Vì vậy 2.000 platform thread có thể chiếm tới 2 GB bộ nhớ ngoài Heap (dù thực tế OS chỉ cấp trang nhớ khi dùng tới), một lý do nữa để không tạo thread vô tội vạ.
Stack frame: ví dụ gọi hàm lồng nhau
Mỗi thread có một Stack riêng, hoạt động như chồng đĩa: đĩa đặt sau cùng được lấy ra trước. Mỗi lần gọi một method, JVM đặt lên chồng một "đĩa" gọi là stack frame, chứa tham số, biến cục bộ và chỗ tính toán tạm của lần gọi đó. Method trả về thì frame của nó bị bỏ đi ngay, không cần GC. Đoạn code dưới đây gọi lồng ba tầng main → square → multiply và in ra chuỗi frame đang có trên Stack:
import java.util.Arrays;
import java.util.stream.Collectors;
public class StackFrameDemo {
public static void main(String[] args) {
int base = 2;
int result = square(base) + 1;
System.out.println("result = " + result); // prints: result = 5
}
static int square(int x) {
int y = multiply(x, x); // pushes a frame for multiply
return y; // square's frame is popped after this
}
static int multiply(int a, int b) {
String frames = Arrays.stream(new Throwable().getStackTrace())
.map(StackTraceElement::getMethodName)
.collect(Collectors.joining(" <- "));
System.out.println(frames); // prints: multiply <- square <- main
return a * b;
}
}Giải thích: Chương trình in multiply <- square <- main rồi result = 5. Tại thời điểm multiply đang chạy, Stack của thread main trông như sau (frame trên cùng là frame đang chạy):
Stack of thread "main" Heap
+------------------------------------+
| multiply: a=2, b=2 | <- running now
+------------------------------------+
| square: x=2, y=(not yet) | <- waits for multiply
+------------------------------------+
| main: base=2, result=(not yet) |
| args ---------------------------> String[] object
+------------------------------------+- Biến kiểu nguyên thủy (
a,b,x,base) nằm hẳn trong frame. Biến kiểu object nhưargschỉ là tham chiếu nằm trong frame, còn object thật nằm trên Heap. - Khi
multiplytrả về 4, frame của nó biến mất,squarenhậny = 4rồi trả về, frame củasquarebiến mất, cuối cùngmaintínhresult = 5. - Đệ quy không có điểm dừng đặt frame chồng lên mãi cho tới khi hết chỗ trên Stack (mặc định khoảng 1 MB), lúc đó JVM ném
StackOverflowError. Đây cũng chính là thứjstackin ra: mỗi dòngat ...là một frame.
2.3 Garbage Collection
GC root và "reachable" bằng lời thường
GC root là những điểm xuất phát mà JVM chắc chắn đang dùng, giống danh sách "người đang ở trong tòa nhà" mà bảo vệ luôn biết. Ví dụ quen thuộc nhất: biến cục bộ trong frame của các thread đang chạy (xem mục Stack frame ở 2.2) và static field của các class đã nạp. Một object được gọi là reachable (với tới được) nếu từ một GC root nào đó, đi theo các tham chiếu (field trỏ tới object khác, phần tử trong mảng, list, map) ta tới được nó. Không có đường đi nào thì object đó là rác, dù nó vẫn còn trỏ tới object khác hay bị object khác trỏ tới.
Đồ thị object dưới đây mô tả Heap của một service đang xử lý request tạo đơn hàng. Mũi tên là tham chiếu:
GC ROOTS HEAP
[stack of http-exec-1: order] ----> Order#1 ----> Customer#7
|
+-------> ArrayList#3 ----> Item#1
\--> Item#2
[static field ProductCache.MAP] --> HashMap#9 --> Product#5
(no path from any root) Temp#4 <----> Temp#6 (cycle, still garbage)
String#8 (garbage)- Reachable:
Order#1,Customer#7,ArrayList#3,Item#1,Item#2(đi từ biếnordertrên Stack),HashMap#9,Product#5(đi từ static field). - Rác:
Temp#4,Temp#6trỏ vòng vào nhau nhưng không root nào dẫn tới;String#8không ai trỏ tới. - Khi request kết thúc, frame chứa biến
orderbị bỏ đi, nên cả nhánhOrder#1trở thành rác ở lần GC kế tiếp. NhánhHashMap#9thì sống mãi vì static field sống cùng class, đó là lý do static collection là nguồn memory leak số một (xem 2.4).
GC không đếm tham chiếu. Nó bắt đầu từ các GC roots và đánh dấu mọi object reachable (đi tới được); phần còn lại là rác. GC roots gồm: biến cục bộ và tham số trên stack của các thread đang sống, static field của các class đã nạp, tham chiếu JNI, các thread đang chạy, object đang bị giữ monitor. Nhờ cách này, hai object tham chiếu vòng lẫn nhau nhưng không ai từ root trỏ tới vẫn bị thu hồi bình thường.
Các thuật ngữ bạn cần dùng đúng:
- Stop-the-world (STW): khoảng thời gian mọi thread ứng dụng bị dừng để GC làm việc. Mục tiêu của GC hiện đại là giảm thời gian này.
- Minor/Young GC: dọn young gen, thường vài mili giây. Full GC: dọn toàn bộ Heap, lâu hơn nhiều; Full GC xuất hiện thường xuyên là dấu hiệu có vấn đề.
- Throughput vs latency: GC tối ưu tổng thông lượng (Parallel) khác với GC tối ưu độ trễ (ZGC). Không có GC tốt nhất cho mọi trường hợp.
Minor GC, major GC và stop-the-world
Stop-the-world giống việc dọn một căn phòng đông người: muốn biết chắc món đồ nào còn ai dùng, cách an toàn nhất là bảo mọi người đứng yên một lúc. JVM đưa mọi thread ứng dụng về một điểm dừng an toàn (safepoint), làm phần việc cần sự bất động, rồi cho chạy tiếp. Trong khoảng đó, request đang xử lý bị "đứng hình": pause 300 ms nghĩa là p99 latency có thể tăng thêm 300 ms.
| Loại | Dọn vùng nào | Khi nào xảy ra | Chi phí |
|---|---|---|---|
| Minor GC (Young GC) | Eden và Survivor | Eden đầy | Stop-the-world, thường vài ms |
| Major GC | Old generation | Old gần đầy | Tùy GC, G1 đánh dấu song song rồi dọn dần (mixed GC) |
| Full GC | Toàn bộ Heap (và Metaspace) | Dọn thường không kịp, System.gc() | Stop-the-world, có thể hàng giây |
Thuật ngữ "major GC" không được định nghĩa thống nhất: nhiều người dùng nó như "Full GC". Khi phỏng vấn, hãy nói rõ bạn đang nói về vùng nào để tránh hiểu nhầm. Bảng dưới đây mô tả trạng thái Heap qua hai lần minor GC. Object A là entry cache sống lâu; B, C, D, E, F, G là object tạm của các request:
| Thời điểm | Eden | Survivor | Old |
|---|---|---|---|
| Trước minor GC 1 | Đầy: A, B, C, D (C, D đã chết) | S0, S1 rỗng | Các bean |
| Sau minor GC 1 | Rỗng | S0: A (tuổi 1), B (tuổi 1) | Các bean |
| Trước minor GC 2 | Đầy: E, F, G (E, G đã chết); B cũng đã chết | S0: A, B | Các bean |
| Sau minor GC 2 | Rỗng | S1: A (tuổi 2), F (tuổi 1) | Các bean |
| Sau nhiều lần nữa | ... | F đã chết | Các bean, A (được promote) |
Giải thích: Ở mỗi minor GC, JVM chỉ copy những object còn sống từ Eden và Survivor đang dùng sang Survivor còn lại, rồi coi toàn bộ Eden là trống. Object chết (C, D, E, G, B) không cần xử lý gì, vì vậy minor GC nhanh dù Eden lớn: chi phí tỉ lệ với số object sống, không phải số object rác. A sống đủ lâu nên được promote lên Old. Nếu Old cũng đầy mà dọn không kịp, JVM phải chạy Full GC, và đó là lúc service "đứng hình" lâu.
Một dòng GC log (bật bằng -Xlog:gc, xem 2.5) cho biết đúng những con số này:
[0.412s][info][gc] GC(0) Pause Young (Normal) (G1 Evacuation Pause) 24M->3M(256M) 2.915ms
[5.873s][info][gc] GC(7) Pause Young (Normal) (G1 Evacuation Pause) 180M->41M(256M) 6.120ms
[9.004s][info][gc] GC(12) Pause Full (G1 Compaction Pause) 250M->238M(256M) 812.447msGiải thích: 24M->3M(256M) 2.915ms nghĩa là Heap đang dùng 24 MB trước GC, còn 3 MB sau GC, tổng Heap hiện có 256 MB, pause mất gần 3 ms. Hai dòng đầu là minor GC khỏe mạnh: thu hồi phần lớn bộ nhớ trong vài ms. Dòng thứ ba là tín hiệu xấu: Full GC dừng ứng dụng 812 ms mà chỉ thu hồi từ 250 MB xuống 238 MB, tức là gần như mọi object trong Heap vẫn reachable. Khi thấy mẫu này lặp lại, hãy nghĩ tới memory leak hoặc Heap quá nhỏ so với dữ liệu thật.
| GC | Đặc điểm | Khi nào dùng |
|---|---|---|
| Serial | Một thread, đơn giản | Container rất nhỏ, JVM tự chọn khi ít CPU/RAM |
| Parallel | Nhiều thread, tối ưu throughput | Batch job, không quan tâm pause |
| G1 (mặc định từ Java 9) | Chia Heap thành region, mục tiêu pause mặc định 200 ms | Đa số web service |
| ZGC | Gần như mọi việc chạy song song với ứng dụng, pause dưới 1 ms | Heap lớn, yêu cầu độ trễ thấp |
G1 chia Heap thành nhiều region nhỏ, ưu tiên dọn region chứa nhiều rác nhất trước (vì thế tên là Garbage-First) và cố gắng giữ pause dưới -XX:MaxGCPauseMillis. ZGC chính thức dùng production từ Java 15; Java 21 bổ sung Generational ZGC (bật bằng -XX:+UseZGC -XX:+ZGenerational), và từ Java 23 chế độ generational trở thành mặc định của ZGC.
Bẫy thường gặp
JVM chỉ mặc định chọn G1 khi máy được coi là "server-class" (ít nhất 2 CPU và khoảng 2 GB RAM). Pod Kubernetes giới hạn 1 CPU sẽ âm thầm chạy Serial GC. Nếu quan trọng, hãy chỉ định GC tường minh.
Bạn không thể ép GC chạy. System.gc() chỉ là gợi ý; JVM có thể bỏ qua (và nhiều hệ thống tắt hẳn bằng -XX:+DisableExplicitGC). Gọi nó trong code production thường chỉ gây Full GC không cần thiết. finalize() đã bị deprecated từ Java 9 và đánh dấu sẽ xóa (JEP 421, Java 18): không đảm bảo được gọi, gọi lúc nào, và làm chậm GC. Giải phóng tài nguyên thì dùng try-with-resources; trường hợp đặc biệt dùng java.lang.ref.Cleaner.
2.4 OutOfMemoryError, StackOverflowError và memory leak
Thông điệp đi kèm OutOfMemoryError cho biết vùng nào cạn, và hướng điều tra khác nhau hoàn toàn:
| Thông điệp | Ý nghĩa | Hướng điều tra |
|---|---|---|
Java heap space | Heap đầy, GC không thu hồi được | Heap dump, tìm object giữ nhiều bộ nhớ |
GC overhead limit exceeded | GC chạy gần như liên tục mà thu hồi rất ít | Giống heap space, thường là leak |
Metaspace | Nạp quá nhiều class | Proxy/class sinh động, classloader leak |
unable to create native thread | Không tạo thêm được thread | Thread leak, pool không giới hạn, ulimit |
Direct buffer memory | Hết bộ nhớ off-heap của NIO | Netty/WebClient buffer không release |
StackOverflowError khác hẳn: một thread gọi lồng quá sâu, thường do đệ quy không có điểm dừng. Trường hợp thực tế hay gặp nhất trong Spring là serialize JSON entity có quan hệ hai chiều (Order có list OrderItem, OrderItem trỏ ngược về Order), hoặc toString()/hashCode() do Lombok sinh ra đi vòng qua hai entity. Cách sửa: trả DTO thay vì entity, hoặc loại field quan hệ khỏi toString/equals.
Nhắc lại kiến thức
GC chỉ thu hồi object không reachable từ GC root (xem 2.3). HashMap tìm entry theo hashCode() của key; nếu key bị sửa sau khi put, entry nằm sai bucket và không get/remove được nữa (xem 1.2 và 1.5).
Java có GC nhưng vẫn rò rỉ bộ nhớ: object không còn cần nhưng vẫn reachable. Các nguồn điển hình:
- Static collection dùng làm "cache tạm", chỉ thêm mà không bao giờ xóa.
- Cache không giới hạn:
ConcurrentHashMaplàm cache theo userId hoặc theo tham số request. Hãy dùng Caffeine vớimaximumSizevàexpireAfterWrite. - ThreadLocal trong thread pool: thread của Tomcat sống mãi, nên giá trị ThreadLocal không
remove()sẽ sống mãi, và còn có thể lộ dữ liệu sang request của người dùng khác (xem Chương 3). - Listener/callback không hủy đăng ký: đăng ký vào event bus hoặc scheduler rồi quên gỡ khi object hết vòng đời.
- Connection, stream, ResultSet không đóng: rò rỉ cả Heap lẫn tài nguyên bên ngoài (connection pool cạn trước khi Heap đầy).
- Key mutable trong HashMap (xem Chương 1): entry bị lạc, không xóa được.
Ví dụ memory leak và cách sửa
Đoạn code dưới đây mô phỏng một service ghi lại mọi báo cáo đã sinh ra vào một static list "để tiện tra cứu", và phiên bản sửa dùng cache có giới hạn:
import java.util.ArrayList;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
public class LeakDemo {
// LEAK: static list is reachable from a GC root forever, nothing is ever removed
static final List<byte[]> AUDIT_LOG = new ArrayList<>();
static void handleLeaky(int requestId) {
byte[] report = new byte[10_000]; // ~10 KB per request
AUDIT_LOG.add(report); // kept forever
}
// FIX: bounded LRU cache, drops the eldest entry once size exceeds 100
static final Map<Integer, byte[]> RECENT = new LinkedHashMap<>(16, 0.75f, true) {
@Override
protected boolean removeEldestEntry(Map.Entry<Integer, byte[]> eldest) {
return size() > 100;
}
};
static void handleFixed(int requestId) { RECENT.put(requestId, new byte[10_000]); }
public static void main(String[] args) {
for (int i = 0; i < 5_000; i++) { handleLeaky(i); handleFixed(i); }
System.out.println("leaky entries = " + AUDIT_LOG.size()); // prints: leaky entries = 5000
System.out.println("fixed entries = " + RECENT.size()); // prints: fixed entries = 100
}
}Giải thích: Sau 5.000 request, list rò rỉ giữ 5.000 mảng (khoảng 50 MB) còn cache sửa chỉ giữ 100 mảng (khoảng 1 MB).
- Bản rò rỉ:
AUDIT_LOGlà static field, tức là GC root. Mọi mảng đãaddđều reachable, GC không được phép thu hồi. Với 1 triệu request mỗi ngày, service sẽ OOM sau vài giờ hoặc vài ngày, và biểu đồ "Heap sau GC" có dạng răng cưa đi lên. - Bản sửa:
LinkedHashMapvớiaccessOrder = truevàremoveEldestEntrytrảtruekhi vượt 100 phần tử, nên entry cũ nhất bị gỡ khỏi map và trở thành rác. Trên production, bạn nên dùng Caffeine (maximumSize,expireAfterWrite) vì nó thread-safe và có thống kê;LinkedHashMapở đây không thread-safe, chỉ để minh họa ý tưởng. - Nếu mục đích là lưu vết (audit) thật sự, dữ liệu đó thuộc về database hoặc log, không thuộc về Heap.
2.5 Công cụ và cấu hình cần biết
Cấu hình tối thiểu cho một service production. Lệnh dưới đây khởi động một service với Heap cố định, tự ghi heap dump khi OOM và bật GC log, tức là đảm bảo khi sự cố xảy ra lúc 2 giờ sáng thì sáng hôm sau bạn vẫn có bằng chứng để điều tra:
# Fixed 2 GB heap; on OOM write a heap dump to /dumps, then exit
# GC log to /logs/gc.log, rotating at most 5 files x 20 MB
java -Xms2g -Xmx2g \
-XX:+HeapDumpOnOutOfMemoryError \
-XX:HeapDumpPath=/dumps \
-XX:+ExitOnOutOfMemoryError \
-Xlog:gc*:file=/logs/gc.log:time,uptime:filecount=5,filesize=20m \
-jar order-service.jarGiải thích: Khi chạy bình thường, lệnh này không khác gì java -jar; khác biệt chỉ lộ ra lúc có sự cố. Nếu Heap cạn, JVM ghi file .hprof vào /dumps rồi thoát, orchestrator khởi động lại service, còn file dump và GC log vẫn nằm lại để phân tích. Không có các tham số này, JVM có thể "sống dở" sau OOM và bạn mất hết bằng chứng khi restart. Từng tham số:
-Xmsbằng-Xmxtrên server giúp tránh chi phí co giãn Heap và lộ lỗi thiếu bộ nhớ ngay lúc khởi động.HeapDumpOnOutOfMemoryErrorghi file.hprofđúng lúc OOM, đây là bằng chứng quý nhất.HeapDumpPathphải trỏ vào volume còn tồn tại sau khi container restart.ExitOnOutOfMemoryError: JVM sau OOM thường ở trạng thái không đáng tin; để nó chết hẳn cho orchestrator khởi động lại tốt hơn là "sống dở".-Xlog:gc*là unified logging (Java 9+), thay cho-XX:+PrintGCDetailscũ. Cú pháp gồm các phần cách nhau bởi dấu hai chấm:gc*(mọi tag bắt đầu bằng gc),file=...(nơi ghi),time,uptime(mỗi dòng kèm giờ thực và thời gian kể từ lúc JVM khởi động),filecount=5,filesize=20m(xoay vòng để log không lấp đầy ổ đĩa).
Trong container, JVM đã nhận biết giới hạn cgroup từ Java 10 (và 8u191). Nhưng mặc định Heap tối đa chỉ bằng 1/4 bộ nhớ container. Thay vì hard-code -Xmx, dùng -XX:MaxRAMPercentage=75.0 để Heap co giãn theo limit của pod. Đừng đặt 100%: Metaspace, thread stack, code cache, direct buffer đều cần chỗ. Nếu tổng bộ nhớ process vượt limit, kernel kill process (Kubernetes báo OOMKilled, exit code 137) và không có heap dump nào cả, vì đó không phải OutOfMemoryError của Java.
| Công cụ | Dùng để | Lệnh ví dụ |
|---|---|---|
jcmd | Công cụ đa năng, nên dùng mặc định | jcmd <pid> GC.heap_dump /dumps/h.hprof |
jstack | Thread dump: deadlock, thread treo, CPU cao | jstack <pid> hoặc jcmd <pid> Thread.print |
jmap | Histogram object, heap dump | jmap -histo:live <pid> |
jstat | Theo dõi GC theo thời gian | jstat -gcutil <pid> 1000 |
| Eclipse MAT | Phân tích heap dump: dominator tree, leak suspects | Mở file .hprof |
| VisualVM | Giám sát trực quan, thích hợp môi trường dev/test | Kết nối JMX |
| JFR + JMC | Profiling chi phí thấp, dùng được trên production | jcmd <pid> JFR.start duration=120s filename=/dumps/app.jfr |
Lưu ý: tạo heap dump sẽ dừng ứng dụng trong vài giây đến vài chục giây với Heap lớn, và -histo:live kích hoạt Full GC. Trên production hãy tách instance khỏi load balancer trước khi dump nếu có thể. File heap dump chứa dữ liệu thật của người dùng, cần xử lý như dữ liệu nhạy cảm.
Đọc output của jcmd, jstat và jstack
Các lệnh dưới đây là những gì bạn thật sự gõ trên server. Output đã rút gọn, pid giả định là 4021:
jcmd -l # list Java processes: "4021 order-service.jar"
jcmd 4021 GC.heap_info # current heap usage per area
jcmd 4021 GC.class_histogram | head # which classes use the most memory (triggers a full GC)
jstat -gcutil 4021 1000 3 # GC utilization, every 1000 ms, 3 samples
jstack 4021 > /tmp/td-1.txt # thread dump, same as: jcmd 4021 Thread.printGiải thích: jcmd -l giúp lấy pid mà không cần ps. GC.heap_info xem nhanh Heap đang dùng bao nhiêu, GC.class_histogram đếm object theo class, jstat -gcutil theo dõi GC theo thời gian, jstack chụp trạng thái mọi thread. Output mẫu của GC.heap_info và GC.class_histogram:
4021:
garbage-first heap total 2097152K, used 1929380K
region size 1024K, 38 young (38912K), 4 survivors (4096K)
Metaspace used 131072K, committed 132608K, reserved 1179648K
num #instances #bytes class name (module)
-------------------------------------------------------
1: 5012331 641578368 [B (java.base@21.0.4)
2: 5009870 120236880 java.lang.String (java.base@21.0.4)
3: 812345 25995040 com.shop.order.OrderDto
4: 812300 19495200 java.util.concurrent.ConcurrentHashMap$NodeGiải thích: Heap 2 GB đã dùng khoảng 1,84 GB mà young chỉ chiếm 38 MB, tức là gần như toàn bộ nằm ở Old. Histogram cho thấy 812 nghìn OrderDto cùng số lượng tương đương ConcurrentHashMap$Node: gần như chắc chắn có một ConcurrentHashMap đang giữ OrderDto mà không bao giờ xóa. [B là byte[], phần lớn là nội dung của String. Histogram chỉ cho biết cái gì chiếm bộ nhớ; muốn biết ai giữ nó, bạn cần heap dump và Path to GC Roots trong Eclipse MAT. Output mẫu của jstat -gcutil:
S0 S1 E O M CCS YGC YGCT FGC FGCT GCT
0.00 100.00 62.15 97.80 96.12 91.40 1520 8.412 37 29.871 38.283
0.00 100.00 88.40 98.95 96.12 91.40 1520 8.412 38 30.690 39.102
0.00 0.00 12.03 99.10 96.12 91.40 1521 8.431 39 31.502 39.933Giải thích: Các cột S0, S1, E, O, M là phần trăm đã dùng của Survivor, Eden, Old, Metaspace. YGC/FGC là số lần minor/Full GC, YGCT/FGCT là tổng giây đã tốn. Cột O luôn trên 97% và FGC tăng 1 mỗi giây (37 → 38 → 39), mỗi lần tốn khoảng 0,8 giây: ứng dụng dành gần hết thời gian cho GC mà Old không giảm. Đây là dấu hiệu leak hoặc Heap thiếu, và thường đi kèm triệu chứng CPU cao, request timeout. Một đoạn thread dump từ jstack:
"http-nio-8080-exec-7" #45 daemon prio=5 os_prio=0 cpu=58231.40ms elapsed=612.33s tid=0x00007f3c2c0a1000 nid=0x3039 runnable
java.lang.Thread.State: RUNNABLE
at java.util.regex.Pattern$Loop.match(java.base@21.0.4/Pattern.java:4790)
at java.util.regex.Matcher.matches(java.base@21.0.4/Matcher.java:729)
at com.shop.order.PhoneValidator.isValid(PhoneValidator.java:18)
at com.shop.order.OrderController.create(OrderController.java:42)
"http-nio-8080-exec-9" #47 daemon prio=5 os_prio=0 cpu=12.10ms elapsed=612.30s tid=0x00007f3c2c0a3000 nid=0x303b waiting for monitor entry
java.lang.Thread.State: BLOCKED (on object monitor)
at com.shop.order.InvoiceNumberGenerator.next(InvoiceNumberGenerator.java:25)
- waiting to lock <0x000000070ff1a2b8> (a java.lang.Object)Giải thích: Mỗi thread gồm một dòng tiêu đề và các frame at ... (frame trên cùng là dòng đang chạy). Cách đọc:
- Tên thread (
http-nio-8080-exec-7) cho biết thread thuộc pool nào: đây là thread xử lý request của Tomcat. cpu=58231.40mslà tổng thời gian CPU thread đã dùng; 58 giây là bất thường với một thread xử lý request.nid=0x3039là id thread ở hệ điều hành dạng hex:printf '%x' 12345ra3039, khớp với TID 12345 màtop -H -p 4021báo đang chiếm CPU.- Stack trace cho thấy thread kẹt trong regex do
PhoneValidator.isValiddòng 18 gọi. Nếu 3 lần dump liên tiếp đều thấy đúng đoạn này, gần như chắc chắn là catastrophic backtracking. - Thread thứ hai ở trạng thái
BLOCKED, dòngwaiting to lock <0x...2b8>cho biết nó chờ monitor của một object. Tìm thread có dòng- locked <0x...2b8>để biết ai đang giữ lock. Nếu có deadlock, cuối file dump còn có mụcFound one Java-level deadlockliệt kê từng cặp "chờ lock nào, lock đó đang bị ai giữ".
2.6 Kịch bản: production bị OOM hoặc CPU 100%, bạn làm gì?
Đây là câu tôi dùng để phân biệt người từng trực sự cố với người chỉ đọc tài liệu. Câu trả lời tốt luôn có hai pha: khôi phục dịch vụ trước, tìm nguyên nhân gốc sau, và thu thập bằng chứng trước khi restart.
Kịch bản OOM:
- Xác nhận loại lỗi: xem log có
java.lang.OutOfMemoryErrorkèm thông điệp gì, hay pod bịOOMKilled(exit 137). Hai trường hợp điều tra khác nhau. - Giảm tác động: nếu có nhiều instance, để load balancer/Kubernetes chuyển tải; scale thêm pod hoặc rollback nếu lỗi xuất hiện ngay sau một lần deploy.
- Thu thập bằng chứng: lấy file
.hproftừHeapDumpPath, GC log, metric Heap (Prometheus/Grafana qua Micrometer). Nếu instance còn sống nhưng Heap đang tăng dần, chủ động lấy heap dump bằngjcmd. - Phân tích heap dump bằng Eclipse MAT: xem Leak Suspects, Dominator Tree, đi theo Path to GC Roots để biết ai đang giữ object. Thường sẽ thấy một
HashMapstatic hoặc một cache chiếm phần lớn Heap. - Đối chiếu với biểu đồ: Heap sau mỗi lần GC tăng dần theo thời gian (dạng răng cưa đi lên) là leak; Heap tăng vọt theo một request là tải dữ liệu quá lớn (query không phân trang, đọc cả file vào bộ nhớ).
- Sửa gốc: giới hạn cache, phân trang/stream dữ liệu, đóng tài nguyên,
remove()ThreadLocal. Chỉ tăng-Xmxkhi đã chứng minh ứng dụng thực sự cần nhiều bộ nhớ hơn. - Phòng ngừa: alert khi Heap sau GC vượt ngưỡng, test tải, ghi lại postmortem.
Kịch bản CPU 100%:
- Xác định process:
topđể thấy process Java nào chiếm CPU. - Tìm thread:
top -H -p <pid>liệt kê thread, lấy TID của thread nóng nhất, đổi sang hex bằngprintf '%x' <tid>. - Lấy thread dump 3–5 lần cách nhau vài giây bằng
jstack <pid>, tìm thread cónid=0x...khớp với số hex. Nếu cùng một stack trace lặp lại qua nhiều lần dump, đó là thủ phạm. - Nếu các thread nóng là thread GC (
GC Thread,G1 Conc), vấn đề thật là bộ nhớ:jstat -gcutilsẽ cho thấy Old gen gần đầy và số Full GC tăng liên tục. Quay về kịch bản OOM. - Nguyên nhân thường gặp: vòng lặp vô hạn do điều kiện sai, regex bị catastrophic backtracking với input lạ, busy-wait, xử lý collection khổng lồ trong một request, retry không có backoff.
- Khi không rõ, bật JFR vài phút hoặc dùng async-profiler để có flame graph, thay vì đoán.
Mẹo từ người phỏng vấn
Khi kể kịch bản này, hãy gắn với một sự cố bạn từng gặp: triệu chứng, cách phát hiện, lệnh đã chạy, nguyên nhân gốc, và bạn đã làm gì để không tái diễn. Một câu chuyện thật 2 phút có giá trị hơn 10 phút liệt kê công cụ.
2.7 Câu hỏi phỏng vấn thường gặp
Câu hỏi (Cơ bản): Heap và Stack khác nhau thế nào? Object có bao giờ nằm trên stack không?
Heap chứa object, dùng chung giữa các thread, do GC quản lý. Stack riêng từng thread, chứa frame của các lời gọi method với biến cục bộ và tham chiếu, tự giải phóng khi method kết thúc. Biến cục bộ kiểu object chỉ là tham chiếu nằm trên stack, object thật nằm trên Heap.
Câu hỏi đào sâu
"Có trường hợp object không cấp phát trên Heap không?" — Về mặt ngôn ngữ, không. Nhưng JIT có escape analysis: nếu object không thoát ra khỏi method, C2 có thể thay nó bằng các biến rời (scalar replacement), khiến việc cấp phát bị loại bỏ hoàn toàn. Đây là tối ưu của JVM, code không điều khiển được.
Câu hỏi (Hiểu sâu): Metaspace khác PermGen ra sao? Vì sao thay đổi?
PermGen nằm trong vùng quản lý của JVM với kích thước cố định, hay gây OOM khi ứng dụng sinh nhiều class hoặc redeploy nhiều lần. Metaspace (Java 8) dùng native memory, tự mở rộng, mặc định không giới hạn. Static field và String pool đã nằm trong Heap.
Bẫy thường gặp
Nói "Metaspace không giới hạn nên không bao giờ OOM". Classloader leak hoặc sinh proxy động liên tục vẫn làm Metaspace phình to, và trong container nó ăn vào giới hạn bộ nhớ của pod.
Câu hỏi (Cơ bản): GC xác định object nào là rác như thế nào?
Bằng reachability từ GC roots (biến trên stack của thread đang sống, static field, JNI, thread đang chạy), không phải đếm tham chiếu. Object không đi tới được từ root nào là rác, kể cả khi chúng tham chiếu vòng lẫn nhau.
Câu hỏi đào sâu
"Vậy memory leak trong Java là gì?" — Là object không còn dùng nhưng vẫn reachable, ví dụ nằm trong static Map, cache không giới hạn, ThreadLocal chưa remove, listener chưa hủy.
Câu hỏi (Cơ bản): Có ép được GC chạy không? System.gc() và finalize() thì sao?
Không. System.gc() chỉ là gợi ý, JVM có thể bỏ qua, và trên production thường gây Full GC không cần thiết. finalize() đã deprecated và sẽ bị xóa, không đảm bảo được gọi. Tài nguyên phải đóng tường minh bằng try-with-resources.
Bẫy thường gặp
Trả lời "gọi System.gc() để giải phóng bộ nhớ khi thấy Heap cao". Heap cao không phải vấn đề; Heap sau GC tăng dần mới là dấu hiệu leak.
Câu hỏi (Hiểu sâu): G1 và ZGC khác nhau thế nào? Bạn chọn GC ra sao?
G1 (mặc định từ Java 9) chia Heap thành region, cân bằng throughput và pause, mục tiêu pause khoảng 200 ms, phù hợp đa số service. ZGC làm gần như mọi việc song song với ứng dụng, pause dưới 1 ms kể cả Heap rất lớn, đổi lại tốn thêm CPU. Tôi giữ G1 mặc định, chỉ đổi khi số liệu p99 latency cho thấy GC pause là nguyên nhân.
Câu hỏi đào sâu
"Làm sao biết GC pause gây chậm?" — Đọc GC log hoặc JFR, so thời điểm pause với thời điểm latency tăng vọt; metric jvm.gc.pause của Micrometer cũng cho thấy điều này.
Câu hỏi (Thực chiến): Pod Kubernetes của bạn bị restart với OOMKilled nhưng không có heap dump. Tại sao?
Vì đó là kernel kill process do vượt memory limit của container, không phải OutOfMemoryError của JVM. Nguyên nhân thường là Heap đặt quá sát limit, không chừa chỗ cho Metaspace, thread stack, direct buffer. Cách xử lý: dùng -XX:MaxRAMPercentage khoảng 70–75%, theo dõi bộ nhớ native bằng Native Memory Tracking (-XX:NativeMemoryTracking=summary, xem bằng jcmd <pid> VM.native_memory summary).
Câu hỏi (Thực chiến): Production bị OOM, bạn xử lý từng bước thế nào?
Khôi phục dịch vụ trước (chuyển tải, scale, rollback), nhưng giữ lại bằng chứng: heap dump từ HeapDumpOnOutOfMemoryError, GC log, metric. Phân tích heap dump bằng Eclipse MAT (Leak Suspects, Dominator Tree, Path to GC Roots), đối chiếu biểu đồ Heap sau GC để phân biệt leak với tải đột biến. Sửa nguyên nhân gốc rồi mới tính chuyện tăng -Xmx, và bổ sung alert.
Bẫy thường gặp
"Tôi tăng -Xmx lên gấp đôi." Nếu là leak, bạn chỉ trì hoãn sự cố thêm vài giờ và khiến heap dump lần sau to hơn, khó phân tích hơn.
Câu hỏi (Thực chiến): CPU lên 100%, làm sao tìm ra dòng code gây ra?
top -H -p <pid> tìm thread nóng, đổi TID sang hex, đối chiếu nid trong vài lần jstack liên tiếp. Nếu thread nóng là thread GC thì quay về điều tra bộ nhớ. Không rõ ràng thì dùng JFR hoặc async-profiler để có flame graph.
Câu hỏi (Cơ bản): Một class được JVM nạp và khởi tạo khi nào?
Class được nạp lười: chỉ khởi tạo khi được dùng thật lần đầu. Quá trình gồm ba bước: nạp (đọc bytecode, tạo object Class), liên kết (verify, cấp giá trị mặc định cho static field, resolve), khởi tạo (chạy lệnh gán static và static block theo thứ tự viết).
- Kích hoạt khởi tạo:
new, gọi static method, đọc/ghi static field không phải hằng số, khởi tạo class con,Class.forName. - Không kích hoạt: đọc hằng số compile-time (
static final int MAX = 10), tạo mảngConfig[], khai báo biến kiểuConfig.
Câu hỏi đào sâu
"Static block ném exception thì sao?" — Lần đầu nhận ExceptionInInitializerError, các lần sau nhận NoClassDefFoundError vì class bị đánh dấu khởi tạo thất bại. Nhiều người đọc log thấy NoClassDefFoundError và đi tìm JAR bị thiếu, trong khi nguyên nhân thật nằm ở dòng log đầu tiên.
Câu hỏi (Đọc code): Đoạn code dưới đây in ra gì?
public class InitQuiz {
static class Parent {
static { System.out.println("Parent init"); }
static String NAME = "parent";
}
static class Child extends Parent {
static { System.out.println("Child init"); }
static final String TAG = "child"; // compile-time constant
}
public static void main(String[] args) {
System.out.println(Child.TAG);
System.out.println(Child.NAME); // NAME is declared in Parent
}
}Đáp án: In ra child, Parent init, parent. Không có dòng Child init.
Child.TAGlà hằng số compile-time, được chép thẳng vàomain, không class nào bị khởi tạo.Child.NAMEthực chất là field khai báo ởParent. JVM chỉ khởi tạo class khai báo field đó, tức làParent, nên inParent initrồiparent.Childvẫn chưa được khởi tạo vì chưa có ai dùng thứ gì thuộc riêng nó.
Câu hỏi (Cơ bản): Stack frame là gì? Nó được tạo và hủy khi nào?
Stack frame là "ngăn" trên Stack của thread dành riêng cho một lần gọi method, chứa tham số, biến cục bộ và giá trị tính toán tạm. Frame được đặt lên đỉnh Stack khi method bắt đầu và bị bỏ đi ngay khi method trả về hoặc ném exception ra ngoài. Vì vậy biến cục bộ không cần GC dọn, và mỗi thread có biến cục bộ riêng nên chúng tự nhiên thread-safe. Gọi lồng main → square → multiply thì có ba frame chồng lên nhau, frame multiply ở trên cùng.
Câu hỏi (Đọc code): Chương trình dưới đây in ra gì?
public class DeepRecursion {
static int depth = 0;
static void dive() {
depth++;
dive(); // no base case
}
public static void main(String[] args) {
try {
dive();
} catch (StackOverflowError e) {
System.out.println("caught, depth > 1000: " + (depth > 1000)); // prints: caught, depth > 1000: true
}
}
}Đáp án: In caught, depth > 1000: true. Giá trị depth chính xác thay đổi theo máy và -Xss (thường vài nghìn đến vài chục nghìn), nên đề chỉ in phép so sánh.
- Mỗi lần gọi
dive()thêm một frame; không có điểm dừng nên Stack đầy và JVM némStackOverflowError. - Bắt được vì đây là
Errortrong cùng thread, và khi exception lan ngược ra tớimainthì mọi framediveđã bị bỏ, Stack có chỗ trở lại. Trên production, không nên bắtStackOverflowErrorđể "chạy tiếp"; hãy sửa đệ quy (thêm điểm dừng, chuyển sang vòng lặp).
Câu hỏi (Cơ bản): Vì sao Heap được chia thành young và old generation?
Vì đa số object chết rất trẻ, nên tách chúng ra để dọn thường xuyên với chi phí thấp. DTO, String tạm, list trung gian của một request chết ngay khi request xong. Minor GC chỉ quét young gen và chỉ copy số ít object còn sống, nên nhanh. Object sống lâu (bean, cache) được chuyển lên old gen và ít khi phải dọn. Nếu không chia, mỗi lần GC phải duyệt cả Heap nhiều GB chỉ để thu hồi đống object tạm.
Câu hỏi (Hiểu sâu): Minor GC, major GC và Full GC khác nhau thế nào? Cái nào stop-the-world?
Minor GC dọn young gen, Full GC dọn toàn bộ Heap; "major GC" thường dùng để chỉ việc dọn old gen, nghĩa không thống nhất. Minor GC luôn stop-the-world nhưng ngắn (vài ms). G1 dọn old gen bằng cách đánh dấu song song với ứng dụng rồi dọn dần trong các mixed GC có pause ngắn. Full GC là stop-the-world toàn phần, có thể kéo dài hàng giây với Heap lớn.
Câu hỏi đào sâu
"ZGC có stop-the-world không?" — Có, nhưng rất ngắn (dưới 1 ms) và không tăng theo kích thước Heap, vì phần việc nặng (đánh dấu, di chuyển object) chạy đồng thời với ứng dụng. Cái giá là tốn thêm CPU và bộ nhớ.
Câu hỏi (Hiểu sâu): Vì sao minor GC nhanh dù Eden có thể rất lớn?
Vì chi phí của minor GC tỉ lệ với số object còn sống, không phải số object rác. GC đi từ GC root, chỉ chạm tới object reachable và copy chúng sang Survivor; phần còn lại của Eden được coi là trống ngay lập tức, không cần duyệt từng object chết. Nếu 98% object trong Eden đã chết, minor GC chỉ phải copy 2%. Ngược lại, nếu ứng dụng giữ nhiều object sống qua minor GC (cache lớn tạo liên tục), minor GC sẽ chậm và promote nhiều lên old.
Câu hỏi (Đọc code): Sau dòng đánh dấu // HERE, object nào đủ điều kiện bị GC thu hồi?
public class ReachQuiz {
static class Node { Node next; String name; Node(String n) { name = n; } }
static Node root;
public static void main(String[] args) {
Node a = new Node("A");
Node b = new Node("B");
Node c = new Node("C");
a.next = b;
b.next = c;
c.next = b; // B and C point to each other
root = new Node("R");
a = null;
b = null;
c = null;
// HERE
System.out.println(root.name); // prints: R
}
}Đáp án: A, B, C đều đủ điều kiện bị thu hồi; chỉ R còn reachable.
- R được static field
roottrỏ tới, static field là GC root. - Sau khi
a,b,cbị gánnull, không biến cục bộ nào còn trỏ tới A, B, C. B và C trỏ vòng vào nhau nhưng không có đường đi từ root nào, nên vẫn là rác. Đây là lý do Java không dùng reference counting: đếm tham chiếu thì B và C mỗi cái vẫn có 1 tham chiếu và sẽ không bao giờ bị thu hồi. - "Đủ điều kiện" không có nghĩa là bị thu hồi ngay; GC chạy khi nào là do JVM quyết định.
Câu hỏi (Hiểu sâu): Vì sao JVM không dùng reference counting như một số ngôn ngữ khác?
Vì reference counting không thu hồi được vòng tham chiếu và tốn chi phí cập nhật bộ đếm ở mỗi phép gán. Hai object trỏ vào nhau luôn có bộ đếm lớn hơn 0 dù không ai dùng. Mỗi lần gán tham chiếu phải tăng/giảm bộ đếm, trong môi trường đa luồng còn phải làm nguyên tử, rất đắt. Tracing từ GC root giải quyết vòng tham chiếu một cách tự nhiên và cho phép copy/nén object để tránh phân mảnh.
Câu hỏi (Đọc code): Tìm lỗi bộ nhớ trong service dưới đây.
import java.util.HashMap;
import java.util.Map;
public class ExchangeRateService {
private static final Map<String, Double> CACHE = new HashMap<>();
public double rate(String from, String to, String requestId) {
String key = from + "-" + to + "-" + requestId; // one entry per request
return CACHE.computeIfAbsent(key, k -> fetchRate(from, to));
}
private double fetchRate(String from, String to) { return 24_500.0; } // simulated remote call
}Đáp án: Có hai lỗi: memory leak và không thread-safe.
- Key chứa
requestId, mỗi request sinh một key mới nên cache không bao giờ trúng mà chỉ phình ra.CACHElà static field, tức là GC root, nên mọi entry sống mãi: Heap tăng dần cho tới OOM. HashMapbị gọi đồng thời từ nhiều thread Tomcat, có thể mất entry hoặc hỏng cấu trúc bên trong.- Sửa: key chỉ gồm
from-to; dùng Caffeine vớimaximumSizevàexpireAfterWrite(tỷ giá cũng cần hết hạn), hoặc ít nhất làConcurrentHashMapvới số key có giới hạn.
Câu hỏi (Đọc code): Class dưới đây có giữ bộ nhớ lâu hơn cần thiết không?
import java.util.ArrayList;
import java.util.List;
public class ReportBuilder {
private final byte[] rawData = new byte[50_000_000]; // 50 MB, only needed while building
class Summary { // inner class (not static)
String text() { return "size=" + rawData.length; }
}
static final List<Object> SUMMARIES = new ArrayList<>();
public static void main(String[] args) {
SUMMARIES.add(new ReportBuilder().new Summary());
System.out.println(SUMMARIES.size()); // prints: 1
}
}Đáp án: Có. Mỗi Summary giữ ngầm 50 MB rawData.
- Inner class không
staticluôn mang một tham chiếu ẩn tới object bao ngoài (ReportBuilder.this). GiữSummarytrong static list nghĩa là giữ luônReportBuildervà mảng 50 MB của nó, dùSummarychỉ cần một con số. - Sửa: dùng
staticnested class hoặc record, chỉ truyền vào dữ liệu thực sự cần (ví dụrecord Summary(int size)). Đây là nối kiến thức giữa N1.3 (static nested class không cần object bao ngoài) và GC reachability.
Câu hỏi (Hiểu sâu): StackOverflowError và OutOfMemoryError khác nhau thế nào?
StackOverflowError là một thread hết chỗ trên Stack riêng của nó; OutOfMemoryError là JVM không cấp phát được bộ nhớ ở một vùng chung (Heap, Metaspace, native...).
| StackOverflowError | OutOfMemoryError | |
|---|---|---|
| Vùng nhớ | Stack của một thread | Heap, Metaspace, direct memory, native |
| Nguyên nhân thường gặp | Đệ quy không điểm dừng, toString vòng qua entity | Leak, dữ liệu quá lớn, Heap quá nhỏ |
| Ảnh hưởng | Thường chỉ thread đó | Có thể mọi thread |
| Cách điều tra | Đọc stack trace lặp lại | Heap dump, GC log |
Câu hỏi (Hiểu sâu): Tăng -Xss có phải cách sửa StackOverflowError không?
Hiếm khi. -Xss tăng kích thước Stack của mọi thread, nhân với hàng trăm thread là tốn thêm nhiều bộ nhớ native, trong khi đệ quy vô hạn vẫn tràn dù Stack to đến đâu. Chỉ tăng khi thuật toán đệ quy có độ sâu hợp lệ nhưng lớn (ví dụ duyệt cây sâu), và ngay cả khi đó, chuyển sang vòng lặp với Deque tự quản lý thường là giải pháp tốt hơn.
Câu hỏi (Hiểu sâu): Vì sao nên đặt -Xms bằng -Xmx trên server?
Để Heap có kích thước ổn định ngay từ đầu, tránh chi phí co giãn và phát hiện thiếu bộ nhớ ngay lúc khởi động. Nếu -Xms nhỏ, JVM phải mở rộng Heap dần dần qua nhiều lần GC khi tải tăng, gây pause không cần thiết. Trên server, bộ nhớ đã dành riêng cho service, nên không có lý do tiết kiệm. Trong container thì dùng -XX:InitialRAMPercentage và -XX:MaxRAMPercentage bằng nhau cho cùng mục đích.
Câu hỏi (Hiểu sâu): Static field liên quan gì tới class loading, GC root và memory leak?
Static field được tạo khi class khởi tạo, sống cùng class, và là một GC root, nên mọi thứ nó trỏ tới đều không bị thu hồi chừng nào class còn được nạp.
- Class loading (2.1): giá trị mặc định được gán ở bước prepare, giá trị thật ở bước initialization.
- Vị trí: giá trị static field nằm trong Heap (gắn với object
Class), không phải Metaspace. - GC: class của ứng dụng gần như không bao giờ bị unload, nên
static Mapchỉ thêm không xóa là leak kinh điển. - Nối với ch03: static field cũng là trạng thái chia sẻ giữa mọi thread, nên còn phải thread-safe.
Câu hỏi (Hiểu sâu): Shallow heap và retained heap trong Eclipse MAT khác nhau thế nào?
Shallow heap là kích thước của chính object; retained heap là tổng bộ nhớ được giải phóng nếu object đó bị thu hồi, tức là gồm cả những object chỉ reachable qua nó. Một HashMap có shallow heap vài chục byte nhưng retained heap có thể là 2 GB nếu nó là con đường duy nhất tới hàng triệu entry. Khi tìm leak, bạn sắp xếp theo retained heap trong Dominator Tree, rồi dùng Path to GC Roots để biết field nào (thường là static) đang giữ nó.
Câu hỏi (Hiểu sâu): Stop-the-world là gì và vì sao GC cần dừng ứng dụng?
Là khoảng thời gian mọi thread ứng dụng bị dừng ở safepoint để GC làm những việc cần Heap đứng yên. Nếu ứng dụng vẫn đổi tham chiếu trong lúc GC đang đánh dấu hoặc di chuyển object, GC có thể bỏ sót object sống hoặc để thread đọc địa chỉ cũ. GC hiện đại (G1, ZGC) làm phần lớn việc đồng thời với ứng dụng nhờ các cơ chế như barrier, chỉ dừng rất ngắn ở vài pha đồng bộ. Hệ quả thực tế: pause GC cộng thẳng vào latency của các request đang xử lý.
Câu hỏi (Hiểu sâu): Parallel GC và G1, chọn cái nào cho một batch job chạy đêm?
Batch job thường ưu tiên throughput, nên Parallel GC là ứng viên tốt; web service ưu tiên latency nên giữ G1. Batch job không có người dùng chờ từng request, pause 1 giây không sao, miễn tổng thời gian job ngắn nhất. Parallel GC dùng mọi core để dọn trong lúc dừng, ít chi phí đồng thời hơn G1. Tuy vậy, hãy đo bằng GC log trước khi đổi: với Heap vừa phải, G1 mặc định thường đủ tốt.
Câu hỏi (Cơ bản): jcmd, jstack, jmap, jstat: khi nào dùng cái nào?
Mặc định dùng jcmd vì nó gom gần hết chức năng; jstat để theo dõi GC theo thời gian.
- CPU cao, thread treo, deadlock: thread dump bằng
jstack <pid>hoặcjcmd <pid> Thread.print. - Heap đầy, nghi leak:
jcmd <pid> GC.heap_info,GC.class_histogram, rồiGC.heap_dumpđể phân tích bằng MAT. - GC chạy dày:
jstat -gcutil <pid> 1000, kèm GC log. - Profiling trên production:
jcmd <pid> JFR.start.
Câu hỏi (Đọc code): Đọc đoạn thread dump dưới đây, vấn đề nằm ở đâu?
"order-worker-3" #58 prio=5 cpu=15.20ms nid=0x4a21 waiting for monitor entry
java.lang.Thread.State: BLOCKED (on object monitor)
at com.shop.StockService.reserve(StockService.java:31)
- waiting to lock <0x0000000712ab0010> (a com.shop.StockService)
"order-worker-1" #56 prio=5 cpu=90412.77ms nid=0x4a1f runnable
java.lang.Thread.State: RUNNABLE
at java.net.SocketInputStream.read(SocketInputStream.java:168)
at com.shop.SupplierClient.check(SupplierClient.java:54)
at com.shop.StockService.reserve(StockService.java:35)
- locked <0x0000000712ab0010> (a com.shop.StockService)Đáp án: order-worker-1 đang giữ lock của StockService trong lúc gọi HTTP sang nhà cung cấp, khiến order-worker-3 (và các thread khác) phải xếp hàng ở trạng thái BLOCKED.
- locked <...0010>ở worker-1 và- waiting to lock <...0010>ở worker-3 cùng địa chỉ, nên worker-1 là thread giữ lock.- Worker-1 hiện
RUNNABLEdù đang chờ socket: thread chờ I/O ở tầng native vẫn làRUNNABLE(xem 3.1). - Sửa: đưa lời gọi HTTP ra ngoài vùng
synchronized, đặt timeout cho HTTP client, hoặc khóa theo từng sản phẩm thay vì cả service. Đây không phải deadlock: lock sẽ được nhả khi HTTP trả về, chỉ là rất chậm.
Câu hỏi (Thực chiến): Heap dump 6 GB mở trong MAT cho thấy một HashMap chiếm 4 GB. Bạn làm gì tiếp?
Tìm ai giữ HashMap đó và vì sao nó không bao giờ nhỏ lại.
- Chuột phải vào HashMap trong Dominator Tree, chọn Path to GC Roots (loại trừ weak/soft reference) để thấy chuỗi tham chiếu, ví dụ
static PriceCache.MAP. - Xem mẫu key/value bên trong: key có chứa thành phần duy nhất theo request (requestId, timestamp) không, có key mutable không.
- Tìm trong code mọi chỗ
putvàremovevào map đó; thường sẽ thấy chỉ cóput. - Sửa bằng cache có giới hạn và hết hạn, thêm metric kích thước cache, và tạo alert cho Heap sau GC.
Câu hỏi đào sâu
"Nếu Path to GC Roots dẫn tới một thread của Tomcat?" — Rất có thể là ThreadLocal chưa remove(): giá trị nằm trong threadLocals của thread pool sống mãi (xem 3.9).
Câu hỏi (Thực chiến): GC log cho thấy Full GC mỗi phút, Heap sau GC vẫn ở 95%. Chuyện gì đang xảy ra?
Gần như mọi object trong Heap vẫn reachable: hoặc có leak, hoặc Heap quá nhỏ cho lượng dữ liệu sống thật. Ứng dụng lúc này dành phần lớn thời gian cho GC, CPU cao, request timeout, sắp tới là OutOfMemoryError (có thể là GC overhead limit exceeded). Để phân biệt: nếu Heap sau GC tăng dần theo ngày thì là leak, lấy heap dump; nếu nó ổn định ở mức cao ngay sau khởi động và tỉ lệ với tải thì có thể cần thêm Heap hoặc giảm dữ liệu giữ trong bộ nhớ (cache nhỏ hơn, phân trang).
Câu hỏi (Thực chiến): Pod bị OOMKilled nhưng dashboard cho thấy Heap chỉ dùng 60%. Vì sao?
Vì Kubernetes đo toàn bộ bộ nhớ process, còn Heap chỉ là một phần. Ngoài Heap còn Metaspace, thread stack (500 thread x 1 MB), code cache, direct buffer của Netty, bộ nhớ của GC. Cách điều tra: bật -XX:NativeMemoryTracking=summary, chạy jcmd <pid> VM.native_memory summary để xem từng vùng; kiểm tra số thread (jcmd <pid> Thread.print | grep -c '^"'); kiểm tra direct buffer. Sửa bằng cách giảm MaxRAMPercentage xuống khoảng 70–75%, giới hạn pool thread, hoặc tăng memory limit nếu đã đo được nhu cầu thật.
Câu hỏi (Thực chiến): Sau mỗi lần deploy, vài phút đầu p99 latency tăng gấp 3 rồi tự hết. Vì sao và xử lý thế nào?
Đó là giai đoạn warm-up: JIT chưa biên dịch code nóng, class chưa nạp, cache và connection pool còn trống. Trong vài phút đầu, code chạy ở chế độ thông dịch hoặc C1, class được nạp lười lần đầu (2.1), connection pool mở dần. Cách xử lý: readiness probe chỉ báo sẵn sàng sau khi gọi thử các endpoint chính (warm-up request), rolling deploy từ từ, tạo sẵn connection tối thiểu trong pool; với Java mới có thể cân nhắc CDS/AppCDS để nạp class nhanh hơn.
Câu hỏi đào sâu
"Làm sao chứng minh là JIT?" — Bật -Xlog:jit+compilation=debug hoặc JFR, đối chiếu thời điểm latency giảm với thời điểm các method nóng được C2 biên dịch.
2.8 Tóm tắt trọng tâm
- Class được nạp lười qua ba bước nạp, liên kết, khởi tạo; static block chỉ chạy khi class được dùng thật lần đầu, nên lỗi trong static block lộ ra muộn dưới dạng
ExceptionInInitializerError. - Mỗi lời gọi method tạo một stack frame trên Stack riêng của thread, và frame biến mất khi method trả về, vì vậy biến cục bộ không cần GC và tự nhiên thread-safe.
- Mọi object nằm trên Heap dùng chung; biến trên Stack chỉ giữ tham chiếu, vì vậy nhiều thread có thể cùng nhìn thấy một object.
- Heap chia young/old vì đa số object chết trẻ, nhờ đó minor GC chỉ copy số ít object sống và rất nhanh.
- Giá trị static field và String pool nằm trong Heap, còn Metaspace chỉ chứa metadata của class; tài liệu nói ngược lại là đã lỗi thời.
- GC xác định rác bằng reachability từ GC root chứ không đếm tham chiếu, vì vậy vòng tham chiếu vẫn được thu hồi.
- Memory leak trong Java là object không còn dùng nhưng vẫn reachable, và static collection, cache không giới hạn, ThreadLocal không
remove()là ba nguồn phổ biến nhất. - Stop-the-world cộng thẳng vào latency, vì vậy Full GC lặp lại mà thu hồi ít là tín hiệu cần điều tra ngay.
- G1 là lựa chọn mặc định hợp lý; chỉ đổi GC khi số liệu cho thấy GC pause là nguyên nhân gây chậm.
- Cấu hình production phải có
HeapDumpOnOutOfMemoryErrorvà GC log, vì không có bằng chứng thì không thể tìm nguyên nhân gốc sau khi restart. - OOMKilled của Kubernetes khác
OutOfMemoryError: kernel kill process nên không có heap dump, và phải chừa 25–30% bộ nhớ cho vùng ngoài Heap. - Histogram cho biết cái gì chiếm bộ nhớ, còn heap dump với Path to GC Roots mới cho biết ai đang giữ nó.
- Khi CPU 100%, hãy đối chiếu TID nóng (đổi sang hex) với
nidtrong nhiều lần thread dump liên tiếp, vì một lần dump đơn lẻ dễ gây hiểu nhầm.