Command Palette

Search for a command to run...

[Java Basics] Chuỗi String trong Java: tính bất biến, String pool và các method thường dùng

String là type bạn đụng tới trong mọi chương trình Java, và cũng là type gây bất ngờ nhiều nhất cho người mới. Nó trông giống primitive vì có cú pháp literal, và so sánh hai chuỗi bằng == thì compile trót lọt dù gần như không bao giờ đó là thứ bạn muốn.

Bài này nói về String thực chất là gì, vì sao nó không bao giờ bị sửa, string pool ảnh hưởng thế nào tới phép so sánh identity, và những method đáng thuộc lòng. Mọi output bên dưới đều lấy từ việc compile và chạy thật trên OpenJDK 21.0.6.

Chuỗi String trong Java

Bắt đầu từ một sự thật duy nhất mà mọi thứ khác đều suy ra từ đó: String là một class.

String là một class, không phải primitive

Java có tám primitive type. String không nằm trong số đó. Nó là một class bình thường trong java.lang, được khai báo final, và một variable String giữ reference tới object nằm trên heap chứ không giữ trực tiếp giá trị.

String literal = "Java";                              // cú pháp literal
String built   = new String("Java");                  // cấp phát tường minh
String chars   = new String(new char[] {'J','a','v','a'});

System.out.println(literal.getClass().getName());
System.out.println(built.equals(chars));
System.out.println(built == chars);
java.lang.String
true
false

Hai điểm khiến nó có vẻ đặc biệt dù chỉ là class thường:

  • Cú pháp literal. Không class nào khác tạo được bằng cách viết "Java". Compiler đưa mọi literal vào constant pool của file class, rồi JVM trả về một object.
  • Hỗ trợ operator. + được nạp chồng cho String, và chỉ cho String trong toàn bộ Java.

Còn lại đều là hành vi object bình thường: nó có thể null, gọi method bằng dấu ., và truyền vào method là truyền reference.

Chú ý khác biệt với char: char là primitive giữ một UTF-16 code unit và dùng nháy đơn; String là reference type và dùng nháy kép. 'J'"J" là hai type khác nhau.

Vì sao String là immutable?

Một object String không bao giờ thay đổi sau khi được tạo. Mọi method trông như đang sửa nội dung thật ra đều trả về một String mới và để nguyên object gốc.

Đây là lỗi ai cũng mắc đúng một lần:

String s = "hello";

s.toUpperCase();                    // kết quả bị vứt đi
System.out.println(s);              // hello

String upper = s.toUpperCase();     // giữ lại kết quả
System.out.println(upper);          // HELLO
System.out.println(s);              // hello
hello
HELLO
hello

Dòng println đầu tiên in ra hello chứ không phải HELLO. toUpperCase() không hề thất bại và cũng không hề "không làm gì" — nó đã tạo một object mới và trả về, chỉ là giá trị trả về đó bị vứt thẳng vào sọt rác. Bẫy này lặp lại y hệt với trim, replace, substring, strip và mọi method biến đổi khác. Compiler im lặng vì bỏ qua giá trị trả về là code hợp lệ.

Gán lại variable cũng không mâu thuẫn với điều đó. s = s + "d" chỉ đổi object mà s đang trỏ tới, còn object cũ vẫn nguyên vẹn.

String s = "abc";
System.out.println(System.identityHashCode(s));
s = s + "d";
System.out.println(s);
System.out.println(System.identityHashCode(s));
705927765
abcd
21685669

Hai identity hash khác nhau nghĩa là hai object khác nhau. Object "abc" vẫn tồn tại, chỉ là không còn ai trỏ tới nó nữa.

Tính bất biến của String trong Java

Vì sao class này được thiết kế như vậy? Ba lý do, và tất cả đều là hệ quả của việc "giá trị không bao giờ đổi":

  1. Chia sẻ an toàn. Một object immutable có thể truyền cho method bất kỳ, đưa vào cache, hoặc dùng làm key của map mà không cần copy phòng thủ và không sợ ai đó sửa nó sau lưng bạn.
  2. Hash code được cache. String tính hashCode() một lần rồi lưu lại, đó chính là thứ khiến HashMap với key String chạy nhanh. Cách đó chỉ đúng khi nội dung không bao giờ đổi.
  3. Pooling. Các literal giống nhau có thể dùng chung một object — nội dung của phần tiếp theo — và việc dùng chung chỉ an toàn khi không ai sửa được giá trị đang chia sẻ.

String pool và điều mà == thực sự so sánh

JVM giữ một pool các object String — gọi là string pool, nằm trong heap. Mọi string literal trong code đều được intern: lần xuất hiện đầu tiên đưa object vào pool, những literal sau có cùng nội dung sẽ nhận lại đúng object đó. new String("hi") bỏ qua toàn bộ cơ chế này và luôn cấp phát một object mới.

String pool trong Java

Toàn bộ câu chuyện nằm trong một chương trình. Hãy chạy nó trước khi tin bất kỳ lời giải thích nào, kể cả bài này:

String a = "hi";
String b = "hi";
String c = new String("hi");
String d = new String("hi");
String e = "h" + "i";              // cả hai toán hạng là compile-time constant
String part = "h";                 // một variable bình thường
String f = part + "i";             // ghép lúc runtime

System.out.println("a == b            " + (a == b));
System.out.println("c == d            " + (c == d));
System.out.println("a == c            " + (a == c));
System.out.println("a == e            " + (a == e));
System.out.println("a == f            " + (a == f));
System.out.println("a == f.intern()   " + (a == f.intern()));
System.out.println("a.equals(b)       " + a.equals(b));
System.out.println("a.equals(c)       " + a.equals(c));
System.out.println("a.equals(f)       " + a.equals(f));
a == b            true
c == d            false
a == c            false
a == e            true
a == f            false
a == f.intern()   true
a.equals(b)       true
a.equals(c)       true
a.equals(f)       true

Đọc năm kết quả identity theo thứ tự:

Trường hợpKết quảLý do
"hi" == "hi"trueHai literal cùng trỏ về một object trong pool
new String("hi") == new String("hi")falseHai lần cấp phát riêng biệt, không cái nào nằm trong pool
"hi" == new String("hi")falseObject trong pool so với object mới trên heap
"hi" == "h" + "i"trueCả hai toán hạng là constant nên compiler gộp sẵn thành literal "hi" và intern nó
"hi" == part + "i"falsepart không phải constant nên phép ghép chạy lúc runtime và kết quả không được intern

Trường hợp thứ tư đáng nhìn kỹ. "h" + "i" không hề chạy: javac tính sẵn và ghi "hi" vào constant pool. Trường hợp thứ năm nhìn y hệt trong source nhưng giá trị chỉ biết được lúc runtime, nên một object mới được tạo và không vào pool. Thêm final cho part sẽ kéo nó về trường hợp thứ tư, vì một local final khởi tạo bằng literal chính là compile-time constant.

intern() là lối thoát thủ công: nó trả về object trong pool có cùng nội dung, và thêm vào pool nếu chưa có. Đó là lý do a == f.intern() cho true.

Cái bẫy: == trên hai string do bạn ghép, parse hoặc đọc từ input sẽ luôn ra false dù nội dung giống hệt, vì những string đó không nằm trong pool. Code "chạy đúng" nhờ == trên literal lúc test sẽ hỏng ngay khi có dữ liệu thật.

== hay .equals(): quy tắc

Quy tắc gói trong một câu: == hỏi hai reference có trỏ tới cùng một object hay không, còn .equals() hỏi hai string có cùng nội dung ký tự hay không. So sánh nội dung thì luôn dùng .equals().

Quanh nó có bốn thứ đáng biết:

String a = "Java";
String b = "java";
String n = null;

System.out.println(a.equals(b));                 // phân biệt hoa thường
System.out.println(a.equalsIgnoreCase(b));

System.out.println("apple".compareTo("banana"));
System.out.println("banana".compareTo("apple"));
System.out.println("apple".compareTo("apple"));
System.out.println("apple".compareTo("apples"));
System.out.println("Java".compareTo("java"));

System.out.println(Objects.equals(n, "Java"));
System.out.println(Objects.equals(n, null));
System.out.println("Java".equals(n));
false
true
-1
1
0
-1
-32
false
true
false
  • equalsIgnoreCase là bản không phân biệt hoa thường.
  • compareTo trả về int chứ không phải boolean: âm nếu chuỗi gọi đứng trước argument, bằng 0 nếu bằng nhau, dương nếu đứng sau. Độ lớn chỉ là chi tiết cài đặt — tại vị trí khác nhau đầu tiên, nó là hiệu hai giá trị char ('J' là 74, 'j' là 106, nên ra -32), còn khi một chuỗi là tiền tố của chuỗi kia thì nó là hiệu độ dài. Hãy so với 0, đừng so với -1 hay 1.
  • Objects.equals(x, y) trong java.util xử lý được null ở cả hai phía và trả true khi cả hai cùng null.
  • "literal".equals(x) là idiom an toàn với null: đặt literal bên trái thì object gọi method không bao giờ là null.

Ý cuối cùng quan trọng vì thứ tự ngược lại sẽ ném exception:

String s = null;
System.out.println(s.equals("Java"));
Exception in thread "main" java.lang.NullPointerException: Cannot invoke "String.equals(Object)" because "s" is null
	at A5.main(A5.java:4)

Thông báo NullPointerException chi tiết có từ Java 14 và bật mặc định từ Java 15. Tên variable chỉ hiện khi class được compile với -g; không có thông tin debug thì bạn nhận được một tên sinh tự động dạng "<local3>".

Bảng tra method của String

Mọi ví dụ trong bảng này đều đã chạy thật. s"Hello, Java" ở những dòng không viết rõ object gọi.

MethodTrả về gìVí dụ thật
length()Số char unit, không phải số ký tựs.length() cho 11
charAt(int)Ký tự char tại index, đếm từ 0s.charAt(0) cho H
indexOf(String)Index đầu tiên tìm thấy, hoặc -1s.indexOf("a") cho 8, s.indexOf("z") cho -1
lastIndexOf(String)Index cuối cùng tìm thấy, hoặc -1s.lastIndexOf("a") cho 10
contains(CharSequence)true nếu xuất hiện ở bất kỳ đâus.contains("Java") cho true
startsWith(String)Kiểm tra tiền tốs.startsWith("Hello") cho true
endsWith(String)Kiểm tra hậu tốs.endsWith("va") cho true
substring(int)Từ index đó tới hếts.substring(7) cho Java
substring(int, int)Khoảng [begin, end) — không lấy ends.substring(0, 5) cho Hello
toUpperCase()String mới viết hoas.toUpperCase() cho HELLO, JAVA
toLowerCase()String mới viết thườngs.toLowerCase() cho hello, java
trim()Cắt các ký tự tới U+0020 ở hai đầuVới chuỗi đệm EM SPACE, length vẫn là 4
strip() (Java 11+)Cắt whitespace theo chuẩn UnicodeCùng chuỗi đó, length còn 2
isEmpty()true chỉ khi length bằng 0" ".isEmpty() cho false
isBlank() (Java 11+)true khi rỗng hoặc toàn whitespace" ".isBlank() cho true
replace(CharSequence, CharSequence)Thay theo nghĩa đen, không regex"192.168.0.1".replace(".", "-") cho 192-168-0-1
replaceAll(String, String)Thay theo regex"192.168.0.1".replaceAll(".", "-") cho -----------
split(String)Tách theo regex, bỏ phần rỗng ở cuối"192.168.0.1".split("\\.") cho [192, 168, 0, 1]
matches(String)true khi toàn bộ chuỗi khớp regexs.matches("[0-9]+") cho false
concat(String)Nối thêm một Strings.concat("!") cho Hello, Java!
repeat(int) (Java 11+)Lặp lại chuỗi n lần"a".repeat(3) cho aaa
chars() (Java 8+)IntStream các giá trị char"a1b2c3".chars().filter(Character::isDigit).count() cho 3
toCharArray()Một bản copy char[] mới"a,b".toCharArray() cho [a, ,, b]
compareTo(String)Thứ tự sắp xếp dạng int"apple".compareTo("banana") cho -1
equalsIgnoreCase(String)So sánh không phân biệt hoa thường"Java".equalsIgnoreCase("java") cho true
String.join(sep, parts)Nối các phần bằng một dấu phân cáchString.join(" - ", "a", "b", "c") cho a - b - c
String.format(fmt, args)Một String đã formatString.format("%s is %d", "x", 5) cho x is 5

Ba dòng trong đó giấu bẫy thật.

replace là literal, replaceAll là regex. Chúng không phải cùng một method chỉ khác mức độ áp dụng — cả hai đều thay mọi lần xuất hiện, nhưng replaceAll coi argument đầu tiên là regular expression, và . trong regex nghĩa là "ký tự bất kỳ".

String ip = "192.168.0.1";

System.out.println(ip.replace(".", "-"));       // theo nghĩa đen
System.out.println(ip.replaceAll(".", "-"));    // regex: "." khớp mọi ký tự
System.out.println(ip.replaceAll("\\.", "-"));  // regex đã escape

System.out.println(Arrays.toString(ip.split("\\.")));
System.out.println(Arrays.toString(ip.split(".")));
System.out.println(ip.split(".").length);

System.out.println(Arrays.toString("a,b,,c,,".split(",")));
System.out.println(Arrays.toString("a,b,,c,,".split(",", -1)));
192-168-0-1
-----------
192-168-0-1
[192, 168, 0, 1]
[]
0
[a, b, , c]
[a, b, , c, , ]

split cũng nhận regex, và split(".") trả về mảng rỗng. Mọi ký tự đều thành dấu phân cách nên mọi phần tử đều rỗng, mà split lại bỏ hết các phần rỗng ở cuối, kết quả là length bằng 0. Bạn cần split("\\."). Hai dòng cuối cho thấy nửa còn lại của quy tắc: phần rỗng ở cuối chỉ được giữ khi truyền limit âm.

trimstrip hiểu Unicode khác nhau. trim() có từ Java 1.0, cắt mọi ký tự có mã từ U+0020 trở xuống — một định nghĩa whitespace hợp lý vào năm 1996. strip(), thêm vào từ Java 11, cắt mọi thứ mà Character.isWhitespace chấp nhận.

String em = "\u2003hi\u2003";   // U+2003 EM SPACE at both ends

System.out.println(em.length());
System.out.println(em.trim().length());
System.out.println(em.strip().length());

System.out.println("[" + "  hi  ".trim() + "]");
System.out.println("[" + "  hi  ".strip() + "]");
System.out.println("  ".isEmpty());
System.out.println("  ".isBlank());
4
4
2
[hi]
[hi]
false
true

Với khoảng trắng ASCII thông thường thì hai method hành xử giống nhau. Với dữ liệu copy từ trình duyệt, bảng tính hay file PDF thì không. Code mới nên dùng strip(). Cùng ranh giới đó áp cho hai method kiểm tra rỗng: isEmpty() xét độ dài, isBlank() xét whitespace.

substring và charAt: giới hạn index

Index đếm từ 0, và substring(begin, end) không lấy vị trí end. Index bằng đúng độ dài là hợp lệ với substring nhưng không hợp lệ với charAt.

String s = "Java";
System.out.println(s.substring(4));   // hợp lệ: một String rỗng
System.out.println(s.charAt(4));      // ném exception

Exception in thread "main" java.lang.StringIndexOutOfBoundsException: Index 4 out of bounds for length 4

Dòng trống đầu tiên chính là chuỗi rỗng mà substring(4) trả về. Dạng hai argument báo lỗi theo khoảng thay vì theo một index:

System.out.println("Java".substring(2, 9));
Exception in thread "main" java.lang.StringIndexOutOfBoundsException: Range [2, 9) out of bounds for length 4

Ký hiệu nửa mở [2, 9) nói rằng index đầu được lấy còn index cuối thì không. substring(3, 1) cũng cho Range [3, 1) out of bounds for length 4, vì begin không được lớn hơn end. indexOf thì không bao giờ ném exception — nó trả -1 khi không tìm thấy, nên "Java".substring("Java".indexOf("z")) hỏng với Range [-1, 4) out of bounds for length 4 thay vì cho bạn biết là tìm không ra.

Escape sequence và text block

Bên trong một literal thường, dấu backslash mở đầu một escape sequence.

EscapeÝ nghĩa
\nXuống dòng
\tTab
\"Dấu nháy kép
\\Dấu backslash
\'Dấu nháy đơn
\rCarriage return
\uXXXXCode unit ứng với giá trị hex đó
\s (Java 15+)Một dấu cách không bị cắt khi dọn khoảng trắng cuối dòng

Một literal chứa JSON đầy dấu nháy hoặc nhiều dòng sẽ rất khó đọc. Java 15 chính thức đưa text block thành tính năng ổn định: ba dấu nháy kép mở và đóng literal, xuống dòng là xuống dòng thật, và dấu nháy không cần escape.

String json = """
        {
          "name": "Java",
          "year": 1995
        }""";
System.out.println(json);
System.out.println("length = " + json.length());
System.out.println("starts with '{': " + json.startsWith("{"));

String block = """
        one
        two
        """;
System.out.println("ends with a newline: " + block.endsWith("\n"));

String joined = """
        the quick \
        brown fox""";
System.out.println("[" + joined + "]");
{
  "name": "Java",
  "year": 1995
}
length = 36
starts with '{': true
ends with a newline: true
[the quick brown fox]

Hai quy tắc giải thích output trên:

  • Khoảng trắng thừa bị loại bỏ. Compiler tìm mức thụt lề nhỏ nhất trong tất cả các dòng không rỗng cả dòng chứa dấu đóng, rồi cắt đúng chừng đó khỏi mọi dòng. Vì vậy nội dung bắt đầu ở cột 0 dù source thụt vào tám dấu cách, còn các dòng lồng bên trong vẫn giữ được hai dấu cách thừa của mình.
  • Vị trí dấu đóng quyết định ký tự xuống dòng cuối. Đặt """ ngay sau nội dung cuối như ở json thì không có newline ở cuối. Đặt nó trên một dòng riêng như ở block thì có.

Backslash ở cuối dòng là dấu nối dòng: joined chỉ có một dòng vì newline sau quick đã bị bỏ. Text block vẫn là object String bình thường — không có type riêng nào cả, và mọi method vẫn dùng được như thường.

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

String trong Java lưu các UTF-16 code unit. length() trả về số code unit chứ không phải số ký tự mà người đọc nhìn thấy. Những gì nằm ngoài Basic Multilingual Plane — phần lớn emoji chẳng hạn — chiếm hai code unit, gọi là một surrogate pair.

String nhìn dưới dạng UTF-16 code unit, một emoji chiếm hai ô

length() đếm ô, không đếm ký tự:

static void show(String s) {
    System.out.println(s + "  length=" + s.length()
            + "  codePointCount=" + s.codePointCount(0, s.length()));
}

public static void main(String[] args) {
    show("Java");
    show("chao 👋");
    show("đại học");
    show("Hoàng");            // one precomposed code point for a-grave
    show("Hoa\u0300ng");      // 'a' plus U+0300, a combining grave accent

    String s = "chao 👋";
    System.out.println(s.substring(0, 6));
    System.out.println(s.substring(0, 7));
}
Java  length=4  codePointCount=4
chao 👋  length=7  codePointCount=6
đại học  length=7  codePointCount=7
Hoàng  length=5  codePointCount=5
Hoàng  length=6  codePointCount=6
chao ?
chao 👋

Ba kết quả riêng biệt nằm trong đó:

  • "chao 👋" nhìn thấy sáu ký tự nhưng length() báo 7, vì emoji là một surrogate pair. codePointCount(0, length()) báo con số trung thực là 6.
  • Tiếng Việt gõ bình thường thì không sao. Mọi chữ cái tiếng Việt dạng dựng sẵn, kể cả đ, đều là một code point trong BMP, nên "đại học" đếm ra 7 theo cả hai cách.
  • Tiếng Việt cũng có thể tới dưới dạng tách rời — một chữ cái gốc cộng thêm dấu thanh riêng, đúng như cách filesystem của macOS sinh ra. Khi đó "Hoa\u0300ng" có năm chữ cái nhìn thấy được nhưng lại là sáu code unit và sáu code point, và không con số nào khớp với thứ người đọc thấy.

Cắt ngang một surrogate pair sẽ tạo ra một surrogate lẻ: substring(0, 6) in ra chao ? vì nửa con emoji không phải một ký tự hợp lệ. substring(0, 7) giữ nguyên được cặp.

Quy tắc thực dụng: dùng length() cho kích thước buffer và tính toán index, dùng codePointCount khi cần báo số lượng cho người dùng, và đừng bao giờ cắt chuỗi tại một index tính theo giả định mỗi ký tự là một unit.

Vì sao dùng + trong vòng lặp lại chậm

+ trên string không nối tại chỗ — nó không thể, vì String là immutable. Mỗi phép + dựng hẳn một object mới và copy cả hai toán hạng vào đó. Nằm trong vòng lặp, việc tuyến tính trở thành việc bậc hai.

Mỗi lần += cấp phát một String mới, còn StringBuilder ghi vào một buffer duy nhất

Chi phí nằm ở việc copy, và nó lớn dần theo từng vòng lặp:

javac compile mỗi phép + thành một lời gọi invokedynamic tới StringConcatFactory, và trong vòng lặp thì lời gọi đó nằm ngay trong thân lặp:

 9: iload_3
10: ldc           #17    // int 50000
12: if_icmpge     28
15: aload_2
16: invokedynamic #18,  0 // InvokeDynamic #0:makeConcatWithConstants
21: astore_2
22: iinc          3, 1
25: goto          9

Mỗi vòng lặp là một String mới, cái sau dài hơn cái trước. StringBuilder giữ một buffer co giãn được và append thẳng vào đó, chỉ tạo String cuối cùng đúng một lần.

static final int N = 50_000;

static long withPlus() {
    long t = System.nanoTime();
    String s = "";
    for (int i = 0; i < N; i++) s = s + "x";
    return System.nanoTime() - t;
}

static long withBuilder() {
    long t = System.nanoTime();
    StringBuilder sb = new StringBuilder();
    for (int i = 0; i < N; i++) sb.append("x");
    sb.toString();
    return System.nanoTime() - t;
}

Chạy cả hai bảy lần, bỏ hai lần đầu làm warm-up rồi lấy kết quả tốt nhất:

+                52.01 ms
StringBuilder     0.06 ms
ratio             867x

Qua bốn lần chạy JVM riêng biệt trên cùng một máy, + dao động trong khoảng 49 đến 58 ms còn StringBuilder giữ nguyên 0.06 ms, nên tỉ lệ rơi vào khoảng 820x đến 960x. Hãy coi các con số này là tham khảo — chúng phụ thuộc vào máy, vào JVM và vào heap — nhưng hình dạng thì không đổi: khoảng cách càng giãn ra khi số vòng lặp tăng, vì một bên là bậc hai còn một bên là tuyến tính.

StringBuilder là công cụ để dựng chuỗi theo từng mảnh, và sb.append(x) gần như là toàn bộ API bạn cần cho việc đó. Nó tăng buffer ra sao, và nó khác StringBuffer thế nào, là nội dung của một bài sau trong series này.

Hai điều mà chuyện trên không có nghĩa. Một phép + giữa vài giá trị trên một dòng thì hoàn toàn ổn — compiler sinh ra một lời gọi ghép duy nhất cho cả biểu thức chứ không phải một lời gọi cho mỗi dấu cộng. Và + nằm ngoài vòng lặp cũng ổn. Chỉ riêng việc ghép lặp đi lặp lại vào một variable tích lũy mới là bậc hai.

String.format, formatted và printf

String.format dựng ra một chuỗi đã format; System.out.printf in thẳng ra; còn "...".formatted(args), thêm từ Java 15, là dạng method của String.format với chính chuỗi gọi làm pattern.

System.out.printf("%s was released in %d%n", "Java", 1995);
System.out.printf("pi = %.2f%n", 3.14159);
System.out.printf("[%-10s][%10s]%n", "left", "right");
System.out.printf("%05d  %,d%n", 42, 1234567);
System.out.printf("100%% done%n");

String row = String.format("%-10s %5d %8.2f", "coffee", 3, 4.5);
System.out.println("[" + row + "]");
System.out.println("[" + "%-10s %5d".formatted("tea", 12) + "]");
Java was released in 1995
pi = 3.14
[left      ][     right]
00042  1,234,567
100% done
[coffee         3     4.50]
[tea           12]

Những conversion đáng thuộc:

Ký hiệuÝ nghĩa
%sObject bất kỳ, qua toString(); null in ra là null
%dGiá trị nguyên — int, long, short, byte
%fGiá trị thực; %.2f cố định hai chữ số thập phân
%nKý tự xuống dòng của hệ thống — nên dùng thay cho \n trong chuỗi format
%-10sCăn trái trong ô rộng 10; bỏ dấu trừ thì căn phải
%05dĐệm số 0 cho đủ độ rộng 5
%,dThêm dấu phân cách hàng nghìn
%%Một dấu phần trăm thật

Các conversion được kiểm tra lúc runtime chứ không phải lúc compile, nên dùng sai type sẽ ra exception chứ không phải lỗi compile:

Exception in thread "main" java.util.IllegalFormatConversionException: d != java.lang.String

%d với argument là String vẫn compile trót lọt và chỉ hỏng khi dòng đó chạy. Đọc thông báo đó là "conversion d không nhận java.lang.String".

FAQ

Có nên gọi .intern() không?

Gần như không, trong code ứng dụng. new String("pool").intern() == "pool" cho true, tức là nó đúng như quảng cáo, nhưng intern tốn một lần tra pool và pool là cấu trúc dùng chung cho cả JVM. Chỉ dùng khi bạn đang giữ cực nhiều chuỗi trùng nhau và đã đo được lượng bộ nhớ. Nó không phải cách làm cho == trở nên an toàn.

Mọi phép + đều tạo object mới?

Phép ghép lúc runtime thì có. Nhưng vài method lại trả về chính object gọi khi không có gì để làm:

Biểu thức, với a"ab"Kết quả identity
a + ""== a cho false — một object mới
a.concat("")== a cho true — trả lại chính object gọi
a.substring(0)== a cho true
"AB".toUpperCase()== "AB" cho true

Nên s.trim() trên chuỗi vốn đã sạch, hay toUpperCase() trên chuỗi vốn đã viết hoa, có thể trả lại đúng object cũ. Đừng bao giờ dựa vào điều đó — nó là chi tiết cài đặt, và cũng chính là lý do == không đáng tin.

Nên dùng trim() hay strip()?

Dùng strip() từ Java 11 trở lên. Nó theo định nghĩa whitespace hiện đại của Unicode nên cắt được cả những dấu cách rộng và ký tự phân cách lạ mà trim() bỏ sót. Chỉ giữ trim() khi bắt buộc phải build trên Java 8. Lưu ý cả hai đều không cắt non-breaking space U+00A0, vì Character.isWhitespace trả false cho ký tự đó.

Kiểm tra null hoặc rỗng thế nào cho an toàn?

s == null || s.isBlank() bao trọn cả null, rỗng và toàn khoảng trắng trong một biểu thức, và nhờ short-circuit nên isBlank() không bao giờ bị gọi trên reference null. Còn khi bạn so với một giá trị đã biết, hãy đặt literal lên trước: "yes".equals(input) trả false với input null thay vì ném exception.

Vì sao "" + value chuyển được mọi thứ thành String?

+ có một toán hạng là String sẽ tự chuyển toán hạng kia bằng String.valueOf, và method này trả về đúng chữ null cho reference null thay vì ném exception. "" + null cho null còn "" + 7 cho 7. Khi mục đích là chuyển đổi thì nên dùng String.valueOf(x) — nhưng cẩn thận là String.valueOf(null) viết với literal trần sẽ khớp vào overload nhận char[] và ném NullPointerException: Cannot read the array length because "value" is null. Hãy cast sang Object như String.valueOf((Object) null), hoặc dùng Objects.toString(x, "-") để có giá trị mặc định.

Đảo ngược một String thế nào?

String không có method reverse(), vì đảo ngược dù sao cũng sinh ra object mới. new StringBuilder(s).reverse().toString() biến Java thành avaJ. Nó hiểu surrogate pair nên ab👋 đảo thành 👋ba với emoji còn nguyên, nhưng nó không hiểu dấu thanh tách rời — một chữ tiếng Việt dạng tách rời sẽ trở về với dấu gắn nhầm chữ cái.

Kết luận

Cả bài rút lại thành bốn điều. String là một class mà instance của nó không bao giờ đổi, nên mọi method đều trả về object mới và bỏ qua giá trị trả về là một bug âm thầm. Literal được intern vào một pool dùng chung, khiến == tình cờ chạy đúng trên literal và sai với mọi thứ khác — hãy dùng .equals(), hoặc Objects.equals khi một phía có thể null. length() đếm UTF-16 code unit, nên emoji và tiếng Việt dạng tách rời không đếm giống cách người đọc đếm. Và + trong vòng lặp là bậc hai; StringBuilder mới là thứ cần dùng.

Bài tiếp theo nói về việc đọc dữ liệu người dùng nhập bằng Scanner: khởi tạo trên System.in, khác biệt giữa nextIntnextLine, ký tự xuống dòng còn sót lại trong buffer, và cách kiểm tra dữ liệu trước khi parse.

Bài viết liên quan

[Java Basics] Đệ quy trong Java: cách hoạt động và khi nào nên dùng

Đệ quy trong Java hoạt động ra sao: base case và recursive case, trace factorial theo từng frame, StackOverflowError thật khi thiếu base case, độ sâu đệ quy và -Xss, vì sao Fibonacci naive cần 2692537 lời gọi cho fib(30) trong khi memoisation chỉ cần 59, đệ quy so với vòng lặp, và vì sao JVM không tối ưu tail call.

[Java Basics] Phương thức trong Java: khai báo và gọi method

Cách khai báo và gọi method trong Java: các thành phần của một declaration, static method so với instance method và lỗi non-static method cannot be referenced from a static context, return statement, call stack và StackOverflowError, cách đọc stack trace, Javadoc, cùng những lỗi compiler thật mà người mới hay gặp.

[Java Basics] Mảng một chiều trong Java: khai báo, khởi tạo và duyệt mảng

Hướng dẫn đầy đủ về mảng một chiều trong Java - mọi cách khai báo, giá trị mặc định, field length, index và ArrayIndexOutOfBoundsException, duyệt mảng bằng for và for-each, cái bẫy reference, copy thật với Arrays.copyOf và System.arraycopy, cùng Arrays.equals, mọi ví dụ đều compile và chạy trên JDK 21.

[Java Basics] Toán tử trong Java: số học, so sánh, logic, gán và toán tử ba ngôi

Hướng dẫn đầy đủ về toán tử trong Java — số học, so sánh, logic, gán, bitwise và ba ngôi — với mọi kết quả đều được compile và chạy thật trên JDK 21, kèm các bẫy về chia số nguyên, số dư, số thực và short-circuit.