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.
![]()
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 choString, và chỉ choStringtrong 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' và "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.

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":
- 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.
- Hash code được cache.
StringtínhhashCode()một lần rồi lưu lại, đó chính là thứ khiếnHashMapvới keyStringchạy nhanh. Cách đó chỉ đúng khi nội dung không bao giờ đổi. - 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.

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ợp | Kết quả | Lý do |
|---|---|---|
"hi" == "hi" | true | Hai literal cùng trỏ về một object trong pool |
new String("hi") == new String("hi") | false | Hai lần cấp phát riêng biệt, không cái nào nằm trong pool |
"hi" == new String("hi") | false | Object trong pool so với object mới trên heap |
"hi" == "h" + "i" | true | Cả hai toán hạng là constant nên compiler gộp sẵn thành literal "hi" và intern nó |
"hi" == part + "i" | false | part 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 rafalsedù 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
equalsIgnoreCaselà bản không phân biệt hoa thường.compareTotrả vềintchứ 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-1hay1.Objects.equals(x, y)trongjava.utilxử lý đượcnullở cả hai phía và trảtruekhi 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 là "Hello, Java" ở những dòng không viết rõ object gọi.
| Method | Trả 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ừ 0 | s.charAt(0) cho H |
indexOf(String) | Index đầu tiên tìm thấy, hoặc -1 | s.indexOf("a") cho 8, s.indexOf("z") cho -1 |
lastIndexOf(String) | Index cuối cùng tìm thấy, hoặc -1 | s.lastIndexOf("a") cho 10 |
contains(CharSequence) | true nếu xuất hiện ở bất kỳ đâu | s.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ết | s.substring(7) cho Java |
substring(int, int) | Khoảng [begin, end) — không lấy end | s.substring(0, 5) cho Hello |
toUpperCase() | String mới viết hoa | s.toUpperCase() cho HELLO, JAVA |
toLowerCase() | String mới viết thường | s.toLowerCase() cho hello, java |
trim() | Cắt các ký tự tới U+0020 ở hai đầu | Với chuỗi đệm EM SPACE, length vẫn là 4 |
strip() (Java 11+) | Cắt whitespace theo chuẩn Unicode | Cù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 regex | s.matches("[0-9]+") cho false |
concat(String) | Nối thêm một String | s.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ách | String.join(" - ", "a", "b", "c") cho a - b - c |
String.format(fmt, args) | Một String đã format | String.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.
trim và strip 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 |
|---|---|
\n | Xuống dòng |
\t | Tab |
\" | Dấu nháy kép |
\\ | Dấu backslash |
\' | Dấu nháy đơn |
\r | Carriage return |
\uXXXX | Code 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 và 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ư ởjsonthì không có newline ở cuối. Đặt nó trên một dòng riêng như ởblockthì 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.

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ưnglength()báo7, 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ả
đvàạ, đều là một code point trong BMP, nên"đại học"đếm ra7theo 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.

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 |
|---|---|
%s | Object bất kỳ, qua toString(); null in ra là null |
%d | Giá trị nguyên — int, long, short, byte |
%f | Giá trị thực; %.2f cố định hai chữ số thập phân |
%n | Ký tự xuống dòng của hệ thống — nên dùng thay cho \n trong chuỗi format |
%-10s | Căn trái trong ô rộng 10; bỏ dấu trừ thì căn phải |
%05d | Đệm số 0 cho đủ độ rộng 5 |
%,d | Thê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 là "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?
Vì + 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 nextInt và nextLine, 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.