CodeGym /Các khóa học /JAVA 25 SELF /Biến cục bộ, rò rỉ bộ nhớ, tham chiếu yếu

Biến cục bộ, rò rỉ bộ nhớ, tham chiếu yếu

JAVA 25 SELF
Mức độ , Bài học
Có sẵn

1. Biến cục bộ được lưu ở đâu

Bắt đầu với điều đơn giản nhất: biến cục bộ. Đây là các biến được khai báo bên trong phương thức và chỉ tồn tại trong thời gian phương thức đó thực thi. Vòng đời của chúng ngắn ngủi: ngay khi phương thức kết thúc, tất cả biến cục bộ của nó biến mất không để lại dấu vết.

Trong Java, biến cục bộ được lưu trên stack. Mỗi luồng có stack riêng của nó. Nếu một phương thức gọi phương thức khác, một “frame” (stack frame) mới với các biến cục bộ và địa chỉ trả về sẽ được thêm vào stack. Khi phương thức kết thúc, frame của nó bị loại khỏi stack.

Ví dụ: vòng đời của biến cục bộ

public class LocalVariableDemo {
    public static void main(String[] args) {
        int a = 42; // biến cục bộ a chỉ tồn tại trong main
        printSquare(a);
        // Ở đây biến b đã không còn nữa!
    }

    public static void printSquare(int b) {
        int square = b * b; // biến cục bộ square
        System.out.println("Bình phương: " + square);
        // Sau khi thoát khỏi printSquare, tất cả biến cục bộ biến mất
    }
}

Điểm quan trọng: nếu biến cục bộ là một tham chiếu đến đối tượng (ví dụ, String, Scanner, mảng) thì chính tham chiếu nằm trên stack, còn đối tượng — ở heap! Khi tham chiếu biến mất và không còn tham chiếu nào khác đến đối tượng, trình gom rác có thể xóa đối tượng.

Minh họa

Stack (cho main):
| int a = 42      |
| args            |
-------------------
Heap:
| [các đối tượng được tạo qua new] |

2. Rò rỉ bộ nhớ trong Java: huyền thoại hay hiện thực?

Nhiều người mới nghĩ: “Java có trình gom rác! Vậy thì không thể có rò rỉ bộ nhớ!” Tiếc là đó là một huyền thoại bị phá vỡ ngay ở dự án lớn đầu tiên.

Trình gom rác chỉ xóa những đối tượng không còn bất kỳ tham chiếu sống nào. Nếu đâu đó còn một tham chiếu (dù ở nơi ít ngờ tới nhất), đối tượng sẽ treo trong bộ nhớ đến cùng. Hoặc cho đến khi xảy ra OutOfMemoryError.

Ví dụ 1: Collection static — cái bẫy

import java.util.ArrayList;
import java.util.List;

public class MemoryLeakDemo {
    // Ôi, collection static này!
    private static final List<String> BIG_LIST = new ArrayList<>();

    public static void main(String[] args) {
        for (int i = 0; i < 1_000_000; i++) {
            BIG_LIST.add("Chuỗi số " + i);
        }
        System.out.println("Đã thêm một triệu dòng");
        // Dù main kết thúc, BIG_LIST vẫn ở trong bộ nhớ chừng nào JVM còn sống
    }
}

Chuyện gì xảy ra?

  • Biến static BIG_LIST sống bao lâu lớp còn hoạt động (thường là cho đến cuối vòng đời JVM).
  • Tất cả chuỗi được thêm vào danh sách sẽ không thể bị GC xóa — luôn có tham chiếu đến chúng qua BIG_LIST.
  • Nếu bạn vô tình quên dọn các collection như vậy — sẽ gặp rò rỉ bộ nhớ.

Ví dụ 2: Listener không hủy đăng ký (listeners)

import java.util.ArrayList;
import java.util.List;

class EventSource {
    private final List<Runnable> listeners = new ArrayList<>();

    public void addListener(Runnable listener) {
        listeners.add(listener);
    }

    // ... các phương thức khác ...
}

public class ListenerLeakDemo {
    public static void main(String[] args) {
        EventSource source = new EventSource();
        Runnable listener = () -> System.out.println("Sự kiện!");
        source.addListener(listener);
        // Nếu quên gọi source.removeListener(listener), listener sẽ ở trong bộ nhớ mãi mãi!
    }
}

Vấn đề: nếu listener không còn cần thiết nhưng không bị xóa khỏi danh sách — nó và tất cả đối tượng mà nó tham chiếu sẽ ở lại trong bộ nhớ.

Ví dụ 3: Cache không bao giờ được xóa

import java.util.HashMap;
import java.util.Map;

public class CacheLeakDemo {
    private static final Map<String, byte[]> CACHE = new HashMap<>();

    public static void main(String[] args) {
        for (int i = 0; i < 1_000_000; i++) {
            // Mỗi lần tạo một mảng 1 KB
            CACHE.put("key" + i, new byte[1024]);
        }
        System.out.println("Đã thêm một triệu phần tử vào cache");
        // Cache phình to, hết bộ nhớ, OutOfMemoryError!
    }
}

Kết luận: ngay cả với GC, bạn vẫn có thể gặp rò rỉ bộ nhớ nếu không quản lý vòng đời đối tượng!

3. Tham chiếu yếu (WeakReference) và những người bạn

Đôi khi chúng ta cần cache hoặc collection mà ở đó các đối tượng có thể bị GC xóa nếu không còn ai tham chiếu đến chúng. Đó là lý do tham chiếu yếu (WeakReference) ra đời.

Tham chiếu thông thường (strong)

String s = new String("hello"); // tham chiếu strong

Đối tượng s sẽ tồn tại trong bộ nhớ miễn là còn ít nhất một tham chiếu strong.

Tham chiếu yếu (WeakReference)

import java.lang.ref.WeakReference;

public class WeakRefDemo {
    public static void main(String[] args) {
        String strong = new String("Xin chào, thế giới!");
        WeakReference<String> weak = new WeakReference<>(strong);

        System.out.println("Trước khi dọn dẹp: " + weak.get()); // có tham chiếu

        strong = null; // bỏ tham chiếu strong

        System.gc(); // yêu cầu GC dọn bộ nhớ (không được đảm bảo!)

        // Sau một thời gian, weak.get() có thể trở thành null
        System.out.println("Sau GC: " + weak.get());
    }
}

Cách hoạt động?

  • Miễn là còn ít nhất một tham chiếu strong đến đối tượng, GC sẽ không xóa nó.
  • Nếu chỉ còn tham chiếu yếu, đối tượng có thể bị xóa ở lần gom rác tiếp theo.
  • Phương thức weak.get() trả về đối tượng nếu nó còn sống, hoặc null nếu đối tượng đã bị xóa.

Sử dụng tham chiếu yếu ở đâu?

Ứng dụng chính — cache, nơi không quá quan trọng nếu đối tượng bị xóa khỏi bộ nhớ. Ví dụ, nếu bạn cache ảnh nhưng không muốn cache chiếm hết bộ nhớ.

Ví dụ: WeakHashMap

WeakHashMap là collection mà khóa được lưu qua tham chiếu yếu. Nếu không còn ai tham chiếu đến khóa, cặp tương ứng sẽ bị xóa khỏi map.

import java.util.Map;
import java.util.WeakHashMap;

public class WeakHashMapDemo {
    public static void main(String[] args) {
        Map<Object, String> map = new WeakHashMap<>();
        Object key = new Object();
        map.put(key, "Giá trị");

        System.out.println("Trước khi dọn dẹp: " + map);

        key = null; // bỏ tham chiếu strong tới khóa

        System.gc(); // yêu cầu GC làm việc

        // Sau một thời gian, map sẽ rỗng!
        try { Thread.sleep(100); } catch (InterruptedException ignored) {}
        System.out.println("Sau GC: " + map);
    }
}

Lưu ý: WeakHashMap chỉ áp dụng cho khóa — giá trị được lưu bằng tham chiếu strong thông thường.

Soft, Weak, Phantom: toàn bộ họ tham chiếu

Trong Java có bốn loại tham chiếu (sắp theo “độ mạnh”):

Loại tham chiếu Khi nào GC xóa đối tượng? Dùng ở đâu?
Strong
Chỉ khi không còn bất kỳ tham chiếu strong nào Biến thông thường, collection
Soft
Khi thiếu bộ nhớ Cache muốn giữ lâu hơn
Weak
Ở lần chạy GC tiếp theo nếu không còn tham chiếu strong Cache, WeakHashMap, listener
Phantom
Sau finalization, để theo dõi việc xóa đối tượng Tác vụ đặc biệt, dọn dẹp ngoài heap
  • SoftReference — đối tượng bị xóa khi thiếu bộ nhớ (phù hợp cho cache ảnh, v.v.).
  • WeakReference — bị xóa ở lần gom rác đầu tiên nếu không còn tham chiếu khác.
  • PhantomReference — “ma” nhất, dùng cho kịch bản phức tạp, hiếm khi dùng cho người mới.

4. Thực hành: ví dụ rò rỉ bộ nhớ và cách sửa

Ví dụ rò rỉ: danh sách static

import java.util.ArrayList;
import java.util.List;

public class LeakExample {
    private static final List<byte[]> list = new ArrayList<>();

    public static void main(String[] args) {
        for (int i = 0; i < 100_000; i++) {
            list.add(new byte[1024 * 1024]); // 1 MB
            if (i % 10 == 0) System.out.println("Đã thêm " + i + " MB");
        }
    }
}

Điều gì sẽ xảy ra?
Chương trình sẽ nhanh chóng ngốn hết bộ nhớ khả dụng và sập với OutOfMemoryError, vì danh sách static giữ tham chiếu tới tất cả các mảng đã tạo.

Sửa: dùng tham chiếu yếu

Nếu không quá quan trọng việc mọi đối tượng luôn sẵn có, bạn có thể lưu chúng qua tham chiếu yếu:

import java.lang.ref.WeakReference;
import java.util.ArrayList;
import java.util.List;

public class LeakFixed {
    private static final List<WeakReference<byte[]>> list = new ArrayList<>();

    public static void main(String[] args) {
        for (int i = 0; i < 100_000; i++) {
            list.add(new WeakReference<>(new byte[1024 * 1024]));
            if (i % 10 == 0) System.out.println("Đã thêm " + i + " MB");
            System.gc(); // Gợi ý cho GC (không đảm bảo dọn ngay!)
        }
    }
}

Bây giờ các mảng có thể bị GC xóa nếu không còn ai tham chiếu — danh sách chỉ giữ tham chiếu yếu.

5. Các kịch bản rò rỉ bộ nhớ thường gặp

  • Listener sự kiện: quên xóa listener — đối tượng sống mãi.
  • Collection static: cache không dọn, danh sách toàn cục — tất cả có thể dẫn đến rò rỉ.
  • Lớp lồng và lambda: nếu lớp lồng hoặc lambda bắt (capture) tham chiếu tới đối tượng bên ngoài, nó sẽ không bị xóa chừng nào đối tượng bên ngoài còn sống.

Ví dụ với lớp lồng

public class Outer {
    private byte[] bigArray = new byte[1024 * 1024 * 100]; // 100 MB

    public Runnable createTask() {
        // Lớp lồng ẩn danh bắt (capture) tham chiếu tới Outer!
        return new Runnable() {
            @Override
            public void run() {
                System.out.println("Nhiệm vụ đang chạy");
            }
        };
    }

    public static void main(String[] args) {
        Outer outer = new Outer();
        Runnable task = outer.createTask();
        // Ngay cả khi outer = null, task vẫn giữ tham chiếu tới bigArray!
    }
}

Giải pháp: dùng lớp lồng static hoặc tách logic ra lớp riêng để không giữ tham chiếu thừa.

6. Thực hành: dùng tham chiếu yếu trong cache

Thêm vào ứng dụng học tập một cache đơn giản dùng WeakHashMap.

import java.util.Map;
import java.util.WeakHashMap;

public class ImageCache {
    private final Map<String, byte[]> cache = new WeakHashMap<>();

    public void put(String name, byte[] data) {
        cache.put(name, data);
    }

    public byte[] get(String name) {
        return cache.get(name);
    }

    public static void main(String[] args) {
        ImageCache cache = new ImageCache();
        cache.put("cat", new byte[1024 * 1024]); // 1 MB
        System.out.println("Đã thêm mèo vào cache");

        // Nếu không còn tham chiếu nào tới khóa "cat", đối tượng có thể bị GC xóa
    }
}

Trong ứng dụng thực tế (ví dụ, thư viện xử lý ảnh), tham chiếu yếu giúp tránh tràn bộ nhớ nhờ tự động loại bỏ dữ liệu ít dùng.

7. Strong vs Weak: dùng khi nào?

  • Tham chiếu strong — mặc định: dùng cho mọi thứ cần tồn tại chắc chắn.
  • Tham chiếu yếu — cho cache, listener, khi không quá quan trọng nếu đối tượng bị xóa.
  • Tham chiếu soft — cho cache nên giữ lâu hơn nhưng có thể xóa khi thiếu bộ nhớ.
  • Tham chiếu phantom — cho kịch bản nâng cao (ví dụ, finalization ngoài heap).

8. Những lỗi thường gặp khi làm việc với bộ nhớ và tham chiếu

Lỗi số 1: “GC sẽ dọn hết cho tôi!” Trình gom rác chỉ xóa đối tượng không còn bất kỳ tham chiếu strong sống nào. Nếu bạn “quên” một tham chiếu đâu đó (ví dụ, trong collection static), đối tượng sẽ sống mãi.

Lỗi số 2: Listener bị bỏ quên. Thêm listener cho một đối tượng nhưng không xóa khi đối tượng bị hủy? Listener và mọi thứ nó bắt sẽ ở lại bộ nhớ.

Lỗi số 3: Cache không dùng tham chiếu yếu. Bạn dùng HashMap thông thường cho cache cần tự động dọn? Tốt hơn hãy dùng WeakHashMap hoặc SoftReference.

Lỗi số 4: Lớp lồng và lambda bắt đối tượng bên ngoài. Lớp lồng (và biểu thức lambda) ngầm giữ tham chiếu đến đối tượng bên ngoài. Nếu bạn giữ thể hiện các lớp như vậy lâu hơn chính đối tượng bên ngoài, sẽ gặp rò rỉ.

Lỗi số 5: Trông đợi GC chạy ngay lập tức. Gọi System.gc() không đảm bảo gom rác diễn ra ngay. Đó chỉ là “lời đề nghị” với JVM, không phải mệnh lệnh.

1
Nhiệm vụ
JAVA 25 SELF, mức độ, bài học
Đã khóa
Sử dụng weak reference – Thông điệp trên mực biến mất 👻
Sử dụng weak reference – Thông điệp trên mực biến mất 👻
1
Nhiệm vụ
JAVA 25 SELF, mức độ, bài học
Đã khóa
Bộ nhớ đệm sử dụng WeakHashMap – Thư viện thông minh thu hồi những cuốn sách bị lãng quên 📚
Bộ nhớ đệm sử dụng WeakHashMap – Thư viện thông minh thu hồi những cuốn sách bị lãng quên 📚
Bình luận
TO VIEW ALL COMMENTS OR TO MAKE A COMMENT,
GO TO FULL VERSION