String: immutability, String pool và mã đơn hàng trong shop-backend

5 câu hỏi~25 phútTừ khóa: immutable, String pool, ==, equals, intern, StringBuilder, String.format, text block, hashCode cache

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 đề

3. Giải pháp: hiểu immutability và String pool

Vì sao Java thiết kế String immutable:

  1. Pool được: chia sẻ an toàn vì không ai sửa được.
  2. Thread-safe miễn phí, không cần lock.
  3. Bảo mật: URL, tên file, tên class truyền vào ClassLoader không bị đổi sau khi đã kiểm tra.
  4. 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

StringStringBuilderStringBuffer
MutableKhôngCóCó
Thread-safeCó (immutable)KhôngCó (synchronized)
Tốc độ nối nhiều lầnChậm O(n²)NhanhHơi chậm hơn Builder
Dùng khiGiá trị, key, hằng sốNối trong method, vòng lặpGần như không còn dùng

7. Khi nào nên dùng / không nên

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.

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.

// 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:

  1. Immutable: đã put vào map thì hashCode không bao giờ đổi, luôn tìm lại được.
  2. hashCode cache: tính một lần rồi lưu trong field, lần sau lấy ngay.
  3. 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, equals so 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ằng new hay nối lúc runtime là object riêng. Vì vậy luôn dùng equals; intern() trả về bản trong pool.

🇬🇧 English:

== compares references while equals compares content. Literals live in the String pool on the heap, so two identical literals may be ==, but strings created with new or built at runtime are separate objects. So always use equals; 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


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.