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:
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" không cho bạn manh mối nào về chỗ cần sửa.
Đâ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.
Đâ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.
Thử ba mươi giây
Trên một ứng dụng Java đang chạy:
jmap -histo:live <pid> | head -20 > h1.txt
sleep 300
jmap -histo:live <pid> | head -20 > h2.txt
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.
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.