Trong một ngôn ngữ có bộ thu gom rác, "rò rỉ bộ nhớ" không phải là quên free — mà là còn một sợi dây tham chiếu giữ đối tượng lại khi lẽ ra nó phải được buông. GC chỉ dọn được thứ không ai còn nắm; rò rỉ là thứ vẫn có người nắm chìa khoá trong khi đáng lẽ đã trả phòng. Nên câu hỏi không phải "cái gì to nhất" mà là "ai đang giữ" — và đó đúng là chỗ biểu đồ heap đánh lừa bạn. Bài này chỉ cách hỏi đúng câu.

Bài hôm qua kết ở dấu hiệu rò rỉ trong GC log: vùng sống sau mỗi Full GC tăng đều và không bao giờ quay lại. Biết là có rò rỉ rồi, giờ phải tìm ra cái gì đang rò. Tôi dựng một rò rỉ giống hệt mã sản xuất thật — bộ nhớ đệm phiên người dùng không bao giờ dọn (số đo dưới đây trên máy tôi, JDK 21):

static final Map<String, PhienNguoiDung> PHIEN = new ConcurrentHashMap<>();
// ... PHIEN.put(p.id, p);   và không bao giờ remove

Chụp

jcmd <pid> GC.heap_dump -overwrite /duong/dan/dump.hprof
  Heap dump file created [279083330 bytes in 0.268 secs]
  kích thước: 267M

Ba điều cần biết trước khi làm việc này trên máy chủ thật:

Nó dừng cả thế giới. 0,268 giây cho 267MB ở đây; với heap 8GB thì tính bằng giây. Đừng chụp vào giờ cao điểm nếu tránh được.

Tệp lớn bằng vùng sống, và bạn cần chỗ trống trên đĩa. Heap 8GB thì chuẩn bị 8GB.

Nó ép một lần Full GC trước khi chụp, nên tệp chỉ chứa đối tượng sống — đúng thứ bạn cần.

Và cấu hình đáng bật thường trực trên sản xuất:

-XX:+HeapDumpOnOutOfMemoryError -XX:HeapDumpPath=/var/dumps

Nó chụp đúng khoảnh khắc hết bộ nhớ, gần như miễn phí khi mọi thứ bình thường. Không có nó, bạn phải chờ sự cố xảy ra lần thứ hai.

Biểu đồ đầu tiên, và vì sao nó vô dụng

jcmd <pid> GC.class_histogram
 num   #instances       #bytes  class name
   1:      283178    257116448  [B
   2:      158097      3794328  java.lang.String
   3:      125000      3000000  Ro$DonHang
   4:       26180      1536096  [Ljava.lang.Object;
   6:       26154       836928  java.util.concurrent.ConcurrentHashMap$Node
   7:       25000       800000  Ro$PhienNguoiDung

Dòng đầu: [B — mảng byte — chiếm 257MB trên tổng 267MB.

Và thông tin đó gần như vô dụng. Mọi rò rỉ bộ nhớ đều trông như vậy. Chuỗi là mảng byte, ảnh là mảng byte, bộ đệm là mảng byte. Biết "byte[] chiếm 96% heap" cũng như biết "nạn nhân chết vì mất máu" — đúng với gần như mọi vụ, và chẳng chỉ ra ai là thủ phạm. Cái kho hàng có 96% diện tích là thùng carton; biết vậy không cho bạn hay ai đã đặt chúng và vì sao chúng không bao giờ được dọn đi.

Đây là chỗ nhiều người dừng lại và bó tay. Có hai đường đi tiếp.

Đường một: so hai ảnh chụp

Kỹ thuật này chỉ cần JDK, chạy được trên máy chủ, và tôi dùng nó nhiều hơn mọi công cụ khác.

jmap -histo:live <pid> > h1.txt
# chờ vài phút dưới tải bình thường
jmap -histo:live <pid> > h2.txt

:live quan trọng — nó ép GC trước, nên chỉ đếm đối tượng thật sự sống.

Hai ảnh chụp cách nhau 12 giây, lấy hiệu:

  lớp                                            +thực thể            +byte
  [B                                              +99.003      +92.448.184
  java.lang.String                                +54.003       +1.296.072
  Ro$DonHang                                      +45.000       +1.080.000
  [Ljava.lang.Object;                              +9.000         +504.000
  java.util.concurrent.ConcurrentHashMap$Node      +9.000         +288.000

Giờ thì bảng biết nói.

ConcurrentHashMap$Node tăng 9.000. Đây là bằng chứng quyết định: 9.000 mục mới trong một bản đồ xuất hiện trong 12 giây và không cái nào bị xoá. Bản đồ chỉ lớn lên. Trong một ứng dụng khoẻ mạnh, số lượng mục của các map phải dao động quanh một mức, không tăng tuyến tính.

Ro$DonHang tăng 45.000 — đúng gấp năm lần 9.000. Tỷ lệ 5:1 khớp với "mỗi phiên có 5 đơn hàng", nên nó xác nhận cấu trúc dữ liệu chứ không phải một nguồn rò rỉ riêng.

[B tăng 92MB — vẫn đứng đầu về dung lượng, nhưng giờ ta biết nó là mảng chi tiết nằm bên trong DonHang, không phải một thủ phạm độc lập.

Chú ý cách suy luận: lớp của chính bạn quan trọng hơn lớp của JDK. Bỏ qua [B, String, Object[]; tìm những lớp có tên gói của bạn, và tìm những cấu trúc chứa (HashMap$Node, ConcurrentHashMap$Node, ArrayList, LinkedHashMap$Entry) đang tăng đều.

Đường hai: mở dump bằng Eclipse MAT

Biểu đồ nói cái gì nhiều; MAT nói ai đang giữ. Đó là khác biệt.

MAT là công cụ đồ hoạ, nên phần này tôi mô tả quy trình chứ không kèm số đo như phần trên. Bốn bước tôi luôn làm theo thứ tự này:

Một: mở dump, xem Leak Suspects. MAT tự chạy phân tích và thường chỉ thẳng ra thủ phạm ngay ở màn hình đầu. Với một rò rỉ kinh điển như ví dụ trong bài, nó sẽ nói "một thực thể ConcurrentHashMap giữ 250MB".

Hai: mở Dominator Tree. Đây là màn hình quan trọng nhất, và cần hiểu hai khái niệm:

Kích thước nông là bộ nhớ của riêng đối tượng đó — với một HashMap thì chỉ vài chục byte.

Kích thước giữ lại là toàn bộ bộ nhớ sẽ được giải phóng nếu đối tượng đó biến mất — tức là nó cộng cả cây con mà chỉ mình nó giữ.

Biểu đồ histogram sắp theo kích thước nông, nên HashMap không bao giờ lọt vào top. Dominator tree sắp theo kích thước giữ lại, và lúc đó cái map giữ 250MB nhảy lên đầu ngay. Cái ông chủ nhà tự thân chẳng sở hữu gì mấy, nhưng nắm cả toà nhà — phá ông ta đi là trống cả toà.

Đây chính là lý do biểu đồ đầu bài vô dụng còn MAT thì không.

Ba: chuột phải → Path to GC Roots → exclude weak/soft references. Nó cho bạn chuỗi tham chiếu giữ đối tượng lại, đọc từ gốc GC xuống. Với ví dụ trong bài, chuỗi là: trường tĩnh Ro.PHIEN → ConcurrentHashMap → node → PhienNguoiDung → ArrayList → DonHang → byte[].

Dòng đầu tiên của chuỗi ấy là chỗ cần sửa. Loại trừ tham chiếu yếu và mềm rất quan trọng: chúng không ngăn GC dọn, nên chuỗi đi qua chúng là đường cụt.

Bốn: dùng OQL nếu cần đếm. MAT có ngôn ngữ truy vấn giống SQL:

SELECT * FROM java.util.concurrent.ConcurrentHashMap WHERE size > 10000

Rất hợp để trả lời "map nào có nhiều mục bất thường".

Năm nguồn rò rỉ hay gặp

Từ những gì tôi từng gặp và từ những bài trước của sê-ri:

Bộ nhớ đệm không có giới hạn và không có hạn dùng. Nguồn phổ biến nhất, và là ví dụ của bài này. Chữa bằng LinkedHashMap với removeEldestEntry, hoặc Caffeine với maximumSize và expireAfterWrite.

ThreadLocal không remove() — bài 78 đã đo: 110MB còn lại sau GC.

Listener và callback đăng ký mà không gỡ. Đối tượng đăng ký sống mãi vì nguồn phát sự kiện giữ nó.

static collection. Một static List được add mà không bao giờ remove là rò rỉ theo định nghĩa. Tìm bằng cách grep static.*List\|static.*Map là bước rà soát rẻ nhất.

Classloader không được giải phóng — bài 81. Dấu hiệu là metaspace tăng chứ không phải heap.

Khi nào dùng cách nào

GC log (bài 87) trả lời "có rò rỉ không". Rẻ nhất, bật thường trực.

So hai biểu đồ trả lời "cấu trúc nào đang lớn lên". Chạy được trên sản xuất, không cần tải tệp về.

Heap dump kèm MAT trả lời "chính xác chỗ nào trong mã". Đắt nhất — dừng ứng dụng, tệp lớn, phải tải về — nhưng dứt điểm.

Đi theo thứ tự đó thì phần lớn sự cố dừng lại ở bước hai, và bạn không phải chụp dump 8GB trên máy chủ sản xuất lúc nửa đêm. Muốn tự thử ngay bước hai: trên một ứng dụng đang chạy, gõ jmap -histo:live <pid> | head -20 > h1.txt, sleep 300, rồi lại jmap -histo:live <pid> | head -20 > h2.txt và diff h1.txt h2.txt. Nếu số lượng của một lớp nào đó thuộc gói của bạn tăng đều sau năm phút tải bình thường, bạn vừa tìm ra một rò rỉ mà chưa cần chụp dump nào.

Mẫu số chung

Cái định nghĩa "rò rỉ = còn một sợi dây tham chiếu thừa" không phải chuyện riêng của Java — nó đúng cho mọi runtime có bộ thu gom rác, và đó là điều làm nó khác hẳn C, nơi rò rỉ là quên free. Trong ngôn ngữ có GC, bộ dọn sẵn lòng thu hồi — nó chỉ bị chặn bởi một tham chiếu bạn quên buông, thường là một bộ nhớ đệm, một danh sách tĩnh, một listener chưa gỡ. Và quy trình chẩn đoán hội tụ y hệt ở mọi nơi. Go có pprof: go tool pprof xem inuse_space, và mẹo so hai ảnh chụp ở trên chính là pprof -base — lấy hiệu hai heap profile để thấy cái gì đang phình. Node/JavaScript dùng heap snapshot của Chrome DevTools, với đúng hai khái niệm của MAT — "retained size" và "dominator view" — cùng tên, cùng cách đọc. .NET có dotnet-gcdump, cũng xếp theo kích thước giữ lại. Python thì tracemalloc để so hai mốc, và gc.get_referrers/objgraph để lần ngược "ai đang giữ" — đúng vai Path to GC Roots.

Điểm chung, và là thứ đáng mang theo, gồm hai vế. Vế tư duy: trong ngôn ngữ có GC, đừng hỏi "cái gì to nhất" mà hỏi "ai đang giữ nó sống" — kích thước lớn là triệu chứng, tham chiếu thừa mới là bệnh, và đó là lý do biểu đồ "byte[] chiếm 96%" đánh lừa bạn ở mọi ngôn ngữ. Vế quy trình: so hai ảnh chụp cách nhau vài phút để tìm thứ đang lớn lên, rồi lần chuỗi tham chiếu ngược về gốc để tìm chỗ sửa — và khái niệm "kích thước giữ lại" (retained), khác với kích thước bản thân, là cái duy nhất đưa cấu trúc chứa thật sự (cái map, cái list) lên đầu bảng. Ai cầm được hai ý đó thì gỡ được rò rỉ ở bất kỳ runtime nào, kể cả runtime mình chưa từng gặp.

Ngày mai: JFR và JMC — máy ghi có sẵn trong JDK, chi phí gần bằng không, và những gì nó thấy mà log không thấy.