Scope và shadowing trong Java: biến sống ở đâu và bao lâu?
Hiểu rõ block scope, luật shadowing biến và lifetime trong Java. Nắm vững điều kiện effectively final cho lambda và cách dùng từ khóa this để tránh bug.
TL;DR: Scope quyết định phạm vi mã nguồn mà tên biến có hiệu lực, trong khi lifetime quyết định thời gian ô nhớ tồn tại trên RAM. Java cấm khai báo hai biến local trùng tên trong các block lồng nhau, nhưng cho phép tham số hoặc biến local che (shadow) field của class. Khi bị shadow, dùng từ khóa this để truy xuất field. Biến local được lambda chụp phải là effectively final. Trong bài học trước về khai báo phương thức, ta đã thấy tham số có phạm vi trong toàn bộ thân hàm.
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. Mô hình phòng ốc phản ánh scope và shadowing như thế nào?
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 hoặc 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 hoạt động theo từng cặp ngoặc nhọn như thế nào?
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. Vì sao luật shadowing của Java lại nghiêm ngặt 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. 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 mà 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, hãy dùng this.. Đây là pattern chuẩn mực, không phải anti-pattern.
Hai luật vừa nêu ("cấm local trùng local" và "cho phép parameter che field") tưởng như mâu thuẫn, cho tới khi nhìn chúng nằm ở đâu trong các phạm vi lồng nhau:

Tên được tra từ trong ra ngoài, dừng ở lớp đầu tiên có tên đó. Do vậy tham số che được field ở lớp ngoài, và this. là cách chọc thủng lớp che. Nhưng hai lớp local lồng nhau thì Java chặn ngay từ khâu khai báo.
Bug phổ biến: vô tình khai báo local cùng tên field dẫn đến ghi vào local mà 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 hiện đại sẽ cảnh báo "local variable hides a field". Hãy luôn chú ý cảnh báo này.
Thử ngẫmIDE cảnh báo "local variable hides a field" ở method inc() trên. Tắt cảnh báo đó vì thấy nó ồn, code có compile báo lỗi thay bạn không?
4. Vòng đời (lifetime) của từng loại biến kéo dài 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 nạp tới lúc JVM tắt.idsống cùng mỗi objectOrder: object bị GC dọn thìidbiến mất.prefixsống trong một lần gọiprint(): method return thì stack frame pop vàprefixbiến mất.
Thử ngẫmtotalOrdersCreated sống tới khi JVM tắt, còn id chỉ sống cùng object. Một static field đếm tăng dần theo mỗi request production: bộ nhớ nó chiếm có bao giờ tự giảm không?
5. Điều kiện effectively final cho lambda và inner class hoạt động ra sao?
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 hoặ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 (ví dụ đưa vào thread pool). JVM bắt giá trị của biến tại thời điểm tạo lambda. Nếu biến tiếp tục bị thay đổi, giá trị bắt sẽ lệch với thực tế. Do đó Java chọn giải pháp an toàn là cấm sửa đổi.
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 programming.
Biến counter là reference tới array, và reference đó không đổi (effectively final). Việc sửa counter[0] là sửa nội dung object qua reference, không phải gán lại biến reference.
Thử ngẫmhack mảng 1 phần tử qua được rào effectively final vì sửa nội dung object, không gán lại biến. Bọc một int bên trong một class tự viết có sửa được trong lambda theo cùng lý do đó không?
6. Biến trong try-with-resources và catch có scope ra sao?
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 try-with-resources được ưa chuộng: cú pháp vừa tự đóng r, vừa giới hạn r trong block try để tránh tái sử dụng biến đã giải phóng.
7. Những cạm bẫy scope và shadowing nào thường gây lỗi ngầm?
❌ Nhầm 1: Cố dùng biến của loop sau khi vòng lặp 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 tốt hơn là chia tách logic rõ ràng.
❌ 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 để tránh nhầm lẫn.
❌ Nhầm 3: Cố gán lại biến local 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
sum += i;
}
✅ Khai báo sum bên ngoài vòng lặp nếu muốn tích luỹ giá trị.
❌ Nhầm 5: Static field gây memory leak vì giữ reference tới object quá lâu.
class Registry {
static List<User> all = new ArrayList<>(); // giu moi User song mai
}
✅ Static collection cần cơ chế dọn dẹp (WeakReference, eviction, TTL).
8. Oracle JLS quy định chi tiết về scope và shadowing ra sao?
Spec / reference chính thức:
- JLS §6.3 — Scope of a Declaration: định nghĩa chính xác scope của từng 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" (identifier bị hiểu nhầm giữa type/package/variable). JLS §4.12.4 nhấn mạnh "effectively final" nghĩa là biến không bị gán lại sau khi khởi tạo, giúp compiler sinh flag cho lambda capture an toàn.
9. Những điểm cốt lõi nào cần ghi nhớ về scope và lifetime?
- Scope của biến local gắn chặt với block
{}chứa nó: khi thoát khỏi block, tên biến sẽ hết hiệu lực ngay lập tức. - Java cấm khai báo hai biến local trùng tên trong block lồng nhau, nhưng cho phép tham số che field của class (dùng
this.để chỉ rõ field). - Lifetime độc lập với scope: biến local giải phóng khi pop stack frame, instance field tồn tại theo object trên heap, còn static field tồn tại theo class loader.
- Biến local được lambda hoặc anonymous class capture bắt buộc phải là
effectively final(không được gán lại sau khi khai báo).
10. Tự kiểm tra mức độ nắm vững scope và lifetime
- 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); - Q2Đoạn sau compile không?
public static void f(int x) { int x = 10; System.out.println(x); } - Q3Đoạn sau có bug gì?
class Person { private String name; public void setName(String name) { name = name; } } - Q4Đoạn sau compile không? Vì sao?
int counter = 0; Runnable r = () -> counter++; - Q5Phân biệt scope và lifetime của một biến trong Java.
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?
Bài này đáng gửi cho bạn học cùng?
Copy link đã gắn nguồn — dán group, chat, hoặc LinkedIn.
Recursion — Giải thuật đệ quy và cơ chế Call Stack
Bài tiếpMini-challenge: thư viện NumberUtils
Lộ trình Junior Java Backend Developer
Chặng 2/21 · Lập trình từ số 0 với Java — bài 31/32 của chặng
Thảo luận & góp ý
Hỏi đáp về bài này