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.
![]()
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(...) và 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.

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ên | Slot nằm ở đâu | Sống bao lâu |
|---|---|---|
balance (parameter) | frame của method trên stack | một lần gọi |
this.balance (field) | bên trong object trên heap | bằ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.

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, 2 và 3, mỗi object một giá trị.
static int created | int id | |
|---|---|---|
| Có bao nhiêu slot | một, cho cả class | mỗi object một cái |
| Sinh ra khi nào | lúc class được initialise | lúc object được dựng |
| Dùng được khi chưa có object | có | không |
| Truy cập bằng | InstanceCounter.created | a.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
Heavykhông hề initialise class. Một tên type trong khai báo không tính là dùng. - Đọc
Heavy.LIMITcũ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
A và S đã bị inline; B và C 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.Math và java.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.

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 finalthự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ặcCollections.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ố định | Cái gì không bị cố định | Lỗi nếu cố làm |
|---|---|---|---|
| local variable | variable, sau một lần gán | object mà nó trỏ tới | cannot assign a value to final variable x |
| blank local (không khởi tạo) | sau lần gán đầu tiên | object mà nó trỏ tới | variable x might already have been assigned |
| parameter | parameter, ngay từ lúc vào method | object được truyền vào | final parameter x may not be assigned |
| instance field | field, sau khi constructor xong | object mà nó trỏ tới | cannot assign a value to final variable x |
| blank final field | phải được gán trong mọi constructor | object mà nó trỏ tới | variable x might not have been initialized |
static final primitive hoặc String | giá trị, trên toàn chương trình, và inline vào bên gọi | không còn gì để đổi | cannot assign a value to final variable X |
static final array hoặc object | chỉ mỗi reference | mọi phần tử và mọi field của nó | không có — sửa vẫn compile im lặng |
| method | phần cài đặt, chống override | mọi thứ method đó đọc hoặc ghi | overridden method is final |
| class | class, chống kế thừa | các object, nếu chúng có field mutable | cannot 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.

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ạn | Cái gì chạy | Bao nhiêu lần |
|---|---|---|
| Class initialisation | biểu thức khởi tạo static field và block static { }, theo thứ tự source | một lần, ở lần dùng thật đầu tiên |
| Object initialisation | biểu thức khởi tạo instance field và block { }, theo thứ tự source | ở mọi lần new |
| Constructor | body 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 ở đâu | Chuyện gì xảy ra | Message |
|---|---|---|
Viết x = x; thay vì this.x = x; | compile được, field giữ nguyên giá trị mặc định | không có, kể cả với -Xlint:all |
Dùng this trong static method | compile error | non-static variable this cannot be referenced from a static context |
| Đọc instance field từ static method | compile error | non-static variable x cannot be referenced from a static context |
Có statement trước this(...) trên JDK 21 | compile error | call to this must be first statement in constructor |
Gán lại một final parameter | compile error | final parameter x may not be assigned |
Gán một final local hai lần | compile error | variable x might already have been assigned |
Constructor bỏ sót không gán field final | compile error | variable x might not have been initialized |
Gán field final đã có biểu thức khởi tạo | compile error | cannot assign a value to final variable x |
Override một method final | compile error | overridden method is final |
Kế thừa một class final | compile error | cannot inherit from final C |
| Đọc static field trước khi class được dùng | ra giá trị mặc định, hoặc một hằng số đã inline | không có |
Sửa object nằm sau một reference final | compile được, object đổi thật | không có |
Sửa một array public static final | compile được, hằng số đổi cho tất cả mọi người | không có |
| Đổi một compile-time constant public mà không compile lại bên dùng | bên gọi giữ giá trị cũ | không có |
State riêng của từng object lại khai báo static | compile đượ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: final và static 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?
Vì main là static 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 modifier — private, 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ì.