Nền tảng 1. Java cơ bản và OOP (kiến thức Fresher)
Đi làm 2–4 năm vẫn phải ôn "JDK khác JRE thế nào"? Có. Người phỏng vấn hay khởi động bằng vài câu cơ bản để đo nhanh nền móng. Với người có kinh nghiệm, sai câu nền tảng bị trừ điểm nặng hơn Fresher nhiều, vì nó cho thấy bạn code theo thói quen mà không hiểu vì sao. Mà chuyện này không hiếm: ngày nào cũng dùng Spring, nhiều người quên dần thứ tự khởi tạo class hay khác biệt giữa sleep() và wait().
Phần này trả lời các câu vòng Fresher/Junior theo Java 17/21, và chỉ ra chỗ các bộ đáp án lưu truyền đã lỗi thời (interface chỉ có abstract method, finalize(), setPriority()...). Phần cần đào sâu có ở Chương 1, 2, 3.
Mẹo từ người phỏng vấn
Công thức trả lời câu cơ bản ở mức 2–4 năm: một câu định nghĩa gọn, chính xác, rồi thêm đúng một ý thực tế.
- Ví dụ "HashMap khác Hashtable?": nêu khác biệt về null và đồng bộ, rồi thêm "thực tế không ai dùng Hashtable nữa, cần thread-safe thì dùng ConcurrentHashMap".
N1.1 JDK, JRE, JVM và cách chương trình Java chạy
- JVM: máy ảo thực thi bytecode, mỗi hệ điều hành có bản JVM riêng.
- JRE: JVM cộng thư viện chuẩn, đủ để chạy chương trình.
- JDK: JRE cộng công cụ phát triển (
javac,jar,jshell...), dùng để viết và biên dịch.
javac biên dịch .java thành bytecode (.class) không phụ thuộc hệ điều hành; JVM nạp bytecode, vừa thông dịch vừa dùng JIT dịch code chạy nhiều sang mã máy. Nhờ vậy cùng file .class chạy trên mọi máy có JVM: Write Once, Run Anywhere. Các lệnh sau đi qua vòng đời đó với file Hello.java có method main:
javac Hello.java # compile: produces Hello.class (bytecode)
java Hello # start the JVM, load Hello.class, call main
javap -c Hello # show the generated bytecode
java Hello.java # Java 11+: run a single source file directlyGiải thích: javac thuộc JDK, java khởi động JVM. javap -c cho thấy .class chứa lệnh bytecode như invokevirtual, không phải mã máy. Dự án thật build bằng Maven/Gradle.
Static/dynamic typing: kiểu được kiểm tra lúc biên dịch (Java, Go) hay lúc chạy (Python, JavaScript). Strong/weak typing: ngôn ngữ có tự ép kiểu ngầm giữa các kiểu không liên quan hay không (JavaScript cho "5" * 2 ra 10 là weak). Java là static và strong: int x = "abc"; không biên dịch được, ép (Integer) obj sai kiểu thì ném ClassCastException.
Bytecode và JIT nói bằng lời thường
Bytecode là "ngôn ngữ trung gian" do javac sinh ra: nó không phải mã máy của Intel hay ARM, mà là tập lệnh dành riêng cho JVM. JVM ban đầu thông dịch (đọc từng lệnh bytecode và thực hiện), đồng thời đếm xem method nào được gọi nhiều. Method "nóng" (gọi hàng nghìn lần) được JIT compiler (Just-In-Time, biên dịch đúng lúc cần) dịch sang mã máy thật và lưu lại, lần sau chạy thẳng mã máy nên nhanh hơn nhiều. Có thể hình dung như người phiên dịch: câu lạ thì dịch từng từ, câu nói đi nói lại thì thuộc lòng bản dịch.
Hai trục độc lập: static/dynamic và strong/weak
Hai cặp khái niệm này hay bị trộn vào nhau, nhưng chúng trả lời hai câu hỏi khác nhau:
- Static hay dynamic typing trả lời câu "kiểu được kiểm tra khi nào?". Static: compiler kiểm tra kiểu trước khi chương trình chạy, sai kiểu thì không biên dịch được. Dynamic: biến không có kiểu cố định, kiểu chỉ được kiểm tra lúc chạy, khi dòng code đó thực sự được thực thi.
- Strong hay weak typing trả lời câu "khi hai kiểu không liên quan gặp nhau, ngôn ngữ có tự ép kiểu ngầm để chạy tiếp không?". Strong: không tự ép, báo lỗi (lúc biên dịch hoặc lúc chạy). Weak: tự chuyển đổi ngầm theo luật riêng, chạy tiếp và cho ra một kết quả đôi khi khó đoán.
Vì hai trục độc lập, ta có đủ bốn tổ hợp. Mỗi ô dưới đây kèm một dòng code minh họa. C, Python và JavaScript chỉ được mượn để bạn thấy đủ bốn tổ hợp; câu hỏi phỏng vấn thực tế luôn xoay quanh Java, tức ô đầu tiên:
| Strong (không tự ép kiểu ngầm) | Weak (tự ép kiểu ngầm) | |
|---|---|---|
| Static (kiểm tra lúc biên dịch) | Java: int x = "5"; lỗi biên dịch incompatible types | C: int x = 'A' + 1.5; biên dịch được, x = 66; ép con trỏ (int*) p tùy ý |
| Dynamic (kiểm tra lúc chạy) | Python: "5" + 5 ném TypeError lúc chạy | JavaScript: "5" * 2 ra 10, "5" + 2 ra "52" |
Ở ô C, compiler không phàn nàn khi trộn char, double và int: nó ngầm đổi 'A' thành 65, cộng 1.5 thành 66.5 rồi cắt còn 66. Ở ô Python, chương trình vẫn khởi động bình thường, chỉ khi chạy tới dòng "5" + 5 mới ném lỗi, vì Python không chịu tự đổi số thành chuỗi. Ở ô JavaScript, không có lỗi nào: phép * ép "5" thành số, còn phép + lại ép 2 thành chuỗi.
Java nằm ở ô static và strong, nhưng vẫn có chỗ kiểm tra kiểu lúc chạy: phép ép kiểu tham chiếu. Đoạn code sau biên dịch được, nhưng JVM kiểm tra lại khi chạy và từ chối ép sai:
public class CastCheck {
public static void main(String[] args) {
Object obj = "hello"; // compiler only knows the type is Object
String s = (String) obj; // OK at runtime: the object really is a String
System.out.println(s.length()); // prints: 5
Integer n = (Integer) obj; // compiles, but throws ClassCastException at runtime
}
}Giải thích: chương trình in 5 rồi dừng với ClassCastException: class java.lang.String cannot be cast to class java.lang.Integer. Compiler cho phép ép Object sang Integer vì biết đâu obj thật sự là Integer; JVM kiểm tra object thật lúc chạy và thấy là String nên ném lỗi thay vì âm thầm "đổi" chuỗi thành số. Đó chính là tính strong: Java không bao giờ tự biến "hello" thành một con số. Java chỉ tự nới rộng kiểu giữa các kiểu số có liên quan (int sang long, int sang double), việc này an toàn và không phải weak typing.
Câu hỏi (Cơ bản): JDK, JRE, JVM khác nhau thế nào?
JVM thực thi bytecode, JRE thêm thư viện chuẩn, JDK thêm công cụ phát triển. Ý thực tế: từ Java 11 Oracle không phát hành JRE riêng; Docker image thường dùng JRE của Eclipse Temurin hoặc runtime tạo bằng jlink.
Câu hỏi (Hiểu sâu): Java là ngôn ngữ biên dịch hay thông dịch?
Cả hai: javac biên dịch sang bytecode, JVM thông dịch và JIT biên dịch code "nóng" sang mã máy, nên ứng dụng cần warm-up mới đạt tốc độ cao nhất (Chương 2).
Ghi chú
Một số tài liệu cũ định nghĩa "strong typed là kiểm tra kiểu lúc biên dịch, weak typed là kiểm tra kiểu lúc chạy". Định nghĩa này nhầm hai trục với nhau: kiểm tra lúc nào (biên dịch hay chạy) là static/dynamic typing; còn strong/weak nói về việc ngôn ngữ có tự ép kiểu ngầm giữa các kiểu không liên quan hay không.
- Phản ví dụ: Python kiểm tra kiểu lúc chạy nhưng vẫn là strong (
"5" + 5némTypeError); C kiểm tra lúc biên dịch nhưng là weak (int x = 'A' + 1.5;vẫn biên dịch). - Trả lời đúng: Java là static (compiler bắt
int x = "5";) và strong (không tự đổiStringthành số, ép sai thì némClassCastException).
Bẫy thường gặp
"Có var thì Java thành dynamic typing." Sai: var chỉ là suy luận kiểu lúc biên dịch, biến vẫn có kiểu cố định.
Câu hỏi (Hiểu sâu): Java là static hay dynamic, strong hay weak? ClassCastException xảy ra lúc chạy có làm Java thành dynamic không?
Java là static và strong; hai tính chất này không mâu thuẫn vì chúng thuộc hai trục khác nhau. Static vì mỗi biến, tham số và giá trị trả về có kiểu cố định, compiler kiểm tra trước khi chạy (int x = "5"; không biên dịch được). Strong vì Java không tự đổi giữa các kiểu không liên quan (không có chuyện String tự biến thành số). ClassCastException không biến Java thành dynamic: compiler vẫn bắt phần lớn lỗi kiểu, còn phép ép kiểu tham chiếu là chỗ lập trình viên tự nói "tôi chắc object này là kiểu đó", nên JVM phải kiểm tra thêm lúc chạy để giữ tính strong. Tương tự, var chỉ nhờ compiler suy luận kiểu lúc biên dịch, biến vẫn có kiểu cố định.
Câu hỏi (Đọc code): Dòng nào trong đoạn dưới đây không biên dịch được, dòng nào biên dịch được nhưng lỗi khi chạy?
public class TypingQuiz {
public static void main(String[] args) {
var total = 10; // line A
long big = total; // line B
Object o = total; // line C
String s = (String) o; // line D
// total = "ten"; // line E (uncomment to test)
}
}Đáp án:
Dòng E không biên dịch được; dòng D biên dịch được nhưng ném ClassCastException lúc chạy; A, B, C đều hợp lệ.
- A:
varsuy luậntotallàintlúc biên dịch, kiểu cố định từ đây. - B: nới rộng
intsanglonglà chuyển đổi an toàn giữa hai kiểu số, không phải weak typing. - C:
totalđược autoboxing thànhInteger(xem N1.2) rồi gán vào biến kiểuObject. - D: compiler chấp nhận ép
ObjectsangString, nhưng object thật làIntegernên JVM némClassCastException. - E:
totallàint, gán"ten"bị compiler báoincompatible types: String cannot be converted to int. Đây là bằng chứngvarkhông biến Java thành dynamic typing.
Câu hỏi (Thực chiến): Vì sao ứng dụng Spring Boot vừa khởi động xong thường chậm hơn sau vài phút chạy?
Vì lúc đầu JVM chủ yếu thông dịch bytecode; sau khi đo được method nào "nóng", JIT mới dịch chúng sang mã máy tối ưu. Vài trăm request đầu vì vậy có latency cao hơn (giai đoạn warm-up). Thực tế: load test phải bỏ qua kết quả của giai đoạn warm-up; môi trường cần khởi động cực nhanh (serverless) có thể cân nhắc GraalVM native image hoặc CDS, chi tiết ở Chương 2.
N1.2 Kiểu dữ liệu, wrapper và bẫy số học
Java có 8 kiểu primitive. Giá trị mặc định chỉ áp dụng cho field; biến cục bộ chưa gán mà dùng là lỗi biên dịch.
| Kiểu | Kích thước | Mặc định (field) | Ghi chú |
|---|---|---|---|
byte / short | 8 / 16 bit | 0 | -128..127 / ±32 nghìn |
int | 32 bit | 0 | khoảng ±2,1 tỷ |
long | 64 bit | 0L | id, timestamp |
float / double | 32 / 64 bit | 0.0f / 0.0d | số thực, có sai số |
char | 16 bit | '\u0000' | một đơn vị UTF-16 |
boolean | JVM tự quyết | false | true/false |
Wrapper là gì và "bọc" primitive như thế nào
Primitive là giá trị trần: biến int count = 5; chứa thẳng con số 5, không có method, không thể null. Nếu count là biến cục bộ trong method, con số 5 nằm ngay trong khung (frame) của method đó trên Stack, vùng nhớ riêng của mỗi thread, tự dọn khi method kết thúc. Wrapper là một class bình thường đóng vai "cái hộp" chứa đúng một giá trị primitive. Rút gọn từ mã nguồn JDK, Integer trông như sau:
public final class Integer { // simplified: real class extends Number
private final int value; // the wrapped primitive, never changes
public Integer(int value) { this.value = value; } // deprecated; use Integer.valueOf
public int intValue() { return value; } // unwraps the primitive
// ... parseInt, compare, MAX_VALUE, equals, hashCode ...
}Giải thích: mỗi object Integer chỉ có một field private final int value. final nghĩa là không đổi được giá trị bên trong, nên wrapper là bất biến (immutable): count++ với Integer count thực chất tạo ra (hoặc lấy từ cache) một object Integer khác rồi gán lại biến. Class Integer cũng là final nên không ai kế thừa được.
Object wrapper nằm trên Heap (vùng nhớ dùng chung, nơi mọi object được tạo bằng new sống, do GC dọn). Biến kiểu Integer không chứa số, nó chỉ chứa tham chiếu (có thể hình dung là địa chỉ) trỏ tới object đó:
int a = 5; Integer b = 5; // compiled to Integer.valueOf(5)
STACK (method frame) HEAP
+-----------------+ +--------------------------+
| a | 5 | | Integer object |
+-----------------+ | header (~12 bytes) |
| b | ref ------+-----> | value = 5 |
+-----------------+ +--------------------------+Giải thích: a chứa thẳng 5 (4 byte). b chỉ chứa tham chiếu; con số 5 nằm trong object trên Heap, kèm phần header mà mọi object đều có, nên một Integer tốn khoảng 16 byte cộng thêm tham chiếu. Nếu b = null thì biến không trỏ tới object nào; primitive không có trạng thái này.
| Primitive | Wrapper | Primitive | Wrapper |
|---|---|---|---|
byte | Byte | float | Float |
short | Short | double | Double |
int | Integer | char | Character |
long | Long | boolean | Boolean |
Vì sao Java cần wrapper dù đã có primitive:
- Collection và generic chỉ chứa object.
List<int>là lỗi biên dịch, phải viếtList<Integer>. Generic của Java chỉ nhận kiểu tham chiếu. - Biểu diễn "chưa có giá trị". Cột
discountnullable trong DB map sang fieldInteger discount:nullnghĩa là "chưa nhập", khác hẳn0. Fieldintkhông phân biệt được hai trường hợp này. - Method và hằng tiện ích:
Integer.parseInt("42"),Integer.compare(a, b),Integer.MAX_VALUE,Long.toHexString(...),Character.isDigit(c).
Autoboxing và unboxing: compiler viết hộ bạn
Mỗi primitive có một wrapper (Integer, Long...) là object, có thể null và dùng được trong Collection. Autoboxing là compiler tự gọi Integer.valueOf(), unboxing là tự gọi intValue(). Đoạn code sau cho thấy hai bẫy của cơ chế này:
public class WrapperTrap {
public static void main(String[] args) {
Integer a = 127, b = 127;
Integer c = 128, d = 128;
System.out.println(a == b); // true: same object from the Integer cache
System.out.println(c == d); // false: two different objects
System.out.println(c.equals(d)); // true: compares values
Integer missing = null;
int total = missing; // NullPointerException on unboxing
}
}Giải thích: Integer.valueOf dùng lại object có sẵn cho -128 đến 127 nên a == b true; từ 128 mỗi lần boxing tạo object mới nên == (so địa chỉ) ra false. So sánh wrapper luôn dùng equals(). Dòng cuối thực chất là missing.intValue() nên ném NPE, hay gặp khi gán field Integer nullable của entity vào int.
Để thấy rõ compiler "viết hộ" những gì, so sánh code bạn viết với code tương đương sau khi javac dịch (có thể tự kiểm chứng bằng javap -c, sẽ thấy lệnh gọi Integer.valueOf và Integer.intValue):
import java.util.ArrayList;
import java.util.List;
public class BoxingDesugar {
public static void main(String[] args) {
// What you write:
List<Integer> list = new ArrayList<>();
list.add(7); // autoboxing
int first = list.get(0); // unboxing
Integer counter = 0;
counter++; // unbox, add 1, box again
// What the compiler actually generates:
list.add(Integer.valueOf(7));
int second = list.get(1).intValue();
counter = Integer.valueOf(counter.intValue() + 1);
System.out.println(first + " " + second + " " + counter); // prints: 7 7 2
}
}Giải thích: không có "phép màu" nào ở runtime: autoboxing chỉ là lời gọi Integer.valueOf(x), unboxing là lời gọi x.intValue() được chèn vào lúc biên dịch. Hiểu vậy thì mọi bẫy đều tự giải thích được: valueOf có cache -128..127 nên == lúc đúng lúc sai; intValue() gọi trên null nên ném NPE; counter++ trên Integer tạo object mới mỗi lần.
Chi phí của boxing. Mỗi lần boxing ngoài vùng cache là một object mới trên Heap, GC phải dọn sau đó. Trong vòng lặp lớn, chi phí này thấy rõ:
public class BoxingCost {
public static void main(String[] args) {
Long boxedSum = 0L; // wrapper: each += creates a new Long
for (long i = 0; i < 10_000_000; i++) {
boxedSum += i; // boxedSum = Long.valueOf(boxedSum.longValue() + i)
}
long sum = 0L; // primitive: plain addition, no objects
for (long i = 0; i < 10_000_000; i++) {
sum += i;
}
System.out.println(boxedSum.equals(sum)); // prints: true (same result, very different cost)
}
}Giải thích: hai vòng lặp cho cùng kết quả, nhưng vòng đầu tạo khoảng 10 triệu object Long tạm (gần như toàn bộ nằm ngoài cache), thường chậm hơn vài lần đến cả chục lần so với vòng dùng long (số đo cụ thể tùy máy và JIT). Chỉ đổi một chữ Long thành long là sửa được. Quy tắc: biến tính toán, biến đếm, biến cộng dồn trong vòng lặp dùng primitive; muốn giữ số trong collection lớn mà không boxing thì dùng int[], IntStream, hoặc thư viện collection cho primitive.
Tràn số và sai số dấu phẩy động
Bẫy thứ hai là tràn số và sai số dấu phẩy động:
import java.math.BigDecimal;
public class NumberTrap {
public static void main(String[] args) {
int max = Integer.MAX_VALUE;
System.out.println(max + 1); // -2147483648: overflow, no error raised
System.out.println((long) max + 1); // 2147483648: cast to long before adding
System.out.println((int) 3.99); // 3: narrowing cast drops the fraction
System.out.println(0.1 + 0.2); // 0.30000000000000004
System.out.println(new BigDecimal("0.1").add(new BigDecimal("0.2"))); // 0.3
}
}Giải thích: int tràn thì quay vòng sang số âm, không có exception; cần an toàn thì dùng long hoặc Math.addExact. double lưu ở hệ nhị phân, 0.1 không biểu diễn chính xác nên có sai số. Tiền tệ dùng BigDecimal tạo từ String, khi chia phải chỉ định scale và RoundingMode.
Câu hỏi (Cơ bản): Khi nào dùng primitive, khi nào dùng wrapper?
Primitive cho tính toán và giá trị bắt buộc: nhanh, không null. Wrapper khi cần biểu diễn "chưa có giá trị" (cột DB nullable) hoặc dùng với Collection, generic; id của JPA entity thường là Long.
Câu hỏi (Hiểu sâu): Vì sao không dùng double cho tiền?
double không biểu diễn chính xác đa số số thập phân, sai số tích lũy qua phép tính. Dùng BigDecimal hoặc lưu long theo đơn vị nhỏ nhất.
Bẫy thường gặp
new BigDecimal("2.0").equals(new BigDecimal("2.00")) là false vì khác scale. So sánh giá trị tiền phải dùng compareTo() == 0.
Câu hỏi (Đọc code): Đoạn code dưới đây in ra gì?
import java.util.HashMap;
import java.util.Map;
public class BoxingQuiz {
public static void main(String[] args) {
Long a = 100L, b = 100L;
Long c = 1000L, d = 1000L;
System.out.println(a == b);
System.out.println(c == d);
System.out.println(c.equals(1000)); // careful: argument type
Map<String, Integer> stock = new HashMap<>();
int qty = stock.get("SKU-1");
System.out.println(qty);
}
}Đáp án:
In true, false, false, rồi ném NullPointerException.
a == blà true vìLong.valueOfcũng cache -128..127, hai biến trỏ cùng một object.c == dlà false vì 1000 nằm ngoài cache, hai lần boxing tạo hai object khác nhau.c.equals(1000):1000làintnên được boxing thànhInteger, màLong.equalstrả false khi đối số không phảiLong. Viết đúng làc.equals(1000L)hoặc so sánhc == 1000L(unboxing rồi so số).stock.get("SKU-1")trảnullvì map rỗng; gán vàointsinh ra lời gọiintValue()trênnullnên ném NPE. Sửa bằngstock.getOrDefault("SKU-1", 0).
Câu hỏi (Thực chiến): Field entity nên khai báo int hay Integer? Chọn sai gây lỗi gì?
Cột nullable phải dùng wrapper; cột NOT NULL có thể dùng primitive, còn id thì dùng Long. Nếu cột discount cho phép null mà field là int, Hibernate không gán được null vào primitive và báo lỗi khi load dòng đó (thông báo dạng "Null value was assigned to a property of primitive type"). Ngược lại, dùng Integer cho mọi thứ thì chỗ nào int x = entity.getDiscount(); cũng có nguy cơ NPE khi unboxing. Id dùng Long vì entity mới chưa lưu có id null, đó là cách Spring Data phân biệt entity mới hay cũ.
Câu hỏi (Hiểu sâu): Vì sao List<int> không hợp lệ, và autoboxing thực chất làm gì?
Generic của Java chỉ nhận kiểu tham chiếu (object), nên phải dùng List<Integer>; autoboxing chỉ là compiler chèn Integer.valueOf(x) khi cần object và x.intValue() khi cần primitive. Generic được kiểm tra lúc biên dịch rồi xóa kiểu (type erasure), bên trong ArrayList chỉ có mảng Object[], mà một int không phải Object. Hệ quả: mỗi phần tử là một object riêng trên Heap, tốn bộ nhớ hơn int[] nhiều lần; xử lý số lượng lớn thì cân nhắc int[] hoặc IntStream.
N1.3 static, final, this, super và access modifier
static nghĩa là "thuộc về class, không thuộc từng object": biến static có một bản dùng chung, static method không có this, static block chạy một lần khi nạp class, static nested class tạo được mà không cần object bao ngoài (khác inner class).
Static method không override được; khai báo trùng ở class con là method hiding:
class Parent {
static String who() { return "Parent.static"; }
String name() { return "Parent.instance"; }
}
class Child extends Parent {
static String who() { return "Child.static"; } // hiding, not overriding
@Override String name() { return "Child.instance"; } // real override
}
public class HidingDemo {
public static void main(String[] args) {
Parent p = new Child();
System.out.println(p.who()); // Parent.static: chosen by declared type
System.out.println(p.name()); // Child.instance: chosen by actual object
}
}Giải thích: static method được chọn lúc biên dịch theo kiểu biến (Parent), instance method được chọn lúc chạy theo object thật (Child). Thêm @Override vào Child.who() sẽ lỗi biên dịch.
final: biến không gán lại được, method không override được, class không kế thừa được (String). final khóa tham chiếu, không khóa object: final List<String> tags vẫn add() được. this trỏ tới object hiện tại, super tới phần của class cha; tính tới Java 21, this(...)/super(...) phải là lệnh đầu tiên trong constructor.
| Modifier | Cùng package | Class con khác package | Mọi nơi |
|---|---|---|---|
public | Có | Có | Có |
protected | Có | Có (qua kế thừa) | Không |
| default | Có | Không | Không |
private | Không | Không | Không |
Phần static chạy khi nào: nạp và khởi tạo class
Câu "static block chạy một lần khi nạp class" ở trên đúng nhưng chưa đủ để đọc code phỏng vấn. Cần tách hai bước: JVM nạp (load) file .class vào bộ nhớ, rồi khởi tạo (initialize) class, tức là chạy các lệnh gán static field và các static block. Mọi thứ in ra màn hình từ phần static đều xảy ra ở bước khởi tạo. JVM khởi tạo class một cách "lười": chỉ làm khi class được dùng chủ động lần đầu tiên, cụ thể là khi:
- tạo object bằng
new Foo(); - gọi static method
Foo.bar(); - đọc hoặc ghi một static field không phải hằng compile-time;
- một class con được khởi tạo: class cha luôn được khởi tạo trước;
- gọi reflection như
Class.forName("com.example.Foo"); - class chứa
mainđược khởi tạo trước khimainbắt đầu chạy.
Các trường hợp sau không kích hoạt khởi tạo:
- đọc hằng compile-time: field
static finalkiểu primitive hoặcString, gán bằng giá trị biết ngay lúc biên dịch, ví dụstatic final int MAX = 10;. Compiler chép thẳng giá trị 10 vào chỗ dùng, nên lúc chạy không cần chạm tới classFoo; - khai báo biến kiểu class (
Foo f = null;) hoặc tạo mảng (new Foo[3]chỉ tạo mảng chứa 3 ônull, chưa có objectFoonào).
Hai quy tắc còn lại: khởi tạo xảy ra đúng một lần cho mỗi class (JVM tự đảm bảo, kể cả khi nhiều thread cùng dùng class lần đầu), và static field initializer cùng static block chạy theo thứ tự xuất hiện trong code, từ trên xuống. Đoạn code sau in ra từng bước để bạn đối chiếu:
class Config {
static final int MAX = 10; // compile-time constant: inlined by javac
static int counter = log("static field counter"); // runs 1st during initialization
static { log("static block"); } // runs 2nd (textual order)
static int log(String msg) {
System.out.println("Config: " + msg);
return 0;
}
static void hello() { System.out.println("Config.hello()"); }
}
public class StaticInitDemo {
static { System.out.println("StaticInitDemo static block"); } // before main
public static void main(String[] args) {
System.out.println("main starts");
System.out.println(Config.MAX); // prints: 10, Config is NOT initialized
Config[] arr = new Config[3]; // array only: no initialization
Config ref = null; // variable only: no initialization
Config.hello(); // first active use: initialize Config, then call
Config.hello(); // already initialized: nothing extra printed
}
}Giải thích: output lần lượt là:
StaticInitDemo static block
main starts
10
Config: static field counter
Config: static block
Config.hello()
Config.hello()- Dòng đầu tiên in trước
main startsvì class chứamainphải được khởi tạo trước khi JVM gọimain. Config.MAXin 10 mà không kèm dòng "Config: ..." nào:MAXlà hằng compile-time,javacđã thayConfig.MAXbằng số 10 ngay trong bytecode củaStaticInitDemo.new Config[3]vàConfig ref = nullcũng không in gì.- Lời gọi
Config.hello()đầu tiên kích hoạt khởi tạo:counterđược gán (in "static field counter") rồi mới tới static block, đúng thứ tự viết trong code. Lời gọi thứ hai không in lại, vì mỗi class chỉ khởi tạo một lần.
Nếu đổi MAX thành static final int MAX = computeMax(); thì giá trị chỉ biết lúc chạy, không còn là hằng compile-time, và đọc Config.MAX sẽ kích hoạt khởi tạo như bình thường. Phần static này là bước đầu tiên; thứ tự đầy đủ khi tạo object (instance block, constructor, cha trước con) nằm ở N1.4.
Câu hỏi (Cơ bản): final, finally và finalize khác nhau thế nào?
final khóa biến/method/class; finally là khối dọn dẹp luôn chạy sau try/catch; finalize() từng được GC gọi trước khi thu hồi object, không đảm bảo thời điểm hay có chạy, deprecated từ Java 9 và for removal từ Java 18. Giải phóng tài nguyên dùng try-with-resources.
Câu hỏi (Cơ bản): Từ static method gọi instance method được không?
Không gọi trực tiếp được vì không có this; phải qua một object cụ thể. Chiều ngược lại thì thoải mái.
Bẫy thường gặp
Nghĩ protected chỉ dành cho class con. Thực tế nó mở cho mọi class cùng package, rộng hơn default.
Câu hỏi (Đọc code): Đoạn code dưới đây in ra gì?
class Settings {
static final String NAME = "app"; // compile-time constant
static final long STARTED = System.nanoTime(); // not a compile-time constant
static { System.out.println("Settings initialized"); }
}
public class StaticQuiz {
public static void main(String[] args) {
System.out.println(Settings.NAME);
System.out.println("---");
System.out.println(Settings.STARTED > 0);
System.out.println(Settings.STARTED > 0);
}
}Đáp án:
In app, ---, Settings initialized, true, true.
NAMElàstatic final Stringgán bằng literal nên là hằng compile-time; compiler chép"app"vàoStaticQuiz, đọc nó không khởi tạoSettings.STARTEDgán bằngSystem.nanoTime(), giá trị chỉ biết lúc chạy, nên lần đọc đầu tiên kích hoạt khởi tạoSettingsvà static block in "Settings initialized" trước khi intrue.- Lần đọc thứ hai không in lại vì class chỉ khởi tạo một lần.
Câu hỏi (Hiểu sâu): final List<String> tags = new ArrayList<>(); có làm list bất biến không?
Không: final chỉ khóa biến (không trỏ sang object khác được), không khóa object mà nó trỏ tới. tags.add("java") vẫn chạy; chỉ tags = new ArrayList<>(); mới bị compiler báo lỗi cannot assign a value to final variable tags. Muốn nội dung cũng không đổi được thì dùng List.of(...) hoặc List.copyOf(...), lúc đó add() ném UnsupportedOperationException.
Câu hỏi (Thực chiến): Đặt đoạn nạp cấu hình nặng (đọc file, gọi mạng) trong static block có vấn đề gì?
Static block chạy ngầm ở lần dùng class đầu tiên, khó kiểm soát thời điểm và khó xử lý lỗi. Nếu static block ném exception, JVM báo ExceptionInInitializerError, class bị đánh dấu khởi tạo thất bại và mọi lần dùng sau ném NoClassDefFoundError, kiểu lỗi rất khó đọc trên production. Code static còn khó test vì không thay thế (mock) được. Trong Spring, nạp cấu hình qua Bean (@ConfigurationProperties, @PostConstruct) để container quản lý thứ tự và lỗi rõ ràng.
N1.4 Class, object, constructor và thứ tự khởi tạo
Nhắc lại kiến thức
Phần static của class (static field, static block) chạy đúng một lần khi class được khởi tạo, ở lần dùng chủ động đầu tiên, class cha trước class con (xem N1.3). Mục này nối tiếp: sau phần static là phần của từng object, chạy lại mỗi lần new.
Class là bản thiết kế, object là thực thể tạo bằng new trên Heap. Class không khai báo constructor nào thì compiler thêm default constructor rỗng. Constructor overload được và gọi nhau (chaining): Product(String name) gọi this(name, 0L) để logic kiểm tra chỉ nằm ở constructor đầy đủ.
Thứ tự khởi tạo khi có kế thừa rất hay bị hỏi. Các dòng in dưới đây đánh số theo thứ tự thực tế:
class Base {
static { System.out.println("1. Base static block"); }
{ System.out.println("3. Base instance block"); }
Base() { System.out.println("4. Base constructor"); }
}
class Derived extends Base {
static { System.out.println("2. Derived static block"); }
{ System.out.println("5. Derived instance block"); }
Derived() {
super(); // inserted by the compiler if omitted
System.out.println("6. Derived constructor");
}
}
public class InitOrder {
public static void main(String[] args) {
new Derived(); // prints 1 to 6
new Derived(); // prints only 3 to 6
}
}Giải thích: static block chạy một lần khi class được nạp, cha trước con, nên lần new thứ hai không in lại 1 và 2. Mỗi lần new, constructor con gọi super() đầu tiên, nên toàn bộ phần của cha (field initializer, instance block, constructor) xong rồi mới tới phần của con.
Class cha chỉ có constructor có tham số
Constructor là khối code chạy khi tạo object, dùng để đưa object về trạng thái hợp lệ ban đầu. Hai quy tắc của compiler dẫn tới lỗi rất hay gặp:
- Nếu constructor của class con không tự gọi
this(...)haysuper(...)ở dòng đầu, compiler tự chènsuper();, tức là gọi constructor không tham số của class cha. - Compiler chỉ tạo default constructor khi class không khai báo constructor nào. Bạn viết một constructor có tham số thì compiler không tạo constructor rỗng nữa.
Ghép hai quy tắc lại: class cha chỉ có Animal(String name) thì lời gọi super() do compiler chèn không tìm được constructor phù hợp. Code sai:
class Animal {
private final String name;
Animal(String name) { this.name = name; } // only constructor: no default one is generated
}
class Dog extends Animal {
Dog() { } // compiler inserts super(); here
}javac báo nguyên văn:
Dog.java:7: error: constructor Animal in class Animal cannot be applied to given types;
Dog() { } // compiler inserts super(); here
^
required: String
found: no arguments
reason: actual and formal argument lists differ in length
1 errorGiải thích: "cannot be applied to given types" nghĩa là có constructor Animal, nhưng danh sách đối số không khớp: nó cần một String (required: String), còn lời gọi super() ngầm không truyền gì (found: no arguments). Lỗi trỏ vào constructor Dog() chứ không phải dòng nào bạn viết, vì dòng gây lỗi là dòng compiler chèn vào. Nếu Dog không viết constructor nào, compiler tạo Dog() { super(); } và báo lỗi tương tự ở dòng khai báo class. Bản sửa gọi super(name) tường minh:
class Animal {
private final String name;
Animal(String name) { this.name = name; }
String name() { return name; }
}
class Dog extends Animal {
Dog(String name) {
super(name); // explicit call: matches Animal(String)
}
}
public class SuperFix {
public static void main(String[] args) {
System.out.println(new Dog("Milu").name()); // prints: Milu
}
}Giải thích: Dog(String name) chuyển tên lên cho Animal, phần của cha được khởi tạo đầy đủ trước rồi mới tới con. Cách sửa thứ hai là thêm constructor rỗng vào Animal, nhưng chỉ làm vậy khi một con vật "không tên" là hợp lệ về nghiệp vụ; nếu không, bắt buộc truyền tên qua super(name) giúp giữ name luôn có giá trị.
Ví dụ thực tế: JPA entity bắt buộc có constructor không tham số
Khi đọc dữ liệu từ DB, Hibernate (cài đặt JPA mà Spring Boot dùng) phải tự tạo object entity mà không biết constructor nghiệp vụ của bạn cần tham số gì. Nó làm theo hai bước bằng reflection (API cho phép code thao tác với class, constructor, field lúc chạy mà không viết tên cụ thể trong code): gọi constructor không tham số để có object rỗng, rồi gán giá trị từng cột vào field. Ngoài ra, để lazy loading, Hibernate tạo proxy: một class con sinh ra lúc chạy, kế thừa entity của bạn và chỉ truy vấn DB khi có method được gọi. Vì vậy entity:
- phải có constructor không tham số,
publichoặcprotected(dùngprotectedđể code nghiệp vụ không tạo được entity rỗng, còn Hibernate và proxy ở class con vẫn gọi được); - không nên là
final(và các method không nênfinal), nếu không Hibernate không tạo được proxy để lazy load.
import jakarta.persistence.Entity;
import jakarta.persistence.GeneratedValue;
import jakarta.persistence.Id;
@Entity
public class Customer {
@Id @GeneratedValue
private Long id;
private String email;
protected Customer() { } // for Hibernate only (reflection, proxies)
public Customer(String email) { // for business code: email is mandatory
this.email = email;
}
public Long getId() { return id; }
public String getEmail() { return email; }
}Giải thích: code nghiệp vụ chỉ gọi được new Customer("a@b.com"), nên không có khách hàng thiếu email; Hibernate dùng protected Customer() khi load từ DB. Nếu xóa constructor rỗng (chỉ giữ constructor có tham số), compiler không tạo default constructor nữa (quy tắc ở trên), và Hibernate báo lỗi khi cần tạo entity (hoặc ngay lúc khởi động, tùy phiên bản), thông báo dạng org.hibernate.InstantiationException: No default constructor for entity ...; câu chữ cụ thể khác nhau giữa các phiên bản Hibernate 6.x. Lỗi này chỉ xuất hiện lúc chạy, không phải lúc biên dịch, vì Hibernate gọi constructor qua reflection.
Với Lombok, cách viết tương đương thường gặp trong dự án:
import jakarta.persistence.Entity;
import jakarta.persistence.Id;
import lombok.AccessLevel;
import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Getter;
import lombok.NoArgsConstructor;
@Entity
@Getter
@NoArgsConstructor(access = AccessLevel.PROTECTED) // no-arg constructor for Hibernate only
@AllArgsConstructor // required by @Builder
@Builder
public class Product {
@Id
private Long id;
private String name;
}Giải thích: @NoArgsConstructor(access = AccessLevel.PROTECTED) sinh protected Product() {} cho Hibernate; @Builder cần một constructor nhận đủ field, nên đi kèm @AllArgsConstructor (@Builder chỉ tự sinh constructor đầy đủ, package-private, khi class không có constructor tường minh nào và không có annotation @XArgsConstructor nào; ở đây đã có @NoArgsConstructor nên nó không sinh nữa và code builder sinh ra sẽ lỗi biên dịch). Code nghiệp vụ tạo object bằng Product.builder().name("Bút").build().
Vì sao record không dùng làm entity được: record (Java 16+) có mọi field final, chỉ có constructor đầy đủ tham số (không có constructor rỗng), và record luôn là class final. Hibernate không tạo được object rỗng rồi gán field, cũng không tạo được proxy kế thừa nó. Record phù hợp làm DTO, projection kết quả truy vấn, hoặc @Embeddable (Hibernate 6.2+ hỗ trợ), không phải entity.
Câu hỏi (Cơ bản): Khi nào compiler tự tạo default constructor?
Chỉ khi class không khai báo constructor nào. Hệ quả: class cha chỉ có constructor có tham số thì class con phải gọi super(...) tường minh; JPA entity thiếu constructor rỗng sẽ lỗi khi Hibernate tạo object.
Câu hỏi (Cơ bản): Constructor có kế thừa được không? Constructor private dùng làm gì?
Không, class con chỉ gọi constructor cha qua super(...). Constructor private dùng cho utility class, Singleton, hoặc buộc tạo object qua static factory.
Câu hỏi (Đọc code): Đoạn code dưới đây in ra gì?
class Parent {
static { System.out.println("A"); }
{ System.out.println("B"); }
Parent() { System.out.println("C"); }
}
class Kid extends Parent {
static { System.out.println("D"); }
private String tag = log("E");
Kid() { System.out.println("F"); }
static String log(String s) { System.out.println(s); return s; }
}
public class InitQuiz {
public static void main(String[] args) {
new Kid();
System.out.println("--");
new Kid();
}
}Đáp án:
In A D B C E F -- B C E F (mỗi chữ một dòng).
- Lần
new Kid()đầu tiên kích hoạt khởi tạo class: cha trước con nên static block củaParent(A) rồi củaKid(D). - Tạo object: constructor
Kid()gọi ngầmsuper()trước, nên phần của cha chạy xong (instance block B, constructor C). - Sau đó mới tới field initializer của con (
tag = log("E")) rồi thân constructorKid(F). - Lần
newthứ hai không in lại A, D vì phần static chỉ chạy một lần (N1.3).
Câu hỏi (Đọc code): Tìm lỗi. Entity dưới đây chạy unit test thuần thì ổn, nhưng ứng dụng lỗi khi load từ DB. Sai ở đâu?
import jakarta.persistence.Entity;
import jakarta.persistence.Id;
@Entity
public final class Invoice {
@Id
private Long id;
private final String code;
public Invoice(String code) { this.code = code; }
}Đáp án:
Có ba lỗi: thiếu constructor không tham số, class final, và field final. Hibernate cần gọi constructor rỗng qua reflection rồi gán giá trị cột vào field; vì đã có Invoice(String code) nên compiler không tạo constructor rỗng, Hibernate báo InstantiationException lúc chạy. final class làm Hibernate không tạo được proxy cho lazy loading. Field final về nguyên tắc không được gán lại sau constructor, trái với cách Hibernate nạp dữ liệu; đặc tả JPA yêu cầu class entity, method và field persistent không được final. Sửa: bỏ final ở class và field, thêm protected Invoice() {}, giữ constructor có tham số cho code nghiệp vụ. Unit test không phát hiện vì nó gọi thẳng new Invoice("X").
Câu hỏi (Hiểu sâu): Vì sao không nên gọi method có thể bị override từ constructor của class cha?
Vì lúc constructor cha chạy, phần của class con chưa được khởi tạo, nên method override ở con sẽ thấy field của con còn giá trị mặc định (null, 0). Theo thứ tự ở trên, field initializer và constructor của con chỉ chạy sau khi super() xong. Nếu Base() gọi describe() và Derived.describe() đọc field name của Derived, kết quả là null hoặc NPE. Quy tắc: constructor chỉ gọi method private, static hoặc final.
N1.5 Bốn tính chất của OOP
Nhắc lại kiến thức
private chỉ class đó truy cập được, protected mở cho class con và cùng package (xem bảng access modifier ở N1.3). Constructor con luôn gọi constructor cha trước bằng super(...), tường minh hoặc do compiler chèn (xem N1.4).
OOP (lập trình hướng đối tượng) tổ chức chương trình thành các object, mỗi object gói cả dữ liệu (field) lẫn hành vi (method) thao tác trên dữ liệu đó. Bốn tính chất dưới đây là bốn "công cụ" để các object vừa tự bảo vệ, vừa tái sử dụng được, vừa thay thế được cho nhau.
Đóng gói (Encapsulation): giấu dữ liệu bằng private, chỉ cho thay đổi qua method có kiểm tra.
public class BankAccount {
private long balance; // cannot be modified directly from outside
public long getBalance() { return balance; }
public void withdraw(long amount) {
if (amount <= 0 || amount > balance) { // keep the invariant: balance never negative
throw new IllegalArgumentException("Số tiền rút không hợp lệ");
}
balance -= amount;
}
// Intentionally no setBalance()
}Giải thích: số dư không thể âm vì mọi thay đổi phải qua withdraw có kiểm tra. Sinh setter cho mọi field thì phá đóng gói y như để field public.
Chữ bất biến (invariant) ở comment trong code nghĩa là điều kiện luôn phải đúng với object trong suốt vòng đời, ở đây là "số dư không bao giờ âm". Đóng gói tồn tại để giữ các bất biến như vậy: nếu balance là public, bất kỳ dòng code nào ở bất kỳ đâu cũng có thể gán account.balance = -500 và bạn không còn một chỗ duy nhất để kiểm tra.
Kế thừa (Inheritance): class con extends class cha, quan hệ "is-a". Java chỉ cho kế thừa một class (tránh diamond problem), mọi class ngầm kế thừa Object.
Diamond problem (vấn đề kim cương) là tình huống class D kế thừa cả B và C, mà B, C cùng kế thừa A và cùng override một method của A theo hai cách khác nhau. Gọi method đó trên D thì không rõ nên chạy bản của B hay của C (sơ đồ bốn class này có hình quả trám, nên có tên như vậy). Java tránh hẳn vấn đề này bằng cách chỉ cho extends một class. Với default method của interface, Java bắt class tự giải quyết xung đột (xem N1.7).
class Employee {
protected final long baseSalary;
Employee(long baseSalary) { this.baseSalary = baseSalary; }
long salary() { return baseSalary; }
}
class Manager extends Employee { // Manager is-a Employee
private final long allowance;
Manager(long baseSalary, long allowance) {
super(baseSalary); // initialize the parent part first
this.allowance = allowance;
}
@Override
long salary() { return baseSalary + allowance; } // extends the parent's behavior
}Giải thích: Manager dùng lại baseSalary, chỉ thêm phụ cấp và override cách tính lương. super(...) bắt buộc vì Employee không có constructor rỗng.
Đa hình (Polymorphism): một tham chiếu kiểu cha, method của object thật được gọi. Dùng lại hai class trên:
import java.util.List;
public class PayrollDemo {
public static void main(String[] args) {
List<Employee> staff = List.of(new Employee(10), new Manager(20, 5));
long total = 0;
for (Employee e : staff) {
total += e.salary(); // Manager.salary() runs for a Manager object
}
System.out.println(total); // 35
}
}Giải thích: vòng lặp chỉ biết kiểu Employee, nhưng JVM gọi đúng Manager.salary() (25), tổng ra 35. Thêm loại nhân viên mới chỉ cần thêm class con, không sửa vòng lặp.
Trừu tượng (Abstraction): chỉ lộ "làm gì", giấu "làm thế nào", qua interface hoặc abstract class.
interface Notifier {
void send(String to, String message); // contract: says only "what"
}
class EmailNotifier implements Notifier {
public void send(String to, String message) {
System.out.println("Email tới " + to + ": " + message);
}
}
class OrderService {
private final Notifier notifier; // depends on the abstraction
OrderService(Notifier notifier) { this.notifier = notifier; }
void placeOrder(String customer) {
notifier.send(customer, "Đặt hàng thành công");
}
}Giải thích: OrderService không biết thông báo đi bằng email hay SMS; đổi kênh hoặc test chỉ cần truyền implementation khác vào constructor, đúng cách Spring inject dependency.
Câu hỏi (Hiểu sâu): Đóng gói có phải chỉ là field private kèm getter/setter?
Không. Mục tiêu là object tự bảo vệ tính hợp lệ của trạng thái; getter/setter cho mọi field chỉ tạo ra "túi dữ liệu".
Câu hỏi (Cơ bản): Đa hình có mấy loại?
Hai: compile-time (overloading, chọn theo kiểu khai báo) và runtime (overriding, chọn theo object thật). Nói "đa hình" trong OOP thường ngụ ý loại runtime.
Câu hỏi (Đọc code): Đoạn code dưới đây in ra gì?
class Animal {
String sound = "...";
String speak() { return "..."; }
}
class Cat extends Animal {
String sound = "meo"; // hides the parent field
@Override String speak() { return "meo"; } // overrides the parent method
}
public class PolyQuiz {
public static void main(String[] args) {
Animal a = new Cat();
System.out.println(a.sound + " " + a.speak());
}
}Đáp án:
In ... meo. Đa hình runtime chỉ áp dụng cho instance method: a.speak() được chọn theo object thật (Cat). Field thì không override được, chỉ bị che (hiding), và được chọn theo kiểu khai báo của biến (Animal), giống static method ở N1.3. Đây cũng là một lý do để field luôn private và chỉ truy cập qua method.
Câu hỏi (Thực chiến): Vì sao trong Spring ta inject interface (PaymentGateway) thay vì class cụ thể (VnpayGateway)?
Để code phụ thuộc vào abstraction, đổi hoặc giả lập implementation mà không sửa code dùng nó. Service chỉ gọi method trong interface, nên đổi cổng thanh toán, thêm implementation mới, hay truyền một bản mock trong unit test đều không cần sửa service. Đây là trừu tượng cộng đa hình trong thực tế: Spring chọn Bean cụ thể lúc chạy, code của bạn chỉ biết hợp đồng. Khi có nhiều implementation cùng interface, Spring cần @Primary hoặc @Qualifier để biết inject cái nào.
N1.6 Overloading và overriding
Nhắc lại kiến thức
Static method không override được mà chỉ bị che (method hiding), được chọn theo kiểu khai báo (xem N1.3). Đa hình runtime: biến kiểu cha, method của object thật được gọi (xem N1.5).
Chữ ký method (method signature) gồm tên method và danh sách kiểu tham số theo thứ tự; return type và tên tham số không thuộc chữ ký. Vì vậy hai method chỉ khác return type bị coi là trùng và gây lỗi biên dịch.
Overloading: cùng tên method, khác danh sách tham số. Overriding: class con định nghĩa lại method instance của cha với cùng chữ ký.
| Tiêu chí | Overloading | Overriding |
|---|---|---|
| Tham số | Phải khác (số lượng, kiểu, thứ tự) | Phải giống hệt |
| Return type | Tùy ý, nhưng chỉ khác return type là lỗi | Giống hoặc kiểu con (covariant) |
| Access modifier | Tùy ý | Không được hẹp hơn |
| Checked exception | Tùy ý | Không được rộng hơn hoặc thêm mới |
| Thời điểm chọn | Biên dịch (static binding) | Chạy (dynamic binding) |
Static binding nghĩa là overload được chọn theo kiểu khai báo: có print(Object) và print(String), lời gọi Object o = "hi"; print(o); chạy bản Object. Đoạn code sau minh họa các quy tắc override:
Chú thích hai thuật ngữ dùng trong bảng và đoạn code dưới: binding là việc quyết định một lời gọi method sẽ chạy đoạn code nào; static binding quyết định lúc biên dịch, dynamic binding quyết định lúc chạy. Covariant return type nghĩa là method override được phép trả về kiểu con của kiểu cha đã khai báo (cha trả Object, con trả String), vì ai nhận Object thì nhận String vẫn an toàn.
import java.io.FileNotFoundException;
import java.io.IOException;
class Report {
protected Object export() throws IOException { return "raw"; }
}
class PdfReport extends Report {
@Override
public String export() throws FileNotFoundException { // public is wider than protected
return "pdf"; // String: a subtype of Object
} // narrower exception than IOException
}
// Wrong: Object export() throws Exception (narrower access, wider exception)Giải thích: PdfReport hợp lệ theo cả ba quy tắc. Lý do là tính thay thế: code gọi qua kiểu Report chỉ chuẩn bị xử lý IOException và mong gọi được method, class con không được phá kỳ vọng đó. Unchecked exception thì ném tùy ý.
Câu hỏi (Hiểu sâu): Có override được static, private, final method không?
Không. Static chỉ bị hiding (N1.3), private không nhìn thấy từ class con nên viết trùng tên là method mới, final bị compiler cấm.
Câu hỏi (Thực chiến): Vì sao luôn nên dùng @Override?
Để compiler kiểm tra giúp. Viết nhầm equals(Employee o) là overload, HashSet vẫn dùng equals của Object và bug rất khó thấy; có @Override thì lỗi bị chặn khi biên dịch.
Bẫy thường gặp
"Overload được main không?" Được, nhưng JVM chỉ gọi đúng public static void main(String[] args) làm điểm vào.
Câu hỏi (Đọc code): Đoạn code dưới đây in ra gì?
public class OverloadQuiz {
static void print(Object o) { System.out.println("Object"); }
static void print(String s) { System.out.println("String"); }
static void print(long x) { System.out.println("long"); }
static void print(Integer x) { System.out.println("Integer"); }
public static void main(String[] args) {
Object o = "hi";
print(o);
print("hi");
print(5);
}
}Đáp án:
In Object, String, long.
print(o): overload được chọn lúc biên dịch theo kiểu khai báoObject, dù object thật làString.print("hi"): kiểu khai báo làString, bản cụ thể nhất khớp chính xác.print(5):5làint, không cóprint(int). Compiler ưu tiên nới rộng kiểu primitive (intsanglong) trước khi xét autoboxing (intsangInteger, xem N1.2), nên chọnprint(long). Thứ tự ưu tiên này tồn tại để code viết trước Java 5 (chưa có autoboxing) không đổi hành vi.
Câu hỏi (Đọc code): Tìm lỗi. Class con dưới đây có hai lỗi biên dịch. Chỉ ra và giải thích.
import java.io.IOException;
class Exporter {
public String export() throws IOException { return "raw"; }
}
class CsvExporter extends Exporter {
@Override
protected String export() throws Exception { return "csv"; }
}Đáp án:
Lỗi 1: thu hẹp access từ public xuống protected; lỗi 2: khai báo checked exception rộng hơn (Exception rộng hơn IOException). javac báo lần lượt từng lỗi: trước tiên là export() in CsvExporter cannot override export() in Exporter kèm dòng attempting to assign weaker access privileges; was public; sửa access thành public rồi biên dịch lại thì gặp tiếp lỗi cùng tiêu đề kèm dòng overridden method does not throw Exception. Lý do chung là tính thay thế: code đang gọi exporter.export() qua kiểu Exporter được hứa là method public và chỉ phải xử lý IOException; class con không được phá lời hứa đó. Sửa: giữ public và chỉ ném IOException, một kiểu con của nó, hoặc không ném gì.
N1.7 Abstract class, interface và composition
Nhắc lại kiến thức
Trừu tượng là chỉ lộ "làm gì", giấu "làm thế nào"; OrderService phụ thuộc interface Notifier chứ không phụ thuộc EmailNotifier (xem N1.5). Class con gọi constructor cha qua super(...) (xem N1.4).
Nói bằng lời thường: abstract class là một class "làm dở", có sẵn phần chung nhưng để trống vài method cho class con điền vào, nên không tạo object trực tiếp được. Interface là một bản cam kết: "class nào implement tôi thì phải có các method này", không quan tâm class đó thuộc họ nào.
Abstract class không new được, có thể có field, constructor, method thường và method abstract. Interface là hợp đồng hành vi: từ Java 8 có default/static method, Java 9 có private method, nhưng không có field instance.
abstract class Shape { // has state and a constructor
private final String name;
protected Shape(String name) { this.name = name; }
abstract double area(); // subclasses must implement
String describe() { return name + ": " + Math.round(area()); }
}
interface Printable { // a "capability"
void print();
default void printTwice() { print(); print(); } // Java 8+: method with a body
}
class Circle extends Shape implements Printable { // one superclass, many interfaces
private final double r;
Circle(double r) { super("Hình tròn"); this.r = r; }
double area() { return Math.PI * r * r; }
public void print() { System.out.println(describe()); }
}Giải thích: new Circle(2).printTwice() in hai lần "Hình tròn: 13". Shape giữ state và khung chung cho một họ class; Printable là khả năng class nào cũng gắn thêm được. Đó cũng là quy tắc chọn giữa hai loại.
Default method (Java 8+) ra đời để thêm method mới vào interface mà không làm hỏng các class đã implement nó: trước Java 8, thêm stream() vào Collection sẽ bắt mọi class collection trên thế giới phải sửa. Nhưng implement hai interface có default method trùng tên thì Java không tự chọn (đây là dạng diamond problem ở N1.5). Code sai:
interface Greeter { default String hi() { return "Greeter"; } }
interface Welcomer { default String hi() { return "Welcomer"; } }
class Bot implements Greeter, Welcomer { } // does not compilejavac báo:
error: types Greeter and Welcomer are incompatible;
class Bot inherits unrelated defaults for hi() from types Greeter and WelcomerBản sửa: class phải tự override và có thể chọn gọi bản của interface nào bằng cú pháp TênInterface.super.method():
interface Greeter { default String hi() { return "Greeter"; } }
interface Welcomer { default String hi() { return "Welcomer"; } }
public class Bot implements Greeter, Welcomer {
@Override
public String hi() { return Greeter.super.hi() + "+" + Welcomer.super.hi(); } // resolve explicitly
public static void main(String[] args) {
System.out.println(new Bot().hi()); // prints: Greeter+Welcomer
}
}Giải thích: bản sai bị chặn ngay lúc biên dịch vì compiler không đoán thay bạn. Bản sửa override hi() và gọi rõ ràng cả hai bản default; có thể chỉ chọn một. Đây là cách Java cho "đa kế thừa hành vi" qua interface mà vẫn không rơi vào diamond problem mơ hồ.
Composition (has-a) là class chứa object khác và ủy quyền việc cho nó, như OrderService chứa Notifier ở N1.5; inheritance (is-a) là class con là một dạng của cha. Car nên có Engine chứ không extends Engine, vì xe không "là một" động cơ. Vì sao nên ưu tiên composition: Chương 1.
Câu hỏi (Cơ bản): Abstract class có constructor không?
Có. Nó được gọi qua super(...) từ class con để khởi tạo trạng thái chung, như Shape(String name) ở trên.
Câu hỏi (Hiểu sâu): Sau Java 8, interface còn khác abstract class ở đâu?
Ở trạng thái (interface không có field instance, không constructor) và đa kế thừa (implement nhiều interface, chỉ extends một class).
Bẫy thường gặp
Trả lời "interface chỉ chứa method abstract". Câu này sai từ Java 8; người 2–4 năm nói vậy sẽ bị đánh giá là không cập nhật.
Câu hỏi (Thực chiến): Trong dự án Spring, khi nào bạn thật sự dùng abstract class?
Khi nhiều class chia sẻ cùng một khung xử lý và state, chỉ khác vài bước: mẫu Template Method. Ví dụ AbstractImporter có method importFile() cố định các bước đọc file, validate, lưu DB, ghi log, còn parseRow() để abstract cho CsvImporter, ExcelImporter tự cài. Với "khả năng" có thể gắn cho nhiều class không liên quan (gửi thông báo, tính phí) thì dùng interface để Spring inject và dễ mock. Nếu chỉ muốn tái dùng vài method tiện ích, composition (chứa một object helper) thường tốt hơn kế thừa.
N1.8 String cơ bản
Nhắc lại kiến thức
Biến kiểu object chỉ giữ tham chiếu tới object trên Heap; == giữa hai biến tham chiếu so sánh "có trỏ cùng một object không", giống bẫy Integer 128 ở N1.2.
String pool là một vùng trong Heap nơi JVM giữ đúng một object cho mỗi chuỗi literal (chuỗi viết thẳng trong code như "java"). Mọi chỗ trong chương trình viết "java" đều nhận chung object đó, tiết kiệm bộ nhớ. Việc dùng chung này chỉ an toàn vì String bất biến (immutable): không method nào sửa được nội dung object, nên không ai làm "hỏng" chuỗi của người khác.
String bất biến: mọi method "sửa" chuỗi đều trả về String mới; literal nằm trong String pool (thuộc Heap). Method hay dùng: length, charAt, substring(begin, end) (không lấy end), indexOf, contains, replace, split, String.join, equalsIgnoreCase, strip/isBlank (Java 11+). Đoạn code sau phân biệt == với equals():
public class StringCompare {
public static void main(String[] args) {
String a = "java";
String b = "java"; // literal: reuses the pooled object
String c = new String("java"); // new: always creates a new object
System.out.println(a == b); // true: same object
System.out.println(a == c); // false: different objects
System.out.println(a.equals(c)); // true: same content
String s = "hello";
s.toUpperCase(); // creates a new String and discards it
System.out.println(s); // hello
}
}Giải thích: == so tham chiếu, equals() so nội dung. a == b đúng chỉ vì cả hai là literal trong pool; chuỗi từ request hay DB là object mới nên == sai. s không đổi vì String bất biến, phải gán lại s = s.toUpperCase().
Quy tắc "so sánh String phải dùng equals()" dễ bị quên vì test với literal thì == vẫn chạy đúng. Đoạn code sau mô phỏng chuỗi nhận từ request (tạo lúc chạy, không nằm trong pool):
public class StringEqualsBug {
public static void main(String[] args) {
String status = new String("ACTIVE"); // simulates a value read from a request
if (status == "ACTIVE") { // wrong: compares references
System.out.println("== matched");
} else {
System.out.println("== failed"); // prints: == failed
}
if ("ACTIVE".equals(status)) { // right: compares content, null-safe
System.out.println("equals matched"); // prints: equals matched
}
}
}Giải thích: bản sai in "== failed" dù nội dung giống hệt, vì status là object khác với literal trong pool. Bản đúng so nội dung. Đặt literal đứng trước ("ACTIVE".equals(status)) còn tránh được NPE khi status là null; cách khác là Objects.equals(a, b). Với tập giá trị cố định như trạng thái, dùng enum thì so sánh bằng == lại là đúng, vì mỗi hằng enum chỉ có một object.
| String | StringBuilder | StringBuffer | |
|---|---|---|---|
| Thay đổi được | Không | Có | Có |
| Thread-safe | Có (vì bất biến) | Không | Có (synchronized) |
| Dùng khi | Hầu hết trường hợp | Ghép chuỗi trong vòng lặp | Hầu như không dùng nữa |
Câu hỏi (Hiểu sâu): String s = new String("abc") tạo mấy object?
Một hoặc hai: literal "abc" vào pool nếu pool chưa có, và new luôn tạo thêm một object trên Heap.
Câu hỏi (Hiểu sâu): Vì sao không nối chuỗi bằng + trong vòng lặp?
Mỗi lần += tạo String mới và copy nội dung cũ, tổng chi phí O(n²); StringBuilder sửa trực tiếp trên buffer. Vì sao String bất biến: Chương 1.
Câu hỏi (Đọc code): Đoạn code dưới đây in ra gì?
public class PoolQuiz {
public static void main(String[] args) {
String a = "java";
String b = "ja" + "va"; // two literals
String part = "ja";
String c = part + "va"; // variable involved
System.out.println(a == b);
System.out.println(a == c);
System.out.println(a == c.intern());
}
}Đáp án:
In true, false, true.
"ja" + "va"gồm toàn literal nên compiler tính sẵn thành"java"lúc biên dịch (constant folding),btrỏ vào object trong pool.part + "va"có biến (khôngfinal), phép nối chạy lúc runtime và tạo object mới trên Heap, nêna == clà false.c.intern()trả về object trong pool có cùng nội dung, nên bằnga. Dù vậy, trong code thật cứ dùngequals(), đừng dựa vàointern().
Câu hỏi (Hiểu sâu): Vì sao String được thiết kế bất biến?
Để dùng chung an toàn (String pool), an toàn khi nhiều thread cùng đọc, và làm key HashMap đáng tin cậy. Nếu String sửa được, một đoạn code đổi nội dung literal "admin" sẽ làm mọi chỗ khác dùng "admin" đổi theo. String còn cache hashCode sau lần tính đầu, việc này chỉ đúng khi nội dung không bao giờ đổi. Cuối cùng là bảo mật: đường dẫn file, URL, tên class đã kiểm tra xong thì không thể bị sửa lén sau đó.
N1.9 equals() và hashCode() cơ bản
Nhắc lại kiến thức
== so sánh tham chiếu (hai biến có trỏ cùng một object không), equals() so sánh nội dung; nếu class không override thì equals() của Object chính là == (xem N1.8).
HashMap và HashSet không so sánh phần tử mới với toàn bộ phần tử cũ (quá chậm). Chúng chia dữ liệu vào một mảng các bucket (ngăn chứa): hashCode() là một số nguyên tính từ nội dung object, dùng để chọn ngăn; chỉ những object trong cùng ngăn mới được so tiếp bằng equals(). Hình dung tủ hồ sơ chia theo chữ cái đầu: tìm "Nam" thì chỉ lục ngăn N. Nếu hai object "bằng nhau" mà hashCode khác nhau, chúng bị cất vào hai ngăn khác nhau và không bao giờ được so với nhau.
equals() định nghĩa hai object "bằng nhau về nghiệp vụ"; hashCode() giúp HashMap/HashSet chọn bucket. Hợp đồng cốt lõi: equals nhau thì bắt buộc cùng hashCode, nên override equals thì phải override cả hashCode:
import java.util.HashSet;
import java.util.Objects;
import java.util.Set;
public class Employee {
private final String code;
public Employee(String code) { this.code = code; }
@Override
public boolean equals(Object o) { // parameter must be Object
if (this == o) return true;
if (!(o instanceof Employee other)) return false; // null or different type
return Objects.equals(code, other.code);
}
@Override
public int hashCode() { return Objects.hash(code); } // same fields as equals
public static void main(String[] args) {
Set<Employee> set = new HashSet<>();
set.add(new Employee("NV01"));
set.add(new Employee("NV01"));
System.out.println(set.size()); // 1; without hashCode() it prints 2
}
}Giải thích: hai object cùng mã được coi là một nên Set giữ 1. Xóa hashCode() thì hai object rơi vào hai bucket khác nhau và size() ra 2. Class dữ liệu thuần nên dùng record, tự sinh equals/hashCode đúng. Hậu quả khi vi phạm và JPA entity: Chương 1.
Lỗi phổ biến thứ hai là viết equals nhận tham số đúng kiểu class thay vì Object. Code sai trông rất hợp lý:
import java.util.HashSet;
import java.util.Set;
public class EqualsOverloadBug {
static class Emp {
final String code;
Emp(String code) { this.code = code; }
public boolean equals(Emp o) { return code.equals(o.code); } // overload, not override
@Override public int hashCode() { return code.hashCode(); }
}
public static void main(String[] args) {
Set<Emp> set = new HashSet<>();
set.add(new Emp("NV01"));
System.out.println(set.contains(new Emp("NV01"))); // prints: false
}
}Giải thích: equals(Emp o) là một method mới (overload, xem N1.6), không thay thế equals(Object o) của Object. HashSet gọi equals(Object), tức bản gốc so sánh tham chiếu, nên in false dù cùng mã. Nếu thêm @Override vào method sai, compiler chặn ngay: method does not override or implement a method from a supertype. Bản sửa là chữ ký public boolean equals(Object o) như class Employee ở trên, kèm @Override.
Câu hỏi (Cơ bản): Hai object cùng hashCode thì có equals nhau không?
Không nhất thiết, đó là va chạm (collision) và hoàn toàn hợp lệ. Chiều bắt buộc chỉ là equals nhau thì cùng hashCode. Ngược lại, không override thì equals() mặc định của Object chính là ==, so sánh tham chiếu.
Câu hỏi (Đọc code): Đoạn code dưới đây in ra gì?
import java.util.HashSet;
import java.util.Objects;
import java.util.Set;
public class MutableKey {
static class Point {
int x;
Point(int x) { this.x = x; }
@Override public boolean equals(Object o) { return o instanceof Point p && p.x == x; }
@Override public int hashCode() { return Objects.hash(x); }
}
public static void main(String[] args) {
Set<Point> set = new HashSet<>();
Point p = new Point(1);
set.add(p);
p.x = 2; // mutate after insertion
System.out.println(set.contains(p));
System.out.println(set.size());
}
}Đáp án:
In false rồi 1: object vẫn nằm trong set nhưng không tìm lại được. Lúc add, p được cất vào bucket tính từ hashCode của x = 1. Sau khi đổi x = 2, contains(p) tính hashCode mới, tìm ở bucket khác và không thấy. Phần tử "lạc" này không xóa được bằng remove(p) và là một dạng rò rỉ bộ nhớ. Quy tắc: field dùng trong equals/hashCode của object làm key phải bất biến; record hoặc field final giải quyết triệt để.
Câu hỏi (Hiểu sâu): Vì sao "equals nhau thì bắt buộc cùng hashCode", còn chiều ngược lại thì không?
Vì HashMap chỉ gọi equals() với các object trong cùng bucket; hai object bằng nhau mà khác hashCode sẽ nằm hai bucket và không bao giờ được so với nhau. Kết quả là contains trả false, put tạo bản ghi trùng. Chiều ngược lại không cần vì hashCode chỉ là số int (khoảng 4 tỷ giá trị) trong khi số object khác nhau là vô hạn, va chạm là không tránh khỏi; HashMap xử lý va chạm bằng cách so tiếp equals() trong bucket.
Câu hỏi (Thực chiến): Dùng Lombok @Data cho JPA entity có vấn đề gì?
@Data sinh equals/hashCode dựa trên mọi field, gồm cả id do DB sinh và các quan hệ lazy, gây lỗi khó thấy. Entity mới chưa có id, lưu xong id thay đổi làm hashCode đổi, nên entity đang nằm trong HashSet bị "lạc" như câu hỏi trên. hashCode đụng tới collection lazy có thể kích hoạt truy vấn ngoài ý muốn hoặc LazyInitializationException, và hai entity trỏ nhau có thể gây vòng lặp vô hạn (StackOverflowError) ở toString. Thực tế dùng @Getter/@Setter và tự viết equals/hashCode theo business key hoặc theo id với hashCode cố định; chi tiết ở Chương 1.
N1.10 Comparable và Comparator
Nhắc lại kiến thức
int tràn thì quay vòng sang số âm mà không báo lỗi, và Integer.compare là method tiện ích của wrapper (xem N1.2). TreeSet/TreeMap dùng kết quả so sánh thay cho equals() (xem N1.9) để quyết định hai phần tử có trùng không.
Cả compareTo(o) và compare(a, b) đều trả về một số nguyên theo quy ước: âm nghĩa là phần tử thứ nhất đứng trước, 0 nghĩa là bằng nhau về thứ tự, dương nghĩa là đứng sau. Giá trị cụ thể (-1 hay -100) không quan trọng, chỉ dấu là quan trọng. Thứ tự tự nhiên là cách sắp xếp "hiển nhiên" nhất của một kiểu: số tăng dần, chuỗi theo bảng chữ cái, ngày theo thời gian.
Comparable định nghĩa thứ tự tự nhiên ngay trong class (compareTo), mỗi class một cái. Comparator là bộ so sánh tách rời (compare), tạo bao nhiêu cũng được, dùng khi cần nhiều cách sắp xếp hoặc không sửa được class:
import java.util.ArrayList;
import java.util.Comparator;
import java.util.List;
public class SortDemo {
record Student(int id, String name, double gpa) implements Comparable<Student> {
public int compareTo(Student o) { return Integer.compare(id, o.id); } // by id
}
public static void main(String[] args) {
List<Student> list = new ArrayList<>(List.of(
new Student(3, "Chi", 3.5), new Student(1, "An", 3.8), new Student(2, "Bình", 3.5)));
list.sort(null); // null: use Comparable
System.out.println(list.get(0).name()); // An
list.sort(Comparator.comparingDouble(Student::gpa).reversed() // GPA descending
.thenComparing(Student::name)); // then by name
list.forEach(s -> System.out.println(s.name())); // An, Bình, Chi
}
}Giải thích: lần đầu sort theo id nên An đứng đầu; lần sau theo GPA giảm dần, Bình và Chi cùng 3.5 nên xét tiếp tên. comparing().thenComparing() thay cho if-else lồng nhau; Comparator.nullsLast(...) xử lý null.
Câu hỏi (Cơ bản): Khi nào dùng Comparable, khi nào dùng Comparator?
Comparable khi class có một thứ tự tự nhiên rõ ràng (số, ngày, mã). Comparator cho mọi cách sắp xếp khác, nhất là nhiều tiêu chí hoặc class của thư viện.
Câu hỏi (Thực chiến): Sắp xếp theo nhiều tiêu chí thế nào?
Nối thenComparing, đảo chiều bằng reversed(); không cần viết một class Comparator riêng cho mỗi tiêu chí như tài liệu cũ.
Bẫy thường gặp
Viết return this.id - o.id;. Với số lớn phép trừ bị tràn và cho dấu sai; dùng Integer.compare. TreeSet/TreeMap còn coi compareTo == 0 là trùng, Comparator thiếu tiêu chí sẽ làm "mất" phần tử.
Code minh họa cho bẫy "so sánh bằng phép trừ" ở trên, bản sai và bản sửa đặt cạnh nhau:
public class CompareOverflow {
public static void main(String[] args) {
int a = -2_000_000_000, b = 2_000_000_000; // clearly a < b
System.out.println(a - b); // prints: 294967296 (overflow: positive, says a > b)
System.out.println(Integer.compare(a, b)); // prints: -1 (correct: a < b)
}
}Giải thích: hiệu đúng là -4 tỷ, vượt giới hạn int (khoảng ±2,1 tỷ) nên quay vòng thành số dương 294967296; compareTo viết bằng phép trừ sẽ xếp a sau b, sort sai mà không có exception nào. Integer.compare chỉ so sánh, không tính hiệu, nên không bao giờ tràn. Id kiểu long thì dùng Long.compare.
Câu hỏi (Đọc code): Đoạn code dưới đây in ra gì?
import java.util.Comparator;
import java.util.TreeSet;
public class TreeSetQuiz {
record Student(String name, double gpa) {}
public static void main(String[] args) {
TreeSet<Student> set = new TreeSet<>(Comparator.comparingDouble(Student::gpa));
set.add(new Student("An", 3.5));
set.add(new Student("Bình", 3.5));
set.add(new Student("Chi", 3.8));
System.out.println(set.size());
}
}Đáp án:
In 2: "Bình" bị bỏ qua vì cùng GPA với "An". TreeSet không dùng equals() mà coi compare == 0 là trùng. Comparator chỉ xét GPA nên An và Bình "bằng nhau", phần tử thêm sau bị bỏ im lặng. Sửa: thêm tiêu chí phân biệt, ví dụ .thenComparing(Student::name), để comparator chỉ trả 0 khi hai phần tử thật sự trùng.
Câu hỏi (Thực chiến): Danh sách đơn hàng có phân trang, nên sắp xếp trong Java bằng Comparator hay trong SQL?
Sắp xếp trong SQL (ORDER BY) khi có phân trang hoặc dữ liệu lớn; Comparator dành cho dữ liệu đã nằm trong bộ nhớ và nhỏ. Nếu lấy trang 1 (20 dòng) rồi mới sort bằng Java, bạn chỉ sắp xếp 20 dòng đó chứ không phải "20 đơn mới nhất" của toàn bảng. Sort trong DB còn tận dụng được index. Comparator hợp lý khi gộp dữ liệu từ nhiều nguồn (hai API) hoặc tiêu chí sắp xếp tính toán phức tạp không biểu diễn được bằng SQL.
N1.11 Collections Framework tổng quan
Nhắc lại kiến thức
Collection chỉ chứa object, nên List<Integer> chứa các object Integer và số được autoboxing khi thêm vào (xem N1.2). HashSet/HashMap xác định trùng lặp bằng hashCode() rồi equals() (xem N1.9); TreeSet/TreeMap dùng compareTo/Comparator (xem N1.10).
Collections Framework là bộ interface và class có sẵn trong java.util để chứa nhóm object: danh sách, tập hợp, hàng đợi, bảng tra cứu key-value. Các cột "O(1)", "O(n)" trong bảng dưới dùng ký hiệu Big-O, mô tả thời gian tăng thế nào khi số phần tử n tăng: O(1) là gần như không đổi dù có 10 hay 10 triệu phần tử; O(n) là tăng tỉ lệ thuận với n (phải đi qua từng phần tử); O(log n) là tăng rất chậm (1 triệu phần tử chỉ cần khoảng 20 bước), đặc trưng của cấu trúc cây như TreeSet.
Collection(kế thừaIterable)List(có thứ tự, có index, cho trùng):ArrayList,LinkedList,Vector(cũ)Set(không trùng):HashSet,LinkedHashSet,TreeSetQueue/Deque:ArrayDeque,PriorityQueue
Maplà nhánh riêng, không kế thừaCollection:HashMap,LinkedHashMap,TreeMap,Hashtable(cũ),ConcurrentHashMap
| List | Cấu trúc | get(i) / chèn giữa | Thread-safe |
|---|---|---|---|
ArrayList | Mảng động, tăng 1,5 lần | O(1) / O(n) | Không |
LinkedList | Liên kết đôi | O(n) / O(n) | Không |
Vector | Mảng động, tăng gấp đôi | O(1) / O(n) | Có, lỗi thời |
| Set | Thứ tự | null | Thao tác chính |
|---|---|---|---|
HashSet | Không đảm bảo | Cho một null | O(1) |
LinkedHashSet | Thứ tự thêm vào | Cho một null | O(1) |
TreeSet | Sắp xếp | Không cho | O(log n) |
| Map | null | Thứ tự | Thread-safe |
|---|---|---|---|
HashMap | 1 key null, nhiều value null | Không đảm bảo | Không |
LinkedHashMap | Như HashMap | Thứ tự thêm | Không |
TreeMap | Key không null | Sắp theo key | Không |
Hashtable | Không cho null | Không đảm bảo | Có (khóa cả bảng) |
Array vs ArrayList: Array cố định kích thước, chứa được primitive; ArrayList tự tăng, chỉ chứa object (có boxing). Chuyển đổi: list.toArray(new String[0]) và new ArrayList<>(Arrays.asList(arr)).
Iterator duyệt collection bằng hasNext(), next(), remove(). Nó là con trỏ trên chính collection, không copy dữ liệu. Đoạn code sau xóa phần tử an toàn khi đang duyệt:
import java.util.ArrayList;
import java.util.Iterator;
import java.util.List;
public class IteratorDemo {
public static void main(String[] args) {
List<Integer> nums = new ArrayList<>(List.of(1, 2, 3, 4, 5, 6));
// for (Integer n : nums) if (n % 2 == 0) nums.remove(n); // ConcurrentModificationException
Iterator<Integer> it = nums.iterator(); // iterates over the list itself
while (it.hasNext()) {
if (it.next() % 2 == 0) it.remove(); // safe removal via the iterator
}
System.out.println(nums); // [1, 3, 5]
nums.removeIf(n -> n > 3); // Java 8+: shorter
System.out.println(nums); // [1, 3]
}
}Giải thích: for-each dùng iterator ngầm; nums.remove() làm list đổi cấu trúc sau lưng iterator nên lần next() sau ném ConcurrentModificationException (fail-fast). it.remove() cập nhật cả iterator và xóa thật trên list gốc. Tìm phần tử chung (retainAll) và xóa trùng: bài 6, N1.16.
Fail-fast nghĩa là "phát hiện sai thì dừng ngay": iterator ghi nhớ số lần list bị thay đổi cấu trúc lúc nó được tạo, và mỗi lần next() lại kiểm tra; thấy khác là ném ConcurrentModificationException ngay thay vì chạy tiếp với dữ liệu lệch. Tên exception có chữ "Concurrent" nhưng lỗi này xảy ra cả khi chỉ có một thread, như ví dụ trên.
Câu hỏi (Cơ bản): ArrayList và LinkedList, dùng cái nào?
ArrayList truy cập theo index O(1), chèn giữa O(n). LinkedList thêm/xóa đầu cuối O(1) nhưng get(i) O(n) và tốn bộ nhớ cho node. Thực tế gần như luôn dùng ArrayList; cần hàng đợi hai đầu thì dùng ArrayDeque.
Câu hỏi (Cơ bản): List khác Set thế nào? Làm sao để HashMap thread-safe?
List có thứ tự, có index, cho trùng; Set không cho trùng, xác định trùng bằng hashCode() rồi equals(). Cần thread-safe thì dùng ConcurrentHashMap; Hashtable và Collections.synchronizedMap khóa cả map nên chậm (Chương 3).
Bẫy thường gặp
"Iterator tạo bản sao nên duyệt không ảnh hưởng dữ liệu" (có trong một số đáp án cũ) là sai. Bẫy khác: Arrays.asList(arr) có kích thước cố định, gọi add() ném UnsupportedOperationException; List.of() bất biến hoàn toàn.
Câu hỏi (Đọc code): Đoạn code dưới đây in ra gì?
import java.util.ArrayList;
import java.util.List;
public class RemoveQuiz {
public static void main(String[] args) {
List<Integer> list = new ArrayList<>(List.of(1, 2, 3));
list.remove(1); // which overload?
System.out.println(list);
list.remove(Integer.valueOf(1));
System.out.println(list);
}
}Đáp án:
In [1, 3] rồi [3]. List có hai overload: remove(int index) và remove(Object o). Lời gọi remove(1) với 1 là int khớp chính xác remove(int index), nên xóa phần tử ở vị trí 1 (số 2), compiler không autoboxing khi đã có bản khớp primitive (xem N1.6). remove(Integer.valueOf(1)) truyền object nên chọn remove(Object), xóa phần tử có giá trị 1. Bẫy này hay gặp khi xóa id kiểu int khỏi List<Integer>.
Câu hỏi (Thực chiến): Cache dùng HashMap<ProductKey, Price> cứ phình to mãi dù chỉ có vài trăm sản phẩm. Nghi ngờ gì trước tiên?
Nghi ngờ ProductKey không override equals/hashCode (hoặc override sai), nên mỗi lần tạo key mới với cùng nội dung lại thành một entry mới. Không override thì hai key cùng nội dung là hai object khác nhau, get luôn trả null, code "cache miss" rồi put thêm entry. Kiểm tra bằng cách log map.size() hoặc xem heap dump thấy hàng nghìn ProductKey trùng nội dung. Sửa: dùng record ProductKey(...) hoặc tự override đúng; cache thật nên có giới hạn kích thước và thời hạn (Caffeine, Redis).
N1.12 Exception cơ bản
Exception là một object mô tả sự cố xảy ra khi chạy. Khi một dòng code "ném" (throw) exception, JVM dừng method hiện tại và đi ngược lên các method đã gọi nó, tìm khối catch phù hợp; không ai bắt thì thread kết thúc và in stack trace (danh sách các method đang gọi dở, từ chỗ lỗi ngược về main), thứ bạn đọc đầu tiên khi điều tra lỗi production.
ThrowableError: sự cố nghiêm trọng của JVM (OutOfMemoryError,StackOverflowError), ứng dụng không nên bắt.Exception- Checked (không thuộc
RuntimeException):IOException,SQLException. Compiler bắt buộccatchhoặc khai báothrows. RuntimeException(unchecked):NullPointerException,IllegalArgumentException. Không bắt buộc xử lý.
- Checked (không thuộc
Gọi Files.readString(path) mà không xử lý IOException thì không biên dịch được; Integer.parseInt("abc") chỉ ném NumberFormatException lúc chạy. Đoạn code sau gom đủ cú pháp cơ bản:
import java.io.BufferedReader;
import java.io.IOException;
import java.io.StringReader;
public class ExceptionDemo {
static class BalanceException extends RuntimeException { // custom exception
BalanceException(String msg) { super(msg); }
}
static String firstLine(String text) throws IOException { // throws: declaration
try (BufferedReader r = new BufferedReader(new StringReader(text))) { // close() called automatically
return r.readLine();
}
}
public static void main(String[] args) {
try {
System.out.println(firstLine("dòng 1\ndòng 2")); // dòng 1
Integer.parseInt("abc"); // NumberFormatException
} catch (NumberFormatException | IOException e) { // multi-catch
System.out.println("Lỗi: " + e.getMessage());
} finally {
System.out.println("finally luôn chạy");
}
throw new BalanceException("Số dư không đủ"); // throw: raise an exception
}
}Giải thích: chương trình in "dòng 1", dòng lỗi, "finally luôn chạy", rồi dừng với stack trace của BalanceException. throw là câu lệnh ném object, throws là khai báo trên chữ ký method. Try-with-resources tự close() mọi AutoCloseable kể cả khi có exception. Exception nghiệp vụ nên kế thừa RuntimeException và giữ cause khi bọc lỗi khác.
Quy tắc "không nuốt lỗi, bọc lỗi phải giữ nguyên nhân" dễ hiểu hơn khi đặt cạnh nhau. Bản sai che giấu dữ liệu hỏng:
static int parseQuantityWrong(String raw) {
try {
return Integer.parseInt(raw);
} catch (NumberFormatException e) {
return 0; // wrong: bad input silently becomes 0, no log
}
}Bản sửa ném exception nghiệp vụ và truyền lỗi gốc làm cause:
public class WrapCause {
static class OrderException extends RuntimeException {
OrderException(String msg, Throwable cause) { super(msg, cause); }
}
static int parseQuantity(String raw) {
try {
return Integer.parseInt(raw);
} catch (NumberFormatException e) {
throw new OrderException("Invalid quantity: " + raw, e); // keep the original cause
}
}
public static void main(String[] args) {
try {
parseQuantity("abc");
} catch (OrderException e) {
System.out.println(e.getMessage()); // prints: Invalid quantity: abc
System.out.println(e.getCause().getClass().getSimpleName()); // prints: NumberFormatException
}
}
}Giải thích: bản sai biến đầu vào rác "abc" thành số lượng 0, đơn hàng vẫn được tạo và không ai biết có lỗi. Bản sửa báo lỗi rõ nghĩa nghiệp vụ; nhờ truyền e làm cause, stack trace in ra còn có đoạn "Caused by: java.lang.NumberFormatException" chỉ đúng dòng gốc. Tầng trên (ví dụ @RestControllerAdvice trong Spring) bắt OrderException và trả HTTP 400.
Câu hỏi (Hiểu sâu): finally có luôn chạy không?
Gần như luôn, kể cả khi try có return hoặc exception; không chạy khi gọi System.exit(), JVM bị kill/crash, hoặc thread kẹt vô hạn trong try. Đừng return trong finally: nó ghi đè giá trị return của try và nuốt luôn exception.
Câu hỏi (Cơ bản): Checked và unchecked khác nhau thế nào? Error khác Exception ra sao?
Checked bị compiler bắt buộc xử lý, dành cho sự cố bên ngoài có thể phục hồi (file, mạng); unchecked là lỗi lập trình hoặc vi phạm nghiệp vụ; Error là sự cố mức JVM, không nên bắt. Thiết kế exception trong dự án: Chương 1.
Bẫy thường gặp
catch (Exception e) {} rỗng hoặc chỉ e.printStackTrace(). Lỗi bị nuốt, production không có log, và câu hỏi tiếp theo sẽ là "rồi ai biết là có lỗi?".
Câu hỏi (Đọc code): Đoạn code dưới đây in ra gì?
public class FinallyQuiz {
static int f() {
try {
return 1;
} finally {
System.out.println("finally");
}
}
static int g() {
int x = 1;
try {
return x;
} finally {
x = 2; // too late to change the returned value
}
}
public static void main(String[] args) {
System.out.println(f());
System.out.println(g());
}
}Đáp án:
In finally, 1, 1. Ở f(), return 1 tính xong giá trị trả về, rồi finally chạy (in "finally") trước khi method thật sự thoát. Ở g(), giá trị của x (1) đã được lưu lại tại lệnh return; gán x = 2 trong finally chỉ đổi biến cục bộ, không đổi giá trị đã lưu. Nếu finally có return riêng thì nó mới ghi đè, và đó là lý do không bao giờ return trong finally.
Câu hỏi (Thực chiến): Method @Transactional ném checked exception thì transaction có rollback không?
Mặc định không: Spring chỉ rollback với unchecked exception (RuntimeException) và Error; checked exception thì vẫn commit. Đây là lý do exception nghiệp vụ nên kế thừa RuntimeException. Nếu buộc phải ném checked exception, khai báo @Transactional(rollbackFor = Exception.class). Câu này nối kiến thức checked/unchecked ở mục này với Transaction trong Spring (chi tiết ở chương Spring).
N1.13 Thread cơ bản
Nhắc lại kiến thức
Stack là vùng nhớ riêng chứa biến cục bộ và lời gọi method; Heap là vùng nhớ dùng chung chứa mọi object, biến chỉ giữ tham chiếu tới object (xem sơ đồ ở N1.2). Field static chỉ có một bản dùng chung cho cả chương trình (xem N1.3).
Process là chương trình đang chạy, có vùng nhớ riêng. Thread là luồng thực thi trong process; các thread dùng chung Heap (nguồn gốc race condition) nhưng mỗi thread có Stack riêng.
Tạo thread bằng cách kế thừa Thread, truyền Runnable (thường là lambda) vào Thread, hoặc gửi task vào ExecutorService. Dự án thật gần như luôn dùng thread pool:
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;
import java.util.concurrent.Future;
public class CreateThreads {
public static void main(String[] args) throws Exception {
Runnable task = () -> System.out.println("Chạy trên " + Thread.currentThread().getName());
Thread t = new Thread(task, "worker-1");
t.start(); // new thread, run() executes on it
t.join(); // main waits for worker-1 to finish
task.run(); // plain method call: runs on main
try (ExecutorService pool = Executors.newFixedThreadPool(2)) { // Java 19+: pool closed automatically
Future<Integer> f = pool.submit(() -> 1 + 1); // Callable returns a result
System.out.println("Kết quả: " + f.get()); // 2
}
}
}Giải thích: in "Chạy trên worker-1", "Chạy trên main", "Kết quả: 2". Chỉ start() mới tạo thread mới; run() gọi trực tiếp chạy trên thread hiện tại. ExecutorService tái sử dụng và giới hạn số thread, nhận Callable có kết quả. Cấu hình pool cho production: Chương 3.
Khi hai thread cùng sửa một biến, kết quả có thể sai. Đây là race condition kinh điển:
public class CounterRace {
private int count = 0;
void increment() { count++; } // unsafe: read, add, write are 3 steps
// synchronized void increment() { count++; } // fix: one thread at a time
public static void main(String[] args) throws InterruptedException {
CounterRace c = new CounterRace();
Runnable job = () -> { for (int i = 0; i < 100_000; i++) c.increment(); };
Thread t1 = new Thread(job), t2 = new Thread(job);
t1.start(); t2.start();
t1.join(); t2.join();
System.out.println(c.count); // usually less than 200000
}
}Giải thích: hai thread cùng đọc 10, cùng ghi 11, mất một lần tăng, nên kết quả thiếu và mỗi lần một khác. synchronized buộc thread giữ khóa (monitor) của object mới được vào, kết quả luôn 200000. Gọn hơn thì dùng AtomicInteger; volatile không sửa được lỗi này (Chương 3).
Giải nghĩa các thuật ngữ vừa dùng: race condition là lỗi mà kết quả phụ thuộc vào việc thread nào chạy trước, nên mỗi lần chạy một khác và rất khó tái hiện. Thao tác atomic (nguyên tử) là thao tác không thể bị thread khác chen vào giữa; count++ không atomic vì gồm ba bước đọc, cộng, ghi. Monitor là cái khóa mà mọi object Java đều có sẵn; synchronized trên method instance dùng monitor của chính object đó (this). Bản dùng AtomicInteger:
import java.util.concurrent.atomic.AtomicInteger;
public class AtomicCounter {
public static void main(String[] args) throws InterruptedException {
AtomicInteger count = new AtomicInteger();
Runnable job = () -> { for (int i = 0; i < 100_000; i++) count.incrementAndGet(); }; // atomic ++
Thread t1 = new Thread(job), t2 = new Thread(job);
t1.start(); t2.start();
t1.join(); t2.join();
System.out.println(count.get()); // prints: 200000, every run
}
}Giải thích: incrementAndGet() dùng lệnh CPU so-sánh-và-gán (CAS) để tăng một cách atomic, không cần khóa, nên kết quả luôn đúng 200000. count là biến cục bộ nhưng object AtomicInteger nằm trên Heap, cả hai thread cùng thấy nó; đó chính là lý do dữ liệu dùng chung giữa các thread phải được bảo vệ, còn biến cục bộ kiểu primitive trên Stack của mỗi thread thì không cần.
Deadlock là các thread giữ khóa và chờ khóa của nhau theo vòng tròn:
public class DeadlockDemo {
static final Object A = new Object(), B = new Object();
public static void main(String[] args) {
new Thread(() -> { synchronized (A) { pause(); synchronized (B) { } } }).start(); // holds A, waits for B
new Thread(() -> { synchronized (B) { pause(); synchronized (A) { } } }).start(); // holds B, waits for A
}
static void pause() {
try { Thread.sleep(100); } catch (InterruptedException e) { Thread.currentThread().interrupt(); }
}
}Giải thích: chương trình treo vĩnh viễn vì mỗi thread giữ đúng khóa thread kia cần. Phòng tránh: mọi thread lấy khóa theo cùng thứ tự (luôn A rồi B). Phát hiện bằng thread dump (jstack, jcmd <pid> Thread.print), trong đó ghi "Found one Java-level deadlock".
| Tiêu chí | Thread.sleep() | Object.wait() |
|---|---|---|
| Nhả khóa | Không | Có |
| Gọi ở đâu | Bất kỳ đâu | Trong synchronized trên chính object đó |
| Thức dậy | Hết thời gian, interrupt | notify()/notifyAll(), timeout, interrupt |
Trạng thái thread theo Thread.State: NEW (chưa start), RUNNABLE (đang chạy hoặc sẵn sàng), BLOCKED (chờ vào synchronized), WAITING (wait(), join() không thời hạn), TIMED_WAITING (sleep(ms), wait(ms)), TERMINATED. Không có trạng thái "RUNNING" riêng.
Câu hỏi (Cơ bản): start() và run() khác nhau thế nào?
start() yêu cầu JVM tạo thread mới rồi gọi run() trên đó, gọi lần hai ném IllegalThreadStateException. run() gọi trực tiếp chỉ là method thường trên thread hiện tại.
Câu hỏi (Thực chiến): Làm sao chắc chắn thread A chạy xong trước thread B?
Cho B gọi a.join() trước khi làm việc, dùng CountDownLatch, hoặc CompletableFuture.runAsync(a).thenRun(b).
Bẫy thường gặp
Trả lời "dùng setPriority() cho A cao hơn". Priority chỉ là gợi ý cho bộ lập lịch của hệ điều hành, không đảm bảo thứ tự nào cả.
Câu hỏi (Đọc code): Đoạn code dưới đây in ra gì?
public class RunVsStart {
public static void main(String[] args) throws InterruptedException {
Thread t = new Thread(() -> System.out.println(Thread.currentThread().getName()), "worker");
t.run();
t.start();
t.join();
}
}Đáp án:
In main rồi worker. t.run() chỉ là lời gọi method bình thường nên lambda chạy trên thread hiện tại là main. t.start() mới tạo thread mới tên "worker" và chạy lambda trên đó; join() giữ main chờ nên output luôn theo thứ tự này.
Câu hỏi (Thực chiến): API tạo new Thread(...) cho mỗi request để gửi email. Khi traffic tăng, server hết bộ nhớ. Vì sao và sửa thế nào?
Mỗi platform thread tốn một Stack riêng (mặc định khoảng 1 MB) và tài nguyên của hệ điều hành; tạo không giới hạn thì traffic tăng là số thread tăng theo tới khi cạn bộ nhớ (OutOfMemoryError: unable to create native thread). Sửa: dùng thread pool có giới hạn (ExecutorService hoặc @Async với ThreadPoolTaskExecutor cấu hình kích thước và hàng đợi), hoặc đưa việc gửi email vào message queue. Java 21 có virtual thread rẻ hơn nhiều, nhưng vẫn cần giới hạn tài nguyên phía sau như kết nối SMTP (Chương 3).
N1.14 Pass by value, shallow/deep copy và Serializable
Nhắc lại kiến thức
Biến primitive chứa thẳng giá trị; biến kiểu object chỉ chứa tham chiếu trỏ tới object trên Heap (xem N1.2). final khóa tham chiếu chứ không khóa nội dung object (xem N1.3).
Pass by value nghĩa là khi gọi method, Java sao chép giá trị của biến rồi đưa bản sao cho tham số. Pass by reference (có trong C++, C#) nghĩa là tham số chính là biến gốc dưới một tên khác, gán lại tham số thì biến gốc đổi theo. Java không có cơ chế thứ hai; chỉ là với object, "giá trị" được sao chép là tham chiếu.
Java chỉ có pass by value. Với object, method nhận bản sao của tham chiếu: sửa nội dung object thì bên ngoài thấy, gán tham số sang object khác thì bên ngoài không bị ảnh hưởng.
import java.util.ArrayList;
import java.util.List;
public class PassByValue {
static void change(int x, List<String> list) {
x = 100; // modifies the copy, original unchanged
list.add("B"); // same object: content really changes
list = new ArrayList<>(); // reassigns the copy: caller unaffected
list.add("C");
}
public static void main(String[] args) {
int n = 1;
List<String> items = new ArrayList<>(List.of("A"));
change(n, items);
System.out.println(n + " " + items); // 1 [A, B]
}
}Giải thích: n vẫn là 1; list có "B" vì cùng object, nhưng không có "C" vì "C" vào list mới chỉ tham số cục bộ trỏ tới. Nếu có pass by reference, dòng gán lại đã làm items đổi theo.
Shallow copy tạo object mới nhưng dùng chung object con; deep copy sao chép cả object con. Cách khuyên dùng là copy constructor, thay vì clone() vốn thiết kế lỗi (không gọi constructor, ném checked exception, dễ quên field lồng nhau):
import java.util.ArrayList;
import java.util.List;
public class Order {
final List<String> items;
Order(List<String> items) { this.items = items; } // keeps the same reference
Order(Order other) { this.items = new ArrayList<>(other.items); } // copy constructor
public static void main(String[] args) {
Order original = new Order(new ArrayList<>(List.of("Bút")));
Order shallow = new Order(original.items); // shares the list
Order deep = new Order(original);
original.items.add("Vở");
System.out.println(shallow.items); // [Bút, Vở]
System.out.println(deep.items); // [Bút]
}
}Giải thích: bản shallow dùng chung list với bản gốc nên thấy "Vở", bản deep có list riêng. Phần tử ở đây là String bất biến nên copy list là đủ; nếu phần tử là object thay đổi được thì phải copy tiếp từng phần tử.
Serialization chuyển object thành dãy byte (ObjectOutputStream.writeObject), deserialization làm ngược lại. Class phải implement Serializable (interface đánh dấu, không có method). Field transient không được ghi, đọc lại mang giá trị mặc định. serialVersionUID là "phiên bản" class; không tự khai báo thì JVM tự tính, thêm một field là dữ liệu cũ đọc lỗi InvalidClassException. Thực tế backend dùng JSON hoặc Protobuf, và deserialize dữ liệu không tin cậy bằng Java serialization là lỗ hổng bảo mật đã biết.
Chú thích: serialization (tuần tự hóa) là biến object trong bộ nhớ thành dãy byte để ghi file, gửi qua mạng, hay lưu cache; deserialization dựng lại object từ dãy byte đó. transient phù hợp cho dữ liệu nhạy cảm hoặc tính lại được, ví dụ mật khẩu, cache nội bộ.
Câu hỏi (Cơ bản): Java là pass by value hay pass by reference?
Chỉ pass by value. Với object, giá trị được copy là tham chiếu, nên sửa được nội dung nhưng không đổi được biến của caller sang object khác.
Câu hỏi (Hiểu sâu): Làm sao deep copy một object?
Copy constructor tự copy từng field lồng nhau là rõ ràng nhất; serialize rồi deserialize (hoặc qua JSON) cũng được nhưng chậm. Object bất biến thì không cần copy.
Câu hỏi (Đọc code): Đoạn code dưới đây in ra gì?
public class PassQuiz {
static void swap(String a, String b) { String tmp = a; a = b; b = tmp; }
static void shout(StringBuilder sb) {
sb.append("!"); // modifies the shared object
sb = new StringBuilder("new"); // reassigns only the local copy
}
public static void main(String[] args) {
String x = "A", y = "B";
swap(x, y);
System.out.println(x + y);
StringBuilder sb = new StringBuilder("hi");
shout(sb);
System.out.println(sb);
}
}Đáp án:
In AB rồi hi!. swap chỉ đổi chỗ hai bản sao tham chiếu bên trong method, x, y của main không đổi; đây là bằng chứng Java không có pass by reference (không viết được hàm swap như C++). shout sửa nội dung object mà cả hai bên cùng trỏ tới nên thêm "!", còn dòng gán new StringBuilder("new") chỉ đổi tham số cục bộ.
Câu hỏi (Đọc code): Tìm lỗi. Class dưới đây tự nhận là "đóng gói tốt". Lỗi ở đâu?
import java.util.ArrayList;
import java.util.List;
public class Cart {
private final List<String> items = new ArrayList<>();
public List<String> getItems() { return items; } // leaks the internal list
public void add(String item) {
if (items.size() >= 10) throw new IllegalStateException("Cart is full");
items.add(item);
}
}Đáp án:
Getter trả thẳng list nội bộ, bên ngoài gọi cart.getItems().add(...) vượt qua giới hạn 10 món. private final chỉ khóa biến items (N1.3), không khóa nội dung; getter trao bản sao tham chiếu (mục này) nên người gọi sửa được chính list đó, phá đóng gói (N1.5). Sửa: return List.copyOf(items); (bản sao bất biến) hoặc Collections.unmodifiableList(items) (view chỉ đọc).
N1.15 Java 8 cơ bản: lambda, Stream, Optional
Nhắc lại kiến thức
Interface là bản cam kết các method mà class phải có; default và static method có thân nên không tính là method abstract (xem N1.7). List, Set là các Collection chứa dữ liệu trong bộ nhớ (xem N1.11).
Functional interface có đúng một method abstract (nên đánh dấu @FunctionalInterface): Runnable, Comparator, Predicate<T> (T → boolean), Function<T,R> (T → R), Consumer<T>, Supplier<T>. Lambda là cách viết ngắn implementation của nó; method reference (Product::name) còn gọn hơn khi chỉ gọi một method có sẵn.
import java.util.List;
import java.util.Optional;
import java.util.function.Predicate;
import java.util.stream.Collectors;
public class Java8Basics {
record Product(String name, long price) {}
public static void main(String[] args) {
List<Product> products = List.of(new Product("Áo", 200), new Product("Chuột", 350),
new Product("Tai nghe", 900));
Predicate<Product> expensive = p -> p.price() > 300; // lambda
List<String> names = products.stream()
.filter(expensive) // keep products above 300
.map(Product::name) // method reference
.collect(Collectors.toList());
System.out.println(names); // [Chuột, Tai nghe]
Optional<Product> cheap = products.stream().filter(p -> p.price() < 100).findFirst();
System.out.println(cheap.map(Product::name).orElse("Không có")); // Không có
}
}Giải thích: filter, map là thao tác trung gian, lười: chỉ chạy khi gặp thao tác kết thúc như collect, findFirst; list gốc không bị sửa. findFirst trả về Optional vì có thể không tìm thấy, map(...).orElse(...) xử lý cả hai trường hợp không cần kiểm tra null. Java 16+ có .toList() gọn hơn (list bất biến).
Chữ lười (lazy) có nghĩa cụ thể: các bước trung gian chỉ được ghi lại vào pipeline, chưa chạy gì cho tới khi gặp thao tác kết thúc (terminal operation). Đoạn code sau chứng minh:
import java.util.stream.Stream;
public class LazyStream {
public static void main(String[] args) {
Stream<String> s = Stream.of("a", "b", "c")
.filter(x -> { System.out.println("filter " + x); return true; }); // not run yet
System.out.println("before terminal");
System.out.println(s.findFirst().orElse("none")); // terminal: pipeline runs now
}
}Giải thích: output là before terminal, filter a, a. Dòng "filter" không in khi khai báo stream; khi findFirst chạy, stream xử lý từng phần tử qua cả pipeline và dừng ngay khi có kết quả đầu tiên, nên "b", "c" không bao giờ đi qua filter. Hệ quả thực tế: đừng đặt tác dụng phụ (ghi DB, sửa biến ngoài) trong filter/map, vì bạn không kiểm soát được nó chạy bao nhiêu lần.
Câu hỏi (Cơ bản): Functional interface là gì? Tự viết được không?
Interface có đúng một method abstract, nhờ đó lambda biết mình cài đặt method nào. Tự viết được, thêm @FunctionalInterface để compiler chặn method abstract thứ hai; default và static method không tính.
Câu hỏi (Hiểu sâu): Collection khác Stream thế nào?
Collection lưu dữ liệu, duyệt được nhiều lần. Stream là pipeline xử lý dữ liệu, lười và chỉ dùng được một lần; gọi terminal operation lần hai ném IllegalStateException.
Bẫy thường gặp
Gọi optional.get() không kiểm tra, hoặc if (opt.isPresent()) opt.get(), chỉ là kiểm tra null đổi tên. Dùng orElse, orElseThrow, map, ifPresent. Stream nâng cao và Optional đúng chỗ: Chương 1.
Câu hỏi (Đọc code): Đoạn code dưới đây in ra gì?
import java.util.List;
import java.util.stream.Stream;
public class StreamReuse {
public static void main(String[] args) {
Stream<Integer> nums = List.of(1, 2, 3, 4).stream();
System.out.println(nums.filter(n -> n % 2 == 0).count());
System.out.println(nums.count());
}
}Đáp án:
In 2 rồi ném IllegalStateException: stream has already been operated upon or closed. Stream chỉ dùng được một lần: gọi filter trên nums đã "tiêu thụ" nó, lần gọi thứ hai trên cùng biến nums bị từ chối. Cần dùng lại thì giữ Collection gốc và gọi list.stream() mỗi lần, hoặc truyền Supplier<Stream<T>>.
Câu hỏi (Đọc code): Tìm lỗi. Đoạn code dùng Stream dưới đây chạy đúng hôm nay nhưng có vấn đề gì?
static List<String> longNames(List<String> names) {
List<String> result = new ArrayList<>();
names.stream()
.filter(n -> n.length() > 3)
.forEach(n -> result.add(n.toUpperCase())); // side effect on an external list
return result;
}Đáp án:
Dùng forEach để thêm vào list bên ngoài là tác dụng phụ; đổi sang parallelStream() là ArrayList bị nhiều thread cùng ghi và hỏng dữ liệu. Stream được thiết kế để mô tả "lấy gì" rồi để collect/toList() tự gom kết quả an toàn. Sửa: return names.stream().filter(n -> n.length() > 3).map(String::toUpperCase).toList();, ngắn hơn và không phụ thuộc biến ngoài.
N1.16 Bài code Fresher kinh điển
Nhắc lại kiến thức
String bất biến nên không sửa từng ký tự tại chỗ được (xem N1.8); HashMap/HashSet tra cứu trung bình O(1) nhờ hashCode() và equals() (xem N1.9, N1.11). Ký hiệu O(n), O(√n) đo thời gian tăng theo kích thước đầu vào (xem N1.11).
Ở mức 2–4 năm, viết đúng chưa đủ: hãy tự nói độ phức tạp và trường hợp biên (chuỗi rỗng, null, phần tử trùng).
Bài 1: Đảo chuỗi (thường bị cấm dùng StringBuilder.reverse()):
public class ReverseString {
static String reverse(String s) {
char[] chars = s.toCharArray();
for (int l = 0, r = chars.length - 1; l < r; l++, r--) { // two pointers move toward the middle
char tmp = chars[l];
chars[l] = chars[r]; // swap both ends
chars[r] = tmp;
}
return new String(chars);
}
public static void main(String[] args) {
System.out.println(reverse("Java21")); // 12avaJ
}
}Giải thích: String bất biến nên chuyển sang char[] rồi hoán đổi hai đầu. O(n) thời gian, O(n) bộ nhớ; chuỗi rỗng vẫn đúng. Được dùng thư viện thì new StringBuilder(s).reverse() còn xử lý đúng cặp surrogate (emoji).
Bài 2: Kiểm tra palindrome (bỏ qua dấu câu, không phân biệt hoa thường):
public class Palindrome {
static boolean isPalindrome(String s) {
int l = 0, r = s.length() - 1;
while (l < r) {
if (!Character.isLetterOrDigit(s.charAt(l))) { l++; continue; } // skip punctuation
if (!Character.isLetterOrDigit(s.charAt(r))) { r--; continue; }
if (Character.toLowerCase(s.charAt(l)) != Character.toLowerCase(s.charAt(r))) return false;
l++;
r--;
}
return true;
}
public static void main(String[] args) {
System.out.println(isPalindrome("A man, a plan, a canal: Panama")); // true
}
}Giải thích: hai con trỏ so sánh từ hai đầu, dừng ngay khi gặp cặp khác nhau. O(n) thời gian, O(1) bộ nhớ, tốt hơn cách "đảo chuỗi rồi so sánh" tốn thêm O(n).
Bài 3: Đếm tần suất ký tự bằng HashMap:
import java.util.LinkedHashMap;
import java.util.Map;
public class CharFrequency {
public static void main(String[] args) {
Map<Character, Integer> freq = new LinkedHashMap<>(); // HashMap that keeps insertion order
for (char c : "hello world".toCharArray()) {
if (c == ' ') continue;
freq.merge(c, 1, Integer::sum); // absent: put 1, present: add 1
}
System.out.println(freq); // {h=1, e=1, l=3, o=2, w=1, r=1, d=1}
}
}Giải thích: ký tự là key, số lần xuất hiện là value; merge tương đương put(c, getOrDefault(c, 0) + 1). LinkedHashMap (lớp con của HashMap) in theo thứ tự xuất hiện cho dễ kiểm tra. O(n) thời gian, O(k) bộ nhớ với k là số ký tự khác nhau.
Bài 4: Số lớn thứ hai trong mảng (không sort, có phần tử trùng):
public class SecondLargest {
static Integer secondLargest(int[] arr) {
long first = Long.MIN_VALUE, second = Long.MIN_VALUE; // long: distinguishes "not found yet"
for (int x : arr) {
if (x > first) {
second = first; // old max becomes second
first = x;
} else if (x < first && x > second) {
second = x; // skip values equal to first
}
}
return second == Long.MIN_VALUE ? null : (int) second;
}
public static void main(String[] args) {
System.out.println(secondLargest(new int[]{5, 1, 9, 9, 7})); // 7
System.out.println(secondLargest(new int[]{4, 4})); // null
}
}Giải thích: một lần duyệt, giữ hai giá trị lớn nhất. Số 9 thứ hai bị bỏ qua nhờ x < first nên kết quả là 7; mảng toàn giá trị bằng nhau trả null. O(n) thời gian, O(1) bộ nhớ; sort rồi lấy phần tử áp chót là O(n log n) và sai khi có trùng.
Bài 5: Kiểm tra số nguyên tố:
public class PrimeCheck {
static boolean isPrime(int n) {
if (n < 2) return false;
if (n % 2 == 0) return n == 2; // among even numbers only 2 is prime
for (int i = 3; (long) i * i <= n; i += 2) { // try odd divisors up to sqrt(n)
if (n % i == 0) return false;
}
return true;
}
public static void main(String[] args) {
System.out.println(isPrime(29) + " " + isPrime(91)); // true false (91 = 7 x 13)
}
}Giải thích: n có ước lớn hơn √n thì cũng có ước nhỏ hơn √n, nên chỉ thử đến √n: O(√n), bỏ số chẵn giảm thêm một nửa. (long) i * i tránh tràn int khi n gần Integer.MAX_VALUE (bẫy ở N1.2). Liệt kê mọi số nguyên tố đến N thì dùng sàng Eratosthenes.
Bài 6: Xóa phần tử trùng và tìm phần tử chung:
import java.util.ArrayList;
import java.util.HashSet;
import java.util.LinkedHashSet;
import java.util.List;
import java.util.Set;
public class RemoveDuplicates {
public static void main(String[] args) {
List<String> tags = List.of("java", "sql", "java", "git", "sql");
List<String> unique = new ArrayList<>(new LinkedHashSet<>(tags)); // remove duplicates, keep order
System.out.println(unique); // [java, sql, git]
Set<String> common = new HashSet<>(tags); // copy, since retainAll mutates the receiver
common.retainAll(Set.of("git", "java", "k8s")); // keep only elements present in both
System.out.println(common); // java and git, order not guaranteed
}
}Giải thích: LinkedHashSet loại trùng theo equals/hashCode và giữ thứ tự thêm vào (HashSet làm xáo thứ tự; stream().distinct() cho cùng kết quả). retainAll hỏi contains() của tập kia cho từng phần tử, tập kia là Set nên tổng O(n), so với O(n²) nếu lồng hai vòng trên List. Với class tự viết, cần override equals/hashCode (N1.9).
Câu hỏi (Đọc code): Vì sao bài 4 dùng long cho first, second thay vì int? Đoạn code dưới đây in ra gì?
public class SecondLargestEdge {
static Integer secondLargest(int[] arr) {
long first = Long.MIN_VALUE, second = Long.MIN_VALUE;
for (int x : arr) {
if (x > first) { second = first; first = x; }
else if (x < first && x > second) { second = x; }
}
return second == Long.MIN_VALUE ? null : (int) second;
}
public static void main(String[] args) {
System.out.println(secondLargest(new int[]{Integer.MIN_VALUE, 5}));
System.out.println(secondLargest(new int[]{3}));
}
}Đáp án:
In -2147483648 rồi null. Mảng {Integer.MIN_VALUE, 5} có số lớn thứ hai hợp lệ chính là Integer.MIN_VALUE. Nếu dùng int với giá trị khởi đầu Integer.MIN_VALUE làm dấu "chưa có", hàm không phân biệt được "số lớn thứ hai là MIN_VALUE" với "không có số lớn thứ hai" và trả sai null. Long.MIN_VALUE nằm ngoài miền giá trị của int nên dùng làm dấu hiệu an toàn. Mảng một phần tử không có số lớn thứ hai nên trả null.
Câu hỏi (Hiểu sâu): Vì sao tìm phần tử chung của hai List nên chuyển một bên sang HashSet?
Vì contains trên List phải duyệt từng phần tử (O(n)), còn trên HashSet là O(1) trung bình nhờ hashCode chọn thẳng bucket. Lồng hai vòng trên List là O(n x m); chuyển một bên sang Set thì còn O(n + m). Với 100 nghìn phần tử mỗi bên, đó là khác biệt giữa 10 tỷ phép so sánh và khoảng 200 nghìn phép.
Câu hỏi (Thực chiến): Làm xong bài code, người phỏng vấn hỏi "còn gì nữa không?". Nên nói gì?
Tự nêu độ phức tạp thời gian và bộ nhớ, các trường hợp biên đã xử lý và chưa xử lý, và cách viết test cho chúng. Ví dụ với bài đảo chuỗi: đầu vào null (ném NPE hay trả null?), chuỗi rỗng, chuỗi có emoji (cặp surrogate bị đảo ngược thành ký tự lỗi). Ứng viên 2–4 năm được kỳ vọng tự đặt câu hỏi này trước khi bị hỏi, và biết khi nào nên dùng thư viện có sẵn thay vì tự viết.
N1.17 Tóm tắt trọng tâm
- Static/dynamic typing hỏi "kiểm tra kiểu lúc nào", strong/weak hỏi "có tự ép kiểu ngầm không"; Java là static và strong vì compiler chặn
int x = "5";và JVM némClassCastExceptionkhi ép sai. - Wrapper là object trên Heap bọc một field
private final int value, biến chỉ giữ tham chiếu; nó tồn tại vì generic/Collection chỉ nhận object và vì cần biểu diễnnull. - Autoboxing là compiler chèn
Integer.valueOf(x), unboxing là chènx.intValue(); vì vậy==giữa wrapper chỉ đúng trong cache -128..127 và unboxingnullném NPE. - Biến tính toán trong vòng lặp dùng primitive, vì mỗi lần boxing ngoài cache là một object mới cho GC dọn.
- Class được khởi tạo một lần ở lần dùng chủ động đầu tiên, cha trước con, static field và static block chạy theo thứ tự viết; đọc hằng compile-time không kích hoạt khởi tạo vì compiler đã chép thẳng giá trị.
- Compiler tự chèn
super()và chỉ tạo default constructor khi class không có constructor nào, nên cha chỉ có constructor có tham số thì con phải gọisuper(...)tường minh. - JPA entity cần constructor không tham số (
protectedlà đủ) và khôngfinal, vì Hibernate tạo object qua reflection và kế thừa entity để làm proxy; record vì vậy không làm entity được. - Overload chọn lúc biên dịch theo kiểu khai báo, override chọn lúc chạy theo object thật; field và static method chỉ bị che, không đa hình.
- So sánh String và wrapper bằng
equals(), vì==chỉ so tham chiếu và chuỗi từ request/DB không nằm trong pool. - Override
equals(Object)thì phải overridehashCode()trên cùng field, vì HashMap chọn bucket bằng hashCode trước khi gọi equals; field dùng làm key phải bất biến. compareTo/comparedùngInteger.comparethay cho phép trừ để tránh tràn số, và TreeSet coi kết quả 0 là trùng nên comparator phải đủ tiêu chí.- Không nuốt exception; bọc lỗi thì truyền
causeđể stack trace còn dấu vết gốc, và nhớ@Transactionalmặc định chỉ rollback với unchecked exception. - Dữ liệu trên Heap dùng chung giữa các thread phải được bảo vệ (
synchronized,AtomicInteger), vìcount++gồm ba bước và không atomic. - Java chỉ pass by value: với object, giá trị được sao chép là tham chiếu, nên sửa được nội dung nhưng không đổi được biến của caller.
- Stream lười và chỉ dùng một lần; đừng đặt tác dụng phụ trong
filter/map/forEach, hãy gom kết quả bằngcollect/toList().