Command Palette

Search for a command to run...

[Advanced Java] Nested class, Inner class, Local class và Anonymous class trong Java

Java cho bạn đặt một class bên trong một class khác theo bốn cách, và đó không phải bốn cách viết của cùng một thứ. Hai trong số đó tạo ra object âm thầm giữ một reference tới object đã tạo ra nó; hai cái còn lại thì không. Đúng một khác biệt đó quyết định việc một listener bạn đăng ký có giữ tám megabyte buffer màn hình sống mãi hay không, sau khi mọi thứ khác đã buông nó ra.

Các khóa cơ bản thường giới thiệu nested class như một mẹo đặt tên — cách để một helper nhỏ nằm cạnh class dùng nó. Đó là điều ít thú vị nhất về chúng. Bài này đi từ class file: javac ghi ra đĩa những gì cho từng hình thái, nó thêm những synthetic field nào, những field đó tốn gì lúc runtime, và vì sao một anonymous class với một lambda nhìn gần như giống hệt nhau trong source lại tạo ra hai object hoàn toàn khác nhau.

Class Outer chứa bốn hình thái nested, chỉ inner class được nối ngược về enclosing object

Mọi danh sách class file, mọi output của javap, mọi kết quả chạy và mọi message lỗi bên dưới đều lấy từ OpenJDK 21.0.6 (arm64). Chỗ nào behavior phụ thuộc vào language level thì mốc chuyển được tìm bằng cách compile lại đúng source đó với --release, và được ghi đúng theo release mà nó đổi.

Bốn hình thái và điểm khác biệt thật sự

Nested class là bất kỳ class nào khai báo bên trong body của một class khác. Local class khai báo bên trong body của một method, còn anonymous class khai báo ngay bên trong một expression. Đây là một file có đủ cả bốn:

class Outer {
    static class Config { }

    class Session { }

    void open() {
        class Attempt { }
        Runnable r = new Runnable() {
            public void run() { }
        };
        r.run();
        new Attempt();
    }
}

javac Outer.java ghi ra năm class file:

Outer$1.class
Outer$1Attempt.class
Outer$Config.class
Outer$Session.class
Outer.class

Việc lồng nhau chỉ tồn tại ở mức source. JVM không hề có khái niệm class nằm trong class: cả năm cái trên đều là class file top-level riêng biệt, và dấu $ trong tên chỉ là một ký tự identifier bình thường, không mang cấu trúc gì. Thứ sống sót qua quá trình compile là một nhóm attribute — InnerClasses, EnclosingMethod, NestHost — cộng thêm, với hai trong bốn hình thái, một field phụ.

Bốn chỗ khai báo trong một source file được nối tới bốn class file mà javac sinh ra

Cách gọi tên ở đây rất quan trọng vì JLS định nghĩa rất chặt. "Nested class" là tên chung. Static nested class là nested class có static. Inner class là mọi nested class khôngstatic — bao gồm cả local class và anonymous class, vì cả hai đều không được phép static. Vậy "inner class" không đồng nghĩa với "nested class", và compiler cũng nói đúng như vậy: lỗi bạn nhận được khi capture sai trong một anonymous class là local variables referenced from an inner class must be final or effectively final, dùng chữ "inner class" cho cả trường hợp anonymous.

Hình tháiKhai báo ở đâuCó enclosing instanceClass file
static nestedtrong body class, có statickhôngOuter$Config
inner (member)trong body class, không staticOuter$Session
localtrong body methodchỉ khi method là instance methodOuter$1Attempt
anonymoustrong một expressionchỉ khi expression nằm trong instance contextOuter$1

Static nested class là lựa chọn mặc định

Static nested class thực chất là một class top-level bình thường, chỉ tình cờ sống trong namespace của một class khác. Nó không có enclosing instance nên không đọc được instance field của object bên ngoài, và bạn tạo nó mà không cần object nào:

public class Outer {
    static class Config {
        private final String key;
        Config(String key) { this.key = key; }
        String key() { return key; }
    }

    public static void main(String[] args) {
        Config c = new Config("timeout");
        System.out.println("static nested: " + c.key());
    }
}

Từ một file khác, type được viết là Outer.Config và tạo bằng new Outer.Config("retries"), không cần instance nào của Outer. javap -p trên class file không cho thấy gì ngoài đúng những gì bạn viết:

Compiled from "Outer.java"
class Outer$Config {
  private final java.lang.String key;
  Outer$Config(java.lang.String);
  java.lang.String key();
}

Một field, một constructor, một method. Việc lồng vào trong đổi lại được scope và quyền truy cập private với Outer, và không tốn gì lúc runtime.

Vì sao Map.Entry là static nested class

Map.Entry là ví dụ trong thư viện chuẩn mà ai cũng đã dùng mà không để ý. Nó nằm trong Map vì tự nó không có nghĩa gì — entry là khái niệm của Map — nhưng nó static vì entry là dữ liệu, không phải một góc nhìn vào một map object cụ thể. Reflection xác nhận cả hai vế:

Class<?> e = Map.Entry.class;
System.out.println("name        -> " + e.getName());
System.out.println("enclosing   -> " + e.getEnclosingClass().getName());
System.out.println("isMemberOf  -> " + e.isMemberClass());
System.out.println("static?     -> " + Modifier.isStatic(e.getModifiers()));
name        -> java.util.Map$Entry
enclosing   -> java.util.Map
isMemberOf  -> true
static?     -> true

Cũng file đó cho thấy sự tương phản ngay bên trong HashMap, nơi có cả hai loại:

HashMap.Node static? -> true
HashMap.HashIterator static? -> false
HashIterator fields -> 
   java.util.HashMap$Node java.util.HashMap$HashIterator.next
   java.util.HashMap$Node java.util.HashMap$HashIterator.current
   int java.util.HashMap$HashIterator.expectedModCount
   int java.util.HashMap$HashIterator.index
   final java.util.HashMap java.util.HashMap$HashIterator.this$0

HashMap.Node giữ key, value và hash, và nó static. HashMap.HashIterator bắt buộc phải nhìn thấy map đang sống — nó đọc table và so expectedModCount với modCount của map để ném ConcurrentModificationException — nên nó là inner class, và field cuối trong danh sách trên chính là reference mà compiler tự thêm vào. Đó là luật gói gọn trong một ví dụ: cứ để static trừ khi nested type thật sự cần nhìn thấy enclosing object.

Inner class và reference ẩn của nó

Bỏ static đi thì class trở thành một góc nhìn vào đúng một enclosing object:

public class Outer {
    private String name = "outer-object";
    private int counter = 0;

    class Session {
        private String name = "inner-object";
        String describe() {
            return name + " / " + this.name + " / " + Outer.this.name;
        }
        void bump() { counter++; }
    }

    int counter() { return counter; }
}

Session đọc counter, vốn là instance field của Outer. Nó chỉ làm được điều đó nếu biết cái Outer nào. Vì vậy một instance của inner class không thể tồn tại nếu thiếu enclosing object, và cú pháp tạo nó nói thẳng ra điều đó:

Outer outer = new Outer();
Outer.Session s = outer.new Session();
s.bump();
s.bump();
System.out.println("inner: " + s.describe());
System.out.println("outer.counter = " + outer.counter());
inner: inner-object / inner-object / outer-object
outer.counter = 2

Outer.this và outer.new Inner()

Ở đoạn trên có hai cú pháp mà lần đầu nhìn ai cũng thấy lạ.

outer.new Session() là qualified class instance creation expression. Enclosing instance đứng bên trái chữ new. Bên trong một instance method của Outer thì bạn viết new Session() bình thường và compiler tự điền this vào — đó là lý do gọi từ main sẽ hỏng:

E4.java:4: error: non-static variable this cannot be referenced from a static context
        Inner i = new Inner();
                  ^
1 error

Outer.this là qualified this. Bên trong Session.describe(), tên trần name được resolve về khai báo gần nhất, tức là field của chính Session; this.name là cùng một thứ viết rõ ra; còn Outer.this.name vượt qua tầng shadowing để chạm tới enclosing object. Dòng output inner-object / inner-object / outer-object chính là ba expression đó theo thứ tự. Cách qualify này cũng dùng được cho method, và đó là cách duy nhất để gọi một method của outer khi inner class đã override nó.

javac sinh ra gì cho một inner class

Đây là class file mà JVM nhìn thấy:

Compiled from "Outer.java"
class Outer$Session {
  private java.lang.String name;
  final Outer this$0;
  Outer$Session(Outer);
  java.lang.String describe();
  void bump();
}

Bạn viết một field. Ở đó có hai. final Outer this$0 là synthetic — compiler thêm vào, và bạn không gọi tên được nó trong source — còn constructor thì mọc thêm một parameter kiểu Outer mà bạn chưa từng khai báo. Bytecode cho thấy nó được ghi trước mọi thứ, thậm chí trước cả lời gọi constructor của superclass:

  Outer$Session(Outer);
    Code:
       0: aload_0
       1: aload_1
       2: putfield      #1                  // Field this$0:LOuter;
       5: aload_0
       6: invokespecial #7                  // Method java/lang/Object."<init>":()V
       9: aload_0
      10: ldc           #13                 // String inner-object
      12: putfield      #15                 // Field name:Ljava/lang/String;
      15: return

counter++, đọc trong source thì tưởng chỉ là tăng một field, thực ra là hai lần dereference đi qua field đó:

  void bump();
    Code:
       0: aload_0
       1: getfield      #1                  // Field this$0:LOuter;
       4: dup
       5: getfield      #26                 // Field Outer.counter:I
       8: iconst_1
       9: iadd
      10: putfield      #26                 // Field Outer.counter:I
      13: return

Có hai chi tiết đáng biết về việc cơ chế này đã thay đổi ra sao.

Truy cập private không còn đi qua bridge method. Trước Java 11, một inner class đọc field private của enclosing class không thể đọc trực tiếp — JVM sẽ từ chối — nên javac sinh ra một accessor package-private. Đây là một class mà inner class của nó đọc đúng một private int của enclosing class, compile ở --release 8:

Compiled from "Nest.java"
public class Nest {
  private int secret;
  public Nest();
  static int access$000(Nest);
}

--release 11 thì bridge biến mất:

Compiled from "Nest.java"
public class Nest {
  private int secret;
  public Nest();
}

Java 11 mang vào nestmates: class file giờ mang attribute NestHostNestMembers, JVM chấp nhận truy cập private trực tiếp giữa các thành viên cùng một nest, và bridge access$000 không còn nữa.

Field this$0 bị bỏ đi khi không được dùng. Nếu body của inner class không hề đụng tới enclosing instance thì javac đời mới không phát sinh field đó nữa. Vẫn inner class đó, ở --release 17:

Compiled from "Leak.java"
class Leak$Host$InnerListener implements java.lang.Runnable {
  final Leak$Host this$0;
  Leak$Host$InnerListener(Leak$Host);
  public void run();
}

Từ --release 18 trở đi thì field không được sinh ra:

Compiled from "Leak.java"
class Leak$Host$InnerListener implements java.lang.Runnable {
  Leak$Host$InnerListener(Leak$Host);
  public void run();
}

Constructor vẫn giữ nguyên parameter Leak$Host trong cả hai trường hợp — argument chỉ đơn giản bị bỏ đi. Nên phần leak ngay dưới đây là thật nhưng có điều kiện: nó xảy ra khi inner class thực sự dùng enclosing object, mà đó chính là lý do người ta viết inner class.

Cái giá của inner class: enclosing object không bị collect

Reference đó vô hình trong source, và đúng vì thế nó nguy hiểm. Một instance của inner class sống lâu hơn thứ đã tạo ra nó sẽ kéo theo cả thứ đó. Hai listener làm cùng một việc cho thấy khác biệt mà không cần đo đạc gì:

static class Screen {
    final byte[] pixels = new byte[8 * 1024 * 1024];
    String title = "dashboard";

    class InnerListener implements Runnable {
        @Override public void run() { System.out.println(title); }
    }

    static class StaticListener implements Runnable {
        private final String title;
        StaticListener(String title) { this.title = title; }
        @Override public void run() { System.out.println(title); }
    }
}

Cả hai đều in ra title. Một cái đọc qua enclosing object; cái kia được đưa sẵn một bản copy. Bây giờ bỏ strong reference duy nhất tới từng Screen, chỉ giữ lại listener, rồi hỏi một WeakReference xem Screen có sống sót qua một lần collect hay không:

Screen a = new Screen();
Runnable innerListener = a.new InnerListener();
WeakReference<Screen> wa = new WeakReference<>(a);
a = null;
report("keeping the inner listener ", wa);

Screen b = new Screen();
Runnable staticListener = new Screen.StaticListener(b.title);
WeakReference<Screen> wb = new WeakReference<>(b);
b = null;
report("keeping the static listener", wb);
keeping the inner listener  -> Screen STILL REACHABLE
keeping the static listener -> Screen collected
InnerListener  fields -> [final Leak$Screen Leak$Screen$InnerListener.this$0]
StaticListener fields -> [private final java.lang.String Leak$Screen$StaticListener.title]

Hai listener trên cùng một object graph: chỉ inner class giữ cho Screen còn reachable

Đây là chuyện reachability, không phải chuyện nhanh chậm. WeakReference được clear khi referent trở thành weakly reachable, nên "STILL REACHABLE" sau System.gc() nghĩa là vẫn còn một đường strong reference dẫn tới Screen — và danh sách field chỉ đúng tên nó ra. Mảng tám megabyte không phải trọng tâm; nó ở đó để cái giá dễ thấy. Trọng tâm là field cuối cùng trong danh sách.

⚠️ Phiên bản thực tế kinh điển của lỗi này là một event listener, một Runnable submit vào executor sống lâu, hoặc một callback đăng ký với singleton. Registry sống lâu hơn màn hình, listener sống lâu hơn màn hình, và this$0 giữ màn hình — cùng mọi thứ màn hình tham chiếu tới — ngoài tầm với của collector suốt thời gian registry còn giữ listener.

Kiểu double-brace initialization chính là cùng một bug khoác áo khác:

List<String> tags() {
    return new ArrayList<>() {{ add("a"); add("b"); }};
}

Đó không phải cú pháp đặc biệt gì cả. Đó là một anonymous subclass của ArrayList mà instance initializer block của nó gọi add hai lần — và vì nó được tạo trong một instance method, nó là inner class:

tags class  -> DoubleBrace$1
tags fields -> [final DoubleBrace DoubleBrace$1.this$0]
holder      -> STILL REACHABLE

Một list bạn đưa ra ngoài, mang theo cả object đã dựng nên nó. Dùng List.of("a", "b") thay vào đó.

Cách sửa trong mọi trường hợp đều như nhau: để nested class là static và truyền cho nó đúng dữ liệu nó cần, giống như StaticListener nhận một String thay vì tự đi lấy.

Local class được khai báo bên trong method

Local class được khai báo trong một block — body của method, của constructor, của initializer. Scope của nó là block đó; bên ngoài không gọi tên type được, nên nó chỉ hữu ích khi method trả nó về dưới dạng một supertype.

static Supplier<String> makeGreeter(String name) {
    int year = 2026;
    class Greeter implements Supplier<String> {
        @Override public String get() { return "hello " + name + " (" + year + ")"; }
    }
    return new Greeter();
}
hello java (2026)
class      -> LocalDemo$1Greeter
simpleName -> 'Greeter'
isLocal    -> true
enclosing  -> static java.util.function.Supplier LocalDemo.makeGreeter(java.lang.String)

Class file là LocalDemo$1Greeter.class. Chữ số ở đầu là phần phân biệt, vì hai method trong cùng một class đều có thể khai báo một class tên Greeter và hai class file không được trùng tên. Khai báo cùng một tên local class ở hai method sẽ cho ra Numbering$1LocalNumbering$2Local. Khác với anonymous class, local class giữ được getSimpleName(), báo isLocalClass() == true, và mang attribute EnclosingMethod chỉ đúng method sinh ra nó — nhờ vậy stack trace từ local class còn đọc được.

Capture là một bản copy của giá trị effectively final

Greeter đọc nameyear, vốn là một parameter và một local của method đã return xong từ lâu trước khi get() chạy. Stack frame không còn. Nhưng giá trị thì còn, vì compiler đã copy chúng vào object:

class LocalDemo$1Greeter implements java.util.function.Supplier<java.lang.String> {
  final java.lang.String val$name;
  final int val$year;
  LocalDemo$1Greeter();
  public java.lang.String get();
  public java.lang.Object get();
}

Thêm hai synthetic field nữa, val$nameval$year. javap in constructor ra như không có tham số, nhưng bytecode lộ hết — nó nạp slot 1 và slot 2 vào hai field đó trước khi gọi Object.<init>, nên descriptor thật nhận (String, int). Giá trị được capture chính là argument của constructor.

Đó là lý do capture đòi hỏi variable phải effectively final. Field là một bản chụp; nếu local vẫn còn đổi được về sau thì bản chụp và variable sẽ lệch nhau, và không có câu trả lời hợp lý nào cho việc bên nào đúng. Java từ chối thẳng:

Capture.java:8: error: local variables referenced from an inner class must be final or effectively final
            @Override public String get() { return label; }
                                                   ^
1 error

Effectively final nghĩa là variable không bao giờ được gán lại sau khi khởi tạo, dù bạn có viết final hay không. Cái bẫy hay dính nhất là variable của vòng lặp, vì hai dạng for khác nhau:

for (String s : List.of("a", "b", "c")) {
    ok.add(new Supplier<String>() {
        @Override public String get() { return s; }
    });
}

Đoạn này compile được và in ra abc: enhanced for khai báo một s mới ở mỗi vòng và không bao giờ gán lại nó. Còn for cơ bản dùng lại đúng một variable và tăng nó lên, nên nó không effectively final và capture nó sẽ hỏng với đúng lỗi trên. Muốn capture trong for cơ bản thì copy giá trị sang một local mới ngay trong body trước đã.

Capture một object mutable thì không bị ảnh hưởng: reference không được gán lại, nhưng object mà nó trỏ tới thì sửa thoải mái. Một local class cho thấy cả hai vế cùng lúc, vì khác anonymous class, nó được phép khai báo constructor và được tạo nhiều hơn một lần:

static Supplier<String> build(String base) {
    List<String> seen = new ArrayList<>();
    class Tagger implements Supplier<String> {
        private final String suffix;
        Tagger(String suffix) { this.suffix = suffix; }
        @Override public String get() {
            seen.add(suffix);
            return base + suffix + seen;
        }
    }
    Supplier<String> t = new Tagger("-a");
    System.out.println(t.get());
    return new Tagger("-b");
}
root-a[-a]
root-b[-a, -b]

baseseen đều bị capture — javap cho thấy val$baseval$seen nằm cạnh suffix do bạn khai báo — và cả hai instance cùng append vào một list. Capture đóng băng reference, không bao giờ đóng băng object phía sau nó.

Anonymous class là một subclass không tên

Anonymous class gộp phần khai báo và phần khởi tạo vào một expression. new Job("job-7") { ... } nghĩa là "định nghĩa một subclass của Job với body này, rồi đưa tôi một instance của nó".

Job j = new Job("job-7") {
    private int runs;
    { runs = 1; }
    @Override void execute() {
        System.out.println("running " + id() + " (runs=" + runs + ")");
    }
};
running job-7 (runs=1)
name        -> AnonShapes$1
simpleName  -> ''
superclass  -> AnonShapes$Job
constructors-> [AnonShapes$1(java.lang.String)]

Class file là AnonShapes$1.class — đánh số chứ không đặt tên, theo thứ tự compiler gặp các expression bên trong từng enclosing class. Anonymous class thứ hai trong cùng class sẽ là AnonShapes$2, còn cái nằm trong một nested class thì đếm lại từ đầu: trong một file thử nghiệm, năm cái ra thành Numbering$1, Numbering$2, Numbering$1Local, Numbering$2LocalNumbering$Helper$1. getSimpleName() trả về chuỗi rỗng, nên một anonymous class trong stack trace hay trong log gần như không nói cho bạn biết nó từ đâu ra.

Anonymous class được và không được khai báo gì

Nó được khai báo field, method và instance initializer block, và được truyền argument cho constructor của superclass — new Job("job-7") ở trên đã làm vậy, và constructor sinh ra là AnonShapes$1(java.lang.String).

không được khai báo constructor của chính nó. Không có tên nào để viết constructor cả, và javac đọc phần bạn viết như một method thiếu return type:

E1.java:5: error: invalid method declaration; return type required
            E1() { }
            ^
1 error

Instance initializer block là thứ thay thế, và đó là thứ duy nhất bạn có.

Nó cũng chỉ có đúng một supertype: expression new nêu tên một class để extends hoặc một interface để implements, và grammar không có chỗ đặt cái thứ hai. Một type cần hai interface, cần constructor, hoặc cần hai instance thì phải có tên — và đó chính là việc của local class.

Anonymous class và lambda

Hai dòng này nhìn như một thứ viết hai kiểu:

Task anon   = new Task() { @Override public void run() { ... } };
Task lambda = () -> { ... };

Không phải vậy. Dưới đây là phần so sánh chạy trong cùng một chương trình, nơi cả hai body đều in ra this.getClass().getName().

Anonymous class và lambda đặt cạnh nhau: this trỏ vào đâu, và javac ghi gì ra disk

this trỏ tới hai object khác nhau

anon  this      -> AnonVsLambda$1
anon  outer     -> AnonVsLambda instance
lambda this     -> AnonVsLambda
lambda owner    -> AnonVsLambda instance

Bên trong anonymous class, this là chính object anonymous đó, vì anonymous class một class và body của nó là scope riêng. Muốn chạm tới enclosing object thì phải viết AnonVsLambda.this.

Bên trong lambda, this vẫn là enclosing object, không đổi. Body của lambda không mở scope mới cho this, super hay cho tên nói chung; nó nằm cùng scope với code xung quanh, điều thường được mô tả là transparent với this. Đây không phải chi tiết vụn: đó là lý do lambda không tự tham chiếu tới chính nó được, và là lý do đổi một anonymous class thành lambda sẽ âm thầm đổi nghĩa của mọi chữ this không qualify trong body. Đó cũng là lý do một Runnable anonymous gọi this.toString() để làm log tag và bản lambda "tương đương" của nó in ra hai thứ khác nhau.

Tính transparent đó cũng áp dụng cho tên. Parameter của lambda không được shadow một local variable của method bao ngoài:

Shadow.java:5: error: variable s is already defined in method m()
        Function<String, String> f = s -> s.toUpperCase();
                                     ^
1 error

Bản anonymous class y hệt thì compile được, vì apply(String s) là một method thật trong một class thật và parameter của nó mở ra scope mới.

Một bên ghi ra class file, một bên không

Hai file, mỗi file ba implementation:

public class Anons {
    Supplier<String> a = new Supplier<>() { public String get() { return "a"; } };
    Supplier<String> b = new Supplier<>() { public String get() { return "b"; } };
    Supplier<String> c = new Supplier<>() { public String get() { return "c"; } };
}
public class Lambdas {
    Supplier<String> a = () -> "a";
    Supplier<String> b = () -> "b";
    Supplier<String> c = () -> "c";
}
Anons$1.class
Anons$2.class
Anons$3.class
Anons.class
Lambdas.class

Bốn class file so với một. Bytecode cho thấy lý do — anonymous class là một lần allocate thật của một type thật, còn lambda chỉ là một call site invokedynamic:

   0: new           #15                 // class AnonVsLambda$1
   3: dup
   4: aload_0
   5: invokespecial #17                 // Method AnonVsLambda$1."<init>":(LAnonVsLambda;)V
   8: astore_1
   9: aload_0
  10: invokedynamic #20,  0             // InvokeDynamic #0:run:(LAnonVsLambda;)LAnonVsLambda$Task;
  15: astore_2

Class implementation của lambda được LambdaMetafactory sinh ra lần đầu call site đó chạy, nên nó tồn tại lúc runtime nhưng không bao giờ thành file. Nó xuất hiện với một cái tên có kèm địa chỉ nên khác nhau giữa các lần chạy — một lần chạy cho ra AnonVsLambda$$Lambda/0x000000e001000400 — thêm một lý do nữa để đừng phụ thuộc vào getClass() của lambda.

Hệ quả lúc runtime không chỉ là số lượng file. Một lambda không capture gì được tạo đúng một lần rồi dùng lại:

static Supplier<String> make() { return () -> "constant"; }
same instance? -> true
fields         -> []
anon same?     -> false
anon classes   -> Lam$1 / Lam$2

Hai lần gọi make() trả về cùng một object. Hai anonymous class giống hệt nhau về cú pháp là hai class khác nhau, và mỗi chữ new là một object mới. Lambda có capture thì vẫn allocate — giá trị capture được trở thành field, y như trong anonymous class:

anon   fields   -> [final AnonVsLambda AnonVsLambda$1.this$0]
lambda fields   -> [private final AnonVsLambda AnonVsLambda$$Lambda/0x000000e001000400.arg$1]

Cả hai đều capture enclosing instance, một bên tên this$0 và một bên tên arg$1. Một lambda trong instance method có đụng tới instance field thì cũng capture this — nên lambda bị một registry sống lâu giữ lại sẽ leak y hệt inner class. Lambda không miễn nhiễm, nó chỉ rẻ hơn khi tạo.

Lambda chỉ dùng được với functional interface

Khác biệt cuối cùng mới là thứ quyết định giùm bạn. Lambda là một phép chuyển sang functional interface — interface có đúng một abstract method. Mọi thứ khác đều bị từ chối:

E3.java:4: error: incompatible types: TwoJobs is not a functional interface
        TwoJobs t = () -> System.out.println("x");
                    ^
    multiple non-overriding abstract methods found in interface TwoJobs
1 error

Anonymous class không bị ràng buộc như vậy. Nó extends được abstract class, extends được một concrete class không final, hoặc implements một interface với bao nhiêu method cũng được. Nên luật rất ngắn: dùng lambda khi target là functional interface và body không cần this; dùng anonymous class khi không phải vậy, hoặc khi bạn cần state, cần nhiều method, hoặc cần một tên type thật.

anonymous classlambda
thisobject mớienclosing object
Supertypeclass hoặc interface bất kỳchỉ functional interface
Methodbao nhiêu supertype cần cũng đượcđúng một
Field tự khai báođượckhông
Class filemột cái cho mỗi expressionkhông có
Instance khi không capturemỗi lần một object mớidùng lại
Tên class lúc runtimeOuter$1, cố địnhsinh ra, khác nhau mỗi lần chạy

Những lỗi bạn sẽ gặp

non-static variable this cannot be referenced from a static contextnew Inner() gọi từ main hoặc bất kỳ method static nào. Không có enclosing instance để đưa vào. Hoặc viết outer.new Inner(), hoặc tự hỏi ngay từ đầu class đó có nên static không. Gần như lúc nào cũng nên.

local variables referenced from an inner class must be final or effectively final — một local được capture đang bị gán lại ở đâu đó. Copy nó sang một local mới ngay tại chỗ capture.

Illegal static declaration in inner class — cho tới Java 16, inner class hoàn toàn không được khai báo member static, trừ hằng số compile-time. Ở --release 15:

E2.java:4: error: Illegal static declaration in inner class E2.Inner
        static int counter = 0;
                   ^
  modifier 'static' is only allowed in constant variable declarations
E2.java:5: error: Illegal static declaration in inner class E2.Inner
        static void help() { }
                    ^
  modifier 'static' is only allowed in constant variable declarations
2 errors

Đúng file đó compile sạch ở --release 16 trở lên — Java 16 gỡ hạn chế này như một phần của thay đổi cho phép khai báo record bên trong inner class. Lưu ý static final int LIMIT = 10; thì luôn hợp lệ, vì constant variable được inline và không cần chỗ lưu.

invalid method declaration; return type required trong body của anonymous class — bạn đang viết constructor. Dùng instance initializer block.

Một class file bạn không ngờ tới. Outer$1 xuất hiện trong stack trace, trong lỗi serialization hay lỗi proxy chính là một anonymous class. Outer$1Attempt là một local class. Nếu bạn đang serialize, nhớ rằng cả hai đều mang theo đúng những gì compiler quyết định capture, mà có thể bao gồm nguyên cả enclosing object.

Chọn cái nào

Đi từ trên xuống và dừng ở mục khớp đầu tiên.

  1. Type đó có cần nhìn thấy state của enclosing object không? Nếu không — và đây là trường hợp phổ biến — viết static nested class. Nó là một class bình thường với cái tên nằm trong scope và không có field ẩn nào.
  2. Nó có thật sự mô hình hóa một góc nhìn vào đúng một enclosing instance như HashMap.HashIterator không? Vậy thì inner class là đúng, nhưng phải giữ vòng đời của nó ngắn hơn enclosing object. Đừng bao giờ đưa nó cho thứ gì sống lâu.
  3. Nó chỉ dùng trong đúng một method và cần một cái tên, cần nhiều method, hoặc cần state riêng? Local class.
  4. Nó là một implementation dùng một lần cho đúng một abstract method và không cần this? Lambda.
  5. Cũng như trên nhưng target không phải functional interface, hoặc body cần this, cần state, cần nhiều method? Anonymous class.

FAQ

Nested class và inner class trong Java khác nhau thế nào?

"Nested class" là tên chung cho mọi class khai báo bên trong một class khác. Inner class là nested class không có static, nên nó có enclosing instance và compiler cấp cho nó một synthetic field this$0 trỏ tới đó. Static nested class không có field này và được tạo bằng new Outer.Config(...); inner class thì cần outer.new Session(). Local class và anonymous class theo định nghĩa của ngôn ngữ cũng là inner class, và đó là lý do message lỗi về capture nhắc tới "inner class" cho cả hai.

Vì sao inner class gây memory leak?

Vì nó giữ một strong reference tới enclosing object mà bạn không hề viết và không nhìn thấy trong source. Nếu instance của inner class được đăng ký với thứ gì sống lâu — một executor, một event bus, một static cache — thì reference đó giữ cho enclosing object, và mọi thứ object đó tham chiếu tới, vẫn reachable suốt thời gian đăng ký còn hiệu lực. Trong bài test ở trên, một WeakReference tới object 8 MB vẫn chưa được clear sau System.gc() khi inner listener còn bị giữ, và được clear ngay khi thay bằng static nested listener tương đương. Khai báo class là static rồi truyền cho nó dữ liệu nó cần sẽ xóa hẳn reference đó.

this$0 trong Java là gì?

Đó là synthetic field mà javac thêm vào inner class để giữ enclosing instance. javap -p trên class file hiện nó ra dưới dạng final Outer this$0, và constructor được sinh ra nhận thêm một parameter Outer để điền vào. Bạn không gọi tên nó trong source được — Outer.this là cách đọc nó ở mức ngôn ngữ. Từ Java 18, compiler bỏ field này khi body của inner class không hề dùng enclosing instance, dù parameter của constructor vẫn còn.

Anonymous class có constructor được không?

Không. Không có tên class nào để viết constructor, và compiler báo lại là invalid method declaration; return type required. Dùng instance initializer block — một cặp { ... } trần trong body — để khởi tạo, và truyền argument cho constructor của superclass ngay trong expression new: new Job("job-7") { ... } compile ra một constructor nhận String.

Lambda có phải chỉ là cách viết ngắn của anonymous class?

Không, và khác biệt là quan sát được. this trong lambda là enclosing object, còn trong anonymous class là object mới. Lambda không sinh ra class file — ba lambda trong một class cho một class file, ba anonymous class cho bốn cái — vì nó compile thành một call site invokedynamic mà class implementation được sinh lúc runtime. Lambda không capture gì thì được tạo một lần rồi dùng lại, còn mỗi expression anonymous class đều allocate. Và lambda chỉ nhắm được vào functional interface, còn anonymous class extends được mọi class không final hoặc implements mọi interface.

Khi nào nên để nested class là static?

Bất cứ khi nào nó không dùng tới enclosing instance, tức là hầu hết trường hợp. Dạng static không có field ẩn, không bị ràng vòng đời vào object bên ngoài, tạo được mà không cần object nào, và sau này tách ra file riêng cũng không phải viết lại. Map.EntryHashMap.Nodestatic; HashMap.HashIterator, thứ bắt buộc phải quan sát map đang sống, thì không. Nếu trong code review có câu hỏi "vì sao inner class này không static", câu trả lời thường là nó nên static.

Vì sao local variable được capture phải effectively final?

Vì capture copy giá trị vào một synthetic field của class được sinh ra — chính là val$name trong output javap ở trên — ngay lúc construct. Object sống lâu hơn stack frame của method, nên không có vùng nhớ chung nào để giữ hai bên đồng bộ. Nếu local vẫn gán lại được về sau thì field và variable sẽ âm thầm lệch nhau. Biến yêu cầu đó thành lỗi compile là cách loại bỏ sự mơ hồ. Lưu ý hạn chế nằm ở việc gán lại variable, không phải ở việc thay đổi object mà nó trỏ tới.

Local class và anonymous class khác nhau thế nào?

Local class có tên, tạo được nhiều lần, khai báo được constructor, và implements được nhiều interface; class file của nó là Outer$1NamegetSimpleName() không rỗng. Anonymous class được khai báo và khởi tạo trong cùng một expression, chỉ có đúng một supertype, không khai báo được constructor, và compile ra một Outer$1 đánh số. Dùng local class khi bạn cần tạo nó hai lần, cần constructor, hoặc muốn một cái tên đọc được trong stack trace.

Kết luận

Chọn giữa bốn hình thái không phải chuyện phong cách. Static nested class là một class top-level với cái tên nằm trong scope và không có state ẩn. Inner class là một object hoàn toàn khác: nó mang theo một reference do compiler sinh ra trỏ về thứ đã tạo ra nó, chính reference đó làm cho Outer.thisouter.new Inner() có nghĩa, và cũng chính nó giữ tám megabyte buffer màn hình còn reachable sau khi mọi thứ khác đã buông ra. Local class là một inner class bị giới hạn trong một block, với giá trị capture được copy vào các synthetic field val$ — đó là gốc của luật effectively final. Anonymous class là một local class bạn không đặt tên, nên bạn nhận đúng một supertype, không có constructor, và một class file tên Outer$1.

Và lambda không phải anonymous class viết ngắn. Nó là một phép chuyển sang functional interface với scope khác — this vẫn là enclosing object — cách compile khác, invokedynamic thay vì một class file, và cách allocate khác khi nó không capture gì. Dùng nó khi target là functional interface và body không cần identity riêng; đổi sang anonymous class ngay khi một trong hai điều đó không còn đúng.

Bài tiếp theo trong series: enum nâng cao — constant-specific body, enum implements interface, EnumMapEnumSet, cùng lý do enum là cách đúng để viết singleton.

Bài viết liên quan

[Advanced Java] Iterator, ListIterator và fail-fast so với fail-safe trong Java

Iteration trong Java thực sự chạy thế nào trên OpenJDK 21: hai field cursor và lastRet của Iterator, vòng lặp for nâng cao nhìn qua javap, set và add của ListIterator, cơ chế modCount và expectedModCount đằng sau ConcurrentModificationException, một trường hợp thật mà fail-fast im lặng không nổ, snapshot của CopyOnWriteArrayList, iterator weakly consistent của ConcurrentHashMap, và cách tự viết một Iterable.

[Advanced Java] Queue, Deque, Stack và PriorityQueue trong Java

Queue, Deque, Stack và PriorityQueue trên OpenJDK 21: hai họ method của Queue và chính xác từng cái làm gì khi queue rỗng hoặc đầy, bảng method đầy đủ của Deque cùng cách dùng như stack, vì sao Stack extends Vector là một sai lầm thiết kế với cả hai bất ngờ được chứng minh, và bằng chứng rằng PriorityQueue là một binary heap mà toString cùng iterator đều không theo thứ tự priority.

[Java Basics] Field, method và constructor trong Java

Field, instance method và constructor trong Java: giá trị default của field, field initialiser, overloading constructor và chaining bằng this(...), thứ tự khởi tạo chứng minh bằng lệnh in, cùng mọi error message thật của javac từ việc viết void trước constructor tới việc đặt this(...) không phải đầu tiên.

[Java Basics] Vòng lặp lồng nhau, break và continue trong Java

Vòng lặp lồng nhau trong Java và hai keyword cắt ngang vòng lặp: số lần thân vòng lặp trong chạy, break chỉ thoát vòng lặp gần nhất, continue bỏ qua update trong while, labeled break và continue, cùng bẫy switch nằm trong loop.