Scope, shadowing và lifetime — biến sống ở đâu, đến khi nào
Block scope trong Java, shadowing biến cùng tên, lifetime của local vs field, effectively final, và vì sao closure (lambda) bắt biến theo rule đặc biệt.
Bạn khai báo int x = 10 ở đầu method, rồi ở giữa có block { int x = 20; }. Compile được không? Nếu bạn từng viết C/C++, câu trả lời sẽ làm bạn bất ngờ. Còn khi parameter trùng tên field, truy cập field thế nào? Và nếu lambda bên trong đọc x, phiên bản nào được lấy?
Những câu hỏi trên đều liên quan đến scope — phạm vi mà một cái tên biến có ý nghĩa, và lifetime — khoảng thời gian biến tồn tại trên memory. Hiểu hai khái niệm này giúp bạn tránh bug tinh vi: biến bị shadow, closure bắt sai giá trị, NPE do dùng biến ngoài scope.
1. Analogy — phòng trong nhà
Căn nhà có nhiều phòng. Bạn dán nhãn "remote TV" trên kệ phòng khách — chỉ phòng khách hiểu nhãn này, phòng bếp không biết. Phòng ngủ (phòng ngang hàng, không lồng trong phòng khách) cũng dán nhãn "remote TV" trên remote điều hòa — không sao, hai phòng độc lập, không ai nhầm với ai. Nhưng nhà còn có bảng nhãn chung treo ở sảnh (field của class) — khi một phòng dán nhãn trùng tên với nhãn ở sảnh, người đứng trong phòng đó sẽ hiểu nhãn theo nghĩa của phòng. Nhãn ở sảnh bị che (shadow).
| Đời thường | Java |
|---|---|
| Phòng | Block { ... } |
| Nhãn trên kệ | Tên biến local |
| Bảng nhãn chung ở sảnh | Field của class |
| Nhãn trong phòng trùng nhãn sảnh | Shadowing — local/parameter che field |
| Hai phòng ngang hàng dán nhãn trùng | Hợp lệ — hai scope không giao nhau |
| Đồ còn/hết khi đóng phòng | Lifetime biến |
Mỗi cặp { } mở một scope mới. Biến khai báo trong scope chỉ thấy được trong scope đó và các scope con bên trong. Ra khỏi scope, tên biến không còn ý nghĩa.
2. Block scope — phạm vi đi theo { }
Scope trong Java bám theo block (cặp { }):
public static void demo() {
int a = 1; // scope: toan method
if (true) {
int b = 2; // scope: trong block if
System.out.println(a + b); // OK — thay ca a (outer) va b (inner)
}
// System.out.println(b); // COMPILE ERROR — b khong con thay
System.out.println(a); // OK
}
Scope lồng nhau theo cây — biến ngoài thấy trong; biến trong không lan ra ngoài.
2.1 Scope của parameter và for-counter
Parameter có scope = toàn body method:
public static int doubled(int x) {
return x * 2; // x thay trong het body
}
Biến khai báo trong for (int i = 0; ...) có scope = chỉ trong for:
for (int i = 0; i < 10; i++) { ... }
// System.out.println(i); // COMPILE ERROR
Biến khai báo với for-each (for (int x : arr)), try-with-resources, catch (Exception e) — cùng nguyên tắc, scope hẹp trong block tương ứng.
3. Shadowing — luật của Java nghiêm hơn C/C++
Java cấm khai báo local mới trùng tên khi biến local cũ còn trong scope — kể cả trong block con (JLS §6.4):
int x = 1;
int x = 2; // COMPILE ERROR — variable x is already defined
int y = 1;
if (condition) {
int y = 2; // COMPILE ERROR — scope cua y ngoai phu ca block nay
}
Compiler báo variable y is already defined in method .... Đây là điểm khác biệt với C/C++ — nơi block con được phép khai báo biến trùng tên che biến ngoài:
// C/C++ — hop le, nhung de gay bug kho thay
int x = 1;
if (condition) {
int x = 2; // shadow x ngoai — compiler im lang
}
Vì sao Java cấm? Local che local là nguồn bug kinh điển trong C/C++: người đọc tưởng code đang sửa biến ngoài, thực chất chỉ đụng biến trong — và compiler không nói gì. Java chọn fail sớm tại compile time để loại hẳn lớp bug này.
Chỉ có hai trường hợp được dùng lại tên một cách hợp lệ:
(1) Hai block ngang hàng — scope của biến cũ đã đóng trước khi khai báo lại:
{
int temp = 1;
} // scope cua temp ket thuc tai day
{
int temp = 2; // OK — hai scope khong giao nhau
}
Hai biến temp hoàn toàn độc lập. Đây không phải shadowing — chỉ là tái sử dụng tên sau khi tên cũ hết hiệu lực.
(2) Local / parameter che field — đây mới là shadowing đúng nghĩa trong Java (xem 3.1).
Parameter cũng theo luật cấm trên. Scope của parameter phủ toàn bộ method body, nên không khai báo lại được ở bất kỳ đâu trong body:
public static void f(int x) {
for (int x = 0; x < 10; x++) { ... } // COMPILE ERROR — trung ten parameter
{
int x = 99; // COMPILE ERROR — scope cua parameter phu ca block con
}
}
3.1 Shadow field bằng local/parameter — dạng shadowing bạn gặp hằng ngày
Khi học OOP, bạn sẽ gặp pattern này liên tục:
class Person {
private String name;
public void setName(String name) { // parameter 'name' shadow field 'name'
this.name = name; // dung this de tro field
}
}
name trong body của setName ám chỉ parameter, không phải field. Muốn truy cập field bị shadow → dùng this.. Đây là pattern tiêu chuẩn, không phải anti-pattern.
Bug phổ biến: vô tình khai báo local cùng tên field → code ghi vào local, field không đổi.
class Counter {
private int count = 0;
public void inc() {
int count = this.count; // shadow
count++; // chi sua local
// quen gan lai this.count = count
}
}
IDE sáng sủa sẽ warning "local variable hides a field". Bật cảnh báo đó lên.
4. Lifetime — biến sống bao lâu
Scope quyết định ở đâu dùng được tên biến. Lifetime quyết định bao lâu ô nhớ tồn tại.
| Loại biến | Scope | Lifetime |
|---|---|---|
| Local / parameter | Block chứa nó | Từ lúc khai báo đến khi thoát block (stack frame pop) |
| Field (instance) | Mọi instance method của class | Bằng lifetime của object (đến khi GC thu hồi) |
Field static | Truy xuất qua ClassName.field từ mọi nơi | Từ khi class được load đến khi JVM tắt |
Ví dụ:
class Order {
static int totalOrdersCreated = 0; // static field
private int id; // instance field
public Order(int id) {
this.id = id;
totalOrdersCreated++;
}
public void print() {
String prefix = "Order-"; // local — song trong print()
System.out.println(prefix + id);
}
}
totalOrdersCreatedsống từ khi classOrderđược JVM load đến khi JVM tắt.idsống cùng mỗi objectOrder— object bị GC thìidbiến mất.prefixsống trong một lần gọiprint()— method return → frame pop →prefixbiến mất.
5. Effectively final — điều kiện để biến dùng trong lambda / inner class
Lambda và anonymous inner class được phép đọc biến local của method bao ngoài, nhưng biến đó phải effectively final — khai báo rồi không bị gán lại trước/sau lambda.
public static void demo() {
int x = 10;
Runnable r = () -> System.out.println(x); // OK — x effectively final
int y = 20;
y = 21;
Runnable r2 = () -> System.out.println(y); // COMPILE ERROR — y khong effectively final
}
Lý do: lambda có thể chạy sau khi method bao đã return (vd submit vào thread pool). JVM bắt giá trị của biến tại lúc tạo lambda — nếu biến tiếp tục bị thay đổi, giá trị bắt sẽ lệch với giá trị hiện tại. Java chọn giải pháp an toàn: cấm.
5.1 Workaround khi cần "biến thay đổi" trong lambda
// Cach 1: array 1 phan tu (hack co dien)
int[] counter = {0};
list.forEach(x -> counter[0]++);
// Cach 2: AtomicInteger (dung cho multi-thread)
AtomicInteger c = new AtomicInteger(0);
list.forEach(x -> c.incrementAndGet());
// Cach 3: reduce thay vi side-effect (functional style)
long count = list.stream().filter(x -> x > 0).count();
Trong đa số trường hợp, cách 3 — chuyển side-effect thành reduce/stream — là sạch nhất và đúng tinh thần functional.
Biến counter là reference tới array — reference đó không đổi (effectively final). Việc sửa counter[0] là sửa object qua reference, không phải gán lại biến. Lambda chỉ yêu cầu reference effectively final, không quan tâm nội dung object.
6. Scope của try-with-resources và catch
try (BufferedReader r = new BufferedReader(new FileReader("a.txt"))) {
// r thay trong day
} catch (IOException e) {
// e thay trong day
System.out.println(e.getMessage());
}
// r, e ra ngoai scope — khong dung duoc
Đây là lý do bài "Variables" Module 2 nhấn mạnh try-with-resources — nó tự đóng r và làm r ra khỏi scope luôn, tránh reuse biến đã đóng.
7. Pitfall tổng hợp
❌ Nhầm 1: Cố dùng biến của for sau khi vòng kết thúc.
for (int i = 0; i < 10; i++) { ... }
System.out.println(i); // COMPILE ERROR
✅ Khai báo i trước for nếu cần dùng sau — nhưng thường thiết kế lại sạch hơn.
❌ Nhầm 2: Shadow field bằng local mà quên this..
void setAge(int age) { age = age; } // tu gan local cho chinh no
✅ this.age = age;. Hoặc đổi tên parameter (newAge) để rõ ý.
❌ Nhầm 3: Cố gán lại biến dùng trong lambda.
int x = 0;
list.forEach(item -> x++); // COMPILE ERROR
✅ Dùng reduce/count, hoặc AtomicInteger nếu thực sự cần mutable.
❌ Nhầm 4: Khai báo biến trong vòng loop nhưng tưởng giữ giá trị giữa các vòng.
for (int i = 0; i < 5; i++) {
int sum = 0; // reset moi vong — co the khong phai y do
sum += i;
}
✅ Nếu muốn tích luỹ, khai báo sum ngoài loop.
❌ Nhầm 5: Static field bị "rò rỉ" memory vì tham chiếu object không release.
class Registry {
static List<User> all = new ArrayList<>(); // giu moi User song mai
}
✅ Static collection cần chiến lược xoá (weak reference, TTL, cleanup). Sẽ đào ở module Memory & GC.
8. 📚 Deep Dive Oracle
Spec / reference chính thức:
- JLS §6.3 — Scope of a Declaration — định nghĩa chính xác scope của mọi loại declaration.
- JLS §6.4 — Shadowing and Obscuring — rules shadowing giữa declaration và reference.
- JLS §4.12.4 — final Variables — định nghĩa "effectively final".
- JLS §15.27.2 — Lambda Body — yêu cầu capture biến effectively final.
Ghi chú: JLS §6.4 phân biệt "shadow" (tên hẹp hơn che tên rộng) và "obscure" (một identifier bị hiểu nhầm giữa type/package/variable). Shadow gặp thường xuyên; obscure chỉ gặp khi đặt tên biến trùng tên class — tránh luôn. §4.12.4 quan trọng: "effectively final" = "không bị gán lại sau khi khai báo", compiler sinh flag để lambda capture được.
9. Tóm tắt
- Scope của biến local là block (
{ }) chứa nó. Ra khỏi block, tên biến không còn ý nghĩa. - Java cấm khai báo local mới trùng tên khi local cũ (hoặc parameter) còn trong scope — kể cả trong block con. Đây là điểm khác C/C++. Chỉ được dùng lại tên ở block ngang hàng, sau khi scope cũ đã đóng.
- Shadowing trong Java nghĩa là local/parameter che field cùng tên (và nested class che biến bao ngoài) — không phải local che local. Muốn truy cập field bị che, dùng
this.. - Lifetime: biến local sống theo stack frame; instance field sống theo object; static field sống từ khi class được load đến khi JVM tắt.
- Lambda / inner class capture biến local yêu cầu biến đó effectively final (không bị gán lại).
- Muốn mutable trong lambda: reduce/stream (sạch), hoặc
AtomicInteger/ array 1 phần tử (hack). try-with-resourcesgiới hạn scope của resource trong try block — không reuse được ngoài.
10. Tự kiểm tra
Q1Đoạn sau compile được không? Vì sao?int x = 1;
if (true) {
int x = 2;
System.out.println(x);
}
System.out.println(x);
▸
int x = 1;
if (true) {
int x = 2;
System.out.println(x);
}
System.out.println(x);Không compile. Compiler báo variable x is already defined in method ....
Scope của x ngoài phủ từ chỗ khai báo đến hết block chứa nó — bao gồm cả block if bên trong. Java cấm khai báo local mới trùng tên khi local cũ còn trong scope (JLS §6.4), kể cả ở block con.
Đây là điểm khác C/C++ — ở đó đoạn này hợp lệ (in 2 rồi 1). Java cấm vì local che local là nguồn bug khó thấy: người đọc tưởng đang sửa biến ngoài nhưng thực ra chỉ đụng biến trong. Muốn giá trị khác trong block, đặt tên mới.
Q2Đoạn sau compile không?public static void f(int x) {
int x = 10;
System.out.println(x);
}
▸
public static void f(int x) {
int x = 10;
System.out.println(x);
}Không. Scope của parameter x phủ toàn bộ method body — mọi khai báo int x bên trong body, kể cả trong block con, đều đụng độ với parameter → compile error variable x is already defined:
public static void f(int x) {
{
int x = 10; // van COMPILE ERROR — scope parameter phu ca block con
}
}Muốn một giá trị dẫn xuất, đặt tên mới: int doubled = x * 2;. Trường hợp duy nhất parameter được "trùng tên" hợp lệ là parameter che field cùng tên — pattern this.name = name trong setter/constructor.
Q3Đoạn sau có bug gì?class Person {
private String name;
public void setName(String name) {
name = name;
}
}
▸
class Person {
private String name;
public void setName(String name) {
name = name;
}
}name = name gán parameter name cho chính nó — không đụng đến field name của object. Method chạy xong, field vẫn giá trị cũ.
Fix:
this.name = name;this. ép truy cập field bị shadow bởi parameter. Đây là pattern chuẩn trong Java — đổi tên parameter thành newName cũng được, nhưng convention giữ cùng tên cho rõ ý "parameter đúng là giá trị mới của field".
Q4Đoạn sau compile không? Vì sao?int counter = 0;
Runnable r = () -> counter++;
▸
int counter = 0;
Runnable r = () -> counter++;Không compile. Lambda yêu cầu biến local được capture phải effectively final (JLS §4.12.4) — không bị gán lại sau khi khai báo. counter++ là counter = counter + 1 → gán lại → vi phạm.
Lý do: lambda có thể chạy sau khi method bao return (vd submit vào thread pool). Java không cho phép "biến local" tồn tại sau khi stack frame pop — giải pháp là capture value tại lúc tạo lambda, và cấm mutation để không lệch giữa "giá trị đã capture" và "giá trị hiện tại".
Fix: dùng AtomicInteger (reference effectively final, nội dung mutable), hoặc thiết kế lại bằng stream().count() / reduce.
Q5Phân biệt scope và lifetime của một biến, lấy ví dụ minh họa.▸
Scope = ở đâu tên biến có ý nghĩa (phạm vi source code mà compiler cho phép tham chiếu).
Lifetime = bao lâu ô nhớ của biến tồn tại ở runtime.
Ví dụ:
class Foo {
static int counter = 0; // scope: moi noi co the truy qua Foo.counter
// lifetime: tu khi class load den khi JVM tat
private int id; // scope: moi instance method cua Foo
// lifetime: bang lifetime cua object instance
void hello() {
int n = 10; // scope: trong body hello()
// lifetime: tu khi frame push den khi hello() return
}
}Hai khái niệm độc lập: biến có thể out-of-scope nhưng vẫn alive (vd biến local đã thoát block nhưng stack frame chưa pop — không xảy ra trong Java, nhưng có ngôn ngữ cho phép). Ngược lại biến có thể in-scope nhưng chưa khởi tạo → NPE hoặc "variable might not have been initialized" compile error.
Bài tiếp theo: Mini-challenge: thư viện NumberUtils với method tĩnh
Bài này có giúp bạn hiểu bản chất không?
Hỏi đáp về bài này
Chưa có câu hỏi
Có gì chưa rõ trong bài? Đặt câu hỏi đầu tiên — câu trả lời từ cộng đồng giúp bạn (và người sau).
Đặt câu hỏi đầu tiên