String: immutability, String pool và mã đơn hàng trong shop-backend
Tiếp đi: String. Mình dùng String suốt mà sao phỏng vấn vẫn hỏi nhiều thế?
Vì String là class được dùng nhiều nhất trong Java, và nó có vài hành vi "lạ" mà người mới hay dính bug: == lúc đúng lúc sai, nối chuỗi trong vòng lặp chậm bất thường. Ta đi qua bằng một tình huống trong shop-backend.
1. Bài toán: so sánh mã voucher và sinh mã đơn hàng
// Kiểm tra voucher người dùng gửi lên
String code = request.getVoucherCode(); // "SALE50" đọc từ JSON
if (code == "SALE50") { // ❌ test local chạy, production không chạy
order.applyDiscount(50);
}
// Xuất báo cáo CSV cho 100.000 đơn
String csv = "";
for (Order o : orders) {
csv += o.getId() + "," + o.getTotal() + "\n"; // ❌ chạy mấy phút
}
2. Vấn đề
==so sánh địa chỉ object, không so nội dung. Literal"SALE50"nằm trong String pool; chuỗi đọc từ JSON là object mới trên heap → hai địa chỉ khác nhau →false.- String immutable: mỗi lần
+=tạo một String mới và copy toàn bộ nội dung cũ. n lần nối → tổng copy ~ n²/2 ký tự → O(n²).
3. Giải pháp: hiểu immutability và String pool
- Immutable:
Stringlàfinal class, mảng bên trong (byte[] valuetừ Java 9, compact strings) làprivate final, không có method nào sửa nó.toUpperCase(),replace(),concat()đều trả về String mới. - String pool: vùng trong heap (từ Java 7) giữ một bản duy nhất cho mỗi literal. Hai literal giống nhau dùng chung một object.
- equals() so nội dung. StringBuilder là bản mutable để nối nhiều lần.
Vì sao Java thiết kế String immutable:
- Pool được: chia sẻ an toàn vì không ai sửa được.
- Thread-safe miễn phí, không cần lock.
- Bảo mật: URL, tên file, tên class truyền vào
ClassLoaderkhông bị đổi sau khi đã kiểm tra. - Cache hashCode: tính một lần, lưu vào field
hash; làm key HashMap rất nhanh.
4. Code từng bước
Bước 1: So sánh đúng cách
if ("SALE50".equals(code)) { ... } // literal đứng trước: code null cũng không NPE
if (Objects.equals(code, expected)) { ... } // cả hai có thể null
if ("sale50".equalsIgnoreCase(code)) { ... } // không phân biệt hoa thường
Bước 2: Thấy rõ == vs equals
String a = "SALE50";
String b = "SALE50";
String c = new String("SALE50");
String d = "SALE" + "50"; // hằng số compile-time → gộp thành literal
String part = "SALE";
String e = part + "50"; // tính lúc runtime → object mới
a == b; // true (cùng object trong pool)
a == c; // false (new luôn tạo object mới)
a == d; // true
a == e; // false
a == e.intern(); // true (intern trả về bản trong pool)
a.equals(c); // true (cùng nội dung)
Bước 3: Nối chuỗi trong vòng lặp bằng StringBuilder
public String exportCsv(List<Order> orders) {
StringBuilder sb = new StringBuilder(orders.size() * 32); // đoán trước capacity
sb.append("id,customer,total\n");
for (Order o : orders) {
sb.append(o.getId()).append(',')
.append(o.getCustomer().getName()).append(',')
.append(o.getTotal()).append('\n');
}
return sb.toString();
}
// Hoặc gọn hơn khi chỉ cần nối với dấu phân cách:
String ids = orders.stream().map(Order::getId).collect(Collectors.joining(","));
Bước 4: Định dạng và chuỗi nhiều dòng
String orderNo = String.format("ORD-%s-%06d", LocalDate.now(), seq); // ORD-2026-10-14-000042
String msg = "Đơn %s: %,d đ".formatted(order.getId(), amount); // Java 15
// Text block (Java 15): JSON mẫu trong test, SQL dài
String json = """
{
"customerId": "C001",
"items": [{"productId": "P01", "qty": 2}]
}
""";
Test nhanh: curl -X POST localhost:8080/orders -H 'Content-Type: application/json' -d '{"voucherCode":"SALE50"}' → với == không được giảm giá, với equals thì được.
5. Cách hoạt động
Stack Heap
┌─────────── String pool ───────────┐
a ──────────────────►│ "SALE50" │
b ──────────────────►│ ▲ │
d ──────────────────►│ │ (intern trả về đây) │
└───┼────────────────────────────────┘
c ─────────────────► "SALE50" (object riêng, do new)
e ─────────────────► "SALE50" (object riêng, nối lúc runtime)
csv += x trong vòng lặp:
"a" → copy → "a,b" → copy → "a,b,c" → ... mỗi vòng copy lại tất cả
StringBuilder:
[a,b,c,_,_,_,_,_] append ghi tiếp vào mảng, đầy thì tăng gấp đôi (amortized O(1))
Lưu ý: a + b trên một dòng thì compiler đã tự tối ưu (Java 9+ dùng invokedynamic / StringConcatFactory), không cần StringBuilder. Vấn đề chỉ nằm ở vòng lặp, vì mỗi vòng là một phép nối riêng.
6. So sánh
| String | StringBuilder | StringBuffer | |
|---|---|---|---|
| Mutable | Không | Có | Có |
| Thread-safe | Có (immutable) | Không | Có (synchronized) |
| Tốc độ nối nhiều lần | Chậm O(n²) | Nhanh | Hơi chậm hơn Builder |
| Dùng khi | Giá trị, key, hằng số | Nối trong method, vòng lặp | Gần như không còn dùng |
7. Khi nào nên dùng / không nên
- So sánh nội dung: luôn
equals.==chỉ dùng cho enum, primitive, hoặc khi thật sự muốn so identity. - Nối trong vòng lặp:
StringBuilderhoặcCollectors.joining. Nối 2–3 chuỗi trên một dòng: cứ dùng+. - Không tự gọi
new String("..."); không lạm dụngintern()(pool là hash table native, intern hàng triệu chuỗi rác làm chậm và tốn bộ nhớ). - Dữ liệu nhạy cảm như password: nên giữ
char[]để xóa được sau khi dùng; String immutable nằm trong heap tới khi GC.
Mình hiểu String immutable rồi. Nhưng mình viết s = s.toUpperCase() thì s vẫn đổi đấy thôi?
Bạn hiểu gần đúng, chỉ cần tách hai thứ: biến và object. Immutable là object không đổi, còn biến s (không final) thì gán lại được.
String s = "sale50"; // s → object #1 "sale50"
s.toUpperCase(); // tạo object #2 "SALE50" rồi vứt, s vẫn → #1
s = s.toUpperCase(); // s giờ trỏ sang object #3 "SALE50"; #1 không hề bị sửa
String code = "sale50";
String alias = code; // alias và code cùng trỏ #1
code = code.toUpperCase();
System.out.println(alias); // "sale50" — chứng minh #1 không đổi
trước: s ──► "sale50"
sau: s ──┐ "sale50" (vẫn nguyên, ai còn giữ thì vẫn thấy)
└► "SALE50" (object mới)
Đây cũng là lỗi kinh điển: gọi code.trim(); mà không gán lại, tưởng đã trim. Muốn biến cũng không đổi thì thêm final; final khóa biến, immutable khóa object, hai khái niệm khác nhau.
intern() có đáng dùng để tiết kiệm bộ nhớ không?Bảng Product có cột category chỉ khoảng 20 giá trị nhưng load 1 triệu dòng. Gọi intern() để tiết kiệm bộ nhớ có ổn không?
Đúng ý tưởng: 1 triệu object String trùng nội dung, intern xong còn 20. Nhưng có vài điểm cần biết trước khi làm.
intern()tra một hash table native (StringTable). Mỗi lần gọi có chi phí hash + lock nhẹ; với 20 giá trị thì ổn, với dữ liệu nhiều giá trị khác nhau (email, mã đơn) thì pool phình, chậm, không đáng.- Cách gọn hơn: map sang enum (
Category.valueOf(raw)) nếu tập giá trị cố định, hoặc tự dùngMap<String,String>làm cache có kiểm soát. - G1 có
-XX:+UseStringDeduplication: GC tự gộp mảngbyte[]của các String trùng nội dung, không cần sửa code (object String vẫn riêng, chỉ mảng bên trong dùng chung).
// Cách nên làm khi giá trị cố định
public enum Category { ELECTRONICS, FASHION, BOOK /* ... */ }
@Enumerated(EnumType.STRING)
private Category category;
Tóm lại: intern là công cụ hợp lệ nhưng hiếm khi là lựa chọn đầu tiên; đo bằng heap dump trước khi tối ưu.
Cho mình hỏi thêm: String làm key HashMap tốt vì sao? Nếu mình dùng StringBuilder làm key thì sao?
Câu hỏi hay. String tốt vì ba lý do:
- Immutable: đã put vào map thì hashCode không bao giờ đổi, luôn tìm lại được.
- hashCode cache: tính một lần rồi lưu trong field, lần sau lấy ngay.
- equals/hashCode theo nội dung: hai String khác object nhưng cùng chữ vẫn trùng key.
StringBuilder thì không override equals/hashCode, nó dùng của Object (so địa chỉ):
Map<StringBuilder, Integer> stock = new HashMap<>();
StringBuilder key = new StringBuilder("P01");
stock.put(key, 10);
stock.get(new StringBuilder("P01")); // null — khác object
key.append("-X"); // vẫn tìm được bằng chính 'key', nhưng nội dung đã khác: rất khó debug
Quy tắc: key của Map nên là immutable và có equals/hashCode theo giá trị (String, Long, enum, record). Bài tiếp theo đi sâu vào chuyện này với Order.
Phỏng vấn hỏi String immutable, String pool, String và StringBuilder khác nhau thì trả lời sao?
Ba câu hay gặp nhất:
Câu 1: Vì sao String immutable?
🇻🇳 Tiếng Việt:
String là final class, mảng ký tự bên trong là private final và không có method nào sửa nó; mọi method "sửa" đều trả về String mới. Thiết kế này cho phép String pool chia sẻ một object cho nhiều biến, thread-safe không cần lock, an toàn khi truyền vào ClassLoader hay URL, và cache được hashCode nên làm key HashMap rất nhanh.
🇬🇧 English:
String is a final class whose internal array is private and final, and no method modifies it; every "modifying" method returns a new String. This lets the String pool share one instance safely, makes it thread-safe without locking, keeps it secure when passed to class loaders or URLs, and allows the hash code to be cached, which makes it an ideal HashMap key.
Câu 2: == và equals khác nhau gì với String? String pool là gì?
🇻🇳 Tiếng Việt:
==so sánh tham chiếu,equalsso nội dung. Literal được đặt trong String pool trên heap nên hai literal giống nhau có thể==true, nhưng chuỗi tạo bằngnewhay nối lúc runtime là object riêng. Vì vậy luôn dùngequals;intern()trả về bản trong pool.
🇬🇧 English:
==compares references whileequalscompares content. Literals live in the String pool on the heap, so two identical literals may be==, but strings created withnewor built at runtime are separate objects. So always useequals;intern()returns the pooled instance.
Câu 3: String, StringBuilder, StringBuffer?
🇻🇳 Tiếng Việt:
String immutable nên nối trong vòng lặp tạo nhiều object và copy lặp lại, O(n²). StringBuilder mutable, append vào mảng có sẵn, nhanh, không thread-safe. StringBuffer giống Builder nhưng synchronized, gần như không cần dùng nữa. Nối vài chuỗi trên một dòng thì dùng
+, compiler đã tối ưu.
🇬🇧 English:
String is immutable, so concatenating in a loop creates many objects and copies repeatedly, which is O(n²). StringBuilder is mutable, appends into a growable buffer, and is fast but not thread-safe. StringBuffer is the synchronized version and is rarely needed today. For a few strings on one line,
+is fine because the compiler optimizes it.
Cách nhớ ngắn gọn
- Immutable = pool + thread-safe + bảo mật + cache hash.
==địa chỉ,equalsnội dung; literal đứng trước.equals.- Vòng lặp nối chuỗi → StringBuilder.
finalkhóa biến, immutable khóa object.
Tự kiểm tra
"SALE" + "50" == "SALE50" trả về gì? Còn part + "50" với part = "SALE"?
Cái đầu true vì là hằng compile-time được gộp thành literal. Cái sau false vì nối lúc runtime tạo object mới (trừ khi part là final hằng số).
Vì sao csv += ... trong vòng lặp 100.000 đơn rất chậm?
Mỗi vòng tạo String mới và copy toàn bộ nội dung cũ, tổng chi phí O(n²). StringBuilder append vào buffer, amortized O(1) mỗi lần.
code.trim(); không gán lại thì code có bị trim không?
Không. String immutable, trim() trả về object mới; phải viết code = code.trim().
Vì sao viết "SALE50".equals(code) thay vì code.equals("SALE50")?
Literal không bao giờ null nên không có NPE khi code null.
Vì sao StringBuilder không nên làm key HashMap?
Nó không override equals/hashCode (so địa chỉ) và mutable; nội dung đổi sau khi put làm map khó đoán.