Java có bốn cách đặt một lớp bên trong lớp khác, và khác biệt giữa chúng nằm ở một thứ mà bạn không nhìn thấy trong mã nguồn: một trường ẩn do trình biên dịch tự thêm vào.

Trường đó tên là this$0, và nó là nguyên nhân của một lớp lỗi rò rỉ bộ nhớ rất khó tìm — vì nhìn mã thì không có gì bất thường.

Hôm nay ta lôi nó ra xem bằng phản chiếu.

Bốn loại

public class LopLong {
    private String biMat = "dữ liệu của đối tượng ngoài";

    static class Tinh { }                          // 1. lớp lồng tĩnh
    class Noi { String doc() { return biMat; } }   // 2. lớp nội

    Runnable taoAnDanh() {
        return new Runnable() {                    // 3. lớp ẩn danh
            @Override public void run() { ... }
        };
    }
    void cucBo() {
        class TrongPhuongThuc { ... }              // 4. lớp cục bộ
    }
}

Cả ba loại sau đều đọc được biMat — một trường private của lớp ngoài:

  lớp nội đọc: dữ liệu của đối tượng ngoài
  ẩn danh đọc: dữ liệu của đối tượng ngoài
  lambda đọc: dữ liệu của đối tượng ngoài
  lớp cục bộ: dữ liệu của đối tượng ngoài

Câu hỏi là: làm sao chúng đọc được? Một lớp riêng biệt không thể tự nhiên nhìn thấy trường private của lớp khác.

Lôi trường ẩn ra xem

Dùng phản chiếu liệt kê các trường thật sự có trong mỗi lớp:

Arrays.stream(c.getDeclaredFields())
      .map(f -> f.getType().getSimpleName() + " " + f.getName())
  Tinh                                 -> (không có trường nào)
  Noi                                  -> LopLong this$0
  LopLong$1                            -> LopLong this$0
  LopLong$$Lambda/0x...                -> LopLong arg$1

Đó là câu trả lời.

Lớp lồng tĩnh không có trường nào — nó độc lập hoàn toàn, chỉ tình cờ được đặt bên trong một lớp khác cho gọn.

Lớp nội và lớp ẩn danhthis$0 — một tham chiếu tới toàn bộ đối tượng ngoài. Trình biên dịch thêm nó vào, truyền qua hàm khởi tạo, và mọi lần bạn viết biMat thì thực chất là this$0.biMat.

Lambda cũng giữ tham chiếu, nhưng dưới tên arg$1 và theo cơ chế khác — nó chỉ bắt giá trị cần dùng chứ không sinh ra một lớp lồng thật sự. Chi tiết này sẽ quan trọng ở chặng lập trình hàm.

Vì sao this$0 gây rò rỉ bộ nhớ

Giờ ghép hai sự thật lại:

Lớp nội giữ tham chiếu tới toàn bộ đối tượng ngoài, kể cả những phần nó không dùng.

Bộ thu gom rác chỉ dọn được đối tượng khi không ai còn trỏ tới nó.

Kết quả là chỉ cần một đối tượng nhỏ của lớp nội còn sống, cả đối tượng ngoài — có thể rất to — không được dọn.

Tình huống kinh điển:

class BoNhoDemLon {
    private final byte[] duLieu = new byte[100_000_000];   // 100 MB

    class MotSoDo {                     // lớp nội, tí xíu
        int lay() { return 42; }
    }

    MotSoDo taoSoDo() { return new MotSoDo(); }
}

// Ở nơi khác trong chương trình:
List<Object> giuLai = new ArrayList<>();
giuLai.add(new BoNhoDemLon().taoSoDo());   // giữ 100 MB mà không hề biết

Danh sách chỉ giữ một đối tượng vài byte. Nhưng qua this$0, nó giữ luôn 100 MB mảng, và mảng đó không bao giờ được dọn.

Điều làm lỗi này khó tìm: heap dump chỉ ra mảng vẫn còn sống, nhưng đường đi tới nó qua một trường tên là this$0 mà bạn chưa từng viết. Nếu chưa biết cơ chế này thì rất dễ kết luận là "JVM không dọn rác".

Cách chữa gọn: thêm static.

static class MotSoDo { ... }

Không có this$0 nữa, không giữ gì cả.

Mặc định hãy đặt static cho mọi lớp lồng, chỉ bỏ đi khi thật sự cần truy cập trạng thái của đối tượng ngoài. Đây là lời khuyên mà tôi cho là đáng nhớ nhất trong bài — nó biến một quyết định vô thức thành có chủ ý, và loại bỏ hẳn một họ lỗi.

Cùng vấn đề, phiên bản hay gặp hơn

Lớp ẩn danh cũng có this$0, nên mẫu này cũng rò rỉ:

class ManHinh {
    private final byte[] anhNen = new byte[50_000_000];

    void dangKy() {
        BoQuanLySuKien.dangKy(new SuKienListener() {    // ẩn danh -> giữ ManHinh
            public void xuLy() { ... }
        });
    }
}

Nếu BoQuanLySuKien sống suốt vòng đời ứng dụng và không ai gỡ đăng ký, mọi ManHinh từng tạo đều không được dọn.

Đây là nguồn gốc kinh điển của rò rỉ bộ nhớ trong ứng dụng có giao diện và trong mọi hệ thống dùng callback. Lambda cũng vậy nếu nó dùng tới trường của đối tượng ngoài — khi đó nó cũng phải bắt this. Lambda chỉ dùng biến cục bộ thì không bắt gì cả.

Khi nào dùng loại nào

Lớp lồng tĩnh — mặc định. Dùng khi lớp phụ chỉ có nghĩa trong ngữ cảnh của lớp ngoài nhưng không cần trạng thái của nó. Map.Entry là ví dụ kinh điển: nó thuộc về Map về mặt khái niệm, nhưng không cần biết map nào.

public class DanhSachLienKet<E> {
    private static class Nut<E> {      // static: không giữ tham chiếu tới danh sách
        E giaTri;
        Nut<E> tiepTheo;
    }
}

Lớp nội — khi thật sự cần đọc trạng thái đối tượng ngoài, và vòng đời hai bên gắn với nhau. Ví dụ: lớp iterator bên trong một collection cần đọc mảng và biến đếm sửa đổi của collection đó.

Lớp ẩn danh — ngày nay hiếm khi cần. Interface một phương thức thì dùng lambda; nhiều phương thức thì thường đáng tách thành lớp riêng có tên.

Chỗ lớp ẩn danh còn hơn lambda: khi cần giữ trạng thái riêng giữa các lần gọi, hoặc khi phải cài nhiều phương thức.

Lớp cục bộ — khai bên trong một phương thức. Tôi gần như không dùng; nếu một lớp chỉ có nghĩa trong một phương thức thì thường phương thức đó đang quá dài.

Tạo đối tượng: cú pháp khác nhau

new LopLong.Tinh();      // lớp lồng tĩnh: chỉ cần tên lớp ngoài
ngoai.new Noi();         // lớp nội: BẮT BUỘC có một đối tượng ngoài

Cú pháp ngoai.new Noi() trông lạ, và chính sự lạ đó là lời nhắc: lớp nội không tồn tại độc lập được.

Không có đối tượng ngoài thì không tạo được lớp nội — kể cả từ trong một phương thức static của chính lớp ngoài. Đây là lỗi biên dịch hay gặp khi ai đó quên static cho lớp lồng rồi thử tạo nó trong main.

Biến bị bắt phải "coi như final"

Lớp ẩn danh, lớp cục bộ và lambda đều đọc được biến cục bộ của phương thức bao ngoài, nhưng chỉ khi biến đó không bị gán lại:

void thu() {
    int dem = 0;
    Runnable r = () -> System.out.println(dem);   // được
    dem++;                                        // LỖI: dem không còn effectively final
}

Lý do: biến cục bộ nằm trên ngăn xếp và biến mất khi phương thức kết thúc, còn lambda có thể sống lâu hơn. Nên Java sao chép giá trị vào đối tượng lambda, và để tránh hai bản lệch nhau, nó cấm gán lại.

Đối tượng thì vẫn sửa nội dung được — sao chép ở đây là sao chép tham chiếu, đúng bài học về truyền tham số. Muốn đếm trong lambda thì dùng một AtomicInteger hoặc mảng một phần tử.

Kiểm tra dự án của bạn

Tìm trong mã tất cả lớp lồng không có static, và với mỗi cái hỏi: nó có dùng trường nào của đối tượng ngoài không?

Nếu không, thêm static vào. Trình biên dịch sẽ báo ngay nếu bạn nhầm, và mỗi lần thêm được là bớt một đường đi giữ đối tượng sống ngoài ý muốn.

Đoạn phản chiếu ở đầu bài chạy trên lớp của bạn cũng cho biết ngay lớp nào đang mang this$0:

Arrays.stream(LopCuaBan.class.getDeclaredFields())
      .forEach(f -> System.out.println(f.getName()));

Ngày mai: record — kiểu dữ liệu bất biến mà Java 16 thêm vào, sinh sẵn equals, hashCode, toString, và xoá luôn ba vấn đề của bài equals hôm trước.