Memory leak & OutOfMemoryError
Tiếp đi: memory leak. order-service chạy được khoảng 3 ngày thì RAM tăng dần rồi chết với OutOfMemoryError: Java heap space, restart lại chạy ổn 3 ngày. Java có GC rồi sao vẫn leak được?
Vì GC chỉ dọn object không còn reachable (bài 2.4). Leak trong Java nghĩa là: object bạn không dùng nữa nhưng vẫn có reference trỏ tới, thường từ một thứ sống mãi như static field. GC không có lỗi, nó chỉ làm đúng việc.
1. Bài toán: "răng cưa" đi lên
Heap sau mỗi GC (Old gen):
Ngày 1 ▁▂▂▃
Ngày 2 ▃▄▄▅
Ngày 3 ▅▆▇█ → Full GC liên tục → OOM
Bình thường: đáy răng cưa nằm ngang. Leak: đáy răng cưa đi lên.
2. Vấn đề: 4 kiểu leak hay gặp trong service Spring
(a) Static map làm cache không giới hạn
@Service
class ProductPriceClient {
private static final Map<String, PriceResponse> CACHE = new ConcurrentHashMap<>();
PriceResponse price(String productId, String customerId, Instant at) {
String key = productId + ":" + customerId + ":" + at; // key gần như không lặp lại
return CACHE.computeIfAbsent(key, k -> callPricingService(productId, customerId));
}
}
Key chứa timestamp → mỗi request một entry mới, không bao giờ xóa.
(b) ThreadLocal không remove trên thread pool
static final ThreadLocal<List<AuditEvent>> EVENTS = ThreadLocal.withInitial(ArrayList::new);
// filter thêm event mỗi request nhưng không gọi EVENTS.remove()
// thread Tomcat sống mãi → list của nó lớn mãi
Chi tiết ThreadLocal: bài 3.8.
(c) Listener / callback đăng ký mà không hủy
// mỗi request tạo một subscriber và đăng ký vào bean singleton
eventBus.register(new OrderStatusListener(request)); // không unregister
// singleton giữ list listener → giữ cả request, body, user...
(d) Queue / buffer không giới hạn
ExecutorService pool = Executors.newFixedThreadPool(10);
// dùng LinkedBlockingQueue KHÔNG giới hạn: consumer chậm (gửi email) → hàng triệu task chờ trong RAM
Đã gặp ở bài 3.3 thread pool: luôn dùng queue có giới hạn + rejection policy.
3. Giải pháp
- Cache: dùng Caffeine với
maximumSize+expireAfterWrite; key không chứa giá trị luôn đổi. - ThreadLocal:
try { ... } finally { tl.remove(); }. - Listener: hủy đăng ký khi xong, hoặc dùng weak reference / scoped bean.
- Queue:
new ThreadPoolExecutor(..., new ArrayBlockingQueue<>(1000), new CallerRunsPolicy()). - Bật heap dump khi OOM để có bằng chứng.
4. Từng bước
Bước 1: luôn bật heap dump khi OOM
JAVA_TOOL_OPTIONS="-XX:+HeapDumpOnOutOfMemoryError \
-XX:HeapDumpPath=/dumps/ \
-XX:+ExitOnOutOfMemoryError" # chết hẳn để K8s restart, đừng sống dở
/dumps phải là volume (PVC/emptyDir đủ lớn) để dump không mất khi pod restart. Dump nặng xấp xỉ heap đang dùng và chứa dữ liệu khách hàng: xử lý như dữ liệu nhạy cảm.
Bước 2: sửa code cache
// pom.xml: com.github.ben-manes.caffeine:caffeine
@Service
class ProductPriceClient {
private final Cache<String, PriceResponse> cache = Caffeine.newBuilder()
.maximumSize(50_000)
.expireAfterWrite(Duration.ofMinutes(5))
.recordStats()
.build();
PriceResponse price(String productId, String customerId) {
return cache.get(productId + ":" + customerId,
k -> callPricingService(productId, customerId));
}
}
Bước 3: phân tích dump bằng Eclipse MAT
1. File → Open Heap Dump → java_pid1.hprof → "Leak Suspects Report"
Problem Suspect 1:
One instance of java.util.concurrent.ConcurrentHashMap loaded by
"LaunchedClassLoader" occupies 712,331,904 (81.4%) bytes.
2. Dominator Tree, sort theo Retained Heap:
Class Name | Shallow | Retained
class com.shop.order.ProductPriceClient | 16 | 712 MB
└ ConcurrentHashMap | 64 | 712 MB
└ ConcurrentHashMap$Node[524288] | 2 MB | 712 MB
3. Path to GC Roots (exclude weak refs) → xác nhận root là static field CACHE
- Shallow heap: kích thước chính object đó.
- Retained heap: bộ nhớ được giải phóng nếu object này biến mất (nó và mọi thứ chỉ nó giữ).
- Dominator: X dominate Y nếu mọi đường từ GC root tới Y đều qua X. Đứng đầu dominator tree = thủ phạm.
Bước 4: kiểm chứng
$ jcmd <pid> GC.class_histogram | head -5 # so sánh 2 lần cách nhau 30 phút
num #instances #bytes class name
1: 4210331 404191776 com.shop.order.PriceResponse ← tăng mãi = nghi phạm
# Sau fix: số instance dao động quanh 50k và đáy răng cưa nằm ngang
5. Cách hoạt động: vì sao GC "bất lực"
GC root: static field ProductPriceClient.CACHE (class sống tới khi JVM tắt, bài 2.3)
│
▼
ConcurrentHashMap ──► Node ──► PriceResponse (request 1, đã xong từ 2 ngày trước)
──► Node ──► PriceResponse (request 2)
──► ... 4 triệu Node
Tất cả vẫn reachable ⇒ GC không được phép dọn ⇒ Old đầy ⇒ Full GC ⇒ OOM
6. So sánh các loại OutOfMemoryError
| Thông báo | Hết cái gì | Nguyên nhân hay gặp | Hướng xử lý |
|---|---|---|---|
| Java heap space | Heap | Leak, load quá nhiều data một lần, heap quá nhỏ | Heap dump + MAT; phân trang |
| GC overhead limit exceeded | Heap (gần đầy) | >98% thời gian GC mà thu <2% (Parallel GC) | Như trên |
| Metaspace | Metadata class | Tạo class động liên tục (Groovy script, proxy, hot redeploy), class loader leak | -Xlog:class+load, MAT xem ClassLoader |
| Direct buffer memory | Off-heap NIO | Netty/WebClient không release ByteBuf, MaxDirectMemorySize nhỏ | Netty leak detector, NMT |
| unable to create native thread | Thread của OS | Tạo thread không giới hạn, ulimit -u, pids limit của container | Thread dump đếm thread, dùng pool |
| Requested array size exceeds VM limit | - | Mảng quá lớn | Sửa thuật toán |
| (Không có exception) Pod OOMKilled exit 137 | Memory limit container | RSS (heap + non-heap) > limit | bài 17.6, bài 16.3 |
7. Khi nào nghi leak, khi nào chỉ là thiếu heap
- Leak: heap sau GC tăng đều theo thời gian, không phụ thuộc traffic đỉnh; restart là "khỏi" một thời gian.
- Thiếu heap / spike: OOM đúng lúc một request lớn (export 1 triệu order) hoặc lúc traffic đỉnh; heap sau GC phẳng.
- Đừng xử lý leak bằng tăng
-Xmx: chỉ kéo dài 3 ngày thành 6 ngày.
Nếu dùng WeakHashMap làm cache thì có tự hết leak không?
Không hẳn, và thường là dùng sai chỗ:
WeakHashMapgiữ key yếu, value mạnh. Key làStringbạn vừa nối ra thì không ai giữ → entry biến mất gần như ngay, cache vô dụng. Key là String literal / interned thì không bao giờ biến mất.- Nếu value trỏ ngược lại key thì key không bao giờ yếu → vẫn leak.
SoftReferencecache: chỉ bị dọn khi heap gần đầy → app chạy sát mép, Full GC liên tục.
| Reference | GC dọn khi | Dùng cho |
|---|---|---|
| Strong | Không còn reachable | Mặc định |
| Soft | Heap sắp hết | Hiếm khi nên dùng |
| Weak | Lần GC tiếp theo nếu chỉ còn weak ref | Metadata gắn vào object của người khác (canonicalizing map) |
| Phantom | Sau khi finalize, để dọn tài nguyên | Cleaner |
Cache nghiệp vụ: dùng Caffeine có giới hạn size + TTL, hoặc Redis (bài 7.2).
Heap dump file 4GB, mở trên laptop không nổi. Production thì không được copy dữ liệu khách hàng ra ngoài. Làm sao?
Đúng là chuyện thật. Vài cách:
- Phân tích ngay trên máy có quyền (bastion/VM trong VPC): MAT có chế độ headless:
./ParseHeapDump.sh /dumps/java_pid1.hprof org.eclipse.mat.api:suspects # sinh java_pid1_Leak_Suspects.zip (HTML), chỉ chứa tên class và kích thước - Bắt đầu bằng class histogram (
jcmd GC.class_histogram): vài KB, không chứa dữ liệu, thường đủ đoán nghi phạm. - Tăng RAM cho MAT trong
MemoryAnalyzer.ini(-Xmx≥ 1–1.5 lần kích thước dump). - Tái hiện trên staging với load test, dump heap nhỏ hơn (
-Xmx512m) để leak lộ ra nhanh.
Xong việc thì xóa dump theo quy trình dữ liệu nhạy cảm của team.
Phỏng vấn hỏi memory leak và OOM thì trả lời sao?
Q1. Java có GC thì memory leak xảy ra thế nào?
🇻🇳 Tiếng Việt:
Leak trong Java là object không còn dùng nhưng vẫn còn reference từ GC root nên GC không được dọn. Hay gặp: static map làm cache không giới hạn, ThreadLocal không remove trên thread pool, listener đăng ký mà không hủy, queue không giới hạn. Dấu hiệu là heap sau mỗi GC tăng dần theo thời gian, cuối cùng Full GC liên tục rồi OutOfMemoryError.
🇬🇧 English:
A leak in Java is an object that is no longer needed but is still referenced from a GC root, so the GC is not allowed to free it. Common causes are an unbounded static map used as a cache, ThreadLocals not removed on pooled threads, listeners that are never unregistered, and unbounded queues. The symptom is the post-GC heap growing over time until full GCs run back to back and an OutOfMemoryError is thrown.
Q2. Bạn xử lý một sự cố OutOfMemoryError trên production thế nào?
🇻🇳 Tiếng Việt:
Trước hết đọc đúng loại OOM: heap, metaspace, direct memory hay native thread, hoặc là container bị OOMKilled. Với heap, em bật sẵn HeapDumpOnOutOfMemoryError ghi ra volume, mở bằng Eclipse MAT, xem Leak Suspects và dominator tree theo retained heap, rồi Path to GC Roots để tìm ai giữ reference. Sửa ở code, ví dụ thay map bằng Caffeine có giới hạn, chứ không chỉ tăng Xmx; sau đó kiểm chứng heap sau GC phẳng lại.
🇬🇧 English:
First I identify the kind of OOM: heap, metaspace, direct memory, native threads, or a container OOMKill. For the heap, I already have HeapDumpOnOutOfMemoryError writing to a volume; I open the dump in Eclipse MAT, check the leak suspects and the dominator tree by retained heap, then use path to GC roots to find who holds the reference. I fix the code, for example replacing the map with a bounded Caffeine cache, rather than just raising Xmx, and then verify that the post-GC heap is flat.
Q3. Shallow heap và retained heap khác gì?
🇻🇳 Tiếng Việt:
Shallow heap là kích thước của riêng object đó. Retained heap là lượng bộ nhớ được giải phóng nếu object đó bị dọn, gồm nó và mọi object chỉ được giữ qua nó. Tìm leak thì nhìn retained heap trong dominator tree.
🇬🇧 English:
Shallow heap is the size of the object itself. Retained heap is the memory that would be freed if that object were collected, meaning the object plus everything reachable only through it. When hunting leaks, look at retained heap in the dominator tree.
Cách nhớ ngắn gọn
- Leak = vẫn reachable nhưng không dùng nữa.
- Static cache, ThreadLocal, listener, unbounded queue.
- Đáy răng cưa đi lên = leak.
- HeapDumpOnOutOfMemoryError + MAT dominator tree + path to GC roots.
- Đọc đúng loại OOM; OOMKilled là chuyện của container.
Tự kiểm tra
Vì sao key cache chứa Instant.now() gây leak?
Key không bao giờ lặp lại, mỗi request thêm entry mới, map không giới hạn và không bị xóa.
ThreadLocal leak cần điều kiện gì?
Thread sống lâu (thread pool) và không gọi remove().
"unable to create native thread" là thiếu heap?
Không. Hết thread/native memory của OS hoặc chạm pids limit.
Retained heap 712 MB ở ProductPriceClient nghĩa là gì?
Nếu bỏ object đó (và static map của nó), 712 MB được giải phóng.
Vì sao nên kèm -XX:+ExitOnOutOfMemoryError?
Sau OOM app ở trạng thái không tin cậy; thoát để orchestrator restart sạch.