Memory leak & OutOfMemoryError

4 câu hỏi~25 phútTừ khóa: memory leak, static cache, ThreadLocal, listener, unbounded queue, Java heap space, GC overhead limit, Metaspace, Direct buffer memory, unable to create native thread, HeapDumpOnOutOfMemoryError, Eclipse MAT, dominator tree, retained heap

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

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

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áoHết cái gìNguyên nhân hay gặpHướng xử lý
Java heap spaceHeapLeak, load quá nhiều data một lần, heap quá nhỏHeap dump + MAT; phân trang
GC overhead limit exceededHeap (gần đầy)>98% thời gian GC mà thu <2% (Parallel GC)Như trên
MetaspaceMetadata classTạo class động liên tục (Groovy script, proxy, hot redeploy), class loader leak-Xlog:class+load, MAT xem ClassLoader
Direct buffer memoryOff-heap NIONetty/WebClient không release ByteBuf, MaxDirectMemorySize nhỏNetty leak detector, NMT
unable to create native threadThread của OSTạo thread không giới hạn, ulimit -u, pids limit của containerThread dump đếm thread, dùng pool
Requested array size exceeds VM limit-Mảng quá lớnSửa thuật toán
(Không có exception) Pod OOMKilled exit 137Memory limit containerRSS (heap + non-heap) > limitbài 17.6, bài 16.3

7. Khi nào nghi leak, khi nào chỉ là thiếu heap

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ỗ:

ReferenceGC dọn khiDùng cho
StrongKhông còn reachableMặc định
SoftHeap sắp hếtHiếm khi nên dùng
WeakLần GC tiếp theo nếu chỉ còn weak refMetadata gắn vào object của người khác (canonicalizing map)
PhantomSau khi finalize, để dọn tài nguyênCleaner

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:

  1. 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
  2. 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.
  3. Tăng RAM cho MAT trong MemoryAnalyzer.ini (-Xmx ≥ 1–1.5 lần kích thước dump).
  4. 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


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.