Chương 1. Java Core: trả lời ở mức "tại sao"
Với ứng viên 2–4 năm, tôi gần như không hỏi "OOP có mấy tính chất" hay "ArrayList khác LinkedList chỗ nào" để nghe bạn đọc định nghĩa. Tôi hỏi để xem bạn có hiểu cơ chế bên dưới và có từng trả giá vì nó trong dự án thật hay chưa. Một câu trả lời kiểu "HashMap tra cứu O(1)" chỉ là điểm xuất phát; điều tôi muốn nghe là khi nào nó không còn O(1), vì sao key phải bất biến, và bạn đã gặp bug nào liên quan. Chương này đi qua các chủ đề Java Core bị hỏi nhiều nhất, ở đúng độ sâu mà một vị trí Junior+/Middle đòi hỏi.
Người phỏng vấn muốn kiểm tra
- Bạn chọn composition hay inheritance dựa trên lý do gì, và biết interface hiện đại (Java 8+) khác abstract class ở đâu.
- Bạn hiểu hợp đồng equals/hashCode và hậu quả cụ thể khi vi phạm trong HashSet/HashMap.
- Bạn giải thích được HashMap hoạt động bên trong: hash, index, collision, treeify, resize.
- Bạn dùng Stream, Optional, record, sealed, pattern matching đúng chỗ, và biết lúc nào không nên dùng.
- Bạn thiết kế exception có chủ đích, không nuốt lỗi, không lạm dụng checked exception.
1.1 OOP: composition, inheritance và interface hiện đại
Nhắc lại kiến thức
Bốn tính chất OOP (đóng gói, kế thừa, đa hình, trừu tượng) và cách override đã có ở N1.5; abstract class, interface và composition ở mức cơ bản đã có ở N1.7. Nhớ nhất một điều: đa hình nghĩa là lời gọi method được quyết định theo kiểu thật của object lúc chạy, không theo kiểu của biến. Mục này dựa hoàn toàn vào ý đó.
Bốn tính chất đóng gói, kế thừa, đa hình, trừu tượng là kiến thức Fresher. Ở mức 2–4 năm, câu hỏi thật là: bạn dùng chúng để giải quyết vấn đề gì, và bạn tránh được cái bẫy nào. Cái bẫy phổ biến nhất là lạm dụng kế thừa để "tái sử dụng code".
Vì sao "ưu tiên composition hơn inheritance"
Kế thừa tạo ra ràng buộc chặt nhất có thể giữa hai class: class con phụ thuộc vào chi tiết cài đặt của class cha, không chỉ vào API công khai. Khi class cha thay đổi cách cài đặt nội bộ, class con có thể hỏng dù không ai sửa nó. Đây gọi là fragile base class problem. Ví dụ kinh điển dưới đây cố đếm số phần tử được thêm vào một HashSet bằng cách kế thừa và override cả add() lẫn addAll(), và cho kết quả sai:
import java.util.Collection;
import java.util.HashSet;
import java.util.List;
public class CountingSet<E> extends HashSet<E> {
private int addCount = 0;
@Override
public boolean add(E e) {
addCount++; // called again from super.addAll() -> counted twice
return super.add(e);
}
@Override
public boolean addAll(Collection<? extends E> c) {
addCount += c.size();
return super.addAll(c); // internally calls add() for each element
}
public static void main(String[] args) {
CountingSet<String> s = new CountingSet<>();
s.addAll(List.of("a", "b", "c"));
System.out.println(s.addCount); // prints: 6 (not 3)
}
}Giải thích: Chạy main in ra 6 thay vì 3. Kết quả sai vì HashSet.addAll (thừa hưởng từ AbstractCollection) gọi lại add() cho từng phần tử, và do đa hình, lời gọi đó rơi vào chính add() đã override. Mỗi phần tử vì thế bị đếm hai lần: một lần khi addAll cộng c.size() (được 3), một lần nữa trong add (thêm 3). Việc addAll gọi lại add là một chi tiết cài đặt mà tài liệu API không hứa sẽ giữ nguyên: nếu phiên bản JDK sau đổi cách cài đặt, kết quả lại đổi theo dù bạn không sửa dòng nào. Nếu dùng composition, tức là class của bạn chứa một Set và chuyển tiếp (delegate) lời gọi, bạn chỉ phụ thuộc vào API công khai của Set, và lỗi này không thể xảy ra.
Fragile base class nói bằng lời thường
Fragile base class (class cha "mong manh") là tình huống class con viết đúng hoàn toàn, nhưng vẫn hỏng khi class cha thay đổi cách làm việc bên trong. Hình dung bạn thuê nhà và đóng đinh treo tranh vào một bức tường, không biết bên trong tường có ống nước; chủ nhà (class cha) đổi đường ống, tranh của bạn làm thủng ống. Bạn không làm gì sai với "mặt ngoài" của bức tường, nhưng lại phụ thuộc vào thứ bên trong mà không ai hứa giữ nguyên. Với kế thừa, "bên trong" chính là việc method nào của class cha gọi method nào khác, và việc bạn override một method có thể làm thay đổi luồng gọi đó.
Cách sửa là composition: class của bạn chứa một object Set làm field và delegate (chuyển tiếp) lời gọi cho nó. Delegate nghĩa là method của bạn làm phần việc riêng (ở đây là đếm) rồi gọi đúng method tương ứng của object bên trong. Đoạn code dưới đây viết lại CountingSet theo cách đó:
import java.util.Collection;
import java.util.HashSet;
import java.util.List;
import java.util.Set;
public class CountingSetFixed<E> {
private final Set<E> inner = new HashSet<>(); // composition: HAS-A set, not IS-A set
private int addCount = 0;
public boolean add(E e) {
addCount++;
return inner.add(e);
}
public boolean addAll(Collection<? extends E> c) {
addCount += c.size();
return inner.addAll(c); // inner calls its OWN add(), never ours
}
public static void main(String[] args) {
CountingSetFixed<String> s = new CountingSetFixed<>();
s.addAll(List.of("a", "b", "c"));
System.out.println(s.addCount); // prints: 3
}
}Giải thích: Lần này chương trình in ra 3. inner.addAll(c) bên trong HashSet vẫn gọi add() cho từng phần tử, nhưng đó là add() của chính HashSet, không phải add() của CountingSetFixed, vì CountingSetFixed không kế thừa HashSet nên không có đa hình nào "bắt" được lời gọi đó. Dù JDK sau này đổi cách cài đặt addAll, kết quả vẫn đúng. Cái giá phải trả là phải tự viết các method muốn công khai (hoặc implement interface Set và delegate toàn bộ); đổi lại bạn chỉ phụ thuộc vào API công khai.
Quy tắc thực tế tôi muốn nghe: dùng kế thừa khi quan hệ thực sự là "is-a" và class cha được thiết kế để kế thừa (thường là abstract class của chính team hoặc của framework); còn lại dùng composition. Trong Spring, gần như mọi service là composition: OrderService có PaymentClient, OrderRepository được inject qua constructor. Cây kế thừa nhiều tầng (Base → AbstractBase → ConcreteBase) trong code nghiệp vụ là mùi code; muốn đổi hành vi theo ngữ cảnh thì dùng Strategy.
Hai thuật ngữ trong đoạn trên: quan hệ is-a ("là một") nghĩa là mọi chỗ dùng class cha đều có thể thay bằng class con mà vẫn đúng, ví dụ SavingsAccount là một Account; còn quan hệ has-a ("có một") là class này dùng class kia làm thành phần, ví dụ OrderService có một PaymentClient. Strategy là mẫu thiết kế trong đó mỗi cách xử lý (tính phí thẻ, tính phí ví) là một class riêng cùng implement một interface, và service nhận interface đó qua constructor, nên muốn đổi hành vi chỉ cần truyền implementation khác thay vì tạo thêm class con.
Interface và abstract class từ Java 8 trở đi
Nhiều tài liệu cũ nói "interface chỉ chứa method trừu tượng". Điều này đã không còn đúng từ Java 8. Hiện tại interface có thể có default method, static method (Java 8) và private method (Java 9). Khác biệt còn lại nằm ở trạng thái và mục đích.
| Tiêu chí | Interface | Abstract class |
|---|---|---|
| Trạng thái (field) | Chỉ hằng public static final | Có field instance bình thường |
| Constructor | Không có | Có, dùng để khởi tạo trạng thái chung |
| Method có thân | default, static, private | Mọi loại, mọi access modifier |
| Đa kế thừa | Một class implement nhiều interface | Chỉ extends một class |
| Mục đích | Hợp đồng hành vi, "có khả năng làm gì" | Khung chung có trạng thái, template method |
Đoạn code dưới đây minh họa một interface hiện đại có đủ ba loại method có thân (default, static, private) nhưng vẫn không có field trạng thái nào:
public interface PaymentGateway {
PaymentResult charge(long amountVnd, String orderId);
// default method: adds behavior without breaking existing implementations
default PaymentResult chargeOrThrow(long amountVnd, String orderId) {
requirePositive(amountVnd);
PaymentResult r = charge(amountVnd, orderId);
if (!r.success()) throw new IllegalStateException("Thanh toán thất bại: " + orderId);
return r;
}
static PaymentGateway noop() { // static factory inside the interface
return (amount, orderId) -> new PaymentResult("NOOP-" + orderId, true);
}
private void requirePositive(long amountVnd) { // private method (Java 9+)
if (amountVnd <= 0) throw new IllegalArgumentException("Số tiền phải dương");
}
record PaymentResult(String transactionId, boolean success) {} // nested type, implicitly static
}Giải thích: Class implement PaymentGateway chỉ bắt buộc cài đặt charge(); chargeOrThrow() được thừa hưởng sẵn nhờ default method, và bên trong nó gọi lại charge() của chính class đó (đa hình). noop() là static factory, gọi qua PaymentGateway.noop() và trả về một lambda được vì interface chỉ có đúng một method trừu tượng. requirePositive() là private nên chỉ dùng nội bộ để các default method không lặp code; class implement không nhìn thấy nó. Điều interface vẫn không làm được là giữ trạng thái: không có field instance, không có constructor.
Lý do Java 8 thêm default method rất thực tế: để thêm stream(), forEach(), removeIf() vào Collection mà không làm vỡ hàng triệu class đang implement interface này. Đó là công cụ tiến hóa API, không phải để biến interface thành abstract class.
Mẹo từ người phỏng vấn
Khi được hỏi "chọn interface hay abstract class", hãy trả lời theo trạng thái: cần chia sẻ field và logic khởi tạo thì abstract class; chỉ cần hợp đồng hành vi thì interface. Trong code Spring hiện đại, phần lớn là interface + composition; abstract class thường chỉ xuất hiện ở dạng template (ví dụ một AbstractMessageConsumer xử lý retry, log, gọi hook handle() cho class con).
Nếu một class implement hai interface có cùng chữ ký default method, trình biên dịch buộc bạn override và chọn rõ ràng, ví dụ A.super.log(). Đây là cách Java xử lý "diamond problem" mà không cần đa kế thừa class.
Diamond problem (bài toán hình thoi) là khi một class nhận cùng một method từ hai nguồn khác nhau và không biết nên dùng bản nào; gọi là hình thoi vì sơ đồ kế thừa có dạng A ở trên, B và C ở giữa, D ở dưới. Java giải quyết theo ba quy tắc: (1) method của class (kể cả class cha) luôn thắng default method của interface; (2) interface cụ thể hơn (interface con) thắng interface cha; (3) nếu vẫn còn xung đột thì class bắt buộc override, nếu không sẽ gặp lỗi biên dịch dạng "class Service inherits unrelated defaults for name() from types Logger and Auditor". Xem câu hỏi Đọc code ở mục 1.9 để thấy ví dụ cụ thể.
1.2 equals() và hashCode(): hợp đồng và hậu quả khi vi phạm
Nhắc lại kiến thức
Ở N1.9: == so sánh hai biến có trỏ cùng một object không, còn equals() so sánh "nội dung" theo cách class tự định nghĩa; hashCode() trả về một số nguyên đại diện cho object, dùng để phân loại nhanh. Mặc định (class Object) cả hai đều dựa trên identity, tức chỉ bằng nhau khi là cùng một object.
Hợp đồng cốt lõi: nếu a.equals(b) là true thì a.hashCode() == b.hashCode(). Chiều ngược lại không bắt buộc: hai object khác nhau có thể trùng hashCode (đó là collision). Ngoài ra equals phải phản xạ, đối xứng, bắc cầu, nhất quán và x.equals(null) trả về false.
Năm tính chất đó nghe học thuật nhưng nói bằng lời thường rất đơn giản:
- Phản xạ: object luôn bằng chính nó,
a.equals(a)là true. - Đối xứng: nếu
a.equals(b)thìb.equals(a). Vi phạm hay gặp:Emailso sánh không phân biệt hoa thường với mộtString, nhưngString.equals(Email)thì luôn false. - Bắc cầu:
abằngb,bbằngcthìabằngc. - Nhất quán: gọi nhiều lần mà object không đổi thì kết quả không đổi (không được dựa vào thời gian hiện tại, số ngẫu nhiên).
- Khác null:
a.equals(null)là false, không được ném NullPointerException.
Để hiểu vì sao hashCode quan trọng, cần biết bucket là gì. HashMap giống một tủ có nhiều ngăn đánh số; mỗi ngăn là một bucket. Khi cất một key, HashMap tính từ hashCode() ra số ngăn và bỏ key vào ngăn đó; khi tìm, nó tính lại số ngăn và chỉ lục trong ngăn đó, nhờ vậy không phải lục cả tủ. Chi tiết cách tính số ngăn nằm ở mục 1.5.
Vì sao hợp đồng này quan trọng? Vì HashMap/HashSet tìm kiếm theo hai bước: dùng hashCode() để chọn bucket, sau đó dùng equals() để so sánh trong bucket. Nếu hai object "bằng nhau" nhưng hashCode khác, chúng rơi vào hai bucket khác nhau và equals() không bao giờ được gọi. Đoạn code dưới đây override equals() cho class Email nhưng quên hashCode(), rồi thêm hai email giống hệt nhau vào một HashSet:
import java.util.HashSet;
import java.util.Objects;
import java.util.Set;
public class EqualsDemo {
static class Email {
private final String value;
Email(String value) { this.value = value; }
@Override
public boolean equals(Object o) {
if (this == o) return true;
if (!(o instanceof Email other)) return false;
return value.equalsIgnoreCase(other.value);
}
// Forgot hashCode() -> falls back to Object's identity hashCode
}
public static void main(String[] args) {
Set<Email> set = new HashSet<>();
set.add(new Email("a@x.com"));
set.add(new Email("a@x.com"));
System.out.println(set.size()); // prints: 2 (usually): the "duplicate" slips into the Set
System.out.println(Objects.hash("a@x.com")); // correct way to build a hashCode from fields
}
}Giải thích: set.size() in ra 2 (gần như chắc chắn), dù theo equals() hai email là một. Vì hashCode() không được override, mỗi new Email(...) nhận hashCode theo identity của object, hai giá trị khác nhau nên HashSet đặt chúng vào hai bucket khác nhau và không bao giờ gọi equals() để phát hiện trùng. Dòng Objects.hash(...) chỉ minh họa hàm tiện ích sinh hashCode từ giá trị field. Để sửa, hashCode() phải dựa trên đúng các field mà equals() dùng, theo cùng quy tắc (ở đây là value.toLowerCase() vì equals so sánh không phân biệt hoa thường), ví dụ @Override public int hashCode() { return value.toLowerCase().hashCode(); }; khi đó set.size() in ra 1. Nếu chỉ cần so sánh theo giá trị các field, hãy dùng record: trình biên dịch sinh sẵn equals, hashCode, toString đúng hợp đồng.
Quy tắc "override equals thì phải override hashCode theo cùng field" dễ nói nhưng dễ sai ở chi tiết. Đoạn code dưới đây là bản sửa của Email: thay vì tự viết equals không phân biệt hoa thường rồi phải nhớ viết hashCode khớp, ta chuẩn hóa dữ liệu ngay khi tạo object và để record lo phần còn lại:
import java.util.HashSet;
import java.util.Set;
public class EqualsFixedDemo {
record Email(String value) {
Email {
value = value.toLowerCase(); // normalize once -> equals and hashCode always agree
}
}
public static void main(String[] args) {
Set<Email> set = new HashSet<>();
set.add(new Email("a@x.com"));
set.add(new Email("A@X.com"));
System.out.println(set.size()); // prints: 1
}
}Giải thích: Chương trình in ra 1. Khối Email { ... } là compact constructor của record: nó chạy trước khi field được gán, nên mọi Email đều lưu email dạng chữ thường. equals và hashCode do record sinh ra dựa trên field value đã chuẩn hóa, nên hai email chỉ khác hoa thường có cùng hashCode, rơi vào cùng bucket và được equals xác nhận là trùng. So với bản sai ở trên, lỗi không còn chỗ để xảy ra vì không ai phải nhớ đồng bộ hai method bằng tay.
Hậu quả thường gặp trong dự án:
- Set chứa phần tử trùng, dẫn đến gửi email hai lần, tính tiền hai lần.
map.get(key)trả về null dù bạn vừaputmột key "giống hệt".- Key mutable: bạn
putobject vào HashMap rồi sửa field tham gia hashCode. Object vẫn nằm ở bucket cũ, không ai tìm thấy, không ai xóa được, về lâu dài thành rò rỉ bộ nhớ. Vì vậy key của HashMap nên là bất biến (String, Long, record, enum).
1.3 String, immutability và String pool
Nhắc lại kiến thức
Ở N1.8: String là immutable (bất biến), tức mọi method như toUpperCase(), replace() đều trả về String mới chứ không sửa chuỗi cũ; literal như "abc" được lưu trong String pool để dùng chung; so sánh nội dung String phải dùng equals().
String bất biến vì bốn lý do thực tế: (1) cho phép String pool chia sẻ an toàn một instance cho nhiều tham chiếu; (2) thread-safe mà không cần đồng bộ; (3) cache được hashCode, nên String là key HashMap rất rẻ; (4) an toàn: tên file, URL, username đã kiểm tra xong thì không thể bị sửa ngầm sau đó.
String pool nằm trong Heap từ Java 7 (trước đó nằm ở PermGen). Chỉ literal và hằng thời điểm biên dịch được tự động đưa vào pool; chuỗi tạo lúc runtime thì không, trừ khi gọi intern(). Đoạn code dưới đây so sánh bằng == (so sánh tham chiếu, tức "có phải cùng một object") giữa chuỗi literal, chuỗi ghép lúc biên dịch và chuỗi ghép lúc runtime:
public class StringPoolDemo {
public static void main(String[] args) {
String a = "ab";
String b = "a" + "b"; // compile-time constant -> folded to "ab", shared from the pool
String p = "a";
String c = p + "b"; // runtime concatenation -> new object on the Heap
System.out.println(a == b); // true
System.out.println(a == c); // false
System.out.println(a.equals(c)); // true: always compare Strings with equals
System.out.println(a == c.intern()); // true: intern() returns the pooled instance
}
}Giải thích: Bốn dòng in ra lần lượt true, false, true, true.
a == blà true vì trình biên dịch gộp"a" + "b"thành literal"ab"ngay lúc biên dịch (constant folding), nênavàbtrỏ cùng một object trong pool.a == clà false vìplà biến thường, phép ghép chỉ thực hiện lúc runtime và tạo object mới trên Heap; nội dung giống nhưng là hai object khác nhau. Nếu khai báofinal String p = "a"thìp + "b"lại thành hằng biên dịch và kết quả đổi thành true, đó là lý do==trên String "lúc đúng lúc sai" rất khó debug.equals()so sánh nội dung nên luôn true;c.intern()tìm trong pool chuỗi có cùng nội dung và trả về chính instance màađang trỏ tới.- Rút ra: luôn so sánh String bằng
equals(), không dựa vào==.
| String | StringBuilder | StringBuffer | |
|---|---|---|---|
| Mutable | Không | Có | Có |
| Thread-safe | Có (vì bất biến) | Không | Có (method synchronized) |
| Dùng khi | Giá trị, key, hằng | Ghép chuỗi trong 1 thread | Hầu như không dùng nữa |
Ghép chuỗi bằng + trong một biểu thức đơn thì không cần lo: từ Java 9 trình biên dịch dùng invokedynamic (StringConcatFactory) và tối ưu tốt. Vấn đề là ghép trong vòng lặp: mỗi vòng tạo một String mới và copy toàn bộ nội dung cũ, chi phí tăng theo bình phương. Khi đó dùng StringBuilder (hoặc String.join, Collectors.joining). StringBuffer gần như không còn lý do dùng: hiếm khi nhiều thread cùng ghép vào một buffer, và nếu có thì đó là vấn đề thiết kế.
invokedynamic là một lệnh bytecode (có từ Java 7) cho phép JVM quyết định cách thực thi một lời gọi lúc chạy lần đầu, thay vì trình biên dịch phải viết sẵn toàn bộ code. Với ghép chuỗi, trước Java 9 trình biên dịch sinh sẵn chuỗi lệnh new StringBuilder().append(...).append(...).toString(); từ Java 9 nó chỉ sinh một lệnh invokedynamic, và JVM chọn chiến lược ghép tối ưu nhất cho phiên bản hiện tại. Lợi ích: nâng cấp JDK là tự nhanh hơn mà không cần biên dịch lại. Lambda (mục 1.8) cũng dùng cơ chế này.
Quy tắc "không ghép chuỗi bằng + trong vòng lặp" minh họa rõ nhất bằng cặp code dưới đây, cùng tạo chuỗi 01234:
public class ConcatLoopDemo {
public static void main(String[] args) {
String bad = "";
for (int i = 0; i < 5; i++) {
bad += i; // BAD: a brand-new String each iteration, old content copied
}
StringBuilder sb = new StringBuilder();
for (int i = 0; i < 5; i++) {
sb.append(i); // GOOD: appends into one growing buffer
}
System.out.println(bad + " " + sb); // prints: 01234 01234
}
}Giải thích: Hai cách cho cùng kết quả 01234, khác nhau ở chi phí. Với bad += i, mỗi vòng tạo một String mới và chép lại toàn bộ nội dung cũ: vòng 1 chép 1 ký tự, vòng 2 chép 2 ký tự... n vòng tốn khoảng n²/2 lần chép, cộng thêm n object rác cho GC. Với 5 phần tử không đáng kể, nhưng ghép 100.000 dòng CSV thì chênh lệch là vài mili giây so với hàng chục giây. StringBuilder giữ một mảng ký tự có thể nới rộng, append chỉ ghi thêm vào cuối nên tổng chi phí gần như tuyến tính.
Ghi chú
Một số tài liệu nói "mỗi lần sửa String, object mới được lưu vào String pool". Không đúng: kết quả của concat, substring, replace... là object thường trên Heap, không tự vào pool.
1.4 Java chỉ có pass-by-value
Nhắc lại kiến thức
Ở N1.14: biến kiểu object trong Java không chứa object mà chứa tham chiếu, giống một tờ giấy ghi địa chỉ nhà. Truyền biến vào method là photo tờ giấy đó: hai tờ cùng chỉ đến một ngôi nhà, nhưng xé hay sửa tờ photo không làm đổi tờ gốc.
Java luôn truyền bản sao giá trị của biến. Với kiểu nguyên thủy, đó là bản sao con số. Với object, đó là bản sao tham chiếu. Vì vậy method có thể sửa trạng thái của object mà tham chiếu trỏ tới, nhưng không thể làm biến bên ngoài trỏ sang object khác. Đoạn code dưới đây truyền một list vào method; method vừa sửa list, vừa gán lại tham số sang một list mới:
import java.util.ArrayList;
import java.util.List;
public class PassByValueDemo {
static void modify(List<String> list) {
list.add("x"); // mutates the shared object -> caller sees it
list = new ArrayList<>(); // only reassigns the local copy of the reference
list.add("y"); // caller does not see this
}
public static void main(String[] args) {
List<String> data = new ArrayList<>();
modify(data);
System.out.println(data); // [x]
}
}Giải thích: Chương trình in ra [x]. Khi gọi modify(data), tham số list nhận bản sao của tham chiếu, cùng trỏ vào một ArrayList với data. Vì vậy list.add("x") sửa object chung và data thấy thay đổi. Lệnh list = new ArrayList<>() chỉ làm bản sao cục bộ trỏ sang object mới; data bên ngoài vẫn trỏ list cũ, nên "y" được thêm vào list mới và list đó bị bỏ đi khi method kết thúc. Nếu Java là pass-by-reference thật, data sẽ bị đổi thành list mới và in ra [y].
Hệ quả thực tế: nếu method nhận vào một collection hoặc entity và âm thầm sửa nó, caller sẽ bị ảnh hưởng. Đó là lý do nên trả về object mới hoặc dùng List.copyOf() để phòng thủ khi nhận input từ bên ngoài.
Đoạn code dưới đây là cặp sai/sửa cho quy tắc trên: một method thêm tag mặc định vào danh sách tag của khách hàng.
import java.util.ArrayList;
import java.util.List;
public class DefensiveCopyDemo {
static List<String> addDefaultBad(List<String> tags) {
tags.add("new"); // BAD: mutates the caller's list
return tags;
}
static List<String> addDefaultGood(List<String> tags) {
List<String> copy = new ArrayList<>(tags); // GOOD: work on a copy
copy.add("new");
return copy;
}
public static void main(String[] args) {
List<String> a = new ArrayList<>(List.of("vip"));
addDefaultBad(a);
List<String> b = new ArrayList<>(List.of("vip"));
List<String> r = addDefaultGood(b);
System.out.println(a + " " + b + " " + r); // prints: [vip, new] [vip] [vip, new]
}
}Giải thích: a in ra [vip, new]: caller chỉ muốn đọc kết quả nhưng list gốc của mình bị sửa theo, vì tham số tags là bản sao của tham chiếu, cùng trỏ vào list của caller. Với addDefaultGood, b vẫn là [vip] và kết quả mới nằm trong r. Trong dự án, lỗi kiểu addDefaultBad thường lộ ra khi list đó là field của một entity JPA hoặc một cấu hình dùng chung: sửa nhầm là thay đổi bị ghi xuống DB hoặc ảnh hưởng mọi request sau. Nếu truyền vào List.of(...) (list không sửa được), addDefaultBad còn ném UnsupportedOperationException.
1.5 Collections: chọn đúng cấu trúc và hiểu HashMap từ bên trong
Nhắc lại kiến thức
Ở N1.11: List giữ thứ tự và cho phép trùng, Set không cho trùng, Map lưu cặp key-value; ArrayList dựa trên mảng, LinkedList dựa trên các node nối nhau, HashMap dựa trên bảng băm. Ký hiệu O(1) nghĩa là thời gian không phụ thuộc số phần tử, O(n) là tăng tuyến tính theo số phần tử, O(log n) là tăng rất chậm (1 triệu phần tử chỉ khoảng 20 bước).
ArrayList và LinkedList trong thực tế
Lý thuyết nói LinkedList chèn/xóa O(1). Thực tế là O(1) chỉ khi bạn đã đứng sẵn ở node đó; muốn chèn vào giữa bạn vẫn phải duyệt O(n) để tìm vị trí. Thêm vào đó, mỗi node LinkedList là một object riêng (thêm hai tham chiếu prev/next và object header), nằm rải rác trên Heap nên CPU cache hoạt động kém. ArrayList dùng mảng liên tục, tăng dung lượng 1,5 lần khi đầy, và System.arraycopy dịch phần tử rất nhanh.
Kết luận thực chiến: dùng ArrayList gần như mọi lúc; cần queue/stack thì dùng ArrayDeque; LinkedList hầu như không có lý do xuất hiện trong code backend mới.
HashMap hoạt động bên trong (Java 8+)
HashMap là một mảng các bucket (Node<K,V>[] table), mặc định dung lượng 16, load factor 0.75. Mảng chỉ được cấp phát ở lần put đầu tiên.
Giải thích từng thuật ngữ trước khi đi vào luồng chạy:
- Bucket: một ô của mảng
table. Mỗi ô chứanull(trống) hoặc node đầu tiên của một danh sách các cặp key-value có cùng chỉ số. - Capacity (dung lượng): số ô của mảng, không phải số phần tử đang lưu.
- Load factor (hệ số tải): tỷ lệ "đầy" cho phép trước khi nới mảng. 0.75 với capacity 16 nghĩa là khi có hơn 12 phần tử thì mảng được nhân đôi. Chọn 0.75 là điểm cân bằng: thấp hơn thì tốn bộ nhớ cho ô trống, cao hơn thì nhiều key chen vào cùng ô và tra cứu chậm đi.
- Collision (va chạm): hai key khác nhau nhưng tính ra cùng chỉ số bucket.
- Hash spreading: HashMap không dùng thẳng
key.hashCode()mà trộn 16 bit cao xuống 16 bit thấp:hash = h ^ (h >>> 16). Lý do: bước sau chỉ dùng các bit thấp để chọn bucket, nếu không trộn thì các hashCode chỉ khác nhau ở bit cao sẽ dồn vào cùng bucket. - Tính index:
index = hash & (n - 1), với n là dung lượng mảng. Khi n là lũy thừa của 2, phép AND cho kết quả như lấy dư cho n nhưng rẻ hơn nhiều; vì thế capacity luôn được làm tròn lên lũy thừa của 2. - Collision: nếu bucket đã có node, HashMap duyệt danh sách liên kết trong bucket, so sánh
hashrồiequals(). Trùng key thì ghi đè value, không trùng thì nối node mới vào cuối. - Treeify: khi một bucket có quá 8 node (
TREEIFY_THRESHOLD = 8) và dung lượng mảng đã đạt ít nhất 64 (MIN_TREEIFY_CAPACITY), danh sách được chuyển thành cây đỏ đen, tra cứu trong bucket từ O(n) xuống O(log n). Nếu mảng còn nhỏ hơn 64, HashMap chọn resize thay vì treeify. Khi resize tách bucket còn từ 6 node trở xuống (UNTREEIFY_THRESHOLD), cây được chuyển ngược về danh sách. - Resize: khi số phần tử vượt
capacity * 0.75, mảng được nhân đôi. Từ Java 8, mỗi node hoặc giữ nguyên index, hoặc chuyển sangindex + oldCapacity, chỉ cần xét một bit (hash & oldCap), không phải tính lại hash.
Đoạn code dưới đây mô phỏng lại bước 1 và bước 2 ở trên: từ một key tính ra chỉ số bucket mà HashMap sẽ dùng.
// Simulates how HashMap picks a bucket
static int bucketIndex(Object key, int capacity) {
int h = (key == null) ? 0 : key.hashCode(); // null key always hashes to 0 -> bucket 0
int hash = h ^ (h >>> 16); // mix the high bits into the low bits
return hash & (capacity - 1); // capacity is always a power of 2
}Giải thích: Với capacity 16, capacity - 1 bằng 15, tức 0b1111; phép & giữ lại đúng 4 bit thấp của hash nên kết quả luôn nằm trong khoảng 0–15. Kết quả này bằng hash % 16 nhưng chỉ tốn một lệnh AND và không bao giờ âm (còn % với hash âm cho ra số âm). Bước XOR với h >>> 16 kéo ảnh hưởng của 16 bit cao xuống, để hai key chỉ khác nhau ở bit cao vẫn có cơ hội rơi vào hai bucket khác nhau. Nếu capacity không phải lũy thừa của 2, ví dụ 10 thì capacity - 1 là 0b1001, phép AND chỉ cho ra 4 giá trị (0, 1, 8, 9) và phần lớn bucket bị bỏ trống, đó là lý do HashMap luôn làm tròn capacity lên lũy thừa của 2.
Nhìn tận mắt bảng bucket
Đoạn code dưới đây dùng đúng công thức trên để tính bucket của vài key String với capacity 16 và 32, giúp bạn thấy collision và resize xảy ra thế nào:
public class BucketDemo {
static int bucketIndex(Object key, int capacity) {
int h = (key == null) ? 0 : key.hashCode();
return (h ^ (h >>> 16)) & (capacity - 1);
}
public static void main(String[] args) {
for (String k : new String[] {"Aa", "BB", "an", "binh", "cuong"}) {
System.out.println(k + ": " + bucketIndex(k, 16) + " -> " + bucketIndex(k, 32));
}
// prints: Aa: 0 -> 0 | BB: 0 -> 0 | an: 13 -> 13 | binh: 15 -> 15 | cuong: 15 -> 31
}
}Giải thích: "Aa" và "BB" có cùng hashCode() là 2112 (đây là ví dụ kinh điển của collision thật, không chỉ trùng chỉ số), nên luôn chung bucket 0. "binh" và "cuong" có hashCode khác nhau nhưng 4 bit thấp sau khi trộn giống nhau, nên với capacity 16 cùng rơi vào bucket 15. Khi capacity tăng lên 32, phép AND xét thêm một bit (bit giá trị 16): bit đó của "binh" là 0 nên giữ bucket 15, của "cuong" là 1 nên chuyển sang 15 + 16 = 31. Đó chính là quy tắc "giữ nguyên index hoặc chuyển sang index + oldCapacity" ở bước Resize. Vẽ lại bằng chữ, bảng bucket trông như sau:
capacity = 16 capacity = 32 (after resize)
index | bucket content index | bucket content
------+------------------------- ------+----------------
0 | "Aa" -> "BB" (same hash) 0 | "Aa" -> "BB"
1 | null ... | null
... | null 13 | "an"
13 | "an" 15 | "binh"
14 | null ... | null
15 | "binh" -> "cuong" (collision) 31 | "cuong"Giải thích: Mỗi dòng là một bucket; dấu -> là con trỏ next nối các node trong cùng bucket thành danh sách liên kết. Khi gọi get("cuong") lúc capacity 16, HashMap tính ra bucket 15, so hash và equals với "binh" (không khớp), đi tiếp sang "cuong" (khớp). Danh sách trong bucket càng dài, tra cứu càng chậm; sau resize, "cuong" được tách ra bucket riêng nên tra cứu lại nhanh.
Treeify: khi danh sách trong bucket quá dài
Giả sử hashCode viết tệ (ví dụ luôn trả về 42) hoặc kẻ tấn công cố tình gửi nhiều key trùng hash. Mọi key dồn vào một bucket, danh sách dài hàng nghìn node và mỗi lần get phải duyệt tuần tự, HashMap thoái hóa thành một danh sách liên kết O(n). Treeify là việc HashMap đổi danh sách trong bucket đó thành cây đỏ đen, một dạng cây nhị phân tìm kiếm tự cân bằng: mỗi node có con trái nhỏ hơn, con phải lớn hơn, và cây tự xoay để không bao giờ bị lệch hẳn về một phía, nên tìm một key chỉ cần đi từ gốc xuống khoảng log₂(n) bước.
bucket 7 before treeify (9 nodes, linked list):
k1 -> k2 -> k3 -> k4 -> k5 -> k6 -> k7 -> k8 -> k9 get(k9): 9 comparisons
bucket 7 after treeify (red-black tree, ordered by hash, then compareTo):
k5
/ \
k3 k7
/ \ / \
k2 k4 k6 k8
/ \
k1 k9 get(k9): 4 comparisonsGiải thích: Với 9 node, danh sách cần tới 9 lần so sánh để tìm k9, còn cây chỉ cần 4 (k5 → k7 → k8 → k9). Cây được sắp theo giá trị hash trước; nếu hash trùng nhau hoàn toàn, HashMap dùng compareTo khi key implement Comparable, nếu không thì phải dò cả hai nhánh. Đó là lý do tài liệu khuyên key nên implement Comparable. Node cây tốn khoảng gấp đôi bộ nhớ node thường, nên HashMap chỉ treeify khi thật cần (trên 8 node và capacity từ 64).
Hệ quả thực tế:
- Nếu biết trước số phần tử, khởi tạo với dung lượng phù hợp để tránh resize liên tục. Từ Java 19 có sẵn
HashMap.newHashMap(expectedSize)tự tính capacity theo load factor. - Treeify là lưới an toàn khi hashCode tệ hoặc bị tấn công (cố tình gửi nhiều key trùng hash), không phải lý do để viết hashCode cẩu thả. Để dùng cây hiệu quả, key nên implement
Comparable. - HashMap cho phép một key
null(luôn ở bucket 0) và nhiều valuenull.ConcurrentHashMapvàHashtablethì không. - HashMap không thread-safe. Ở Java 7, resize đồng thời có thể tạo vòng lặp vô hạn trong danh sách bucket (CPU 100%). Java 8 đã sửa lỗi vòng lặp này, nhưng dùng chung HashMap giữa nhiều thread vẫn mất dữ liệu. Dùng
ConcurrentHashMap(xem Chương 3).
LinkedHashMap và TreeMap
| HashMap | LinkedHashMap | TreeMap | |
|---|---|---|---|
| Thứ tự duyệt | Không đảm bảo | Theo thứ tự chèn (hoặc truy cập) | Theo key đã sắp xếp |
| get/put | O(1) trung bình | O(1) trung bình | O(log n) |
| Cấu trúc | Mảng bucket | HashMap + danh sách liên kết đôi | Cây đỏ đen |
| Dùng khi | Mặc định | Cần giữ thứ tự, LRU cache | Range query, floorKey, headMap |
LinkedHashMap với accessOrder = true và override removeEldestEntry là cách viết LRU cache đơn giản nhất trong một câu phỏng vấn (xem ví dụ ngay dưới). TreeMap dùng compareTo/Comparator thay cho equals, vì vậy compareTo cần nhất quán với equals, nếu không hai key "khác nhau" theo equals nhưng compare bằng 0 sẽ bị coi là một.
LRU (Least Recently Used) là chiến lược bộ nhớ đệm: khi đầy thì bỏ phần tử lâu nhất chưa được dùng. Đoạn code dưới đây viết một LRU cache chứa tối đa 2 phần tử bằng LinkedHashMap:
import java.util.LinkedHashMap;
import java.util.Map;
public class LruCacheDemo {
static class LruCache<K, V> extends LinkedHashMap<K, V> {
private final int maxSize;
LruCache(int maxSize) {
super(16, 0.75f, true); // accessOrder = true: get() moves the entry to the end
this.maxSize = maxSize;
}
@Override
protected boolean removeEldestEntry(Map.Entry<K, V> eldest) {
return size() > maxSize; // evict the least recently used entry
}
}
public static void main(String[] args) {
LruCache<String, Integer> cache = new LruCache<>(2);
cache.put("a", 1);
cache.put("b", 2);
cache.get("a"); // "a" becomes the most recently used
cache.put("c", 3); // over capacity -> "b" is evicted
System.out.println(cache.keySet()); // prints: [a, c]
}
}Giải thích: Chương trình in ra [a, c]. Tham số thứ ba true của constructor bật chế độ "thứ tự truy cập": mỗi lần get hoặc put, entry được đưa xuống cuối danh sách, nên đầu danh sách luôn là entry lâu nhất chưa dùng. Sau mỗi put, LinkedHashMap gọi removeEldestEntry; trả về true thì entry đầu bị xóa. Vì "a" vừa được get, entry bị loại là "b". Để ý đây là kế thừa hợp lý: removeEldestEntry là hook mà LinkedHashMap thiết kế sẵn để class con override, đúng điều kiện "class cha được thiết kế để kế thừa" ở mục 1.1. Cache này không thread-safe; production thường dùng Caffeine (xem Chương 8).
Fail-fast iterator
Fail-fast nghĩa là "hỏng thì báo ngay": thay vì chạy tiếp với dữ liệu đã bị thay đổi dưới chân và cho ra kết quả sai âm thầm, iterator chủ động ném exception ngay khi phát hiện bất thường. Giống thủ quỹ đếm tiền: nếu thấy ai đó rút bớt tiền khỏi xấp đang đếm, dừng lại báo lỗi tốt hơn là ghi một con số sai vào sổ.
Các collection trong java.util như ArrayList, HashMap giữ một bộ đếm modCount. Iterator ghi nhớ giá trị này lúc tạo; nếu collection bị sửa cấu trúc bằng đường khác ngoài chính iterator, lần gọi next() tiếp theo sẽ ném ConcurrentModificationException. Đoạn code dưới đây cần xóa các id chẵn khỏi list; dòng bị comment là cách viết sai kinh điển, và lỗi của nó xảy ra ngay cả trong một thread:
import java.util.ArrayList;
import java.util.List;
public class FailFastDemo {
public static void main(String[] args) {
List<Integer> ids = new ArrayList<>(List.of(1, 2, 3, 4));
// for (Integer id : ids) { if (id % 2 == 0) ids.remove(id); } // ConcurrentModificationException
ids.removeIf(id -> id % 2 == 0); // correct way (or iterator.remove())
System.out.println(ids); // [1, 3]
}
}Giải thích: Nếu bỏ comment dòng for-each, chương trình ném ConcurrentModificationException. Vòng for-each thực chất dùng Iterator; ids.remove(id) sửa list trực tiếp (không qua iterator) nên modCount tăng, và ở lần next() kế tiếp iterator thấy modCount khác giá trị đã ghi nhớ nên ném lỗi. Nguy hiểm hơn, nếu phần tử bị xóa đúng là phần tử kế cuối, vòng lặp kết thúc sớm mà không ném lỗi và âm thầm bỏ sót phần tử cuối. Cách đúng là removeIf: ArrayList tự duyệt và xóa bên trong nên trạng thái luôn nhất quán, in ra [1, 3]; hoặc dùng iterator.remove(), hoặc tạo list mới bằng Stream filter.
Fail-fast chỉ là cơ chế phát hiện lỗi "cố gắng hết sức", không phải cơ chế đồng bộ. Các collection trong java.util.concurrent như ConcurrentHashMap, CopyOnWriteArrayList có iterator weakly consistent: không ném exception, nhưng có thể không phản ánh các thay đổi xảy ra sau khi iterator được tạo.
1.6 Generics: type erasure và PECS
Nhắc lại kiến thức
Generics là cách viết List<String> thay vì List trơn để trình biên dịch kiểm tra kiểu phần tử, đã dùng xuyên suốt khi học Collections ở N1.11. Quan hệ kế thừa Integer là một Number (N1.5) sẽ được dùng lại ở phần PECS.
Generics kiểm tra kiểu ở compile-time, sau đó bị xóa khỏi bytecode (type erasure): List<String> và List<Integer> lúc runtime đều chỉ là List, trình biên dịch tự chèn lệnh ép kiểu khi lấy phần tử ra. Java chọn cách này để bytecode mới tương thích với code viết trước Java 5.
Type erasure (xóa kiểu) nghĩa là trình biên dịch dùng thông tin <T> để kiểm tra lỗi, rồi xóa nó đi khi sinh bytecode: T được thay bằng giới hạn trên của nó (Object nếu không khai báo, hoặc Comparable nếu viết T extends Comparable<T>), và một lệnh ép kiểu được chèn vào chỗ lấy giá trị ra. Hai đoạn code dưới đây cho thấy điều đó: đoạn thứ nhất là code bạn viết.
import java.util.ArrayList;
import java.util.List;
public class ErasureBefore {
static <T extends Comparable<T>> T max(List<T> items) {
T best = items.get(0);
for (T x : items) {
if (x.compareTo(best) > 0) best = x;
}
return best;
}
public static void main(String[] args) {
List<String> names = new ArrayList<>(List.of("an", "binh"));
String top = max(names);
System.out.println(top); // prints: binh
System.out.println(new ArrayList<String>().getClass() == new ArrayList<Integer>().getClass()); // prints: true
}
}Đoạn thứ hai là (gần đúng) những gì còn lại trong bytecode sau khi xóa kiểu, viết lại thành Java để dễ đọc:
import java.util.ArrayList;
import java.util.List;
@SuppressWarnings({"rawtypes", "unchecked"})
public class ErasureAfter {
static Comparable max(List items) { // T erased to its bound: Comparable
Comparable best = (Comparable) items.get(0);
for (Object o : items) {
Comparable x = (Comparable) o;
if (x.compareTo(best) > 0) best = x;
}
return best;
}
public static void main(String[] args) {
List names = new ArrayList(List.of("an", "binh")); // List<String> becomes raw List
String top = (String) max(names); // cast inserted by the compiler
System.out.println(top); // prints: binh
}
}Giải thích: Cả hai chương trình in ra binh. Ở bản "sau khi xóa kiểu", không còn <T> hay <String> nào: max làm việc với Comparable và Object, còn lệnh (String) ở chỗ gọi là do trình biên dịch chèn vào. Dòng in true ở bản đầu chứng minh lúc runtime ArrayList<String> và ArrayList<Integer> là cùng một class. Từ đây suy ra các hệ quả bên dưới: new T() không làm được vì lúc chạy không còn biết T là gì; instanceof List<String> không làm được vì object không mang theo <String>.
Hệ quả bạn cần nói được:
- Không thể
new T(),new T[], hayobj instanceof List<String>(với obj kiểu Object). - Không thể overload
process(List<String>)vàprocess(List<Integer>): sau khi xóa kiểu, hai method có cùng chữ ký. - Thông tin generic khai báo trên class, field, method signature vẫn còn trong metadata và đọc được bằng reflection. Đó là cách Jackson
TypeReference<List<OrderDto>>hay SpringParameterizedTypeReferencegiữ được kiểu: tạo một subclass ẩn danh để kiểu generic nằm trong khai báo superclass.
Trước khi đến PECS, cần hai khái niệm. Wildcard là dấu ? trong kiểu generic, nghĩa là "một kiểu nào đó chưa biết": List<? extends Number> là "list của một kiểu con nào đó của Number", List<? super Integer> là "list của một kiểu cha nào đó của Integer". Invariant (bất biến kiểu) nghĩa là dù Integer là con của Number, List<Integer> vẫn không phải con của List<Number>.
Ví dụ đời thường cho PECS: bạn cần lấy trái cây ra từ một giỏ để ăn. Giỏ táo hay giỏ cam đều được, vì thứ lấy ra chắc chắn là trái cây: đó là Basket<? extends Fruit>. Nhưng bạn không được bỏ thêm quả nào vào giỏ đó, vì không biết nó là giỏ chỉ dành cho táo hay cho cam. Ngược lại, bạn cần một cái thùng để bỏ táo vào: thùng táo, thùng trái cây hay thùng thực phẩm đều nhận táo được, đó là Box<? super Apple>; nhưng lấy từ thùng ra thì bạn chỉ biết chắc đó là "một món đồ" (Object). Giỏ là producer (cho ra), thùng là consumer (nhận vào).
PECS (Producer Extends, Consumer Super): nếu tham số chỉ để đọc ra (producer), dùng ? extends T; nếu chỉ để ghi vào (consumer), dùng ? super T. Đoạn code dưới đây viết một method copy tổng quát, với yêu cầu chép được một List<Integer> vào một List<Number>:
import java.util.ArrayList;
import java.util.List;
public class PecsDemo {
// src produces T (read) -> extends; dest consumes T (write) -> super
static <T> void copy(List<? extends T> src, List<? super T> dest) {
for (T item : src) {
dest.add(item);
}
}
public static void main(String[] args) {
List<Integer> ints = List.of(1, 2, 3);
List<Number> numbers = new ArrayList<>();
copy(ints, numbers); // compiles thanks to the wildcards
System.out.println(numbers); // [1, 2, 3]
}
}Giải thích: Chương trình in ra [1, 2, 3]. Nếu khai báo không có wildcard, copy(List<T> src, List<T> dest), thì lời gọi copy(ints, numbers) không biên dịch được: generic trong Java là invariant, List<Integer> không phải là List<Number> dù Integer là Number, nên không có T nào khớp cả hai tham số. (Nếu được coi là một, ta có thể add một Double vào list vốn chỉ chứa Integer.) Với wildcard, chẳng hạn T = Integer: src kiểu List<? extends Integer> chỉ cần đọc ra Integer, còn dest kiểu List<? super Integer> chấp nhận ghi Integer vào, nên List<Number> hợp lệ.
Với List<? extends Number> bạn không thể add (trừ null) vì trình biên dịch không biết list thật là List<Integer> hay List<Double>. Với List<? super Integer> bạn add được Integer, nhưng đọc ra chỉ nhận được Object. Bạn sẽ thấy PECS khắp JDK, ví dụ Collections.copy, Comparator<? super T> trong List.sort.
Lỗi PECS hay gặp nhất trong code dự án là viết tham số List<Number> rồi ngạc nhiên vì không truyền được List<Integer>. Đoạn code dưới đây ghi lại bản sai (trong comment, kèm thông báo lỗi) và bản sửa:
import java.util.List;
public class PecsSumDemo {
// BAD: static double sumBad(List<Number> xs) { ... }
// sumBad(ints) -> compile error: incompatible types:
// List<Integer> cannot be converted to List<Number>
static double sum(List<? extends Number> xs) { // GOOD: xs only produces values -> extends
double total = 0;
for (Number n : xs) total += n.doubleValue();
return total;
}
public static void main(String[] args) {
List<Integer> ints = List.of(1, 2, 3);
List<Double> prices = List.of(1.5, 2.5);
System.out.println(sum(ints) + " " + sum(prices)); // prints: 6.0 4.0
}
}Giải thích: Chương trình in ra 6.0 4.0. Bản sumBad(List<Number>) từ chối List<Integer> vì generic là invariant. Vì sum chỉ đọc phần tử ra (producer), khai báo List<? extends Number> cho phép nhận list của bất kỳ kiểu con nào của Number, và mỗi phần tử đọc ra đều dùng được như Number. Đổi lại, trong sum không thể xs.add(...), điều hoàn toàn hợp lý với một hàm tính tổng.
1.7 Exception: thiết kế chứ không chỉ cú pháp
Nhắc lại kiến thức
Ở N1.12: exception là object mô tả lỗi, được "ném" (throw) lên theo chuỗi lời gọi cho đến khi có khối catch bắt; finally chạy dù có lỗi hay không; stack trace là danh sách các method đang được gọi tại thời điểm lỗi, dùng để tìm dòng gây lỗi.
Throwable có hai nhánh: Error (lỗi mức JVM như OutOfMemoryError, StackOverflowError; không nên bắt để "xử lý") và Exception. Trong Exception, nhánh RuntimeException là unchecked, còn lại là checked (trình biên dịch buộc bạn catch hoặc khai báo throws).
Checked exception có ý tưởng tốt (buộc caller xử lý tình huống có thể phục hồi) nhưng trong thực tế gây nhiều phiền toái: không dùng được tự nhiên trong lambda/Stream, làm lộ chi tiết cài đặt qua chữ ký method. Vì vậy Spring gần như chỉ dùng unchecked exception (ví dụ bọc SQLException thành DataAccessException). Một chi tiết hay bị hỏi: mặc định @Transactional chỉ rollback với RuntimeException và Error, không rollback với checked exception trừ khi khai báo rollbackFor.
try-with-resources (Java 7) tự đóng mọi resource implement AutoCloseable, theo thứ tự ngược với lúc mở, kể cả khi có exception. Nếu cả khối try lẫn close() đều ném lỗi, lỗi của close() được gắn vào lỗi chính dưới dạng suppressed (getSuppressed()), không đè mất lỗi gốc như cách viết finally thủ công. Đoạn code dưới đây đếm số dòng của một file import: dùng try-with-resources để luôn đóng file, và chuyển checked IOException thành unchecked exception có giữ cause:
import java.io.BufferedReader;
import java.io.IOException;
import java.io.UncheckedIOException;
import java.nio.file.Files;
import java.nio.file.Path;
public class ImportService {
public static class InvalidImportFileException extends RuntimeException {
public InvalidImportFileException(String message, Throwable cause) {
super(message, cause); // always keep the cause so the original stack trace survives
}
}
public long countLines(Path file) {
try (BufferedReader reader = Files.newBufferedReader(file)) { // close() runs automatically when leaving try
return reader.lines().count();
} catch (IOException e) {
throw new UncheckedIOException("Không đọc được file " + file, e); // checked -> unchecked, keep e as the cause
}
}
}Giải thích: Khi file đọc được, method trả về số dòng và reader được đóng tự động khi rời khối try, không cần finally. Khi file không đọc được (không tồn tại, thiếu quyền), IOException (checked) bị bắt và ném lại dưới dạng UncheckedIOException kèm e làm cause. Nhờ vậy caller không bị buộc khai báo throws, method dùng được trong lambda/Stream, còn nguyên nhân gốc vẫn hiện ở dòng "Caused by: java.nio.file.NoSuchFileException..." trong log. InvalidImportFileException là mẫu custom exception nghiệp vụ: kế thừa RuntimeException và constructor luôn nhận cause. Nếu bọc lỗi mà không truyền cause, log chỉ còn dòng thông báo bên ngoài và bạn không biết file lỗi vì không tồn tại hay vì thiếu quyền.
Khái niệm suppressed exception nhắc ở trên dễ hiểu nhất khi chạy thử. Đoạn code dưới đây có một resource mà close() luôn ném lỗi, trong khi khối try cũng ném lỗi:
public class SuppressedDemo {
static class Res implements AutoCloseable {
@Override
public void close() {
throw new IllegalStateException("close failed");
}
}
public static void main(String[] args) {
try (Res r = new Res()) {
throw new RuntimeException("main failure");
} catch (RuntimeException e) {
System.out.println(e.getMessage()); // prints: main failure
System.out.println(e.getSuppressed()[0].getMessage()); // prints: close failed
}
}
}Giải thích: Exception bắt được là lỗi gốc main failure, còn lỗi close failed không mất đi mà nằm trong mảng getSuppressed(). Nếu viết theo kiểu cũ với finally { r.close(); }, exception từ close() sẽ thay thế exception gốc, và log chỉ còn "close failed", đúng loại thông tin gây hiểu nhầm khi điều tra sự cố.
Khi nào tạo custom exception? Khi nó mang ý nghĩa nghiệp vụ mà tầng trên cần xử lý khác đi: InsufficientBalanceException được map thành HTTP 422 với mã lỗi riêng trong @RestControllerAdvice, còn lỗi hệ thống thành 500. Đừng tạo custom exception chỉ để đổi tên IllegalArgumentException.
Những điều khiến tôi trừ điểm khi đọc code:
catch (Exception e) {}hoặc chỉe.printStackTrace(): nuốt lỗi, hệ thống chạy tiếp với trạng thái sai và không ai biết.- Log rồi ném lại ở mọi tầng: một lỗi in ra năm lần trong log. Quy tắc: hoặc xử lý (và log), hoặc ném lên, log một lần ở biên (global handler).
- Bọc exception mà không truyền
cause: mất stack trace gốc, debug production như mò kim đáy bể. returntrongfinally: nuốt mất exception đang bay ra.- Bắt
InterruptedExceptionrồi bỏ qua: phải khôi phục cờ bằngThread.currentThread().interrupt()(xem Chương 12).
Quy tắc "bọc exception thì phải giữ cause" được minh họa bằng cặp sai/sửa dưới đây, khi parse số lượng từ input người dùng:
public class WrapCauseDemo {
static int parseQty(String s) {
try {
return Integer.parseInt(s);
} catch (NumberFormatException e) {
// BAD: throw new IllegalArgumentException("Invalid quantity"); -> root cause lost
throw new IllegalArgumentException("Invalid quantity: " + s, e); // GOOD: keep cause
}
}
public static void main(String[] args) {
try {
parseQty("12a");
} catch (IllegalArgumentException e) {
System.out.println(e.getMessage() + " | cause: " + e.getCause().getClass().getSimpleName());
// prints: Invalid quantity: 12a | cause: NumberFormatException
}
}
}Giải thích: Bản sửa in ra Invalid quantity: 12a | cause: NumberFormatException, và stack trace đầy đủ sẽ có dòng "Caused by: java.lang.NumberFormatException: For input string: "12a"". Với bản sai, getCause() trả về null (dòng in trên sẽ ném NullPointerException), log chỉ còn "Invalid quantity" mà không biết giá trị nào, lỗi gì. Hai thay đổi nhỏ: đưa giá trị gây lỗi vào message và truyền e làm tham số cuối.
1.8 Java 8 đến 21: những gì bạn phải dùng được
Nhắc lại kiến thức
Ở N1.15: lambda như x -> x * 2 là cách viết ngắn một hàm; functional interface là interface có đúng một method trừu tượng để lambda "lắp" vào; Stream là chuỗi thao tác xử lý dữ liệu (lọc, biến đổi, gom) trên collection mà không sửa collection gốc; Optional là "hộp" có thể chứa hoặc không chứa giá trị.
Nếu CV ghi Java 17/21 mà bạn chỉ dùng được lambda, người phỏng vấn sẽ nhận ra ngay. Dưới đây là những tính năng tôi kỳ vọng ứng viên 2–4 năm dùng thành thạo.
Lambda và functional interface
Functional interface có đúng một method trừu tượng (Function, Supplier, Consumer, Predicate...); lambda là cách viết ngắn một instance của nó. Lambda chỉ dùng được biến cục bộ effectively final vì nó capture giá trị, không capture biến. Lambda được tạo qua invokedynamic lúc runtime, không sinh file .class riêng lúc biên dịch như anonymous class.
Giải thích hai cụm từ trong đoạn trên. Effectively final là biến không khai báo final nhưng từ lúc gán đến hết phạm vi không bị gán lại lần nào; trình biên dịch coi nó như final. Lambda chỉ được đọc những biến như vậy vì nó có thể chạy muộn hơn, ở thread khác, sau khi method chứa biến đã kết thúc, nên Java chép giá trị vào lambda và cấm gán lại để bản chép không bao giờ lệch với biến gốc. invokedynamic (đã giới thiệu ở mục 1.3) cho phép lần đầu chạy tới lambda, JVM mới sinh class cài đặt functional interface trong bộ nhớ; nhờ vậy không có file Outer$1.class cho mỗi lambda và JVM tự do tối ưu (ví dụ lambda không capture biến nào thì dùng lại một instance duy nhất).
Đoạn code dưới đây là lỗi effectively final kinh điển khi cộng dồn trong forEach, và cách sửa:
import java.util.List;
public class EffectivelyFinalDemo {
public static void main(String[] args) {
List<Integer> prices = List.of(100, 200, 300);
// BAD: int total = 0;
// prices.forEach(p -> total += p);
// compile error: local variables referenced from a lambda expression
// must be final or effectively final
int total = prices.stream().mapToInt(Integer::intValue).sum(); // GOOD: let the stream reduce
System.out.println(total); // prints: 600
}
}Giải thích: Bản sai không biên dịch được vì total += p gán lại biến cục bộ bên trong lambda. Bản sửa để Stream tự cộng dồn và trả về kết quả, in ra 600. Một số người "lách" bằng mảng một phần tử int[] total = {0} hoặc AtomicInteger; cách đó biên dịch được nhưng che giấu side effect và sai khi chuyển sang parallel stream, nên chỉ dùng khi thật cần.
Stream: map, flatMap, lazy evaluation
map: một phần tử thành một phần tử.flatMap: một phần tử thành một stream rồi trải phẳng, ví dụ từ danh sách đơn hàng lấy ra toàn bộ dòng hàng.- Các thao tác trung gian (
filter,map,sorted) là lazy: chỉ chạy khi có thao tác kết thúc (collect,count,findFirst). Dữ liệu chảy theo từng phần tử qua cả pipeline, nhờ vậyfindFirst,anyMatch,limitcó thể dừng sớm (short-circuit). - Một stream chỉ dùng được một lần.
Lazy evaluation (đánh giá lười) nghĩa là các bước filter, map chỉ được ghi lại chứ chưa chạy, giống viết sẵn công thức nấu ăn; chỉ khi có người gọi món (thao tác kết thúc) thì bếp mới nấu, và nấu từng đĩa một. Đoạn code dưới đây in log trong filter để thấy thứ tự thực thi:
import java.util.List;
import java.util.stream.Stream;
public class LazyStreamDemo {
public static void main(String[] args) {
List<String> names = List.of("an", "binh", "cuong", "dung");
Stream<String> pipeline = names.stream()
.filter(n -> { System.out.println("filter " + n); return n.length() > 3; });
System.out.println("pipeline built"); // nothing has been filtered yet
String first = pipeline.findFirst().orElseThrow();
System.out.println("result " + first);
// prints: pipeline built | filter an | filter binh | result binh
}
}Giải thích: Dòng pipeline built in ra trước mọi dòng filter, chứng tỏ tạo pipeline không chạy gì cả. Khi findFirst() được gọi, phần tử an đi qua filter (không đạt), rồi binh (đạt) và stream dừng ngay; cuong và dung không bao giờ được xét. Đó là short-circuit. Hệ quả thực tế: đặt filter rẻ và loại nhiều phần tử lên trước map đắt; và đừng đặt logic quan trọng trong peek vì nếu không có thao tác kết thúc, nó không bao giờ chạy.
Đoạn code dưới đây tính tổng số lượng bán ra theo từng SKU trên toàn bộ đơn hàng. Mỗi đơn có nhiều dòng hàng, nên cần flatMap để trải phẳng trước khi gom nhóm:
import java.util.List;
import java.util.Map;
import java.util.stream.Collectors;
public class StreamDemo {
record OrderLine(String sku, int qty) {}
record Order(String id, String customer, List<OrderLine> lines) {}
public static void main(String[] args) {
List<Order> orders = List.of(
new Order("O1", "an", List.of(new OrderLine("A", 2), new OrderLine("B", 1))),
new Order("O2", "binh", List.of(new OrderLine("A", 5))));
Map<String, Integer> qtyBySku = orders.stream()
.flatMap(o -> o.lines().stream()) // Stream<OrderLine>
.collect(Collectors.groupingBy(OrderLine::sku,
Collectors.summingInt(OrderLine::qty)));
List<String> ids = orders.stream().map(Order::id).toList(); // Java 16+
System.out.println(qtyBySku + " " + ids); // prints: {A=7, B=1} [O1, O2]
}
}Giải thích: Chương trình in ra {A=7, B=1} [O1, O2] (thứ tự key của HashMap không được đảm bảo). Nếu dùng map(o -> o.lines()) ta chỉ được Stream<List<OrderLine>>, mỗi phần tử là cả một list; flatMap trải ba dòng hàng (A×2, B×1, A×5) thành một Stream<OrderLine> duy nhất. groupingBy(OrderLine::sku, summingInt(OrderLine::qty)) gom theo SKU và cộng số lượng: A = 2 + 5 = 7, B = 1. Pipeline chỉ thực sự chạy khi gặp thao tác kết thúc collect/toList, đúng tinh thần lazy evaluation ở trên.
Một số bẫy hay gặp: Stream.toList() (Java 16) trả về list không sửa được, trong khi Collectors.toList() hiện trả về ArrayList nhưng không cam kết điều đó; Collectors.toMap ném IllegalStateException khi trùng key nếu bạn không truyền hàm merge.
Bẫy toMap rất hay gây lỗi production vì dữ liệu test thường không có trùng lặp. Đoạn code dưới đây dựng map từ email sang tên người dùng, trong khi danh sách có hai bản ghi cùng email:
import java.util.List;
import java.util.Map;
import java.util.stream.Collectors;
public class ToMapDemo {
record User(String email, String name) {}
public static void main(String[] args) {
List<User> users = List.of(new User("a@x.com", "An"), new User("a@x.com", "An Nguyen"));
// BAD: users.stream().collect(Collectors.toMap(User::email, User::name));
// -> IllegalStateException: Duplicate key a@x.com (attempted merging values An and An Nguyen)
Map<String, String> byEmail = users.stream()
.collect(Collectors.toMap(User::email, User::name, (first, second) -> second)); // GOOD: keep latest
System.out.println(byEmail); // prints: {a@x.com=An Nguyen}
}
}Giải thích: Bản sai ném IllegalStateException ngay khi gặp email thứ hai. Tham số thứ ba là merge function: nhận hai value trùng key và trả về value được giữ lại, ở đây chọn bản sau nên in ra {a@x.com=An Nguyen}. Chọn giữ bản nào là quyết định nghiệp vụ; nếu trùng key thực sự là dữ liệu hỏng thì cứ để exception nổ nhưng log rõ, còn nếu một key có nhiều giá trị hợp lệ thì dùng groupingBy thay vì toMap.
Parallel stream chạy trên ForkJoinPool.commonPool(), dùng chung toàn JVM, số thread mặc định bằng số core trừ một. Chi tiết khi nào không nên dùng nằm ở câu hỏi phỏng vấn cuối chương.
Optional: dùng đúng chỗ
Optional được thiết kế làm kiểu trả về cho method có thể không có kết quả, như findById. Không nên dùng làm field của entity/DTO (không Serializable, tốn bộ nhớ), không dùng làm tham số method, không bọc collection (trả về list rỗng thay vì Optional<List>). Đoạn code dưới đây có một Optional đã chứa giá trị "an", và so sánh orElse với orElseGet khi giá trị mặc định phải lấy từ một method tốn kém:
import java.util.Optional;
public class OptionalDemo {
static String loadDefaultName() {
System.out.println("gọi DB lấy tên mặc định...");
return "guest";
}
public static void main(String[] args) {
Optional<String> name = Optional.of("an");
String a = name.orElse(loadDefaultName()); // STILL calls loadDefaultName()
String b = name.orElseGet(OptionalDemo::loadDefaultName); // called only when empty
String c = name.map(String::toUpperCase).orElseThrow(); // throws NoSuchElementException if empty
System.out.println(a + " " + b + " " + c); // prints: an an AN
}
}Giải thích: Chương trình in dòng "gọi DB lấy tên mặc định..." một lần rồi mới in an an AN. Dòng đó đến từ orElse: dù Optional có giá trị và kết quả cuối cùng vẫn là "an", loadDefaultName() vẫn bị gọi. Lý do là orElse(x) luôn tính x trước khi gọi (vì Java tính giá trị mọi đối số trước khi gọi method), nên nếu x là lời gọi tốn kém hoặc có side effect, hãy dùng orElseGet: nó nhận một Supplier và chỉ gọi Supplier khi Optional rỗng, vì vậy dòng b ở trên không in thêm gì. Và optional.get() không kiểm tra thì chẳng khác gì dùng null; hãy dùng orElseThrow() để ý đồ rõ ràng.
record, sealed, pattern matching, text block, var
| Tính năng | Phiên bản chính thức | Dùng để làm gì |
|---|---|---|
var | Java 10 | Suy luận kiểu biến cục bộ, không dùng cho field |
| Switch expression | Java 14 | switch trả về giá trị, không fall-through |
| Text block | Java 15 | Chuỗi nhiều dòng: SQL, JSON trong test |
Record, pattern matching instanceof | Java 16 | DTO bất biến; if (o instanceof Email e) |
| Sealed class/interface | Java 17 | Giới hạn tập class con được phép |
Pattern matching cho switch, record pattern | Java 21 | Rẽ nhánh theo kiểu, kiểm tra đầy đủ |
| Virtual threads | Java 21 | Thread nhẹ cho tác vụ I/O (xem Chương 3) |
Ba khái niệm cần hiểu bằng lời thường trước khi đọc ví dụ. Sealed (niêm phong) là khi interface hoặc class tự liệt kê danh sách class con được phép; không ai khác implement được, giống danh sách khách mời đóng. Pattern matching là cú pháp vừa kiểm tra kiểu vừa ép kiểu vừa đặt tên biến trong một bước. Exhaustive (đầy đủ) là switch đã xử lý mọi khả năng có thể xảy ra, và trình biên dịch kiểm chứng được điều đó chỉ khi biết trước danh sách khả năng, tức là nhờ sealed hoặc enum. Đoạn code dưới đây so sánh instanceof kiểu cũ và kiểu pattern matching (Java 16):
public class InstanceofPatternDemo {
static String describe(Object o) {
// Old style: test, then cast by hand
// if (o instanceof String) { String s = (String) o; return "text of length " + s.length(); }
if (o instanceof String s && !s.isEmpty()) { // test + cast + bind in one step
return "text of length " + s.length();
}
if (o instanceof Integer i) {
return "number " + (i + 1);
}
return "unknown";
}
public static void main(String[] args) {
System.out.println(describe("java") + ", " + describe(41) + ", " + describe(3.5));
// prints: text of length 4, number 42, unknown
}
}Giải thích: o instanceof String s chỉ đúng khi o là String, và khi đúng thì biến s đã có kiểu String, dùng ngay được trong cùng điều kiện (!s.isEmpty()) và trong thân if. Bản cũ phải viết tên kiểu hai lần và ép kiểu tay, dễ sai khi copy-paste. 3.5 là Double nên rơi xuống unknown.
Sức mạnh thật sự đến khi kết hợp sealed + record + switch pattern: trình biên dịch biết mọi trường hợp con, nên switch không cần default, và khi thêm một loại mới, mọi switch chưa xử lý sẽ báo lỗi biên dịch, thay vì lọt xuống nhánh default và sinh bug lúc chạy. Đoạn code dưới đây tính phí cho ba loại thanh toán được phép (thẻ, chuyển khoản, ví điện tử), và dùng thêm text block cho câu SQL:
public class PaymentFee {
sealed interface Payment permits Card, BankTransfer, Wallet {}
record Card(long amount, boolean international) implements Payment {}
record BankTransfer(long amount) implements Payment {}
record Wallet(long amount, String provider) implements Payment {}
static long fee(Payment p) {
return switch (p) {
case Card c when c.international() -> c.amount() * 3 / 100; // guard: international card 3%
case Card c -> c.amount() * 15 / 1000; // domestic card 1.5%
case BankTransfer(long amount) -> Math.min(amount / 1000, 10_000); // record pattern
case Wallet w -> 0;
}; // no default needed: the compiler knows every case is covered
}
public static void main(String[] args) {
var sql = """
SELECT id, amount FROM payments
WHERE status = 'PENDING'
""";
System.out.println(fee(new Card(1_000_000, true)) + "\n" + sql); // prints: 30000, then the SQL
}
}Giải thích: Chương trình in ra 30000 (1.000.000 × 3% cho thẻ quốc tế), sau đó là câu SQL.
sealed ... permits Card, BankTransfer, Walletkhai báo đúng ba loại con được phép, nên switch liệt kê đủ ba loại là exhaustive và không cầndefault.case Card c when ...là guard: thẻ quốc tế tính 3%, thẻ còn lại rơi xuống case sau với 1,5%. Thứ tự case quan trọng: đặtcase Card clên trước thì case cówhenphía sau bị trình biên dịch báo lỗi vì không bao giờ tới được (dominated).BankTransfer(long amount)là record pattern: bóc fieldamountra biến ngay trong case, không cần gọi accessor.- Nếu thêm
record Crypto(long amount) implements Paymentvàopermitsmà chưa sửafee, code không biên dịch được vì switch chưa bao quát hết; vớidefaultthì lỗi này sẽ trôi xuống runtime. - Text block
"""giữ nguyên xuống dòng và tự cắt phần thụt lề chung, nên viết SQL/JSON nhiều dòng không cần+và\n.
Record là bất biến nông: field là final, nhưng nếu field là List thì nội dung list vẫn sửa được. Hãy sao chép phòng thủ trong compact constructor, ví dụ items = List.copyOf(items);. Record rất hợp làm DTO, value object, key của Map; không dùng làm JPA entity vì entity cần constructor không tham số và trạng thái thay đổi được.
Đoạn code dưới đây cho thấy "bất biến nông" nghĩa là gì, và bản sửa bằng compact constructor:
import java.util.ArrayList;
import java.util.List;
public class RecordCopyDemo {
record CartBad(List<String> items) {} // BAD: stores the caller's list as-is
record Cart(List<String> items) {
Cart {
items = List.copyOf(items); // GOOD: unmodifiable defensive copy
}
}
public static void main(String[] args) {
List<String> src = new ArrayList<>(List.of("book"));
CartBad bad = new CartBad(src);
Cart good = new Cart(src);
src.add("pen"); // caller mutates the original list
System.out.println(bad.items() + " " + good.items()); // prints: [book, pen] [book]
}
}Giải thích: CartBad in ra [book, pen]: field items là final nên không trỏ sang list khác được, nhưng nó trỏ chung list với src, nên khi caller thêm "pen" thì record "bất biến" cũng đổi theo. Cart in ra [book] vì compact constructor đã chép sang một list riêng không sửa được; gọi good.items().add(...) sẽ ném UnsupportedOperationException. List.copyOf còn từ chối phần tử null, một lợi ích phụ khi làm value object.
Về var: dùng khi kiểu đã hiển nhiên ở vế phải, tránh khi làm mất thông tin (var result = service.process()). Java 21 còn thêm Sequenced Collections: getFirst(), getLast(), reversed() cho List, Deque, LinkedHashSet, và firstEntry(), lastEntry() cho LinkedHashMap.
1.9 Câu hỏi phỏng vấn thường gặp
Câu hỏi được nhóm theo chủ đề: OOP, equals/hashCode, HashMap, String, pass-by-value, Collections (iterator, List), Generics, Exception, rồi Java 8–21. Nhãn trong ngoặc cho biết mức độ: Cơ bản (phải trả lời trôi chảy), Hiểu sâu (hỏi cơ chế, so sánh), Thực chiến (tình huống dự án, tìm lỗi), Đọc code (đoán output hoặc lỗi trước khi xem đáp án).
Câu hỏi (Hiểu sâu): Khi nào bạn dùng kế thừa, khi nào dùng composition?
Tôi mặc định dùng composition, vì nó chỉ phụ thuộc vào API công khai và thay thế được lúc runtime (inject implementation khác, mock khi test). Kế thừa chỉ dùng khi quan hệ thật sự là "is-a" và class cha được thiết kế để mở rộng.
- Ví dụ lỗi của kế thừa: override
addvàaddAllcủa HashSet để đếm, kết quả đếm gấp đôi vìaddAllgọi lạiadd. - Trong dự án: tôi từng thay một cây
BaseServiceba tầng bằng các component nhỏ inject vào service (validator, mapper, notifier), test dễ hơn nhiều.
Câu hỏi đào sâu
"Vậy Liskov Substitution liên quan gì?" — Nếu class con không thể thay thế class cha mà giữ đúng hành vi (ví dụ override method để ném UnsupportedOperationException), đó là dấu hiệu kế thừa sai và nên chuyển sang composition hoặc tách interface.
Câu hỏi (Cơ bản): Fragile base class là gì? Cho ví dụ.
Ý chính: là khi class con hỏng vì class cha đổi cách cài đặt bên trong, dù API công khai không đổi và không ai sửa class con.
Ví dụ kinh điển là CountingSet extends HashSet override cả add và addAll để đếm: vì HashSet.addAll gọi lại add, mỗi phần tử bị đếm hai lần (thêm 3 phần tử, đếm ra 6). Class con đã vô tình phụ thuộc vào việc "addAll có gọi add hay không", một chi tiết mà tài liệu không hứa giữ nguyên. Cách phòng tránh: dùng composition (chứa một Set và delegate), hoặc nếu bạn viết class cha thì hoặc đánh dấu final, hoặc ghi rõ trong Javadoc method nào gọi method nào để class con biết.
Câu hỏi (Cơ bản): Sau Java 8, interface và abstract class còn khác nhau ở đâu?
Ý chính: interface không giữ trạng thái (không field instance, không constructor) và một class implement được nhiều interface; abstract class có trạng thái nhưng chỉ kế thừa được một.
- Interface có
default,static(Java 8) vàprivatemethod (Java 9), nhưng field chỉ là hằngpublic static final. - Abstract class có field, constructor, method với mọi access modifier; phù hợp làm khung chung (template method) có trạng thái.
- Chọn theo câu hỏi: "tôi cần chia sẻ trạng thái và logic khởi tạo không?" Có thì abstract class, không thì interface.
Bẫy thường gặp
Trả lời "interface chỉ có method trừu tượng". Câu này sai từ Java 8 và cho người phỏng vấn thấy bạn học từ tài liệu cũ. Ví dụ phản chứng có sẵn trong JDK: Collection.stream() và Comparator.reversed() đều là default method.
Câu hỏi (Đọc code): Đoạn code dưới đây biên dịch được không? Nếu được thì in ra gì?
public class DiamondDemo {
interface Logger { default String name() { return "Logger"; } }
interface Auditor { default String name() { return "Auditor"; } }
static class Service implements Logger, Auditor {
@Override
public String name() {
return Logger.super.name() + "+" + Auditor.super.name();
}
}
public static void main(String[] args) {
System.out.println(new Service().name());
}
}Đáp án: Biên dịch được và in ra Logger+Auditor.
Servicenhận hai default method cùng chữ kýname()từ hai interface không liên quan, nên Java bắt buộc override; nếu xóa methodname()trongService, trình biên dịch báo "types Logger and Auditor are incompatible; class Service inherits unrelated defaults for name()".- Cú pháp
Logger.super.name()gọi đúng bản default của interfaceLogger; đây là cách duy nhất để gọi default method của một interface cụ thể. - Nếu
Servicekế thừa một class có methodname(), method của class sẽ thắng và không còn xung đột (quy tắc "class thắng interface").
Câu hỏi (Cơ bản): Chuyện gì xảy ra nếu override equals() mà không override hashCode()?
Hai object bằng nhau theo equals có hashCode khác nhau (theo identity), nên rơi vào hai bucket khác nhau trong HashMap/HashSet. Kết quả: Set chứa phần tử trùng, map.get() với key "giống hệt" trả về null, contains() trả false.
- Cách sửa: hashCode dùng đúng các field mà equals dùng; tốt nhất dùng
recordhoặcObjects.hash(...). - Key của HashMap nên bất biến; nếu sửa field tham gia hashCode sau khi put, entry bị "lạc" và không xóa được.
Câu hỏi đào sâu
"Với JPA entity thì viết equals/hashCode thế nào?" — Không dùng tất cả field (lazy collection có thể bị load, gây vòng lặp). Thường so sánh theo id khi id khác null và trả về hashCode cố định theo class, để hashCode không đổi trước và sau khi persist.
Câu hỏi (Hiểu sâu): Nếu hashCode() luôn trả về cùng một số (ví dụ 42) thì chương trình có sai không?
Ý chính: không sai về kết quả, vì hợp đồng vẫn được giữ (object bằng nhau thì hashCode bằng nhau), nhưng hiệu năng sụp đổ vì mọi key dồn vào một bucket.
Mọi get/put phải duyệt hết danh sách trong bucket đó và gọi equals từng node, từ O(1) thành O(n). Từ Java 8, khi bucket vượt 8 node (và capacity từ 64) thì treeify thành cây, còn O(log n), nhưng vẫn chậm hơn nhiều và tốn gấp đôi bộ nhớ node. Câu này kiểm tra bạn có phân biệt được đúng (hợp đồng) và tốt (phân bố đều) không.
Câu hỏi đào sâu
"Vậy hashCode tốt là thế nào?" — Dùng đúng các field của equals, phân bố đều, rẻ để tính và không đổi khi object nằm trong collection. Thực tế cứ dùng Objects.hash(...) hoặc record, không cần tự nghĩ công thức nhân 31.
Câu hỏi (Đọc code): Đoạn code dưới đây in ra gì?
import java.util.HashMap;
import java.util.Map;
import java.util.Objects;
public class MutableKeyDemo {
static class Sku {
String code;
Sku(String code) { this.code = code; }
@Override public boolean equals(Object o) { return o instanceof Sku s && s.code.equals(code); }
@Override public int hashCode() { return Objects.hash(code); }
}
public static void main(String[] args) {
Map<Sku, Integer> stock = new HashMap<>();
Sku key = new Sku("A1");
stock.put(key, 10);
key.code = "B2";
System.out.println(stock.get(key) + " " + stock.get(new Sku("A1")) + " " + stock.size());
}
}Đáp án: In ra null null 1. Entry vẫn nằm trong map nhưng không ai tìm thấy.
- Lúc
put, entry được đặt vào bucket tính từ hash của"A1"và node lưu lại giá trị hash đó. stock.get(key): key giờ có code"B2", hash mới khác, nên HashMap tìm ở bucket khác (hoặc cùng bucket nhưng hash lưu trong node không khớp), trả về null.stock.get(new Sku("A1")): tìm đúng bucket cũ, hash khớp, nhưngequalsso"A1"với code hiện tại"B2"của key trong map, không khớp, trả về null.size()vẫn là 1: entry "lạc" chiếm bộ nhớ mãi mãi. Cách sửa: key phải bất biến, ví dụrecord Sku(String code).
Câu hỏi (Thực chiến): Sau khi save() một entity JPA, set.contains(entity) bỗng trả về false. Vì sao và sửa thế nào?
Ý chính: hashCode của entity được tính từ id, mà id đổi từ null sang giá trị thật khi persist, nên entity "lạc bucket" y như ví dụ key mutable.
Tình huống điển hình: bạn thêm các OrderItem mới (chưa có id) vào Set<OrderItem>, rồi lưu xuống DB. Hibernate gán id, hashCode đổi, contains/remove trên set đó không còn thấy phần tử. Đây là chỗ kiến thức equals/hashCode (mục 1.2) nối với cách JPA sinh id (Chương 6).
- Cách sửa phổ biến:
equalsso sánh theo id khi id khác null (và cùng class), cònhashCodetrả về một giá trị cố định theo class, ví dụgetClass().hashCode(), để không đổi trước và sau khi persist. - Hoặc dùng business key bất biến có ngay từ lúc tạo (mã đơn hàng, UUID do ứng dụng sinh).
- Không dùng
@Datacủa Lombok cho entity: nó sinh equals/hashCode trên mọi field, kể cả collection lazy, gây load ngoài ý muốn và vòng lặp vô hạn khi hai entity tham chiếu nhau.
Câu hỏi đào sâu
"hashCode cố định thì mọi entity cùng một bucket, có chậm không?" — Có, nhưng set entity trong một aggregate thường chỉ vài chục phần tử nên không đáng kể; đổi lại tính đúng được đảm bảo. Nếu cần set lớn thì dùng UUID sinh sẵn làm business key.
Câu hỏi (Hiểu sâu): Giải thích HashMap hoạt động bên trong.
Trả lời theo đúng luồng put: trộn hash h ^ (h >>> 16), tính index = hash & (n - 1), bucket trống thì đặt node, có rồi thì so hash và equals để ghi đè hoặc nối cuối. Bucket vượt 8 node và capacity từ 64 thì thành cây đỏ đen, chưa đủ 64 thì resize. Vượt 0.75 capacity thì nhân đôi mảng. Sau đó chủ động nói hệ quả: key phải bất biến, nên khởi tạo capacity khi biết trước kích thước, không thread-safe.
Câu hỏi đào sâu
"Vì sao capacity phải là lũy thừa của 2?" — Để hash & (n - 1) thay được phép chia lấy dư và để khi resize mỗi node chỉ cần xét một bit để biết giữ nguyên index hay chuyển sang index + oldCap. "Vì sao không treeify ngay từ đầu?" — Node cây tốn gấp đôi bộ nhớ; với hashCode tốt, xác suất một bucket có 8 node là cực nhỏ.
Câu hỏi (Cơ bản): Load factor là gì? Vì sao mặc định là 0.75?
Ý chính: load factor là ngưỡng "đầy" mà vượt qua thì HashMap nhân đôi mảng; 0.75 là điểm cân bằng giữa bộ nhớ và tốc độ.
Với capacity 16 và load factor 0.75, ngưỡng là 12: phần tử thứ 13 kích hoạt resize lên 32. Load factor thấp (0.5) thì ít collision nhưng nửa mảng bỏ trống; cao (1.0) thì tiết kiệm bộ nhớ nhưng collision nhiều hơn, tra cứu chậm đi. Hầu như không có lý do đổi giá trị này; điều bạn nên chỉnh là capacity ban đầu khi biết trước kích thước.
Câu hỏi (Thực chiến): Service nạp 1 triệu bản ghi vào HashMap lúc khởi động, thấy chậm và GC nhiều. Bạn xem xét gì?
Ý chính: kiểm tra resize liên tục (khởi tạo đúng capacity), chất lượng hashCode của key, và kích thước object; sau đó mới nghĩ đến cấu trúc khác.
new HashMap<>()mặc định capacity 16 sẽ resize khoảng 17 lần để chứa 1 triệu phần tử, mỗi lần cấp mảng mới và chia lại node. DùngHashMap.newHashMap(1_000_000)(Java 19+) hoặcnew HashMap<>((int) (1_000_000 / 0.75f) + 1).- Nếu key là class tự viết, kiểm tra hashCode có phân bố đều không (đo bằng profiler, xem có bucket bị treeify không).
- Key/value dạng wrapper (
Long,Integer) tốn khoảng 16 byte mỗi object cộng node 32 byte; với dữ liệu thuần số có thể cân nhắc thư viện map cho primitive hoặc đưa dữ liệu vào cache/DB thay vì giữ trong Heap. - Đo trước và sau bằng log thời gian khởi động và GC log, đừng tối ưu theo cảm giác.
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 TreeSetLengthDemo {
public static void main(String[] args) {
TreeSet<String> names = new TreeSet<>(Comparator.comparing(String::length));
names.add("an");
names.add("bo");
names.add("cuong");
System.out.println(names + " " + names.contains("xy"));
}
}Đáp án: In ra [an, cuong] true.
- TreeSet không dùng
equalsmà dùng comparator để quyết định hai phần tử có trùng không. Comparator ở đây chỉ so độ dài, nên"bo"(dài 2) bị coi là trùng với"an"và không được thêm. contains("xy")cũng chỉ so độ dài, tìm thấy một phần tử dài 2 nên trả về true, dù"xy"chưa từng được thêm.- Bài học: comparator của TreeSet/TreeMap phải nhất quán với equals; muốn sắp theo độ dài mà không mất phần tử thì thêm tiêu chí phụ, ví dụ
Comparator.comparing(String::length).thenComparing(Comparator.naturalOrder()).
Câu hỏi (Cơ bản): Vì sao String bất biến? Khi nào dùng StringBuilder?
Bất biến cho phép String pool chia sẻ instance, thread-safe tự nhiên, cache hashCode (key HashMap rẻ) và an toàn khi truyền qua các tầng. Dùng StringBuilder khi ghép chuỗi trong vòng lặp; một biểu thức + đơn lẻ thì trình biên dịch đã tối ưu.
Bẫy thường gặp
So sánh String bằng ==. Code có thể chạy đúng khi test với literal (cùng pool) rồi sai trên production khi chuỗi đến từ request hoặc DB.
Câu hỏi (Đọc code): Đoạn code dưới đây in ra gì?
public class StringQuiz {
public static void main(String[] args) {
String s = "java";
s.toUpperCase();
final String f = "ja";
String v = "ja";
System.out.println(s);
System.out.println((f + "va") == "java");
System.out.println((v + "va") == "java");
}
}Đáp án: In ra java, true, false trên ba dòng.
s.toUpperCase()trả về một String mới là"JAVA"nhưng kết quả bị bỏ đi;svẫn trỏ"java"vì String bất biến. Muốn đổi phải viếts = s.toUpperCase().flàfinalvà được gán bằng literal nên là hằng biên dịch;f + "va"được gộp thành literal"java"lúc biên dịch, trỏ cùng object trong pool, nên==là true.vkhông final, phép ghép chạy lúc runtime và tạo object mới, nên==là false. Kết luận vẫn là: so sánh String bằngequals().
Câu hỏi (Cơ bản): String pool nằm ở đâu? Khi nào nên gọi intern()?
Ý chính: từ Java 7, String pool nằm trong Heap (trước đó ở PermGen); intern() hầu như không cần gọi trong code ứng dụng.
Literal và hằng biên dịch tự vào pool. intern() đưa một chuỗi tạo lúc runtime vào pool (hoặc trả về bản đã có) để các chuỗi trùng nội dung dùng chung một object. Nó từng được dùng để tiết kiệm bộ nhớ khi có hàng triệu chuỗi lặp lại (mã tỉnh, trạng thái), nhưng pool là một bảng băm cố định kích thước nên lạm dụng làm chậm. Hiện nay G1 có tùy chọn -XX:+UseStringDeduplication tự gộp mảng ký tự trùng mà không cần sửa code.
Bẫy thường gặp
Nói "String pool nằm trong Stack" hoặc "PermGen" khi hỏi về Java 17. Stack chỉ chứa biến cục bộ và tham chiếu; PermGen đã bị bỏ từ Java 8 (thay bằng Metaspace, và Metaspace không chứa String pool).
Câu hỏi (Đọc code): Đoạn code dưới đây in ra gì?
public class SwapQuiz {
static void swap(Integer a, Integer b) { Integer t = a; a = b; b = t; }
static void rename(StringBuilder sb) {
sb.append("!");
sb = new StringBuilder("new");
}
public static void main(String[] args) {
Integer x = 1, y = 2;
swap(x, y);
StringBuilder name = new StringBuilder("an");
rename(name);
System.out.println(x + " " + y + " " + name);
}
}Đáp án: In ra 1 2 an!.
swapchỉ tráo hai bản sao tham chiếu cục bộavàb;x,ybên ngoài không đổi. Không thể viết hàm swap hai biến trong Java.renamesửa object StringBuilder chung quaappend("!"), nênnamethấyan!. Dòng gánsb = new StringBuilder("new")chỉ đổi bản sao cục bộ, không ảnh hưởngname.- Đây là minh chứng gọn nhất cho pass-by-value: sửa object thì bên ngoài thấy, gán lại tham số thì không.
Câu hỏi (Hiểu sâu): Nếu Java chỉ pass-by-value, vì sao method vẫn sửa được object của caller?
Ý chính: vì "giá trị" được chép là tham chiếu, nên bản sao và bản gốc cùng trỏ một object; sửa object thì cả hai thấy, nhưng gán lại tham số thì không.
Pass-by-reference thật (như ref trong C#) cho phép method làm biến của caller trỏ sang object khác; Java không làm được điều đó. Người phỏng vấn thường muốn nghe bạn phân biệt hai hành động: sửa trạng thái object (thấy được bên ngoài) và gán lại biến (không thấy). Hệ quả thực tế: method nhận entity hoặc collection có thể âm thầm sửa dữ liệu của caller, nên cần sao chép phòng thủ hoặc dùng kiểu bất biến.
Câu hỏi (Hiểu sâu): ConcurrentModificationException xảy ra khi nào? Chỉ trong đa luồng à?
Không. Nó xảy ra khi collection bị sửa cấu trúc trong lúc đang duyệt bằng iterator mà không qua chính iterator, kể cả trong một thread, ví dụ gọi list.remove() trong vòng for-each. Cách sửa: removeIf, iterator.remove(), hoặc tạo list mới bằng Stream filter.
Câu hỏi đào sâu
"ConcurrentHashMap có ném lỗi này không?" — Không, iterator của nó là weakly consistent: không ném exception, phản ánh trạng thái tại một thời điểm nào đó và có thể thấy hoặc không thấy các thay đổi đồng thời.
Câu hỏi (Cơ bản): ArrayList và LinkedList, trong dự án bạn chọn cái nào?
Ý chính: gần như luôn là ArrayList; cần queue/stack thì ArrayDeque; LinkedList hầu như không có lý do dùng.
- ArrayList truy cập theo index O(1), dữ liệu nằm liền nhau nên tận dụng CPU cache tốt; thêm vào cuối O(1) trung bình (thỉnh thoảng nới mảng 1,5 lần).
- LinkedList chỉ chèn/xóa O(1) khi đã đứng ở node đó; tìm vị trí vẫn O(n), mỗi node tốn thêm hai tham chiếu và object header.
- Ví dụ trả lời ăn điểm: "Tôi từng đo một list 100.000 phần tử, duyệt ArrayList nhanh hơn LinkedList vài lần dù cùng O(n), vì các node LinkedList nằm rải rác trên Heap."
Câu hỏi (Cơ bản): List.of(), Arrays.asList() và new ArrayList<>() khác nhau thế nào?
Ý chính: List.of không sửa được gì, Arrays.asList sửa được phần tử nhưng không đổi được kích thước, new ArrayList sửa được mọi thứ.
List.of(...) | Arrays.asList(...) | new ArrayList<>(...) | |
|---|---|---|---|
add/remove | UnsupportedOperationException | UnsupportedOperationException | Được |
set(i, x) | UnsupportedOperationException | Được, ghi xuống mảng gốc | Được |
| Phần tử null | NullPointerException | Được | Được |
Lỗi production hay gặp: một method trả về List.of(...) hoặc stream().toList(), nơi khác gọi add và nhận UnsupportedOperationException lúc chạy. Nếu cần list sửa được, tạo rõ ràng new ArrayList<>(source).
Câu hỏi (Hiểu sâu): So sánh iterator fail-fast và weakly consistent. Khi nào dùng CopyOnWriteArrayList?
Ý chính: fail-fast (collection trong java.util) ném ConcurrentModificationException khi phát hiện bị sửa trong lúc duyệt; weakly consistent (collection trong java.util.concurrent) không ném lỗi mà duyệt trên một trạng thái có thể hơi cũ.
- Fail-fast dựa vào
modCount, chỉ là cơ chế phát hiện lỗi, không đảm bảo thread-safe và không hứa lúc nào cũng phát hiện được. CopyOnWriteArrayListmỗi lần ghi chép toàn bộ mảng sang mảng mới; iterator duyệt trên bản chụp lúc tạo. Đọc rất rẻ và không cần khóa, ghi rất đắt.- Dùng khi đọc nhiều, ghi cực hiếm và list nhỏ: danh sách listener, cấu hình nạp lúc khởi động. Không dùng cho list thường xuyên thêm phần tử (mỗi lần
addlà một lần chép mảng).
Câu hỏi đào sâu
"Có người gọi weakly consistent là fail-safe, đúng không?" — Đó là tên gọi không chính thức trong nhiều tài liệu; JDK dùng từ weakly consistent. Nói được cả hai và giải thích ý nghĩa là đủ.
Câu hỏi (Hiểu sâu): Type erasure là gì và ảnh hưởng gì đến code của bạn?
Generic chỉ tồn tại lúc biên dịch; bytecode thay T bằng Object hoặc bound. Vì thế không thể new T(), không overload theo tham số generic, không instanceof List<String>. Muốn giữ kiểu lúc runtime thì truyền Class<T> hoặc dùng "super type token" như TypeReference của Jackson.
Câu hỏi đào sâu
"Giải thích PECS với ví dụ." — copy(List<? extends T> src, List<? super T> dest): src chỉ đọc nên extends, dest chỉ ghi nên super. Nhờ đó copy được List<Integer> vào List<Number>.
Câu hỏi (Hiểu sâu): Vì sao List<Integer> không phải là List<Number> dù Integer là Number?
Ý chính: vì nếu cho phép, ta có thể bỏ một Double vào một list vốn chỉ chứa Integer; generic được thiết kế invariant để chặn lỗi đó ngay lúc biên dịch.
Giả sử được gán List<Number> nums = ints; với ints là List<Integer>. Khi đó nums.add(3.14) hợp lệ về kiểu, và lần sau ints.get(0) trả về một Double trong khi code tin rằng đó là Integer, gây ClassCastException ở một nơi rất xa chỗ gây lỗi. Mảng thì ngược lại, là covariant (Integer[] là Number[]), và vì thế phải kiểm tra lúc runtime: gán Number[] arr = new Integer[1]; arr[0] = 3.14; ném ArrayStoreException. Wildcard ? extends/? super là cách lấy lại tính linh hoạt một cách an toàn.
Câu hỏi (Cơ bản): Tìm lỗi: method printAll(List<Object> items) không nhận được List<String>. Sửa thế nào?
Ý chính: đổi tham số thành List<?> (hoặc List<? extends Object>), vì method chỉ đọc phần tử ra để in.
List<String> không phải List<Object> (generic invariant), nên lời gọi printAll(names) báo lỗi incompatible types: List<String> cannot be converted to List<Object>. List<?> nghĩa là "list của kiểu gì cũng được"; đọc ra được Object, đủ để gọi toString(). Theo PECS, tham số chỉ đọc là producer nên dùng extends; ? trơn là dạng ngắn của ? extends Object.
Câu hỏi (Thực chiến): Checked hay unchecked? Bạn thiết kế exception trong dự án thế nào?
Tôi dùng unchecked cho gần như mọi lỗi nghiệp vụ, tạo một vài exception có ý nghĩa (NotFound, Conflict, BusinessRuleViolation) mang mã lỗi, rồi map sang HTTP status tại @RestControllerAdvice. Checked exception từ thư viện (IOException) được bọc lại kèm cause. Mỗi lỗi chỉ log một lần ở biên.
Bẫy thường gặp
Ném checked exception trong method @Transactional và nghĩ rằng transaction sẽ rollback. Mặc định Spring chỉ rollback với RuntimeException và Error.
Câu hỏi (Hiểu sâu): try-with-resources khác gì so với tự đóng resource trong finally?
Ý chính: try-with-resources tự đóng resource theo thứ tự ngược lúc mở và giữ lại lỗi gốc; finally viết tay dễ quên đóng, dễ để lỗi của close() đè mất lỗi gốc.
- Với
finally { in.close(); }, nếu khối try ném lỗi A vàclose()ném lỗi B, exception bay ra là B, A biến mất khỏi log. - try-with-resources ném A và gắn B vào
A.getSuppressed(), không mất thông tin nào (xemSuppressedDemoở mục 1.7). - Mở nhiều resource trong cùng một câu
try (a; b)thìbđóng trướca, đúng thứ tự phụ thuộc (ví dụ đóngResultSettrướcConnection). - Điều kiện: resource phải implement
AutoCloseable.
Câu hỏi (Thực chiến): Tìm lỗi: job xuất báo cáo thất bại, log chỉ có "export failed" và "Job failed". Đoạn code sai ở đâu?
public class ReportJob {
void run() {
try {
exportToStorage();
} catch (Exception e) {
log("export failed");
throw new RuntimeException("Job failed");
}
}
void exportToStorage() throws Exception { throw new java.io.IOException("Access Denied"); }
void log(String msg) { System.out.println(msg); }
}Đáp án: Có ba lỗi, và cả ba cùng xóa mất nguyên nhân thật "Access Denied".
log("export failed")không ghi exceptione, nên log không có message lẫn stack trace của lỗi gốc.new RuntimeException("Job failed")không truyềnelàm cause, nên stack trace ném lên cũng không có dòng "Caused by".- Vừa log vừa ném lại: nếu tầng trên cũng log, cùng một lỗi xuất hiện nhiều lần. Chọn một: hoặc xử lý và log đầy đủ ở đây, hoặc chỉ ném lên kèm cause và để global handler log.
Bản sửa tối thiểu là throw new IllegalStateException("Export report failed", e); và bỏ dòng log. Ngoài ra, bắt Exception quá rộng sẽ nuốt cả NullPointerException do bug; nếu chỉ muốn xử lý lỗi I/O thì bắt IOException.
Câu hỏi (Cơ bản): orElse và orElseGet khác nhau thế nào?
orElse(x) luôn tính x trước, kể cả khi Optional có giá trị; orElseGet(supplier) chỉ gọi supplier khi Optional rỗng. Nếu giá trị mặc định là lời gọi DB, gọi API hoặc tạo object nặng, dùng orElseGet.
Câu hỏi đào sâu
"Có nên dùng Optional làm field hoặc tham số không?" — Không. Optional dành cho kiểu trả về. Field Optional không Serializable, tham số Optional buộc caller bọc giá trị và vẫn có thể truyền null.
Câu hỏi (Cơ bản): map và flatMap trong Stream khác nhau thế nào?
Ý chính: map biến mỗi phần tử thành đúng một phần tử; flatMap biến mỗi phần tử thành một stream rồi nối các stream đó thành một.
Ví dụ: orders.stream().map(Order::lines) cho Stream<List<OrderLine>>, mỗi phần tử là cả một danh sách; orders.stream().flatMap(o -> o.lines().stream()) cho Stream<OrderLine>, tất cả dòng hàng của mọi đơn nằm trên một dòng chảy. Dùng flatMap khi quan hệ là một-nhiều và bạn muốn xử lý các phần tử "con". Optional cũng có flatMap với ý nghĩa tương tự: tránh Optional<Optional<T>>.
Câu hỏi (Hiểu sâu): Lambda khác anonymous class ở những điểm nào?
Ý chính: lambda không tạo scope mới (từ khóa this trỏ tới object bao ngoài), chỉ dùng được cho functional interface, và được tạo qua invokedynamic thay vì sinh file .class riêng.
thistrong anonymous class là chính object ẩn danh; trong lambda là object của class bao ngoài.- Anonymous class có thể implement interface nhiều method hoặc kế thừa class, có field riêng; lambda thì không.
- Anonymous class luôn tạo instance mới mỗi lần chạy qua; lambda không capture biến có thể được JVM dùng lại một instance.
- Giống nhau: cả hai chỉ dùng được biến cục bộ effectively final.
Câu hỏi (Hiểu sâu): Vì sao một Stream không có thao tác kết thúc thì không chạy gì cả?
Ý chính: các thao tác trung gian là lazy, chỉ xây dựng pipeline; dữ liệu chỉ bắt đầu chảy khi có thao tác kết thúc như collect, count, findFirst, forEach.
Nhờ lazy, Stream xử lý từng phần tử qua cả pipeline rồi mới sang phần tử kế tiếp, nên có thể dừng sớm (findFirst, limit, anyMatch) và không cần tạo collection trung gian. Hệ quả: code như list.stream().peek(this::sendEmail); không gửi email nào cả. Và từ Java 9, count() có thể bỏ qua cả peek/map nếu biết trước kích thước nguồn, nên đừng bao giờ đặt side effect quan trọng trong thao tác trung gian (xem LazyStreamDemo ở mục 1.8).
Câu hỏi (Thực chiến): API báo lỗi 500 với IllegalStateException: Duplicate key trên production, dù chạy ổn ở môi trường test. Bạn điều tra thế nào?
Ý chính: gần như chắc chắn là Collectors.toMap gặp dữ liệu trùng key mà không có merge function; dữ liệu test không có trùng nên không lộ ra.
- Stack trace chỉ tới dòng
collect(Collectors.toMap(...)); message còn ghi rõ key bị trùng và hai value. - Truy ngược dữ liệu: vì sao cùng một key xuất hiện hai lần? Thường là JOIN trả về nhiều dòng, thiếu unique constraint trong DB, hoặc chuẩn hóa hoa thường chưa nhất quán.
- Sửa theo nghiệp vụ: nếu trùng là hợp lệ, thêm merge function (giữ bản mới nhất, cộng dồn) hoặc đổi sang
groupingBy; nếu trùng là dữ liệu hỏng, sửa dữ liệu và thêm unique constraint, đồng thời để lỗi báo rõ ràng. - Bổ sung test với dữ liệu trùng để lỗi không quay lại.
Câu hỏi đào sâu
"toMap còn bẫy nào khác?" — Value null ném NullPointerException (vì dùng HashMap.merge), và map trả về không đảm bảo thứ tự; cần giữ thứ tự thì dùng dạng bốn tham số với LinkedHashMap::new.
Câu hỏi (Thực chiến): Khi nào không nên dùng parallel stream?
Khi có I/O hoặc blocking (chặn common pool dùng chung toàn JVM), dữ liệu nhỏ, nguồn khó chia (LinkedList, iterate), có side effect hoặc cần thứ tự. Trong ứng dụng web, request đã chạy song song trên nhiều thread, nên parallel stream thường chỉ tăng tranh chấp CPU. Tôi chỉ cân nhắc cho tác vụ tính toán thuần CPU trên tập dữ liệu lớn và luôn đo trước khi bật.
Bẫy thường gặp
Dùng parallelStream().forEach(list::add) với ArrayList thường: race condition, mất phần tử hoặc ArrayIndexOutOfBoundsException. Hãy dùng collect.
Câu hỏi (Hiểu sâu): Record, sealed và pattern matching giúp gì trong code nghiệp vụ?
Record thay cho DTO/value object viết tay (equals/hashCode/toString đúng hợp đồng, bất biến). Sealed giới hạn các loại con, kết hợp switch pattern (Java 21) thì compiler kiểm tra đầy đủ: thêm loại thanh toán mới mà quên xử lý là lỗi biên dịch, không phải bug production.
Câu hỏi đào sâu
"Record có thực sự bất biến không?" — Chỉ nông: field final nhưng object bên trong vẫn có thể mutable. Cần List.copyOf trong compact constructor. Record cũng không hợp làm JPA entity.
1.10 Tóm tắt trọng tâm
- Ưu tiên composition vì class chỉ phụ thuộc vào API công khai của thành phần; kế thừa làm class con phụ thuộc chi tiết cài đặt của class cha (fragile base class).
- Từ Java 8, interface có default/static/private method nhưng vẫn không có trạng thái; chọn abstract class chỉ khi cần chia sẻ field và logic khởi tạo.
- Override equals thì phải override hashCode trên cùng các field, vì HashMap/HashSet dùng hashCode để chọn bucket trước khi gọi equals.
- Key của HashMap phải bất biến, vì sửa field tham gia hashCode sau khi put làm entry lạc bucket, không tìm được và không xóa được.
- HashMap chọn bucket bằng
(h ^ (h >>> 16)) & (n - 1), nên capacity luôn là lũy thừa của 2; vượt 0.75 capacity thì nhân đôi mảng. - Bucket quá 8 node và capacity từ 64 thì treeify thành cây đỏ đen, vì đó là lưới an toàn khi hashCode tệ, không phải lý do viết hashCode cẩu thả.
- String bất biến nên chia sẻ được qua pool, thread-safe và cache được hashCode; so sánh String luôn bằng
equals()vì==chỉ so tham chiếu. - Ghép chuỗi trong vòng lặp dùng StringBuilder, vì
+trong vòng lặp chép lại toàn bộ nội dung cũ mỗi lần. - Java chỉ pass-by-value: method sửa được object qua bản sao tham chiếu nhưng không làm biến của caller trỏ sang object khác.
- Sửa collection trong lúc duyệt bằng for-each gây ConcurrentModificationException ngay cả trong một thread; dùng
removeIfhoặciterator.remove(). - Generic bị xóa kiểu sau biên dịch nên không có
new T()hayinstanceof List<String>; generic invariant nên cần PECS: đọc ra dùngextends, ghi vào dùngsuper. - Bọc exception luôn truyền cause và chỉ log một lần ở biên, vì mất cause là mất nguyên nhân gốc khi debug production.
- Stream là lazy và chỉ dùng một lần;
orElseluôn tính đối số cònorElseGetchỉ gọi khi rỗng;toMapcần merge function khi có thể trùng key. - Sealed + record + switch pattern cho phép trình biên dịch kiểm tra đầy đủ các trường hợp, nên thêm loại mới mà quên xử lý là lỗi biên dịch thay vì bug runtime.
- Record chỉ bất biến nông, nên cần
List.copyOftrong compact constructor khi có field là collection.