Command Palette

Search for a command to run...

[Java Basics] Từ khóa this, static và final trong Java

Ba từ khóa này trả lời ba câu hỏi khác nhau về quyền sở hữu. this chỉ ra object mà method đang được gọi lên. static nói rằng một member thuộc về class chứ không thuộc về object nào. final nói rằng một cái tên chỉ được bind đúng một lần. Chúng xuất hiện cạnh nhau suốt ngày — private static final int MAX = 10; dùng luôn hai trong ba — và cũng thường bị hiểu đúng có một nửa.

Bài này tách từng từ khóa ra xử lý riêng, rồi ghép cả ba lại trong thứ tự initialisation mà chúng tạo ra. Mọi dòng output và mọi error message bên dưới đều lấy từ lần compile và chạy thật trên OpenJDK 21.0.6.

Ba từ khóa: this, static, final

Mỗi từ khóa một quy tắc: this là receiver, static thuộc về class, final bind đúng một lần.

this thật ra trỏ vào cái gì

Bên trong một instance method hay một constructor, this là reference tới object mà method được gọi lên — cái receiver. Nó không phải bản copy, không phải object đặc biệt, cũng không phải thứ compiler viết lại rồi bỏ đi. In nó ra cạnh reference bạn dùng để gọi thì hai giá trị bằng nhau.

public class ThisIdentity {
    String name;

    ThisIdentity(String name) {
        this.name = name;
    }

    void identify(ThisIdentity receiver) {
        System.out.println("  this             = " + this);
        System.out.println("  receiver passed  = " + receiver);
        System.out.println("  this == receiver = " + (this == receiver));
        System.out.println("  this.name        = " + this.name);
    }

    public static void main(String[] args) {
        ThisIdentity a = new ThisIdentity("alpha");
        ThisIdentity b = new ThisIdentity("beta");

        System.out.println("a = " + a);
        a.identify(a);
        System.out.println("b = " + b);
        b.identify(b);
    }
}
a = ThisIdentity@2a139a55
  this             = ThisIdentity@2a139a55
  receiver passed  = ThisIdentity@2a139a55
  this == receiver = true
  this.name        = alpha
b = ThisIdentity@5cad8086
  this             = ThisIdentity@5cad8086
  receiver passed  = ThisIdentity@5cad8086
  this == receiver = true
  this.name        = beta

Phần hex phía sau là identity hash của object; máy bạn có thể ra số khác, nhưng hai dòng trong cùng một lần gọi thì luôn trùng nhau. a.identify(...)b.identify(...) chạy đúng cùng một đoạn bytecode, còn this mỗi lần lại là một object khác.

Chỉ có vậy thôi. Mọi lần truy cập field không có tiền tố bên trong instance method đều ngầm là this.something; viết this ra rõ ràng không đổi gì ngoài chuyện dễ đọc — trừ đúng một trường hợp, chính là phần tiếp theo.

this.x = x tách field và parameter thành hai slot khác nhau

Một parameter trùng tên với field sẽ shadow field đó: bên trong method, cái tên trần trụi có nghĩa là parameter. Đây là chỗ duy nhất mà viết this không còn là tuỳ chọn.

Hai lần gọi, hai receiver, và slot của field tách hẳn khỏi slot của parameter

public class Shadowing {
    int balance;

    void setBroken(int balance) {
        balance = balance;      // parameter assigned to itself; the field never moves
    }

    void setFixed(int balance) {
        this.balance = balance; // field on the left, parameter on the right
    }

    public static void main(String[] args) {
        Shadowing acc = new Shadowing();

        acc.setBroken(500);
        System.out.println("after setBroken(500): balance = " + acc.balance);

        acc.setFixed(500);
        System.out.println("after setFixed(500):  balance = " + acc.balance);
    }
}
after setBroken(500): balance = 0
after setFixed(500):  balance = 500

setBroken compile được, chạy được, và không làm gì cả. Nó gán parameter cho chính nó và để field nằm nguyên ở giá trị mặc định 0. javac -Xlint:all không hề cảnh báo gì — đây là loại bug lọt lưới im lặng, và chỉ có inspection của IDE mới bắt hộ bạn.

Hai cái tên nằm ở hai nơi khác nhau, nên chúng mới sống chung được:

TênSlot nằm ở đâuSống bao lâu
balance (parameter)frame của method trên stackmột lần gọi
this.balance (field)bên trong object trên heapbằng tuổi thọ của object

Cách thay thế cho this.balance = balance là đổi tên parameter — void setFixed(int newBalance). Code Java thực tế gần như luôn chọn cách đầu, vì giữ tên parameter trùng tên field là một tín hiệu hữu ích cho biết method này chỉ đơn giản là lưu giá trị đó lại.

this(...) gọi sang constructor khác của cùng class

Một constructor có thể uỷ quyền sang constructor khác của cùng class bằng this(...). Đây là cách chuẩn để diễn đạt giá trị mặc định cho argument, vì Java không có default parameter.

public class ThisChaining {
    String host;
    int port;
    boolean tls;

    ThisChaining(String host, int port, boolean tls) {
        System.out.println("  full constructor: host=" + host + " port=" + port + " tls=" + tls);
        this.host = host;
        this.port = port;
        this.tls = tls;
    }

    ThisChaining(String host, int port) {
        this(host, port, true);          // must be the first statement
        System.out.println("  two-arg constructor finished");
    }

    ThisChaining(String host) {
        this(host, 443);
        System.out.println("  one-arg constructor finished");
    }

    public static void main(String[] args) {
        System.out.println("new ThisChaining(\"api.example.com\")");
        ThisChaining c = new ThisChaining("api.example.com");
        System.out.println("result: " + c.host + ":" + c.port + " tls=" + c.tls);
    }
}
new ThisChaining("api.example.com")
  full constructor: host=api.example.com port=443 tls=true
  two-arg constructor finished
  one-arg constructor finished
result: api.example.com:443 tls=true

Chuỗi gọi đi vào trong trước rồi mới bung ngược ra: constructor một argument gọi cái hai argument, cái đó gọi tiếp cái đầy đủ, và chỉ mình cái đầy đủ mới thật sự gán gì đó. Sau này thêm field thì chỉ có đúng một chỗ phải sửa.

Trên JDK 21, lời gọi uỷ quyền bắt buộc phải là statement đầu tiên trong body của constructor:

ChainNotFirst() {
    System.out.println("before delegating");
    this(443);
}
ChainNotFirst.java:10: error: call to this must be first statement in constructor
        this(443);
            ^
1 error

Java 25 nới lỏng chuyện này bằng flexible constructor bodies, cho phép đặt statement trước lời gọi uỷ quyền miễn là chúng không đọc object đang được dựng. Trên JDK 21 thì quy tắc trên là tuyệt đối.

Truyền this ra ngoài và return this để chaining

this là một reference bình thường, nên truyền được sang method khác và return được ra ngoài. Chính chuyện return nó là thứ làm cho một fluent API chạy được: mỗi setter trả lại đúng object đó, nên các lời gọi nối được thành chuỗi.

public class FluentThis {
    private String host = "localhost";
    private int port = 80;
    private int timeoutMs = 1000;

    FluentThis host(String host) {
        this.host = host;
        return this;            // hand the same object back
    }

    FluentThis port(int port) {
        this.port = port;
        return this;
    }

    FluentThis timeoutMs(int timeoutMs) {
        this.timeoutMs = timeoutMs;
        return this;
    }

    String describe() {
        return host + ":" + port + " timeout=" + timeoutMs + "ms";
    }

    // passing `this` to a method that needs the whole object
    void log() {
        Audit.record(this, "configured");
    }

    public static void main(String[] args) {
        FluentThis cfg = new FluentThis();
        FluentThis same = cfg.host("api.example.com").port(8443).timeoutMs(250);

        System.out.println("cfg.describe()  = " + cfg.describe());
        System.out.println("same == cfg     = " + (same == cfg));
        cfg.log();
    }
}

class Audit {
    static void record(FluentThis source, String event) {
        System.out.println("audit: " + event + " -> " + source.describe());
    }
}
cfg.describe()  = api.example.com:8443 timeout=250ms
same == cfg     = true
audit: configured -> api.example.com:8443 timeout=250ms

same == cfg ra true, và đó mới là chi tiết đáng để ý: cả chuỗi gọi không hề tạo ra object thứ hai. cfg.host(...) sửa chính cfg rồi trả lại nó, và mọi lời gọi tiếp theo trong chuỗi lại sửa tiếp đúng object đó. Tiện thì có tiện, nhưng cũng vì thế mà setter kiểu chaining là lựa chọn tệ khi bạn muốn một bản copy riêng — ai đang giữ reference đó cũng thấy mọi thay đổi.

this không tồn tại trong static context

Một static method không được gọi lên object nào, nên không có receiver nào để this chỉ tới.

public class ThisInStatic {
    int value = 7;

    static void report() {
        System.out.println(this.value);
    }
}
ThisInStatic.java:5: error: non-static variable this cannot be referenced from a static context
        System.out.println(this.value);
                           ^
1 error

Message hơi kỳ — this bị gọi là một variable — nhưng nói vậy lại chính xác: this là một parameter ngầm mà instance method nhận được còn static method thì không. Cùng message đó sẽ hiện ra mỗi khi một static method với tay tới bất cứ thứ gì cần receiver.

static: một bản duy nhất thuộc về class

Một member static thuộc về chính class. Một static field chỉ có đúng một bản, bất kể có bao nhiêu object, và nó tồn tại từ trước khi object đầu tiên ra đời. Instance field thì ngược lại: mỗi object một slot.

Ba object, mỗi cái một id riêng, cùng ghi vào một static field dùng chung

Cách chứng minh gọn nhất là một counter được tăng trong constructor rồi đọc lại qua tên class.

public class InstanceCounter {
    static int created = 0;      // one slot, owned by the class
    int id;                      // one slot per object

    InstanceCounter() {
        created++;               // shorthand for InstanceCounter.created++
        this.id = created;
    }

    public static void main(String[] args) {
        System.out.println("before any object: InstanceCounter.created = " + InstanceCounter.created);

        InstanceCounter a = new InstanceCounter();
        InstanceCounter b = new InstanceCounter();
        InstanceCounter c = new InstanceCounter();

        System.out.println("a.id = " + a.id + "   b.id = " + b.id + "   c.id = " + c.id);
        System.out.println("InstanceCounter.created = " + InstanceCounter.created);
        System.out.println("a sees created = " + a.created + ", b sees " + b.created + ", c sees " + c.created);
    }
}
before any object: InstanceCounter.created = 0
a.id = 1   b.id = 2   c.id = 3
InstanceCounter.created = 3
a sees created = 3, b sees 3, c sees 3

Có hai điều được chứng minh ở đây. created đọc được và bằng 0 khi chưa có object nào, nên nó không nằm bên trong object. Và sau ba lần tạo, cả ba object đều báo 3, vì chúng không đọc ba field khác nhau — chúng cùng đọc một field duy nhất thuộc về class. Ngược lại, id lần lượt là 1, 23, mỗi object một giá trị.

static int createdint id
Có bao nhiêu slotmột, cho cả classmỗi object một cái
Sinh ra khi nàolúc class được initialiselúc object được dựng
Dùng được khi chưa có objectkhông
Truy cập bằngInstanceCounter.createda.id

Gọi static member qua class, đừng gọi qua object

Một static member nên được viết kèm tên class. Java cũng cho phép viết qua một instance reference, và nó compile được, nhưng lại nói sai về bản chất đoạn code.

public class StaticCallSite {
    static int square(int n) {
        return n * n;
    }

    public static void main(String[] args) {
        System.out.println("via the class    : " + StaticCallSite.square(5));

        StaticCallSite obj = new StaticCallSite();
        System.out.println("via an instance  : " + obj.square(5));

        StaticCallSite nothing = null;
        System.out.println("via a null ref   : " + nothing.square(5));
    }
}
via the class    : 25
via an instance  : 25
via a null ref   : 25

Dòng thứ ba mới là dòng đáng nhớ: gọi một static method qua reference null không ném NullPointerException. Compiler chọn method dựa trên static type của nothing, vứt luôn reference đi, rồi sinh ra một lời gọi static bình thường. Không có gì bị dereference cả.

javac sẽ nói cho bạn biết hết, nếu bạn hỏi:

StaticCallSite.java:10: warning: [static] static method should be qualified by type name, StaticCallSite, instead of by an expression
        System.out.println("via an instance  : " + obj.square(5));
                                                      ^
StaticCallSite.java:13: warning: [static] static method should be qualified by type name, StaticCallSite, instead of by an expression
        System.out.println("via a null ref   : " + nothing.square(5));
                                                          ^
2 warnings

Field cũng dính đúng warning đó — a.created ở phần trước sinh ra static variable should be qualified by type name dưới -Xlint:static. Bật lint đó lên và viết ClassName.member ở mọi nơi.

static method không làm được gì

Một static method không có receiver, nên nó không chạm được vào bất cứ thứ gì cần receiver: không instance field, không instance method, không this.

public class StaticTouchesField {
    int instanceCount = 0;
    static int total = 0;

    static void bump() {
        instanceCount++;   // no object to bump it on
        total++;           // fine: static field, static method
    }
}
StaticTouchesField.java:6: error: non-static variable instanceCount cannot be referenced from a static context
        instanceCount++;   // no object to bump it on
        ^
1 error

total++ ở dòng ngay dưới thì không sao. Hạn chế này chỉ có một chiều: static method không với tới được instance state, nhưng instance method thì đọc ghi static state thoải mái, vì instance method có cả receiver lẫn class.

Hai lối thoát khỏi lỗi này là bỏ static khỏi method, hoặc nhận object vào làm parameter rồi dùng nó một cách tường minh.

Static initialiser block và class initialisation lazy

Một block static { ... } chạy đúng một lần, lúc class được initialise. Nó sinh ra cho những việc setup mà một biểu thức khởi tạo field đơn lẻ không diễn đạt nổi — đổ dữ liệu vào bảng tra, đọc một resource, nối vài field lại với nhau.

Các biểu thức khởi tạo static field và các static block chạy theo thứ tự trong source, như một dãy liên tục.

public class StaticBlocks {
    static int a = report("1. static field a");

    static {
        System.out.println("2. static block one");
        b = 20;                     // legal: assigning a field declared below
    }

    static int b = report("3. static field b overwrites the 20 above");

    static {
        System.out.println("4. static block two, b = " + b);
    }

    static int report(String label) {
        System.out.println(label);
        return 99;
    }

    public static void main(String[] args) {
        System.out.println("5. main starts, a = " + a + ", b = " + b);
    }
}
1. static field a
2. static block one
3. static field b overwrites the 20 above
4. static block two, b = 99
5. main starts, a = 99, b = 99

Để ý bước 2 ghi vào b trước khi b được khai báo, rồi bước 3 ghi đè lên. Một static block được phép gán cho field khai báo ở phía dưới, nhưng không được đọc nó — đó là illegal forward reference. Sắp xếp static state theo kiểu phụ thuộc vào vị trí khai báo là cách chắc chắn tạo ra một bug không ai nhìn thấy.

Class initialisation là lazy

Điều thú vị hơn là dãy đó chạy vào lúc nào. Một class được initialise ở lần dùng thật đầu tiên, không phải lúc chương trình khởi động và cũng không phải lúc tên class được nhắc tới. Bài về compile và chạy chương trình Java đã nói tới chuyện class loader phân giải tên một cách lazy; đây vẫn là cơ chế đó, chỉ đi thêm một bước.

class Heavy {
    static final int LIMIT = 100;           // compile-time constant
    static final String NAME = makeName();  // not a compile-time constant

    static {
        System.out.println("   >>> Heavy is being initialised");
    }

    static String makeName() {
        return "heavy";
    }

    static int twice(int n) {
        return n * 2;
    }
}

public class LazyInit {
    public static void main(String[] args) {
        System.out.println("1. main started");

        Heavy ref = null;                       // naming the type
        System.out.println("2. declared a Heavy variable, still nothing");

        System.out.println("3. Heavy.LIMIT = " + Heavy.LIMIT);

        System.out.println("4. about to call Heavy.twice(21)");
        System.out.println("5. Heavy.twice(21) = " + Heavy.twice(21));

        System.out.println("6. Heavy.NAME = " + Heavy.NAME);
    }
}
1. main started
2. declared a Heavy variable, still nothing
3. Heavy.LIMIT = 100
4. about to call Heavy.twice(21)
   >>> Heavy is being initialised
5. Heavy.twice(21) = 42
6. Heavy.NAME = heavy

Ba sự thật rơi ra từ vệt trace đó:

  • Khai báo một variable type Heavy không hề initialise class. Một tên type trong khai báo không tính là dùng.
  • Đọc Heavy.LIMIT cũng không initialise class. Đó chính là quy tắc constant inlining của phần sau, nhìn được từ bên ngoài.
  • Lần dùng thật đầu tiên — gọi Heavy.twice(21) — mới kích hoạt initialisation, và static block chạy trước cái method đang bị nó chặn.

Chạy lại chương trình đó với -verbose:class cho thấy class còn chưa được load cho tới bước 4. Đoạn trích bên dưới chỉ giữ lại những dòng liên quan, đã cắt bớt timestamp đầu dòng và đường dẫn tuyệt đối:

$ java -verbose:class LazyInit
4. about to call Heavy.twice(21)
[info][class,load] Heavy source: file:.../java-a25/
   >>> Heavy is being initialised

Hằng số static final và constant inlining lúc compile

Quy ước cho hằng số là static final kèm tên viết SCREAMING_SNAKE_CASE: static vì một hằng số không đổi theo từng object, final vì nó không được phép bị gán lại.

public class Limits {
    public static final int MAX_RETRIES = 3;          // compile-time constant
    public static final String PREFIX = "v1-";        // compile-time constant
    public static final int[] SIZES = {1, 2, 3};      // NOT a constant
}

Một field static final thuộc primitive type hoặc String, khởi tạo bằng một constant expression, là một compile-time constant. Compiler không sinh ra lệnh đọc field cho nó — nó chép thẳng giá trị vào mọi class có dùng. javap -c cho thấy đúng như vậy:

public class UsesLimits {
    static int budget() {
        return Limits.MAX_RETRIES * 10;
    }

    static String tag() {
        return Limits.PREFIX + "beta";
    }

    static int firstSize() {
        return Limits.SIZES[0];
    }
}
$ javap -c UsesLimits.class
  static int budget();
    Code:
       0: bipush        30
       2: ireturn

  static java.lang.String tag();
    Code:
       0: ldc           #9                  // String v1-beta
       2: areturn

  static int firstSize();
    Code:
       0: getstatic     #11                 // Field Limits.SIZES:[I
       3: iconst_0
       4: iaload
       5: ireturn

budget() chỉ còn đúng một lệnh đẩy số 30 lên. Limits không xuất hiện trong đó một lần nào — phép nhân đã bị gấp lại ngay lúc compile. tag() chỉ là một lệnh ldc với chuỗi thành phẩm "v1-beta". Chỉ có firstSize(), cái đọc array, mới sinh ra một lệnh getstatic thật vào Limits.

Cách đó nhanh, và nó kéo theo một hệ quả mà nhiều người gặp theo kiểu đau đớn. Sửa hằng số, chỉ compile lại đúng class khai báo nó, và bên dùng vẫn giữ giá trị cũ:

$ java Run
Limits.MAX_RETRIES = 3
UsesLimits.budget()= 30
UsesLimits.tag()   = v1-beta

# MAX_RETRIES changed from 3 to 5; only Limits.java recompiled
$ javac Limits.java && java Run
Limits.MAX_RETRIES = 3
UsesLimits.budget()= 30
UsesLimits.tag()   = v1-beta

# everything recompiled
$ javac Limits.java UsesLimits.java Run.java && java Run
Limits.MAX_RETRIES = 5
UsesLimits.budget()= 50
UsesLimits.tag()   = v1-beta

Ngay cả Run cũng in ra số 3 cũ mèm, vì chính Run cũng đã inline hằng số đó. Sửa một compile-time constant public là một thay đổi phá vỡ tương thích nhị phân: mọi bên phụ thuộc đều phải compile lại, và đó là lý do một hằng số công bố trong thư viện khó thay đổi hơn hẳn một method. Build tool compile lại cả module nên giấu mất chuyện này; build lại một phần thì không.

Không phải static final nào cũng là compile-time constant. Biểu thức khởi tạo phải là một constant expression:

public class Kinds {
    public static final int A = 3;                      // constant expression -> inlined
    public static final int B;                          // assigned in a static block
    public static final int C = Integer.parseInt("3");  // method call
    public static final String S = "v" + 1;             // constant expression -> inlined

    static { B = 3; }
}
$ javap -c UsesKinds.class
  static int a();
    Code:
       0: iconst_3
       1: ireturn

  static int b();
    Code:
       0: getstatic     #9                  // Field Kinds.B:I
       3: ireturn

  static int c();
    Code:
       0: getstatic     #13                 // Field Kinds.C:I
       3: ireturn

  static java.lang.String s();
    Code:
       0: ldc           #16                 // String v1
       2: areturn

AS đã bị inline; BC sinh ra lệnh đọc field thật. Nếu bạn cần một hằng số public mà sửa được không phải compile lại cả thế giới, mẹo nằm ở đó: khởi tạo nó trong static block, hoặc qua một lời gọi method, để nó thôi là compile-time constant.

Utility class và khi nào static là lựa chọn sai

Khi một method không cần state của object nào — kết quả chỉ phụ thuộc vào argument — thì static là lựa chọn đúng. Một class gồm toàn method như vậy là utility class, và hình dạng quy ước của nó chặn luôn việc tạo object:

final class Text {
    private Text() {                       // nobody can call new Text()
        throw new AssertionError("no instances of Text");
    }

    static String repeat(String s, int times) {
        return s.repeat(times);
    }

    static boolean isBlank(String s) {
        return s == null || s.isBlank();
    }
}

public class TextUtil {
    public static void main(String[] args) {
        System.out.println(Text.repeat("ab", 3));
        System.out.println(Text.isBlank("   "));
    }
}
ababab
true

Chính constructor private mới biến nó thành utility class, chứ không phải chỉ là một class tình cờ có vài static method. java.lang.Mathjava.util.Arrays đều được dựng theo kiểu này.

Chỗ static đi sai đường là shared mutable state. Một field bị viết thành static chỉ vì thấy tiện sẽ trở thành một giá trị duy nhất cho cả chương trình:

class Cart {
    static int total = 0;      // meant to be per cart, written as per class
    int items = 0;             // correctly per cart

    void add(int price) {
        total += price;
        items++;
    }
}

public class SharedState {
    public static void main(String[] args) {
        Cart alice = new Cart();
        Cart bob = new Cart();

        alice.add(30);
        bob.add(12);
        bob.add(8);

        System.out.println("alice: items = " + alice.items + ", total = " + Cart.total);
        System.out.println("bob  : items = " + bob.items + ", total = " + Cart.total);
    }
}
alice: items = 1, total = 50
bob  : items = 2, total = 50

Giỏ của Alice có đúng một món giá 30, mà báo tổng là 50. Số món thì đúng vì items là instance field; tổng tiền thì sai vì total không phải. Trong ví dụ một thread thì hậu quả chỉ là số sai. Thêm thread vào thì chính field đó trở thành một data race.

Quy tắc đứng vững được: dùng static cho hành vi chỉ phụ thuộc vào argument và cho những hằng số thật sự bất biến; dùng instance field cho mọi thứ mô tả một object cụ thể.

Một nested class cũng khai báo static được, và khi đó nó lại mang nghĩa khác: nested class không giữ reference tới enclosing instance. Chuyện đó thuộc về phần inner class trong khoá nâng cao.

final trên local variable và trên parameter

Trên một local variable hay một parameter, final nghĩa là cái tên chỉ được gán đúng một lần và không bao giờ trỏ lại chỗ khác.

public class FinalLocals {
    static int rounded(final double raw, final int places) {
        final double factor = Math.pow(10, places);
        places = 2;                     // reassigning a final parameter
        return (int) Math.round(raw * factor);
    }

    public static void main(String[] args) {
        final int limit;
        limit = 10;                     // legal: a blank final assigned once
        limit = 20;                     // second assignment
        System.out.println(limit);
    }
}
FinalLocals.java:4: error: final parameter places may not be assigned
        places = 2;                     // reassigning a final parameter
        ^
FinalLocals.java:11: error: variable limit might already have been assigned
        limit = 20;                     // second assignment
        ^
2 errors

Hai message khác nhau, và cái khác nhau đó đáng đọc kỹ. Một parameter khi vào method đã được gán sẵn, nên mọi phép gán đều bị từ chối: final parameter places may not be assigned. Một local khai báo mà chưa khởi tạo là một blank final — nó được gán một lần, ở bất cứ đâu, và compiler theo dõi xem việc đó chắc chắn đã xảy ra hay chưa. Một blank final bị gán hai lần sẽ ra might already have been assigned.

Gán lại một final đã được khởi tạo ngay lúc khai báo thì ra message thứ ba, cái quen mặt nhất:

final int limit = 10;
limit = 20;
FinalLocalInit.java:4: error: cannot assign a value to final variable limit
        limit = 20;
        ^
1 error

final trên local không bắt buộc để một lambda hay anonymous class capture được nó, nhưng variable đó ít nhất phải effectively final — gán một lần rồi không đụng tới nữa:

int factor = 3;
factor = 4;                       // makes `factor` not effectively final
List.of(1, 2).forEach(n -> System.out.println(n * factor));
EffectivelyFinal.java:7: error: local variables referenced from a lambda expression must be final or effectively final
        List.of(1, 2).forEach(n -> System.out.println(n * factor));
                                                          ^
1 error

Xoá phép gán thứ hai đi là compile được. Viết final ngay lúc khai báo làm cho yêu cầu đó hiện rõ ra, và biến một lỗi khó hiểu ở chỗ lambda thành một lỗi hiển nhiên ngay chỗ gán lại.

final trên field: gán đúng một lần

Một field final phải được gán đúng một lần trước khi constructor kết thúc, hoặc bởi biểu thức khởi tạo của nó, hoặc bởi mọi constructor. Compiler kiểm cả hai vế: không được ít hơn một lần, và cũng không được nhiều hơn.

Để hở một nhánh không gán thì bạn nhận được:

public class FinalFieldMissing {
    private final String name;
    private final int size;

    FinalFieldMissing(String name) {
        this.name = name;       // size never assigned on this path
    }
}
FinalFieldMissing.java:7: error: variable size might not have been initialized
    }
    ^
1 error

Dấu mũ chỉ vào dấu ngoặc đóng của constructor, vì đó mới là lúc việc bỏ sót trở thành chung cuộc. Gán hai lần trong cùng một constructor thì lại ra message của blank final:

FinalFieldReassign(int size) {
    this.size = size;
    this.size = size * 2;   // second assignment in the same constructor
}
FinalFieldReassign.java:6: error: variable size might already have been assigned
        this.size = size * 2;   // second assignment in the same constructor
            ^
1 error

Còn một field đã có sẵn biểu thức khởi tạo thì constructor không gán được nữa, vì biểu thức khởi tạo chạy trước và đã tiêu mất lần gán duy nhất:

public class FinalFieldTwice {
    private final int size = 10;

    FinalFieldTwice(int size) {
        this.size = size;       // the initialiser already assigned it
    }
}
FinalFieldTwice.java:5: error: cannot assign a value to final variable size
        this.size = size;       // the initialiser already assigned it
            ^
1 error

Hệ quả thực dụng: một field thay đổi theo argument của constructor thì bắt buộc phải là blank final — khai báo không khởi tạo và gán trong mọi constructor. Đó đúng là việc mà idiom this.x = x đang làm, và cũng là lý do field final với constructor chaining hợp nhau đến vậy. Uỷ quyền bằng this(...) về một constructor gán hết mọi thứ, thế là compiler chỉ còn một đường để kiểm.

final trên reference không làm object trở thành immutable

Đây là điều bị hiểu sai nhiều nhất về final. final ràng buộc variable: reference nằm trong đó không được trỏ sang chỗ khác. Nó không nói một chữ nào về object ở đầu bên kia.

Một final reference mà object vẫn bị sửa, đặt cạnh một giá trị immutable thật sự

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

public class FinalReference {
    public static void main(String[] args) {
        final int[] counts = {1, 2, 3};
        final StringBuilder sb = new StringBuilder("Java");
        final List<String> tags = new ArrayList<>(List.of("a", "b"));
        final String label = "fixed";

        // the OBJECTS are still fully mutable
        counts[0] = 99;
        sb.append(" 21");
        tags.add("c");

        System.out.println("counts = " + Arrays.toString(counts));
        System.out.println("sb     = " + sb);
        System.out.println("tags   = " + tags);
        System.out.println("label  = " + label + " (String has no mutator at all)");
    }
}
counts = [99, 2, 3]
sb     = Java 21
tags   = [a, b, c]
label  = fixed (String has no mutator at all)

Cả bốn variable đều là final, và ba trong bốn object đã bị đổi. Thứ mà final thật sự chặn là chuyện trỏ lại:

final StringBuilder sb = new StringBuilder("Java");
sb.append(" 21");
// re-pointing the reference, not mutating the object
sb = new StringBuilder("Kotlin");
FinalRepoint.java:6: error: cannot assign a value to final variable sb
        sb = new StringBuilder("Kotlin");
        ^
1 error

label không đổi vì một lý do hoàn toàn khác: String không có method nào sửa được nó. Immutable là tính chất của class — không có method làm thay đổi và không có state ghi được — và không từ khóa nào đặt ở variable ban phát được tính chất đó.

Cũng cái bẫy đó nhưng ở dạng hằng số là public static final trên một array:

import java.util.Arrays;

public class PublicConstantArray {
    public static final String[] LEVELS = {"DEBUG", "INFO", "WARN"};
    public static final int MAX = 3;

    public static void main(String[] args) {
        System.out.println("before: " + Arrays.toString(PublicConstantArray.LEVELS));

        PublicConstantArray.LEVELS[0] = "TRACE";   // no compiler complaint at all

        System.out.println("after : " + Arrays.toString(PublicConstantArray.LEVELS));
        System.out.println("MAX   = " + PublicConstantArray.MAX);
    }
}
before: [DEBUG, INFO, WARN]
after : [TRACE, INFO, WARN]
MAX   = 3

LEVELS trông như hằng số, đặt tên như hằng số, và bất kỳ ai ở bất kỳ đâu cũng ghi đè được phần tử của nó — vĩnh viễn, cho cả chương trình. javac -Xlint:all không nói gì cả. Còn MAX mới thật sự là hằng số, vì int không có ruột để mà sửa.

⚠️ Một array hay collection mutable đặt public static final thực chất là một public mutable field mang cái tên đánh lừa. Hãy công bố List.of(...) hoặc Collections.unmodifiableList(...), hoặc trả về một bản copy qua method.

Gói lại trong một câu: static final cho bạn một slot duy nhất, gán một lần, mọi người dùng chung; còn giá trị trong slot đó có sửa được từ bên trong hay không thì hoàn toàn phụ thuộc vào type của nó. Với int, String và các immutable type khác thì hai chuyện đó trùng nhau, và đó là lý do cái bẫy này sống dai.

final method và final class

final trên method nghĩa là không subclass nào được override nó. final trên class nghĩa là không class nào được kế thừa nó, và như vậy thì mọi method của nó cũng coi như final luôn.

final class Config {
    final String describe() {
        return "config";
    }
}

class TunedConfig extends Config {
}
FinalShapes.java:7: error: cannot inherit from final Config
class TunedConfig extends Config {
                          ^
1 error
FinalMethodOverride.java:8: error: describe() in Derived cannot override describe() in Base
    String describe() {
           ^
  overridden method is final
1 error

java.lang.String, Integer và các wrapper type khác đều là class final, và đó là một phần cách chúng bảo đảm tính immutable — không subclass nào thêm được state mutable hay override một method để nói dối về giá trị. Còn khi nào và vì sao bạn nên đánh dấu class và method của mình là final thì đó là câu hỏi thiết kế chỉ có nghĩa khi overriding đã nằm trên bàn; chuyện đó thuộc bài 27 và 28.

final có nghĩa gì ở từng vị trí

final là một từ khóa làm bốn việc, và lẫn lộn giữa chúng chính là thứ đẻ ra cái bug "nhưng tôi để final rồi mà".

Vị tríCái gì bị cố địnhCái gì không bị cố địnhLỗi nếu cố làm
local variablevariable, sau một lần gánobject mà nó trỏ tớicannot assign a value to final variable x
blank local (không khởi tạo)sau lần gán đầu tiênobject mà nó trỏ tớivariable x might already have been assigned
parameterparameter, ngay từ lúc vào methodobject được truyền vàofinal parameter x may not be assigned
instance fieldfield, sau khi constructor xongobject mà nó trỏ tớicannot assign a value to final variable x
blank final fieldphải được gán trong mọi constructorobject mà nó trỏ tớivariable x might not have been initialized
static final primitive hoặc Stringgiá trị, trên toàn chương trình, và inline vào bên gọikhông còn gì để đổicannot assign a value to final variable X
static final array hoặc objectchỉ mỗi referencemọi phần tử và mọi field của nókhông có — sửa vẫn compile im lặng
methodphần cài đặt, chống overridemọi thứ method đó đọc hoặc ghioverridden method is final
classclass, chống kế thừacác object, nếu chúng có field mutablecannot inherit from final C

Dòng đáng nhớ là dòng thứ bảy, và nó là dòng duy nhất không có lấy một cảnh báo nào từ compiler.

Thứ tự initialisation, chạy thật và chứng minh

Ghép cả ba từ khóa lại thì thứ tự chạy của mọi thứ là hoàn toàn xác định. Phần khởi tạo ở mức class xảy ra một lần, ở lần dùng đầu tiên. Phần khởi tạo ở mức object xảy ra ở mọi lần new.

Initialiser của class chạy một lần, rồi instance initialiser và constructor cho từng object

class Widget {
    static int serial = trace("1. static field initialiser", 100);

    static {
        System.out.println("2. static block");
    }

    int id = trace("3. instance field initialiser", ++serial);

    {
        System.out.println("4. instance initialiser block, id = " + id);
    }

    Widget(String label) {
        System.out.println("5. constructor body, label = " + label + ", id = " + id);
    }

    static int trace(String message, int value) {
        System.out.println("   " + message + " -> " + value);
        return value;
    }
}

public class InitOrder {
    public static void main(String[] args) {
        System.out.println("main starts, Widget not touched yet");

        System.out.println("--- new Widget(\"first\") ---");
        new Widget("first");

        System.out.println("--- new Widget(\"second\") ---");
        new Widget("second");

        System.out.println("--- Widget.serial = " + Widget.serial + " ---");
    }
}
main starts, Widget not touched yet
--- new Widget("first") ---
   1. static field initialiser -> 100
2. static block
   3. instance field initialiser -> 101
4. instance initialiser block, id = 101
5. constructor body, label = first, id = 101
--- new Widget("second") ---
   3. instance field initialiser -> 102
4. instance initialiser block, id = 102
5. constructor body, label = second, id = 102
--- Widget.serial = 102 ---

Đọc lại lần nữa đi. Bước 1 và 2 in ra đúng một lần, ở lần new đầu tiên, chứ không phải lúc chương trình khởi động. Bước 3, 4 và 5 in một lần cho mỗi object, theo đúng thứ tự cố định đó: biểu thức khởi tạo instance field và instance initialiser block trước, theo thứ tự trong source, rồi mới tới body của constructor. serial giữ nguyên giá trị giữa hai lần dựng, vì nó là field của class.

Giai đoạnCái gì chạyBao nhiêu lần
Class initialisationbiểu thức khởi tạo static field và block static { }, theo thứ tự sourcemột lần, ở lần dùng thật đầu tiên
Object initialisationbiểu thức khởi tạo instance field và block { }, theo thứ tự sourceở mọi lần new
Constructorbody của constructorở mọi lần new, sau phần ngay trên

Block { ... } của instance hiếm khi được dùng, vì constructor làm được đúng việc đó mà rõ ràng hơn. Công dụng thật sự duy nhất của nó là chia sẻ phần setup cho nhiều constructor không gọi lẫn nhau — mà chaining bằng this(...) thường vẫn tốt hơn.

Lỗi thường gặp và error message tương ứng

Sai ở đâuChuyện gì xảy raMessage
Viết x = x; thay vì this.x = x;compile được, field giữ nguyên giá trị mặc địnhkhông có, kể cả với -Xlint:all
Dùng this trong static methodcompile errornon-static variable this cannot be referenced from a static context
Đọc instance field từ static methodcompile errornon-static variable x cannot be referenced from a static context
Có statement trước this(...) trên JDK 21compile errorcall to this must be first statement in constructor
Gán lại một final parametercompile errorfinal parameter x may not be assigned
Gán một final local hai lầncompile errorvariable x might already have been assigned
Constructor bỏ sót không gán field finalcompile errorvariable x might not have been initialized
Gán field final đã có biểu thức khởi tạocompile errorcannot assign a value to final variable x
Override một method finalcompile erroroverridden method is final
Kế thừa một class finalcompile errorcannot inherit from final C
Đọc static field trước khi class được dùngra giá trị mặc định, hoặc một hằng số đã inlinekhông có
Sửa object nằm sau một reference finalcompile được, object đổi thậtkhông có
Sửa một array public static finalcompile được, hằng số đổi cho tất cả mọi ngườikhông có
Đổi một compile-time constant public mà không compile lại bên dùngbên gọi giữ giá trị cũkhông có
State riêng của từng object lại khai báo staticcompile được, mọi object dùng chung một giá trịkhông có

Năm dòng cuối không sinh ra bất kỳ cảnh báo nào. Bốn trong năm dòng đó chỉ là một hiểu lầm duy nhất khoác áo khác nhau: finalstatic nói về cái slot, không bao giờ nói về giá trị bên trong nó.

FAQ

Có phải viết this trước mọi field không?

Không. Bên trong instance method, cái tên trần trụi đã trỏ đúng vào field rồi, thêm this. phía trước không đổi gì. Nó chỉ bắt buộc khi một parameter hay một local shadow field — chính là trường hợp this.x = x — và hữu ích khi bạn cần truyền hoặc return chính object đó. Vài team viết nó ở mọi nơi cho nhất quán; đó là lựa chọn phong cách, không phải chuyện đúng sai.

Vì sao main không gọi được method của tôi?

mainstatic còn method của bạn thì không. javac báo non-static method f() cannot be referenced from a static context. Hoặc cho method đó static luôn, nếu nó không dùng field nào, hoặc tạo một object trong main rồi gọi method lên object đó. Cách thứ hai mới là cách chương trình thật làm: main dựng một object rồi giao quyền điều khiển cho nó.

Static field có dùng chung giữa các thread không?

Có, và đó chính xác là lý do nó nguy hiểm. Một slot duy nhất, mọi thread đều nhìn thấy, và từ khóa đó không ngụ ý bất kỳ sự đồng bộ nào. Một counter static bị tăng từ nhiều thread có thể mất mát số đếm, và khi tải nặng thì gần như chắc chắn mất. Nếu một static field bắt buộc phải mutable và bị đụng vào đồng thời, nó cần AtomicInteger, một lock, hoặc volatile, tuỳ việc bạn đang làm gì — và static không cho bạn thứ nào trong số đó.

static final khác final ở chỗ nào?

static final là một slot cho cả class, gán một lần, và — với primitive cùng String khởi tạo bằng constant expression — được chép vào mọi class có đọc nó. Còn final thường trên instance field là một slot cho mỗi object, gán một lần cho mỗi object, và hoàn toàn hợp lệ khi các object có giá trị khác nhau. Dùng static final cho giá trị giống nhau ở mọi nơi, dùng final instance field cho giá trị cố định lúc dựng object nhưng khác nhau theo từng object.

final có làm object thread-safe không?

Tự thân thì không, nhưng một field final có mang theo một bảo đảm thật: sau khi constructor kết thúc bình thường, mọi thread nhìn thấy object đó đều thấy field final của nó đã được khởi tạo đúng, không cần đồng bộ thêm. Bảo đảm đó phủ lên field, không phủ lên thứ mà field trỏ tới — một reference final tới một ArrayList mutable vẫn không an toàn y hệt như reference thường. Một class mà mọi field đều final và đều trỏ tới object immutable thì mới thật sự thread-safe.

final có làm code chạy nhanh hơn không?

Coi như là không. JIT compiler tự xác định cái gì thực chất là hằng số dựa trên cả chương trình, và không cần từ khóa đó để inline một method hay gấp một giá trị. Hiệu ứng đo được duy nhất là chuyện inline static final lúc compile đã nói ở trên, mà đó là hành vi của compiler chứ không phải một cú tăng tốc bạn trông cậy được. Dùng final để nói lên ý định, còn hiệu năng thì để JIT lo.

Vì sao sửa hằng số rồi mà giá trị không đổi?

Vì nó là compile-time constant và những class đọc nó đã inline mất giá trị cũ. Compile lại toàn bộ những gì phụ thuộc vào nó — mvn clean, gradle clean, hoặc xoá thư mục output — thì giá trị mới sẽ hiện ra. Nếu một hằng số thật sự cần đổi được mà không phải compile lại bên dùng, hãy làm cho nó thôi là compile-time constant bằng cách khởi tạo trong static block hoặc từ một lời gọi method.

Kết luận

this là receiver: object mà instance method được gọi lên, nằm sẵn ở đó như một reference bình thường mà bạn truyền được, return được, và dùng để tách bạch this.x = x. Nó không tồn tại trong static method, vì static method không có receiver.

static dời một member từ object lên class: một slot, sinh ra lúc class được initialise, dùng được khi chưa có object nào, và mọi object từng tồn tại đều dùng chung. Điều đó làm nó đúng cho helper không state và cho hằng số thật, và sai cho mọi thứ mô tả một object cụ thể. Class initialisation là lazy và chạy một lần, theo thứ tự source qua các biểu thức khởi tạo static field và static block, còn một static final primitive hay String thì bị inline vào mọi bên gọi — nên sửa nó là phải compile lại tất cả những gì đọc nó.

final bind một cái tên đúng một lần. Trên local hay parameter nó cấm gán lại; trên field nó bắt phải gán đúng một lần, bởi biểu thức khởi tạo hoặc bởi mọi constructor; trên method nó cấm override; trên class nó cấm kế thừa. Thứ nó không bao giờ làm là đóng băng object ở đầu bên kia của reference, và đó lại chính là điều nhiều người tin nhất về nó.

Bài tiếp theo trong series: encapsulation, getter, setter và access modifierprivate, public và package-private thật sự kiểm soát cái gì, vì sao phơi một field ra là quyết định không rút lại được, và getter thật ra sinh ra để làm gì.

Bài viết liên quan

[Java Basics] Mảng nhiều chiều trong Java

Mảng nhiều chiều trong Java thực chất là array của array chứ không phải hình chữ nhật - cách tạo và duyệt grid, jagged array, deepToString, deepEquals, shallow vs deep copy, mảng 3 chiều và ví dụ matrix, mọi output đều compile và chạy thật trên JDK 21.

[Java Basics] Đọc và ghi file text trong Java

Đọc và ghi file text trong Java: FileReader và FileWriter, vì sao cần BufferedReader và BufferedWriter, try-with-resources, API hiện đại Files và Path, đường dẫn tương đối, các exception thật khi file không tồn tại, và character encoding quyết định dữ liệu tiếng Việt có sống sót hay không.

[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.

[Java Basics] Tham số, giá trị trả về và overloading trong Java

Parameter, giá trị trả về và overloading trong Java: vì sao Java luôn là pass-by-value, vì sao swap không chạy được, bốn phase của overload resolution, quy tắc varargs, cùng mọi compile error chạy thật trên JDK 21.