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 ab cùng trỏ vào một đối tượng. a == btrue.

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 == cfalse còn a.equals(c)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 == etrue. Còn f + "va" phải ghép lúc chạyf là biến, kết quả là một đối tượng mới ngoài kho, nên a == gfalse.

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 = ?
        """;

Tự kiểm ba mươi giây

Mở jshell, 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, và giờ bạn giải thích được vì sao.

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.