[Java Masterclass] Bóc trần Garbage Collection: Tại sao server đang chạy mượt lại bị "đứng hình"?
Trong thế giới C/C++, lập trình viên phải tự xin cấp phát bộ nhớ (malloc()) và tự tay dọn dẹp (free()). Quên dọn thì rò rỉ bộ nhớ (Memory Leak), dọn sai thì văng lỗi Segmentation Fault sập luôn app.
Java ra đời với lời hứa: "Anh em cứ code đi, bộ nhớ để em lo!". Khi bạn gõ new Object(), Java tự xếp chỗ trên RAM. Khi object đó không còn ai dùng nữa, một "công nhân vệ sinh" tàng hình mang tên Garbage Collector (GC) sẽ tự động đi thu gom để giải phóng RAM.
Nhưng ở đời không ai cho không ai cái gì. Cái giá phải trả cho sự tiện lợi này chính là Hiệu năng bị gián đoạn.
1. Bản đồ quy hoạch của Memory Heap
Để dọn rác nhanh, Java không ném rác bừa bãi mà chia vùng RAM (Heap Memory) ra thành các khu phố cực kỳ khoa học, dựa trên một triết lý gọi là Weak Generational Hypothesis (Giả thuyết Thế hệ yếu): "Đa số các object sinh ra đều sẽ chết yểu rất nhanh".
Heap được chia làm 2 khu đô thị chính:
Khu đô thị trẻ (Young Generation)
Chia làm 3 phân khu nhỏ: Eden, Survivor 0 (S0) và Survivor 1 (S1).
- Eden (Vườn địa đàng): Tất cả các object mới sinh ra (khi bạn gọi chữ new) đều được tống vào đây. Diện tích khu này thường nhỏ và lấp đầy rất nhanh.
- Hầu hết các object sinh ra trong một request API (ví dụ: chuỗi JSON, DTO, entity tạm thời) xử lý xong request là vô dụng ngay lập tức. Chúng sẽ "chết" ngay tại Eden.
Khu đô thị già (Old Generation / Tenured)
- Đây là "viện dưỡng lão", diện tích rất rộng. Chỉ những object nào sống sót qua cực kì nhiều đợt dọn rác ở Young Gen mới được cấp visa chuyển hộ khẩu sang đây.
- Ví dụ về công dân Old Gen: Cấu hình Spring Context, Database Connection Pool, Caches,... (những thứ sống cùng tuổi thọ của server).
2. Thảm họa "Stop-The-World" (STW) diễn ra như thế nào?
Hãy tưởng tượng GC là một đội lao công đang quét nhà. Để quét cho sạch mà không bị ai giẫm rác ra lại, đội trưởng lao công sẽ phải hét lên: "TẤT CẢ ĐỨNG IM, KHÔNG AI ĐƯỢC BƯỚC ĐI!".
Trong Java, hành động "đứng im" đó gọi là Stop-The-World (STW). Khi GC chạy, TOÀN BỘ các luồng (threads) đang xử lý logic app của bạn đều bị JVM đóng băng tạm thời.
Có 2 cấp độ dọn dẹp:
Cấp độ 1: Minor GC (Quét rác khu Trẻ)
Khi khu Eden đầy, Minor GC được kích hoạt.
- Nó hô STW (đóng băng server).
- Nó kiểm tra xem ai còn sống, bế những người sống sót ném sang khu Survivor.
- Nó xóa sạch toàn bộ khu Eden.
- Nó nhả STW cho server chạy tiếp.
Vì "đa số object đều chết trẻ", nên lượng rác gom được cực nhiều, số người sống sót rất ít. Do đó, Minor GC chạy cực nhanh, STW thường chỉ tính bằng vài mili-giây, user không thể nào cảm nhận được.
Cấp độ 2: Major GC / Full GC (Quét rác Viện dưỡng lão)
Sau một thời gian, khu Old Gen cũng sẽ đầy. Lúc này Full GC xuất hiện. Đây chính là cơn ác mộng. Vì Old Gen rất rộng (có thể từ vài GB đến vài chục GB RAM) và chứa nhiều object phức tạp đan chéo nhau, thuật toán để tìm xem object nào thực sự vô dụng cực kì mất thời gian.
- Khi Full GC quét, nó hô STW. Lần này không phải vài mili-giây nữa, mà có thể là vài giây đến hàng chục giây.
- Trong suốt 5 giây đó, server của bạn "chết lâm sàng". Database không thể query, API không nhận request, màn hình app của user xoay đều vòng vòng chờ Timeout. Đây chính là hiện tượng "đứng hình" mà anh em hay thắc mắc!
3. Các "Vũ khí" chống lại Stop-The-World
Để giảm thiểu thời gian STW, qua các phiên bản Java, Oracle đã tung ra nhiều thuật toán GC khác nhau. Tùy vào loại app mà anh em cấu hình cho chuẩn:
- Parallel GC (Mặc định của Java 8): Cử nhiều lao công đi quét rác cùng lúc. Thông lượng (Throughput) cực cao, xử lý khối lượng lớn data rất tốt, nhưng khi nó hô STW thì thời gian dừng khá lâu. Phù hợp cho app chạy Batch, sinh báo cáo Excel cuối ngày.
- G1 GC (Garbage First - Mặc định từ Java 9+): Thay vì chia Heap thành 2 mảng lớn, nó băm Heap ra thành hàng ngàn ô vuông nhỏ. Nó ưu tiên quét ô nào có nhiều rác nhất trước ("Garbage First"). Điểm bá đạo của G1GC là bạn có thể cấu hình: "Ê, dọn rác kiểu gì thì dọn, tao cho mày tối đa 200ms để STW thôi đấy!" (
-XX:MaxGCPauseMillis=200). G1GC cực kì phù hợp cho các Web/API Server. - ZGC & Shenandoah (Tương lai của Java 11+): Đỉnh cao công nghệ dọn rác. Đội lao công này bá đạo ở chỗ: Nó dọn rác song song cùng lúc với lúc người ta xả rác. STW của ZGC luôn dưới 1 mili-giây, bất kể dung lượng Heap của bạn là 1GB hay 16TB! Đánh đổi lại, nó sẽ tốn CPU hơn một chút để quản lý bộ nhớ.
4. Bỏ túi bí kíp (Best Practices) cho anh em Backend
Dù bạn xài GC xịn đến đâu, nếu code ẩu thì server vẫn sụp. Đây là những nguyên tắc nằm lòng:
- KHÔNG BAO GIỜ gọi
System.gc():Hàm này là một lời ép buộc JVM chạy Full GC ngay lập tức. Hãy để JVM tự quyết định lúc nào cần dọn. - Cấu hình RAM vừa đủ, đừng tham: Đừng set
-Xmsvà-Xmx(Heap size) lên tận 32GB cho một con app bé xíu. Heap càng to thì Minor GC chạy ít, nhưng một khi Full GC nó quét thì thời gian STW sẽ lâu đến mức sập cả hệ thống. - Hạn chế xả rác trong vòng lặp (Loop):
// NGU ĐẦN: Khai báo object trong vòng lặp, mỗi lần lặp đẻ ra 1 object rác
for(int i=0; i<10000; i++) {
String str = new String("Tốn RAM");
}
- Sử dụng ThreadPool cẩn thận: Như bài trước đã nói, tạo luồng vô tội vạ sẽ làm nghẽn RAM, ép GC phải chạy liên tục (GC Thrashing). Khi GC dùng hết 98% CPU chỉ để thu hồi được 2% RAM, app của bạn sẽ văng
OutOfMemoryError: GC Overhead Limit Exceeded.
Tổng kết
Hiểu về Garbage Collection là bước đệm để chuyển từ một "Coder biết gõ Java" sang một "Kỹ sư tối ưu hệ thống". Lần tới, nếu thấy biểu đồ API latency tự nhiên giật lên vài giây rồi hết, anh em khoan hãy vội đổ lỗi cho mạng, hãy lôi tool ra soi ngay biểu đồ của Heap Memory và GC nhé.
Ở các bài tiếp theo, chúng ta sẽ tạm gác lại phần cứng OS/JVM để đi vào thứ mà bất kì anh em Backend nào cũng phải đối mặt hàng ngày: Database Transactions & Deadlock - Khi các câu lệnh SQL đâm sầm vào nhau.
Nếu thấy bài viết giải ảo được cho anh em, đừng quên thả một Upvote để series có thêm động lực ra lò tiếp nhé!
All rights reserved