Bộ nhớ/Vì sao vẫn rò bộ nhớ dù có GC — logical leak
24/26
Bài 24 / 26~16 phútQuản lý bộ nhớ ngôn ngữ bậc caoMiễn phí lượt xem

Vì sao vẫn rò bộ nhớ dù có GC — logical leak

GC chỉ thu object không còn ai giữ tham chiếu. Object vẫn reachable nhưng không bao giờ dùng nữa — logical leak — là thứ GC không cứu được.

TL;DR: Garbage collector không phải phép màu xoá mọi bug bộ nhớ. GC chỉ làm một việc: thu hồi object không còn reachable từ root set (stack, static field, thanh ghi). Nếu object vẫn reachable — nghĩa là có ai đó đang giữ tham chiếu tới nó — GC không bao giờ thu, dù object đó không bao giờ được dùng lại. Đây là logical leak (rò bộ nhớ logic): không ai thật sự dùng object, nhưng không ai chịu buông tay. Triệu chứng là heap tăng dần theo thời gian, GC chạy nhiều mà không giải phóng được, cuối cùng OutOfMemoryError. Bài này giải thích cơ chế, các pattern phổ biến gây logical leak, và cách chẩn đoán bằng heap dump + profiler.

Một service Java chạy ổn sau khi deploy. Sau 3 ngày, heap tăng từ 200 MB lên 1.8 GB. GC chạy liên tục nhưng heap không giảm. Cuối cùng OutOfMemoryError: Java heap space và crash. Restart thì lại tốt — rồi 3 ngày sau lại crash. Đây là dấu hiệu kinh điển của memory leak trong ngôn ngữ có GC.

Bài này giải thích tại sao GC không cứu được kịch bản đó, những pattern nào gây ra nó, và cách tìm thủ phạm.

1. Analogy — thư viện sách và người mượn không trả

Hình dung một thư viện tự động. Robot thủ thư kiểm tra mỗi đêm: sách nào không còn ai mượn thì thu về kệ để tái dùng. "Còn ai mượn" nghĩa là tên ai đó còn trong sổ mượn.

Bây giờ một người vô tình ghi tên mình mượn 10.000 cuốn sách vào sổ — rồi bỏ về mà không đọc. Robot thấy 10.000 cuốn còn trong sổ → không thu. Kệ trống hết, thư viện không còn chỗ cho sách mới → tắc nghẽn.

Lỗi không phải ở robot (GC hoạt động đúng — theo sổ). Lỗi ở sổ mượn không được dọn — tham chiếu không được xoá dù không ai thật dùng nữa.

Thư việnBộ nhớ có GC
Robot thủ thưGarbage collector
Sổ mượnDanh sách tham chiếu (reference graph)
Còn ghi trong sổObject reachable từ root
Thu sách về kệThu hồi bộ nhớ (reclaim)
Sách ghi trong sổ nhưng không ai đọcLogical leak — reachable nhưng vô dụng
Khái niệm cốt lõi

Logical leak = object còn reachable (GC không được thu) nhưng không bao giờ được dùng lại trong tương lai. Không phải bug của GC — GC đúng về mặt kỹ thuật. Bug nằm ở code giữ tham chiếu lâu hơn cần thiết.

2. Cơ chế — reachability và root set

GC xác định object nào sống, nào chết bằng cách duyệt đồ thị tham chiếu từ root set. Root set là tập hợp các "neo" luôn được coi là sống:

  • Biến cục bộ trên stack của mọi thread đang chạy
  • Static field của mọi class đã nạp
  • Thanh ghi CPU đang giữ tham chiếu
  • JNI references (Java Native Interface)

GC mark mọi object reachable từ root set (trực tiếp hoặc qua chuỗi tham chiếu), rồi sweep/compact những object không được mark. Object chưa được mark = không reachable = rác an toàn để thu.

flowchart TD
  subgraph roots["Root set"]
    S["Stack / static field"]
  end
  S --> C["Cache<br/>(static Map)"]
  C --> O1["Object A (reachable, dung)"]
  C --> O2["Object B (reachable, khong dung nua)"]
  C --> O3["Object C (reachable, khong dung nua)"]
  X["Object D (khong reachable)"]
  style X fill:#fee2e2,stroke:#ef4444
  style O2 fill:#fef3c7,stroke:#f59e0b
  style O3 fill:#fef3c7,stroke:#f59e0b

Object B và C màu vàng: GC thấy chúng reachable (qua Cachestatic Map) nên không thu — dù về mặt logic chúng đã vô dụng. Object D màu đỏ bị thu đúng.

3. Các pattern gây logical leak phổ biến

3.1 Static collection tăng mãi không clear

Pattern nguy hiểm nhất và phổ biến nhất. Static field tồn tại suốt vòng đời ứng dụng — mọi object được đưa vào một static Map hay List sẽ không bao giờ được GC thu nếu không ai xoá nó ra.

// SAI -- static cache khong gioi han, khong clear
public class UserSessionCache {
    private static final Map<String, UserSession> cache = new HashMap<>();

    public static void put(String token, UserSession session) {
        cache.put(token, session);  // ghi vao, khong bao gio xoa
    }

    public static UserSession get(String token) {
        return cache.get(token);
    }
}

Mỗi request tạo một UserSession mới đưa vào cache. Session hết hạn nhưng không ai gọi cache.remove(). Sau hàng triệu request, cache giữ hàng triệu UserSession — tất cả reachable qua static field cache, GC không thu được.

// DUNG -- dung bounded cache co eviction
import java.util.LinkedHashMap;
import java.util.Map;

public class UserSessionCache {
    private static final int MAX_SIZE = 10_000;
    // LinkedHashMap removeEldestEntry = LRU eviction
    private static final Map<String, UserSession> cache =
        new LinkedHashMap<>(MAX_SIZE, 0.75f, true) {
            @Override
            protected boolean removeEldestEntry(Map.Entry<String, UserSession> eldest) {
                return size() > MAX_SIZE;
            }
        };

    public static synchronized void put(String token, UserSession session) {
        cache.put(token, session);
    }

    public static synchronized UserSession get(String token) {
        return cache.get(token);
    }
}

Hoặc dùng WeakHashMap khi key là object ngắn sống — GC có thể thu key khi không còn tham chiếu strong nào khác, và entry tự biến mất (xem mục 5).

3.2 Listener và callback đăng ký mà không gỡ

Observer pattern yêu cầu subscriber đăng ký với publisher. Nếu subscriber không gỡ đăng ký (unsubscribe) trước khi bị bỏ đi, publisher giữ strong reference tới subscriber — GC không thu được subscriber dù component đó đã "chết" về mặt logic.

// SAI -- MyListener dang ky nhung khong bao gio unregister
public class ReportPanel {
    public ReportPanel(EventBus eventBus) {
        eventBus.register(this);     // publisher giu ref toi this
        // ... khong co unregister o bat ky dau
    }

    @Subscribe
    public void onDataUpdate(DataEvent event) { ... }
}

// Moi lan tao ReportPanel moi, panel cu van song trong EventBus
// Neu tao 1000 panel -> 1000 ReportPanel trong heap
// DUNG -- unregister khi component bi huy
public class ReportPanel implements AutoCloseable {
    private final EventBus eventBus;

    public ReportPanel(EventBus eventBus) {
        this.eventBus = eventBus;
        eventBus.register(this);
    }

    @Subscribe
    public void onDataUpdate(DataEvent event) { ... }

    @Override
    public void close() {
        eventBus.unregister(this);   // buong tham chieu
    }
}

Quy tắc: nếu bạn register, bạn phải unregister. Implement AutoCloseable hoặc hook vào lifecycle của framework (ví dụ @PreDestroy trong Spring).

3.3 ThreadLocal không remove trong thread pool

ThreadLocal lưu giá trị riêng mỗi thread. Trong môi trường thread pool (như Tomcat, Netty), thread không bị huỷ sau mỗi request — chúng được tái dùng. Nếu không gọi ThreadLocal.remove(), giá trị cũ gắn với thread mãi mãi.

// SAI -- ThreadLocal khong remove trong thread pool
public class RequestContextHolder {
    private static final ThreadLocal<RequestContext> context = new ThreadLocal<>();

    public static void set(RequestContext ctx) {
        context.set(ctx);
    }

    public static RequestContext get() {
        return context.get();
    }
    // Khong co remove() !
}

// Moi request goi set(), nhung sau request context cu van o thread
// Thread pool 200 threads -> 200 RequestContext song mai
// DUNG -- remove sau khi xong request (vi du trong Filter)
public class RequestContextFilter implements Filter {
    @Override
    public void doFilter(ServletRequest req, ServletResponse res,
                         FilterChain chain) throws IOException, ServletException {
        RequestContextHolder.set(new RequestContext(req));
        try {
            chain.doFilter(req, res);
        } finally {
            RequestContextHolder.remove();   // luon remove, ca khi exception
        }
    }
}

Lưu ý: dùng finally để đảm bảo remove() chạy kể cả khi request ném exception.

3.4 Cache không giới hạn (không eviction)

Cache in-memory phình mãi nếu không có chiến lược loại bỏ entry cũ (eviction). Khác với pattern 3.1 (static list), đây nói về cache có ý định nhưng thiếu giới hạn.

// SAI -- cache khong co eviction policy
public class ImageCache {
    private final Map<String, byte[]> cache = new HashMap<>();

    public byte[] get(String url) {
        return cache.computeIfAbsent(url, this::downloadImage);
    }

    private byte[] downloadImage(String url) { /* ... */ return new byte[0]; }
}
// Sau khi load 100.000 URL khac nhau -> 100.000 entry trong heap

Fix: dùng cache library có eviction (Caffeine, Guava Cache) với giới hạn kích thước và TTL:

// DUNG -- Caffeine cache co max size + TTL
import com.github.benmanes.caffeine.cache.Cache;
import com.github.benmanes.caffeine.cache.Caffeine;
import java.util.concurrent.TimeUnit;

public class ImageCache {
    private final Cache<String, byte[]> cache = Caffeine.newBuilder()
        .maximumSize(1_000)                      // toi da 1000 entry
        .expireAfterWrite(10, TimeUnit.MINUTES)  // evict sau 10 phut
        .build();

    public byte[] get(String url) {
        return cache.get(url, this::downloadImage);
    }

    private byte[] downloadImage(String url) { /* ... */ return new byte[0]; }
}

3.5 Closure giữ tham chiếu ngoài ý muốn (JavaScript)

Trong JavaScript, closure giữ tham chiếu tới toàn bộ scope bao quanh — kể cả biến không thật sự dùng trong closure. Nếu closure sống lâu (ví dụ là callback của event listener), nó kéo theo mọi thứ trong scope đó.

// SAI -- closure giu ref toi bigData qua leak
function attachHandler() {
    const bigData = loadHeavyData();  // 50 MB
    const smallId = bigData.id;       // chi can id

    element.addEventListener('click', () => {
        // Chi dung smallId, nhung closure giu toan bo bigData
        console.log(smallId);
    });
}

// FIX -- giai phong bigData truoc khi tao closure
function attachHandler() {
    let bigData = loadHeavyData();
    const smallId = bigData.id;
    bigData = null; // tha ref toi du lieu lon ngay khi khong con can
    element.addEventListener('click', () => {
        console.log(smallId);
    });
}

4. Triệu chứng và chẩn đoán

Triệu chứng nhận biết logical leak

  • Heap tăng dần đều theo thời gian (ramp-up tuyến tính hoặc theo bậc)
  • GC chạy nhiều (full GC frequency tăng), mỗi lần GC heap giảm ít — rác "thật" ít, đa phần là logical leak
  • OutOfMemoryError: Java heap space sau nhiều giờ/ngày chạy liên tục
  • Restart giải quyết tạm nhưng vấn đề quay lại sau cùng thời gian
Heap usage theo thời gian
0h: 200 MB
24h: 800 MB
72h: OOM crash
GC chạy liên tục nhưng heap không trở về baseline → dấu hiệu logical leak

Công cụ chẩn đoán

Bước 1 — Heap dump. Lấy snapshot toàn bộ heap:

# Tu command line (pid cua JVM process)
jmap -dump:format=b,file=heap.hprof <pid>

# Hoac set JVM flag de tu dong dump khi OOM
# -XX:+HeapDumpOnOutOfMemoryError -XX:HeapDumpPath=/var/dumps/

Bước 2 — Phân tích với Eclipse MAT hoặc VisualVM. Mở file .hprof và dùng dominator tree:

Dominator tree là công cụ mạnh nhất để tìm leak. Nó liệt kê object theo "bao nhiêu bộ nhớ sẽ được giải phóng nếu object này bị thu" (retained heap). Object ở đầu dominator tree là thủ phạm — nó đang giữ lượng bộ nhớ không tương xứng.

Dominator tree (vi du):
  HashMap$Entry[]   -> retained: 1.2 GB (80% heap!) <- NGHI VAN
    -> UserSession  -> 1.1 KB x 1.000.000 instances
    -> String (token) -> ...

Thấy HashMap chiếm 80% heap retained → đây là static cache không clear.

Bước 3 — Heap histogram để đếm instance theo class:

jmap -histo <pid> | head -20
# num     #instances         #bytes  class name
#   1:       1000000      240000000  com.example.UserSession  <- 1M instances!
#   2:       1000001       16000016  java.util.HashMap$Entry

1 triệu UserSession instance — rõ ràng không nên tồn tại đồng thời.

5. Đào sâu — WeakReference, SoftReference, và cách dùng đúng

Java cung cấp ba loại tham chiếu yếu để giúp GC có thể thu object ngay cả khi còn reachable:

WeakReference<T> — GC thu object khi không còn strong reference nào tới nó, bất kể pressure bộ nhớ. Dùng cho: cache mà key là object ngắn sống (khi key bị GC thu, entry cache tự xoá — đây là cơ chế WeakHashMap).

SoftReference<T> — GC thu object khi bộ nhớ đang khan hiếm (nhưng giữ nếu còn đủ RAM). Dùng cho: cache thuần tuý — nếu RAM đủ thì giữ, thiếu RAM thì nhả.

PhantomReference<T> — GC thu sau khi object finalized. Dùng cho: resource cleanup thay thế finalize() (deprecated Java 9+).

// WeakHashMap: cache key la object ngan song
Map<Session, UserProfile> profileCache = new WeakHashMap<>();
// Khi Session object bi GC thu (het scope, khong ai giu),
// entry tuong ung tu dong bien mat khoi map

// SoftReference: image cache, nha khi can RAM
SoftReference<byte[]> imgRef = new SoftReference<>(loadImage(url));
byte[] img = imgRef.get(); // null neu GC da thu
if (img == null) {
    img = loadImage(url);   // reload neu can
    imgRef = new SoftReference<>(img);
}

Escape analysis (JVM optimization): JVM có thể phân tích object có "thoát" ra ngoài scope không. Nếu không thoát (ví dụ object chỉ sống trong một method), JVM tối ưu cấp phát trên stack thay heap — không cần GC thu. Đây là lý do thêm local object trong tight loop không nhất thiết tốn nhiều heap như bạn tưởng.

Leak qua ClassLoader (web app redeploy): khi redeploy một web app trong Tomcat, ClassLoader cũ phải được GC thu. Nhưng nếu có thread static hay singleton (ví dụ JDBC driver, logging framework) giữ reference tới class thuộc ClassLoader cũ, ClassLoader không thu được — kéo theo mọi class nó load. Đây là lý do PermGen OutOfMemoryError (Java 7 trở về) và metaspace pressure (Java 8+) sau nhiều lần redeploy.

6. Pitfall tổng hợp

Pitfall 1 — Tin "có GC = không leak":

Đây là hiểu lầm phổ biến nhất khi chuyển từ C sang Java/Python. GC bảo vệ bạn khỏi dangling pointerdouble free — không bảo vệ khỏi logical leak. Nếu bạn giữ tham chiếu, object sẽ không bị thu. Trách nhiệm buông tay vẫn thuộc về bạn.

Pitfall 2 — Long-lived object giữ short-lived:

Object lâu dài (cache, singleton, static field) chỉ nên giữ tham chiếu tới object cũng lâu dài. Nếu một singleton giữ List<Request> và append mỗi request vào đó, mọi request object (short-lived) bỗng nhiên sống mãi theo singleton (long-lived).

Pitfall 3 — Quên ThreadLocal.remove() trong thread pool:

Thread pool tái dùng thread — thread không bao giờ chết nên ThreadLocal không bao giờ bị thu theo thread. Không gọi remove() = leak có size bằng (số thread trong pool) × (kích thước object ThreadLocal). Với pool 200 thread và object 1 MB mỗi cái, đó là 200 MB leak cố định.

7. Liên hệ các bài khác

  • Bài 01 — Cấp phát thủ công vs GC: giải thích reachability là tiêu chí GC dùng để quyết định thu hay giữ — nền tảng để hiểu vì sao logical leak xảy ra.
  • Bài 02 — Ref-counting vs tracing GC: tracing GC (mark-and-sweep) hoạt động chính xác dựa trên reachability graph — bài này ứng dụng trực tiếp khái niệm đó.
  • Module 3 — Page fault và swap: khi leak khiến heap phình và RAM cạn, OS đẩy trang ra swap — đây là lúc vấn đề từ CPU-bound chuyển thành disk I/O bound, latency tăng vọt.

8. Tóm tắt

  • GC chỉ thu object không reachable. Object reachable (còn tham chiếu) nhưng không bao giờ dùng = logical leak.
  • Các pattern phổ biến: static collection không giới hạn, listener không unregister, ThreadLocal không remove(), cache không có eviction, closure JS giữ scope lớn.
  • Triệu chứng: heap tăng dần, GC chạy nhiều nhưng không giảm heap, cuối cùng OutOfMemoryError.
  • Chẩn đoán: heap dump → Eclipse MAT / VisualVM → dominator tree tìm object chiếm retained heap lớn bất thường.
  • Fix: bound cache, WeakHashMap / SoftReference khi phù hợp, luôn unregister listener, luôn remove() ThreadLocal trong finally.

9. Tự kiểm tra

Tự kiểm tra
Q1
GC thu object dựa trên tiêu chí gì? Vì sao tiêu chí đó không đủ để ngăn mọi leak?
GC dùng tiêu chí reachability: object còn reachable từ root set (stack, static field, thanh ghi) thì không thu, object không reachable thì thu. Tiêu chí này đúng về mặt kỹ thuật — GC không bao giờ thu nhầm object đang dùng. Nhưng nó không phân biệt được "reachable và đang cần dùng" với "reachable nhưng không bao giờ dùng nữa". Nếu code giữ tham chiếu lâu hơn cần (ví dụ static Map không xoá entry), GC thấy object reachable, giữ nguyên — trong khi về mặt nghiệp vụ object đó đã vô dụng. Đây là logical leak: logic chương trình sai (giữ tham chiếu thừa), không phải GC sai.
Q2
Giải thích vì sao static Map trong Java có thể gây memory leak. Cho ví dụ cụ thể và cách fix.
Static field tồn tại suốt vòng đời ứng dụng (gắn với class, không bao giờ bị GC thu). Nếu static field là một Map và code liên tục put() mà không remove(), mọi value đưa vào đều trở thành reachable qua đường static field → Map → value — GC không thu được. Ví dụ: static Map<String, UserSession> cache ghi session mỗi request nhưng không xoá khi session hết hạn → sau hàng triệu request, heap chứa hàng triệu UserSession. Fix: (1) giới hạn kích thước Map (LRU eviction với LinkedHashMap override removeEldestEntry), (2) dùng Caffeine/Guava Cache với maximumSize + expireAfterWrite, hoặc (3) dùng WeakHashMap nếu key là object ngắn sống (key bị thu thì entry tự xoá).
Q3
Vì sao ThreadLocal có thể gây leak trong thread pool? Cách fix là gì?
ThreadLocal gắn giá trị vào thread; giá trị sống cho đến khi thread chết hoặc ai đó gọi remove(). Trong thread pool (Tomcat, Netty…), thread không bao giờ chết sau request — chúng được tái dùng. Nếu mỗi request gọi set() mà không gọi remove(), thread giữ giá trị của request đó mãi mãi. Với pool 200 thread, đó là 200 object ThreadLocal luôn sống trong heap. Tệ hơn, request sau có thể đọc được giá trị của request trước qua get() (data pollution). Fix: luôn gọi ThreadLocal.remove() trong finally block của Filter/Interceptor, đảm bảo cleanup dù request ném exception. Đây là pattern chuẩn trong Spring RequestContextHolder — framework đã làm sẵn nhưng nếu bạn tự dùng ThreadLocal thì phải tự cleanup.
Q4
Khi mở heap dump và thấy một HashMap chiếm 80% retained heap, bạn sẽ làm gì tiếp theo để xác định đây có phải leak không?
Trước hết, "retained heap 80%" có nghĩa là nếu HashMap đó bị thu, 80% heap sẽ được giải phóng — đây là dấu hiệu rất mạnh của leak. Các bước tiếp theo: (1) Xem class của HashMap — static field hay instance? Nếu static, đó là global cache không bị thu. (2) Đếm instance của value type — ví dụ 1 triệu UserSession trong một HashMap là bất thường; một service bình thường không có 1 triệu session sống đồng thời. (3) Trace tới root bằng "Path to GC Roots" trong Eclipse MAT — xem ai giữ HashMap: static field của class nào, thread nào. (4) Kiểm tra code của class đó xem có put() mà không remove(), hay không có eviction policy. Từ đây biết rõ điểm fix.
Q5
Phân biệt WeakReference và SoftReference. Dùng cái nào cho cache?
WeakReference: GC thu object ngay khi không còn strong reference nào, bất kể bộ nhớ còn nhiều hay ít. Phù hợp khi key là object ngắn sống — bạn muốn cache entry tự biến mất khi key không còn được dùng ở nơi khác (ví dụ WeakHashMap dùng weak key). SoftReference: GC chỉ thu khi bộ nhớ thật sự khan hiếm — giữ object nếu còn RAM. Phù hợp cho cache thuần tuý: "giữ nếu đủ RAM, nhả nếu thiếu". Về mặt lý thuyết, SoftReference hợp hơn cho cache (bạn muốn cache sống lâu). Thực tế: hầu hết production code dùng Caffeine hoặc Guava Cache thay vì SoftReference thủ công, vì chúng kiểm soát eviction tinh tế hơn (LRU, TTL, size-based) và không bị "GC flush hết khi GC chạy full". WeakReference phù hợp hơn khi bạn muốn metadata gắn với object mà không ngăn object đó bị thu.
Q6
Một service Java chạy ổn 3 ngày rồi OOM. Restart thì tốt, 3 ngày sau OOM lại. Bạn sẽ thu thập bằng chứng gì để chẩn đoán?
Pattern "leak dần dần, OOM sau N ngày, restart reset" là logical leak kinh điển. Quy trình thu thập bằng chứng: (1) Bật heap dump tự động khi OOM: thêm JVM flag -XX:+HeapDumpOnOutOfMemoryError -XX:HeapDumpPath=/var/dumps/ trước khi deploy lại — lần OOM sau sẽ có file .hprof. (2) Theo dõi heap growth theo thời gian: dùng JMX metric (java.lang:type=Memory) hoặc APM (Datadog, Prometheus JMX exporter) — vẽ đồ thị heap sau mỗi GC. Nếu đường baseline sau GC tăng dần = leak. (3) Heap histogram định kỳ: jmap -histo <pid> mỗi giờ, so sánh class nào tăng số instance theo thời gian. (4) Phân tích dump bằng Eclipse MAT: dominator tree → tìm class chiếm retained heap lớn bất thường → trace root → fix code.

Bài tiếp theo: Mini-challenge — tối ưu cache miss

Bài này có giúp bạn hiểu bản chất không?

Hỏi đáp về bài này

Chưa có câu hỏi

Đặt câu hỏi

Có gì chưa rõ trong bài? Đặt câu hỏi đầu tiên — câu trả lời từ cộng đồng giúp bạn (và người sau).

Đặt câu hỏi đầu tiên