Hình dung một quầy thông tin dùng chung. Ai hỏi cuốn "java" cũng được chỉ tới đúng một bản đặt sẵn trên kệ — đó là String pool, và hai người cùng hỏi sẽ cầm về cùng một bản. Nhưng nếu bạn tự mang máy photocopy ra nhân một bản riêng (new String), bạn có tờ giấy nội dung y hệt mà là tờ khác. Dấu == hỏi "hai ta có đang chỉ vào cùng tờ trên kệ không", còn equals hỏi "hai tờ giấy có ghi cùng nội dung không". Nhầm hai câu hỏi này là nguồn của gần như mọi lỗi so sánh chuỗi — và sáu dòng dưới đây phơi bày trọn vẹn.

String là kiểu bạn dùng nhiều nhất trong đời viết Java, và cũng là kiểu bị hiểu lầm nhiều nhất.

Bằng chứng: đoạn mã sáu dòng dưới đây in ra sáu giá trị true/false, và tôi chưa gặp ai đoán đúng cả sáu ngay lần đầu.

String a = "java";
String b = "java";
String c = new String("java");
String d = c.intern();
String e = "ja" + "va";      // ghép lúc biên dịch
String f = "ja";
String g = f + "va";         // ghép lúc chạy

Kết quả thật:

a == b (hai literal)        : true
a == c (literal vs new)     : false
a.equals(c)                 : true
a == d (sau intern)         : true
a == e (ghép lúc biên dịch) : true
a == g (ghép lúc chạy)      : false

Sáu dòng đó gói gọn gần như mọi thứ cần biết về String pool. Ta gỡ từng dòng.

String pool: kho chuỗi dùng chung

JVM giữ một kho chuỗi — thường gọi là String pool — nằm trong heap. Mọi chuỗi hằng viết thẳng trong mã nguồn đều được đưa vào kho này lúc nạp lớp, và những chuỗi giống hệt nhau chỉ tồn tại một bản.

Nên a và b cùng trỏ vào một đối tượng. a == b là true.

new String("java") thì khác: từ khoá new bắt buộc tạo đối tượng mới trên heap, nằm ngoài kho. Nội dung giống nhau nhưng là hai đối tượng khác nhau, nên a == c là false còn a.equals(c) là true.

intern() hỏi kho: "có chuỗi nào bằng chuỗi này chưa? có thì trả về bản trong kho". Nên a == d thành true.

Hai dòng cuối mới là chỗ tinh tế. "ja" + "va" được trình biên dịch ghép sẵn thành "java" — nó là hằng số, nên vào kho, nên a == e là true. Còn f + "va" phải ghép lúc chạy vì f là biến, kết quả là một đối tượng mới ngoài kho, nên a == g là false.

Kết luận thực dụng thì vẫn là câu quen thuộc, nhưng giờ bạn biết vì sao:

So sánh chuỗi luôn dùng equals, không dùng ==. Nguy hiểm ở chỗ == vẫn cho kết quả đúng với chuỗi hằng, nên mã sai chạy ngon trong lúc phát triển rồi mới sai khi chuỗi đến từ file, từ mạng, hay từ cơ sở dữ liệu.

Nhân tiện, intern() là thứ nghe hay nhưng đừng lạm dụng. Nó có chi phí tra cứu, và kho chuỗi cũng chiếm bộ nhớ. Chỉ đáng dùng khi bạn có rất nhiều chuỗi trùng lặp đến từ bên ngoài — chẳng hạn đọc một file CSV mười triệu dòng mà cột trạng thái chỉ có năm giá trị khác nhau.

Bất biến nghĩa là gì

String bất biến: một khi đã tạo, nội dung không bao giờ đổi.

String s = "xin chào";
s.toUpperCase();
System.out.println(s);
sau khi gọi toUpperCase()   : xin chào
phải gán lại               : XIN CHÀO

toUpperCase() không sửa s. Nó trả về một chuỗi mới. Quên gán lại là một trong những lỗi đầu tiên ai cũng mắc, và trình biên dịch không cảnh báo vì về mặt cú pháp chẳng có gì sai.

Cùng lỗi đó xảy ra với trim(), replace(), substring(), strip() — mọi phương thức của String đều trả về bản mới.

Vì sao lại thiết kế bất biến? Bốn lý do, và tất cả đều thực tế:

An toàn luồng miễn phí. Chuỗi không đổi thì nhiều luồng đọc chung thoải mái, không cần khoá.

Dùng làm khoá HashMap được yên tâm. Nếu chuỗi đổi được, mã băm của nó đổi theo, và phần tử trong map coi như mất tích. Ta sẽ thấy chuyện này rất rõ ở phần nói về HashMap.

Chia sẻ được. Chính vì bất biến nên String pool mới hoạt động — không sợ ai đó sửa chuỗi trong kho làm ảnh hưởng chỗ khác.

Bảo mật. Tên file, chuỗi kết nối, URL đều là String. Nếu sửa được sau khi đã kiểm tra hợp lệ thì mọi lớp kiểm tra đều vô nghĩa.

Cái giá phải trả là mỗi phép biến đổi lại sinh một đối tượng mới. Và đó là lý do có phần tiếp theo.

Nối chuỗi trong vòng lặp: đo bằng JMH

Ai cũng nghe "dùng StringBuilder thay vì dấu cộng". Nhưng chậm hơn bao nhiêu? Tôi viết một benchmark JMH thật thay vì đoán:

@Benchmark
public String congChuoi() {
    String s = "";
    for (int i = 0; i < n; i++) s += i;
    return s;
}

@Benchmark
public String stringBuilder() {
    StringBuilder sb = new StringBuilder();
    for (int i = 0; i < n; i++) sb.append(i);
    return sb.toString();
}

Chạy trên JDK 21, 3 vòng làm nóng và 5 vòng đo:

Benchmark                (n)  Mode  Cnt   Score   Error  Units
NoiChuoi.congChuoi       100  avgt    5   0.871 ± 0.082  us/op
NoiChuoi.congChuoi      1000  avgt    5  34.349 ± 2.259  us/op
NoiChuoi.stringBuilder   100  avgt    5   0.182 ± 0.003  us/op
NoiChuoi.stringBuilder  1000  avgt    5   2.539 ± 0.077  us/op

Đọc con số này cẩn thận, vì nó nói nhiều hơn một câu "chậm hơn".

Với 100 vòng: 0,871 so với 0,182 micro giây — chậm hơn khoảng 4,8 lần.

Với 1000 vòng: 34,3 so với 2,54 — chậm hơn khoảng 13,5 lần.

Điều đáng chú ý không phải con số tuyệt đối mà là tỷ lệ tăng theo n. Tăng số vòng lên 10 lần thì StringBuilder chậm đi 14 lần (gần tuyến tính, phần dôi là do phải cấp phát lại bộ đệm), còn phép cộng chuỗi chậm đi 39 lần. Vì mỗi vòng lặp nó phải chép lại toàn bộ chuỗi đã có: vòng thứ i chép i ký tự, tổng cộng là bậc hai theo n.

Nghĩa là với chuỗi ngắn thì khác biệt không đáng kể, nhưng nó xấu đi rất nhanh khi dữ liệu lớn lên. Đây chính là kiểu lỗi hiệu năng chạy êm với dữ liệu thử rồi treo với dữ liệu thật.

Đổi lại, ngoài vòng lặp thì dấu cộng hoàn toàn ổn:

String thongBao = "Xin chào " + ten + ", bạn có " + soTin + " tin mới";

Từ Java 9, javac dịch biểu thức này thành invokedynamic gọi makeConcatWithConstants — ta đã nhìn thấy nó trong bytecode ở phần thứ ba — và bản cài đặt hiện tại nhanh ngang hoặc hơn StringBuilder viết tay. Viết new StringBuilder().append(...).append(...) cho một dòng như trên chỉ làm mã khó đọc mà không nhanh hơn.

Đừng vội tin những con số trên máy bạn nếu đo bằng System.currentTimeMillis(). JIT cần thời gian làm nóng, và trình tối ưu có thể xoá hẳn đoạn mã không dùng kết quả. Đó là lý do phải dùng JMH — công cụ này sẽ có một phần riêng ở cuối loạt bài.

length() không phải số ký tự

"👍".length()               : 2
codePointCount              : 1
"Tiếng Việt".length()       : 10

String lưu văn bản theo UTF-16, và length() trả về số đơn vị mã, không phải số ký tự người đọc nhìn thấy. Emoji nằm ngoài mặt phẳng cơ bản nên cần hai đơn vị.

Tiếng Việt thì may mắn: mọi chữ cái có dấu đều nằm gọn trong một đơn vị mã, nên "Tiếng Việt".length() ra đúng 10 như trực giác — với điều kiện chuỗi ở dạng chuẩn hoá NFC. Cùng một chữ "ế" có thể được lưu thành một ký tự, hoặc thành "e" cộng hai dấu rời (dạng NFD), và khi đó độ dài khác hẳn. Văn bản dán từ macOS hay gặp chuyện này. Cần so sánh cho chắc thì chuẩn hoá trước:

String chuan = java.text.Normalizer.normalize(vao, java.text.Normalizer.Form.NFC);

Muốn cắt chuỗi an toàn với emoji thì làm việc theo code point, hoặc dùng BreakIterator nếu cần đúng khái niệm "ký tự người dùng nhìn thấy".

Vài phương thức đáng nhớ

"Java".equalsIgnoreCase("JAVA") : true
"b".compareTo("a")             : 1
" ".isBlank()                   : true
"".isEmpty()                    : true

isEmpty() chỉ đúng khi chuỗi dài 0. isBlank() (Java 11) đúng cả khi chuỗi toàn khoảng trắng — thứ bạn cần trong 90% trường hợp kiểm tra dữ liệu nhập.

strip() (Java 11) giống trim() nhưng hiểu đúng khoảng trắng Unicode. trim() chỉ cắt các ký tự có mã dưới 32, nên nó bỏ sót khoảng trắng không ngắt dòng — thứ hay lẫn vào khi người dùng dán từ Word. Với mã mới, dùng strip().

Vài thứ khác dùng nhiều: repeat(n), lines() để duyệt từng dòng, formatted(...) thay cho String.format, và text block với ba dấu nháy kép cho chuỗi nhiều dòng:

String truyVan = """
        SELECT id, ten
        FROM nguoi_dung
        WHERE trang_thai = ?
        """;

Muốn tự tay thấy cái bẫy quầy-thông-tin thì mở jshell trong ba mươi giây: gõ "a" + "b" == "ab" rồi đoán trước khi Enter; sau đó khai String x = "a"; và gõ x + "b" == "ab". Hai kết quả khác nhau — một bên ghép lúc biên dịch nên vào kho, một bên ghép lúc chạy nên là tờ photocopy riêng — và giờ bạn giải thích được vì sao.

Mẫu số chung

Ba tính chất của String Java — bất biến, có kho dùng chung, và cái bẫy nối-trong-vòng-lặp — lặp lại ở hầu hết ngôn ngữ, với những biến thể nói lên triết lý của từng cái.

  • Python gần nhất: chuỗi bất biết y như Java, có interning chuỗi nhỏ và định danh — và cái bẫy == đổi thành cặp is (so định danh) với == (so nội dung). "a" is "a" ra True nhờ intern, rồi người mới tưởng is so nội dung và dính đúng con bug của Java. Python cũng có O(n²) khi s += x trong vòng lặp; cách đúng là "".join(list).
  • C# gần như sao chép Java: string bất biến, literal được intern tự động, và có StringBuilder cho đúng lý do tránh O(n²). == trên string của C# được nạp chồng để so nội dung — C# sửa luôn cái bẫy của Java bằng cách làm == nghĩa là equals.
  • Go có chuỗi bất biến nhưng không có pool; s += x trong vòng lặp vẫn là O(n²) kinh điển, và strings.Builder là StringBuilder của Go — cùng bài học, cùng cách chữa.
  • Rust rẽ hướng: tách hẳn String (sở hữu, sửa được, push_str rẻ) khỏi &str (lát cắt chỉ đọc). Vì String sửa được tại chỗ nên cái bẫy O(n²) gần như không xảy ra — bạn phải cố tình mới tạo ra nó.

Sợi chỉ chung đáng mang theo: văn bản bất biến là lựa chọn đúng cho an toàn và chia sẻ, nhưng bất biến cộng với ghép tăng dần là cái bẫy O(n²) ở mọi ngôn ngữ — nên mọi ngôn ngữ đều cho bạn một "builder" hoặc một chuỗi sửa-được để dùng trong vòng lặp. Và chuyện == so định danh hay so nội dung là câu hỏi phải hỏi mỗi khi đổi ngôn ngữ: Java và Python bắt bạn dùng equals/==, C# và Rust làm == so nội dung cho bạn. Đoán sai câu đó là cách nhanh nhất để viết một so sánh chạy đúng trên máy mình rồi sai trên dữ liệu thật.

Ngày mai ta chuyển sang mảng — và bắt đầu bằng chuyện vì sao int[10] tự có sẵn mười số không, trong khi biến int cục bộ thì bắt buộc phải gán trước khi dùng.