Câu trả lời ngắn gọn nằm trong một dòng: dùng String cho đoạn text không bao giờ đổi, dùng StringBuilder cho đoạn text bạn dựng dần từng mảnh, còn StringBuffer thì gần như không bao giờ. Câu đó đúng trong hầu hết trường hợp, nhưng lý do phổ biến nhất mà người ta đưa ra — "dấu + chậm lắm, cứ dùng StringBuilder đi" — lại sai đúng ở tình huống người mới gặp nhiều nhất, và biết ranh giới nằm ở đâu chính là khác biệt giữa việc thuộc một quy tắc và việc hiểu nó.
Nên bài này sẽ chứng minh câu trả lời đó. Chúng ta sẽ dịch ngược một phép nối chuỗi ra bytecode để xem javac thực sự sinh ra gì, đếm số object và số ký tự mà mỗi cách phải copy, theo dõi mảng bên trong StringBuilder lớn lên rồi bị thay thế, và cho hai thread cùng append vào một buffer để thấy rõ synchronized đổi lại được gì và tốn gì. Mọi con số và mọi thông báo lỗi 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 bằng quy tắc, rồi mổ xẻ nó.
Quy tắc gói trong một dòng
| Đoạn text của bạn | Dùng |
|---|---|
| Cố định ngay khi tạo ra — hằng số, key của map, một giá trị đã parse | String |
| Ghép từ vài mảnh trong cùng một expression | + ngay trên dòng đó |
| Dựng dần từng mảnh — vòng lặp, nhánh rẽ, một báo cáo dài dần | StringBuilder |
| Nhiều thread cùng dựng vào một buffer chung | StringBuffer, và nên xem lại thiết kế |
Ba câu nhắc lại, vì một bài trước trong series này đã chứng minh hết rồi: một object String không bao giờ đổi sau khi được tạo, mọi method trông như đang sửa nội dung đều trả về một object hoàn toàn mới và để nguyên object gốc, còn các ký tự nằm trong một mảng private mà không ai lấy ra được. String là final nên cũng không subclass nào phá được lời hứa đó. Immutable không phải là thiếu sót — nó chính là thứ khiến một String an toàn khi chia sẻ, an toàn khi cache hash code, và an toàn khi đưa vào pool.
Hệ quả của điều đó chính là toàn bộ lý do StringBuilder tồn tại. s = s + "x" không thể nối tại chỗ, vì làm gì có "chỗ" nào để nối. Nó cấp phát một object mới rồi copy cả hai toán hạng vào đó. Làm một lần thì chi phí vô hình. Làm năm mươi nghìn lần trong một vòng lặp thì bạn đã cấp phát năm mươi nghìn object và copy 1,25 tỉ ký tự chỉ để tạo ra một chuỗi năm mươi nghìn ký tự.
Compiler thực sự làm gì với dấu +
Đây là chỗ phần lớn tutorial nói sai, nên hãy đọc bytecode thay vì đọc lời đồn. Hai method, một cái nối năm toán hạng trong một expression, một cái nối bên trong vòng lặp:
public class Concat {
static String single(String name, int year, String tag) {
return "name=" + name + " year=" + year + " tag=" + tag;
}
static String loop(String[] parts) {
String s = "";
for (int i = 0; i < parts.length; i++) {
s = s + parts[i];
}
return s;
}
}
Compile rồi dịch ngược bằng javac Concat.java và javap -c Concat.class. Method một-expression ra đúng năm instruction:
static java.lang.String single(java.lang.String, int, java.lang.String);
Code:
0: aload_0
1: iload_1
2: aload_2
3: invokedynamic #7, 0 // InvokeDynamic #0:makeConcatWithConstants:(Ljava/lang/String;ILjava/lang/String;)Ljava/lang/String;
8: areturn
Ba dấu +, năm toán hạng, một instruction lo hết phần ghép. Từ Java 9, compiler không còn khai triển dấu + ra thành gì cả; nó sinh đúng một invokedynamic và để runtime tự quyết định cách làm. javap -v cho thấy đầu bên kia là ai:
BootstrapMethods:
0: #61 REF_invokeStatic java/lang/invoke/StringConcatFactory.makeConcatWithConstants:(Ljava/lang/invoke/MethodHandles$Lookup;Ljava/lang/String;Ljava/lang/invoke/MethodType;Ljava/lang/String;[Ljava/lang/Object;)Ljava/lang/invoke/CallSite;
Method arguments:
#57 name=\u0001 year=\u0001 tag=\u0001
StringConcatFactory nhận công thức name=\u0001 year=\u0001 tag=\u0001, trong đó mỗi \u0001 đánh dấu một chỗ trống dành cho argument, rồi dựng một chuỗi method handle để đo từng argument, cấp đúng một mảng vừa khít và điền vào. Không có String trung gian, không có buffer dư, không có cú copy của toString(). Dấu + trên một dòng không hề chậm, và viết tay lại thành chuỗi StringBuilder cũng không làm nó nhanh hơn.
Giờ là bytecode của vòng lặp:
static java.lang.String loop(java.lang.String[]);
Code:
0: ldc #11 // String
2: astore_1
3: iconst_0
4: istore_2
5: iload_2
6: aload_0
7: arraylength
8: if_icmpge 27
11: aload_1
12: aload_0
13: iload_2
14: aaload
15: invokedynamic #13, 0 // InvokeDynamic #1:makeConcatWithConstants:(Ljava/lang/String;Ljava/lang/String;)Ljava/lang/String;
20: astore_1
21: iinc 2, 1
24: goto 5
27: aload_1
28: areturn
Lệnh goto 5 ở offset 24 nhảy ngược về offset 5, nên mọi thứ nằm giữa 5 và 24 chính là thân vòng lặp — và invokedynamic ở offset 15 nằm ngay trong đó. Toàn bộ câu chuyện nằm ở chỗ này. Tối ưu kia áp cho từng expression, mà mỗi vòng lặp lại là một expression riêng, không biết gì về vòng trước. Mỗi lượt lại cấp một mảng mới đủ chứa chuỗi tích lũy cộng thêm một ký tự, copy chuỗi tích lũy vào đó, rồi vứt bản cũ đi. Compiler không thể tự kéo một buffer ra ngoài vòng lặp giúp bạn, vì các giá trị trung gian đều quan sát được: bất kỳ giá trị nào cũng có thể bị gán, in ra hay truyền vào method.
Để so sánh, compile chính file đó bằng javac --release 8 rồi dịch ngược lại method single:
static java.lang.String single(java.lang.String, int, java.lang.String);
Code:
0: new #7 // class java/lang/StringBuilder
3: dup
4: invokespecial #9 // Method java/lang/StringBuilder."<init>":()V
7: ldc #10 // String name=
9: invokevirtual #12 // Method java/lang/StringBuilder.append:(Ljava/lang/String;)Ljava/lang/StringBuilder;
12: aload_0
13: invokevirtual #12 // Method java/lang/StringBuilder.append:(Ljava/lang/String;)Ljava/lang/StringBuilder;
16: ldc #16 // String year=
18: invokevirtual #12 // Method java/lang/StringBuilder.append:(Ljava/lang/String;)Ljava/lang/StringBuilder;
21: iload_1
22: invokevirtual #18 // Method java/lang/StringBuilder.append:(I)Ljava/lang/StringBuilder;
25: ldc #21 // String tag=
27: invokevirtual #12 // Method java/lang/StringBuilder.append:(Ljava/lang/String;)Ljava/lang/StringBuilder;
30: aload_2
31: invokevirtual #18 // Method java/lang/StringBuilder.append:(Ljava/lang/String;)Ljava/lang/StringBuilder;
34: invokevirtual #23 // Method java/lang/StringBuilder.toString:()Ljava/lang/String;
37: areturn
Trước Java 9, javac làm đúng cái việc mà lời đồn bảo bạn nên tự làm bằng tay: cấp một StringBuilder, append từng mảnh, gọi toString(). Lời khuyên kia ra đời từ đó, và trên Java 9 trở lên nó đã lỗi thời với trường hợp một expression — compiler bây giờ sinh ra thứ tốt hơn hẳn.
Đo trên 2.000.000 lần tính chính expression năm toán hạng đó, lấy kết quả tốt nhất trong tám lượt sau bốn lượt warm-up, bản một dòng còn nhanh hơn bản viết tay một chút:
one-expression + 22.0 ms
hand-written StringBuilder 22.9 ms
Hãy coi hai con số này là tham khảo — chúng phụ thuộc vào máy, vào JVM và vào heap. Điều đáng nhớ là tỉ lệ nằm quanh mức một chứ không phải quanh mức tám trăm.
Chi phí của + trong vòng lặp, đếm cụ thể
Thời gian chạy phụ thuộc vào máy. Số đếm thì không, nên hãy đếm. Chương trình dưới đây làm cùng năm mươi nghìn lần append một ký tự theo ba cách, và gắn bộ đếm vào từng cách — đếm số object được cấp phát, và đếm từng ký tự bị copy từ mảng này sang mảng khác:
public class Cost {
static final int N = 50_000;
static void withPlus() {
String s = "";
long objects = 0, copied = 0;
for (int i = 0; i < N; i++) {
copied += s.length() + 1; // both operands go into the new String
s = s + "x";
objects++;
}
System.out.println("s = s + \"x\"");
System.out.println(" new String objects " + objects);
System.out.println(" characters copied " + copied);
}
static void withBuilder(int capacity) {
StringBuilder sb = capacity == 0 ? new StringBuilder() : new StringBuilder(capacity);
long grows = 0, copied = 0;
int cap = sb.capacity();
for (int i = 0; i < N; i++) {
sb.append('x');
if (sb.capacity() != cap) { // the buffer was replaced
grows++;
copied += cap; // the old array is copied into the new one
cap = sb.capacity();
}
}
System.out.println(capacity == 0 ? "new StringBuilder()" : "new StringBuilder(" + capacity + ")");
System.out.println(" buffer grows " + grows);
System.out.println(" characters copied " + (copied + N));
}
public static void main(String[] args) {
withPlus();
withBuilder(0);
withBuilder(N);
}
}
s = s + "x"
new String objects 50000
characters copied 1250025000
new StringBuilder()
buffer grows 12
characters copied 123686
new StringBuilder(50000)
buffer grows 0
characters copied 50000

Đọc ba khối đó cạnh nhau:
- Vòng lặp
+copy 1.250.025.000 ký tự để dựng một chuỗi 50.000 ký tự. Vòng lặp thứ i copy i ký tự, nên tổng làn × (n + 1) / 2— bậc hai theo số vòng lặp. Tăng gấp đôi số vòng thì công việc tăng gấp bốn. StringBuildermặc định copy 123.686: năm mươi nghìn cho chính các lần append, cộng 73.686 ký tự bị dời trong mười hai lần buffer bị thay thế. Ít hơn khoảng mười nghìn lần, và là tuyến tính.- Cấp sẵn capacity thì phần grow biến mất hoàn toàn: 50.000 ký tự, đúng bằng mức sàn lý thuyết. Không cách nào dựng một chuỗi năm mươi nghìn ký tự mà copy ít hơn năm mươi nghìn ký tự.
50.000 object String bị bỏ đi cũng đáng kể. Chúng sống rất ngắn nên garbage collector xử lý khá rẻ, nhưng cấp phát vẫn tốn — và vài object cuối cùng dài tới 50.000 ký tự mỗi cái, tức là đã vượt xa cỡ mà việc cấp phát còn được coi là miễn phí.
StringBuilder: một buffer thay đổi được
StringBuilder là một buffer char kèm một độ dài, bọc trong một API. Nó không phải String, không có quan hệ kế thừa nào với String, và cách duy nhất để lấy ra một String là gọi toString(). Mọi thứ khác đều sửa thẳng vào buffer.
public class Builder {
public static void main(String[] args) {
StringBuilder sb = new StringBuilder("Hello");
sb.append(", ").append("Java").append('!').append(21).append(true);
System.out.println(sb);
System.out.println("length " + sb.length());
System.out.println("charAt(7) " + sb.charAt(7));
System.out.println("indexOf " + sb.indexOf("Java"));
sb.insert(0, ">> ");
System.out.println("insert " + sb);
sb.replace(0, 3, "");
System.out.println("replace " + sb);
sb.deleteCharAt(sb.length() - 1);
System.out.println("deleteChar " + sb);
sb.delete(11, sb.length());
System.out.println("delete " + sb);
sb.setCharAt(0, 'h');
System.out.println("setCharAt " + sb);
sb.setLength(5);
System.out.println("setLength " + sb + " length=" + sb.length());
System.out.println("reverse " + new StringBuilder(sb).reverse());
String out = sb.toString();
System.out.println("toString " + out + " " + out.getClass().getName());
StringBuilder row = new StringBuilder();
String csv = row.append("id").append(',').append("name").append(',').append("email").toString();
System.out.println("chained " + csv);
System.out.println("append returns this " + (row.append("") == row));
}
}
Hello, Java!21true
length 18
charAt(7) J
indexOf 7
insert >> Hello, Java!21true
replace Hello, Java!21true
deleteChar Hello, Java!21tru
delete Hello, Java
setCharAt hello, Java
setLength hello length=5
reverse olleh
toString hello java.lang.String
chained id,name,email
append returns this true
Toàn bộ API, trừ vài overload hiếm dùng:
| Method | Làm gì | Ghi chú |
|---|---|---|
append(x) | Thêm x vào cuối | Có overload cho mọi primitive, char[], Object, CharSequence. Argument null sẽ append bốn ký tự null |
insert(int, x) | Chèn x tại offset đó | Cùng bộ overload; offset được phép bằng đúng length |
delete(int, int) | Xóa khoảng [start, end) | end vượt length thì bị cắt về length chứ không báo lỗi |
deleteCharAt(int) | Xóa một ký tự | Ném StringIndexOutOfBoundsException nếu index sai |
replace(int, int, String) | Thay một khoảng bằng chuỗi bất kỳ | Chuỗi thay không cần cùng độ dài |
reverse() | Đảo ngược buffer tại chỗ | Hiểu surrogate pair; xem bên dưới |
setCharAt(int, char) | Ghi đè một ký tự | Trả về void nên không chain được |
charAt(int) | Đọc một ký tự | Đếm từ 0, giống String |
indexOf(String) | Vị trí đầu tiên, hoặc -1 | Tìm theo nghĩa đen, không phải regex |
length() | Số ký tự đang có trong buffer | Không phải capacity |
setLength(int) | Cắt bớt, hoặc đệm thêm \u0000 nếu bạn tăng lên | setLength(0) là cách rẻ nhất để dùng lại một builder |
capacity() | Kích thước mảng bên trong | Luôn lớn hơn hoặc bằng length() |
toString() | Copy buffer sang một String mới | Chỗ duy nhất tạo ra String |
Chain được là nhờ append trả về chính this chứ không phải object mới — dòng cuối của output chứng minh điều đó bằng ==. Vì vậy sb.append("a").append("b") là một buffer bị ghi hai lần, chứ không phải chuỗi object mới nối nhau kiểu String. setCharAt là ngoại lệ: nó trả về void nên luôn kết thúc một chain.
reverse() đảo buffer tại chỗ và có hiểu surrogate pair, nên một emoji vẫn nguyên vẹn:
System.out.println(new StringBuilder("Java").reverse());
String emoji = "ab👋"; // 'a', 'b', WAVING HAND
String rev = new StringBuilder(emoji).reverse().toString();
System.out.println(rev + " length=" + rev.length());
System.out.println(new StringBuilder("Ho\u00E0ng").reverse()); // precomposed
System.out.println(new StringBuilder("Hoa\u0300ng").reverse()); // a + U+0300
avaJ
👋ba length=4
gnàoH
gǹaoH
Emoji quay về nguyên vẹn vì reverse() nhận ra surrogate pair và giữ đúng thứ tự của cặp đó. Nhưng nó không hiểu dấu thanh tách rời: dòng cuối lấy dạng tách rời của Hoàng — một chữ a rồi tới dấu huyền U+0300 riêng — và đảo từng code unit một, nên dấu huyền rời khỏi chữ a và nhảy sang chữ n. Text đến ở dạng tách rời, đúng như filesystem của macOS sinh ra, cần được normalize trước khi đảo.
Capacity và length
length() là số ký tự đang nằm trong buffer. capacity() là kích thước của mảng đang chứa chúng. Hai con số này gần như không bao giờ bằng nhau, và chính khoảng chênh đó khiến append rẻ.
public class Capacity {
public static void main(String[] args) {
StringBuilder sb = new StringBuilder();
System.out.println("start len=" + sb.length() + " cap=" + sb.capacity());
int cap = sb.capacity();
for (int i = 1; i <= 80; i++) {
sb.append('x');
if (sb.capacity() != cap) {
System.out.println("append #" + i + " len=" + sb.length()
+ " cap=" + cap + " -> " + sb.capacity());
cap = sb.capacity();
}
}
System.out.println("end len=" + sb.length() + " cap=" + sb.capacity());
System.out.println();
System.out.println("new StringBuilder(\"Java\") cap=" + new StringBuilder("Java").capacity());
System.out.println("new StringBuilder(100) cap=" + new StringBuilder(100).capacity());
StringBuilder big = new StringBuilder();
big.append("0123456789012345678901234567890123456789"); // 40 chars in one call
System.out.println("append 40 to an empty one cap=" + big.capacity());
}
}
start len=0 cap=16
append #17 len=17 cap=16 -> 34
append #35 len=35 cap=34 -> 70
append #71 len=71 cap=70 -> 142
end len=80 cap=142
new StringBuilder("Java") cap=20
new StringBuilder(100) cap=100
append 40 to an empty one cap=40

Bốn quy tắc rút thẳng ra từ output đó:
- Capacity mặc định là 16.
new StringBuilder()cấp chỗ cho mười sáu ký tự, nên mười sáu lần append đầu tiên chỉ ghi vào mảng chứ không đụng gì khác. new StringBuilder(String)cấp 16 cộng độ dài chuỗi đó."Java"có bốn ký tự và capacity trả về là 20, chừa sẵn chỗ để append mà chưa phải cấp lại mảng.- Quy tắc lớn lên là capacity cũ nhân hai, cộng hai. 16 thành 34, 34 thành 70, 70 thành 142. Số
+ 2là chi tiết cài đặt của JDK chứ không phải thứ nên dựa vào, nhưng việc nhân đôi mới là thứ khiến tổng lượng copy tuyến tính thay vì bậc hai. - Một lần append lớn hơn cả mức nhân đôi thì được cấp đúng phần nó cần. Append 40 ký tự vào một builder rỗng cho capacity 40 chứ không phải 34, vì nhân đôi vẫn chưa đủ.
Một lần "grow" không phải là thay đổi kích thước — mảng trong Java không co giãn được. Nó cấp một mảng mới lớn hơn rồi copy nội dung cũ sang, và mảng cũ thành rác. Đó chính là 73.686 ký tự mà bản chạy có đếm quy cho phần grow, và cũng chính là phần mà new StringBuilder(expectedSize) xóa sạch:
new StringBuilder() 12 buffer grows, 123,686 characters copied
new StringBuilder(50_000) 0 buffer grows, 50,000 characters copied
Hãy cấp sẵn capacity khi bạn đặt được một con số cho kết quả — một số field cố định, số dòng đã biết, hay input.length() * 2. Đừng dằn vặt khi không đoán được: mười hai lần cấp lại mảng trên năm mươi nghìn lần append không phải thảm họa, mà một capacity quá rộng thì lãng phí bộ nhớ một cách không cần thiết. Nếu builder sống lâu, trimToSize() sẽ trả lại phần dư sau đó.
StringBuffer: cùng API nhưng có lock
StringBuffer và StringBuilder là hai anh em — cả hai cùng extend class AbstractStringBuilder ở mức package, và public API của chúng giống nhau từng method một. Khác biệt duy nhất là một từ khóa:
$ javap java.lang.StringBuffer | grep -c synchronized
47
$ javap java.lang.StringBuilder | grep -c synchronized
0
public synchronized java.lang.StringBuffer append(java.lang.String);
public synchronized int length();
public synchronized char charAt(int);
public synchronized java.lang.String toString();
Bốn mươi bảy method của StringBuffer là synchronized; của StringBuilder thì không method nào. Mỗi lời gọi tới StringBuffer đều phải lấy monitor trên chính object buffer rồi nhả ra khi thoát.
Điều đó đổi lại được gì? Hãy nhìn xem một lần append thật ra làm gì: đọc field count, ghi một ký tự vào index đó, lưu lại count + 1. Ba bước trên state dùng chung. Hai thread chạy ba bước đó mà không có lock thì có thể xen kẽ vào nhau, và rồi cả hai cùng ghi vào một index, cùng lưu một giá trị count mới. Đây là cuộc đua đó, chạy thật:
import java.util.concurrent.atomic.AtomicReference;
public class Race {
static final int PER_THREAD = 100_000;
static final int EXPECTED = 2 * PER_THREAD;
static String run(boolean synced) throws InterruptedException {
StringBuilder sb = new StringBuilder();
StringBuffer bf = new StringBuffer();
AtomicReference<Throwable> boom = new AtomicReference<>();
Runnable task = () -> {
try {
for (int i = 0; i < PER_THREAD; i++) {
if (synced) bf.append('x'); else sb.append('x');
}
} catch (Throwable t) {
boom.compareAndSet(null, t);
}
};
Thread t1 = new Thread(task), t2 = new Thread(task);
t1.start(); t2.start();
t1.join(); t2.join();
int len = synced ? bf.length() : sb.length();
String kind = synced ? "StringBuffer " : "StringBuilder";
String status = boom.get() != null
? "threw " + boom.get()
: (len == EXPECTED ? "OK" : "LOST " + (EXPECTED - len) + " chars");
return kind + " expected " + EXPECTED + " actual " + len + " " + status;
}
public static void main(String[] args) throws Exception {
for (int r = 1; r <= 6; r++) System.out.println("run " + r + " " + run(false));
System.out.println();
for (int r = 1; r <= 6; r++) System.out.println("run " + r + " " + run(true));
}
}
run 1 StringBuilder expected 200000 actual 100136 threw java.lang.ArrayIndexOutOfBoundsException: Index 142 out of bounds for length 142
run 2 StringBuilder expected 200000 actual 144020 LOST 55980 chars
run 3 StringBuilder expected 200000 actual 108289 threw java.lang.ArrayIndexOutOfBoundsException
run 4 StringBuilder expected 200000 actual 146016 LOST 53984 chars
run 5 StringBuilder expected 200000 actual 101312 threw java.lang.ArrayIndexOutOfBoundsException
run 6 StringBuilder expected 200000 actual 158977 LOST 41023 chars
run 1 StringBuffer expected 200000 actual 200000 OK
run 2 StringBuffer expected 200000 actual 200000 OK
run 3 StringBuffer expected 200000 actual 200000 OK
run 4 StringBuffer expected 200000 actual 200000 OK
run 5 StringBuffer expected 200000 actual 200000 OK
run 6 StringBuffer expected 200000 actual 200000 OK

Đây không phải mối nguy lý thuyết phải dụ mãi mới xảy ra. Cả sáu lần chạy không synchronized đều sai, và qua bốn lần khởi động JVM riêng biệt thì kiểu hỏng chia gần đều thành hai dạng:
- Mất dữ liệu âm thầm. Length trả về thiếu hàng chục nghìn ký tự. Một
countđã cũ khiến thread này ghi đè lên ký tự mà thread kia vừa ghi, còn khi hai lần grow đua nhau thì cả một mảng vừa ghi xong bị vứt đi — đó là lý do con số thiếu lớn đến vậy. ArrayIndexOutOfBoundsException. Một thread đọc reference của mảng ngay trước một lần grow còn đọc length ngay sau đó, hoặc hai lần grow đua nhau, và cú ghi rơi ra ngoài một mảng đã cũ.Index 142 out of bounds for length 142chính là dãy capacity ở phần trước hiện lên trong thông báo lỗi. Run 3 và run 5 báo đúng exception đó mà không kèm chi tiết nào: khi JIT đã thấy cùng một chỗ ném lỗi vài lần, nó tái sử dụng một instance dựng sẵn không có stack trace — đó là hành viOmitStackTraceInFastThrowcủa JVM chứ không phải một kiểu hỏng khác.
Mọi lần chạy StringBuffer đều ra đúng 200.000. Đó là thứ mà lock đổi lại được.
Còn cái giá của nó, khi không có tranh chấp, thì nhỏ nhưng có thật. Hai trăm nghìn lần append một ký tự trên một thread, lấy kết quả tốt nhất trong tám lượt sau bốn lượt warm-up:
StringBuilder 0.22 ms
StringBuffer 0.26 ms
Chậm hơn khoảng 20% khi máy đang rảnh, và tệ hơn thấy rõ khi máy đang bận — cả hai con số đều chỉ là số tham khảo chứ không phải kết quả benchmark nghiêm túc. Điều đáng nhớ là hình dạng của nó: đây là một hằng số cộng thêm cho mỗi lời gọi, không phải một thay đổi về độ phức tạp. JVM hiện đại làm cho một monitor không tranh chấp rẻ đi rất nhiều, và StringBuffer còn cache kết quả toString() trong field toStringCache mà StringBuilder không có. Nên lý do để tránh StringBuffer thật ra không nằm ở tốc độ.
Nó nằm ở thiết kế. Hai thread cùng append vào một buffer gần như luôn là một sai lầm ngay từ đầu: thứ tự xen kẽ là ngẫu nhiên, nên kết quả vô nghĩa ngay cả khi không mất ký tự nào. StringBuffer ngăn được hỏng dữ liệu, chứ không cho bạn một kết quả có ý nghĩa. Thứ bạn thật sự cần là mỗi thread một StringBuilder riêng rồi ghép lại ở cuối, hoặc một cấu trúc concurrent đúng nghĩa. StringBuffer có từ Java 1.0, khi chưa hề có lựa chọn nào không synchronized; StringBuilder mãi tới Java 5 mới xuất hiện, đúng vì hóa ra trả tiền cho một cái lock không ai cần mới là trường hợp phổ biến chứ không phải trường hợp hiếm.
So sánh String, StringBuilder và StringBuffer
String | StringBuilder | StringBuffer | |
|---|---|---|---|
| Thay đổi được | Không | Có | Có |
| An toàn với thread | Có, vì không đổi được | Không | Có, mọi method đều synchronized |
| Chi phí dựng trong vòng lặp | Bậc hai | Tuyến tính | Tuyến tính |
| Chi phí thừa khi chạy một thread | — | Không có | Lấy và nhả một monitor ở mỗi lời gọi |
| Có từ | Java 1.0 | Java 5 | Java 1.0 |
equals so sánh | Nội dung ký tự | Identity của object | Identity của object |
| Dùng làm key của map | Được | Không | Không |
| Có cú pháp literal | Có | Không | Không |
| Được pool và intern | Có | Không | Không |
| Dùng khi nào | Hằng số, key, giá trị truyền đi | Dựng text từng mảnh | Code cũ, và buffer chia sẻ giữa thread |
Dòng equals là chỗ hay làm người ta bất ngờ. StringBuilder không override equals hay hashCode, nên cả hai rơi về Object và so sánh identity. Hai builder chứa cùng nội dung không hề bằng nhau, và một builder thì vô dụng khi làm key của HashMap.
Nên dùng cái nào?

Ba câu hỏi là xong, và câu trả lời ở giữa chính là mặc định: đoạn text còn đổi sau khi tạo ra không, nó được dựng trong một expression hay từng mảnh một, và có đúng hai thread dùng chung buffer không.
Nhánh đầu tiên xứng đáng được coi trọng hơn mức người ta thường dành cho nó. Với một đoạn text cố định, chọn StringBuilder không phải là lựa chọn trung tính — nó vứt đi toàn bộ những gì tính immutable mang lại:
import java.util.HashMap;
import java.util.Map;
public class Traps {
public static void main(String[] args) {
StringBuilder a = new StringBuilder("abc");
StringBuilder b = new StringBuilder("abc");
System.out.println("a.equals(b) " + a.equals(b));
System.out.println("a.toString().equals() " + a.toString().equals(b.toString()));
System.out.println("a.compareTo(b) " + a.compareTo(b));
Map<StringBuilder, Integer> byBuilder = new HashMap<>();
byBuilder.put(a, 1);
System.out.println("lookup by an equal key " + byBuilder.get(new StringBuilder("abc")));
Map<String, Integer> byString = new HashMap<>();
byString.put("total", 1);
System.out.println("lookup by a String key " + byString.get(new StringBuilder("total").toString()));
StringBuilder nulls = new StringBuilder();
String missing = null;
System.out.println("append(null) [" + nulls.append(missing) + "]");
new StringBuilder("abc").deleteCharAt(5);
}
}
a.equals(b) false
a.toString().equals() true
a.compareTo(b) 0
lookup by an equal key null
lookup by a String key 1
append(null) [null]
Exception in thread "main" java.lang.StringIndexOutOfBoundsException: Index 5 out of bounds for length 3
Bốn điều nằm trong đó. equals giữa hai builder cùng chứa "abc" cho false; chỉ toString().equals(...) mới so sánh nội dung. compareTo thì có so sánh nội dung — nó được thêm vào StringBuilder từ Java 11 — khiến cặp method này mâu thuẫn nhau, nên đừng bao giờ trộn chúng. Một HashMap dùng key StringBuilder không tìm được key giống nội dung nhưng khác object, còn map dùng key String thì tìm ra dù chuỗi đến từ đâu. Và append(null) sẽ append bốn ký tự null chứ không ném exception, tiện cho tới lúc nó âm thầm ghi chữ null vào output của bạn.
Vậy nên String là lựa chọn đúng mỗi khi một giá trị đúng nghĩa là một giá trị: hằng số, một nhãn kiểu enum, key của map, key của cache, một field mà code khác đọc được, hay bất cứ thứ gì truyền vào method mà method đó có thể giữ lại. Bạn không cần copy phòng thủ, hash code chỉ tính một lần rồi được cache, và không ai sửa được nó sau lưng bạn. StringBuilder là công cụ thi công. Dựng bằng nó, gọi toString(), rồi để String mới là thứ bạn lưu và chia sẻ.
Khi nào concat, join, format hay text block đọc dễ hơn
StringBuilder không phải lúc nào cũng là cách dễ đọc nhất để ghép text, và ba method của String cộng với text block phủ gần hết phần còn lại:
import java.util.List;
public class Alt {
public static void main(String[] args) {
String a = "log";
System.out.println("concat empty returns the receiver " + (a.concat("") == a));
System.out.println("plus empty returns a new object " + (a + "" == a));
System.out.println("concat " + a.concat(".txt"));
System.out.println("join varargs " + String.join("/", "usr", "local", "bin"));
System.out.println("join a List " + String.join(", ", List.of("id", "name", "email")));
System.out.println("join one element [" + String.join(", ", List.of("id")) + "]");
System.out.println("join nothing [" + String.join(", ", List.of()) + "]");
System.out.println("format "
+ String.format("%s took %d ms (%.1f%%)", "build", 412, 91.25));
String sql = """
SELECT id, name
FROM users
WHERE active = %s""".formatted(true);
System.out.println(sql);
}
}
concat empty returns the receiver true
plus empty returns a new object false
concat log.txt
join varargs usr/local/bin
join a List id, name, email
join one element [id]
join nothing []
format build took 412 ms (91.3%)
SELECT id, name
FROM users
WHERE active = true
String.concatnối đúng hai chuỗi, không hơn. Nó từ chối argumentnulltrong khi+sẽ vui vẻ ghi ra chữnull—"log".concat(null)némNullPointerException: Cannot invoke "String.isEmpty()" because "str" is null— và nó trả về chính object gốc khi argument rỗng, đúng như dòng đầu của output cho thấy, còna + ""thì không. Đây là method khá hẹp dùng: mọi chỗ khác thì+rõ ràng hơn.String.joinlà thứ nên dùng khi bạn cần chèn dấu phân cách giữa một danh sách đã biết. Nó lo giúp bạn trường hợp phần tử cuối, đúng cái mà vòng lặp viết tay hay sai, và trả về chuỗi rỗng cho danh sách rỗng thay vì để thừa một dấu phân cách. Dựng"a, b, c"bằngStringBuildercộng thêmif (i > 0)là viết nhiều hơn mà làm được ít hơn.String.format, cùng dạng method"...".formatted(args)thêm từ Java 15, thắng mỗi khi hình dạng của output quan trọng hơn các mảnh ghép — canh lề, đệm khoảng trắng, cố định số chữ số thập phân. Chú ý là nó làm tròn 91.25 thành91.3, và%%sinh ra đúng một dấu phần trăm.- Text block, chính thức ổn định từ Java 15, là câu trả lời cho mọi thứ nhiều dòng: SQL, JSON, một đoạn hướng dẫn. Kết hợp với
formatted, nó dễ đọc hơn hẳn một chuỗiStringBuilder.
Không cái nào trong số này thay được StringBuilder ở đúng phần việc của nó — một biến tích lũy lớn dần qua các vòng lặp hay các nhánh rẽ. Chúng chỉ thay ở những chỗ mà StringBuilder vốn không phải công cụ đúng.
Những lỗi thường gặp
Dựng chuỗi bằng + trong vòng lặp. Chính là thứ cả bài này nói tới. Nó bậc hai, nó cấp một object mỗi vòng, và nó núp sau đoạn code trông hoàn toàn hợp lý. Sửa chỉ mất một dòng: khai báo một StringBuilder trước vòng lặp rồi append bên trong.
Gọi toString() ngay trong vòng lặp. Phiên bản tinh vi hơn của cùng một lỗi, và nó sống sót qua lần sửa đầu tiên — bạn đã chuyển sang StringBuilder nhưng vẫn dựng ra kết quả ở mỗi vòng:
StringBuilder sb = new StringBuilder();
for (int i = 0; i < N; i++) {
sb.append('x');
String snapshot = sb.toString(); // inside the loop
}
toString() inside the loop objects=10000 chars copied=50005000
toString() after the loop objects=1 chars copied=10000
toString() copy toàn bộ buffer sang một String mới ở mỗi lần gọi, nên đặt nó trong vòng lặp là khôi phục đúng cái hành vi bậc hai mà bạn vừa loại bỏ. Hãy gọi một lần, sau vòng lặp.
Dùng StringBuffer theo thói quen. Nếu builder là một biến local — và gần như luôn là vậy — thì không thread nào khác nhìn thấy nó, và cái lock kia hoàn toàn là chi phí thừa. Cứ dùng StringBuilder trừ khi bạn gọi tên được thread thứ hai.
Tưởng StringBuilder an toàn với thread vì nó là class của JDK. Tài liệu ghi rõ nó không an toàn với thread, và bản chạy ở trên cho thấy điều đó nghĩa là gì trong thực tế: mất ký tự và ArrayIndexOutOfBoundsException, ở mọi lần thử. Nếu một builder lọt ra thành field mà nhiều thread đụng tới, StringBuffer chặn được hỏng dữ liệu — nhưng thiết kế thì đã sai từ trước.
Viết lại một dòng + dễ đọc thành chuỗi builder "cho nhanh". Phần bytecode đã trả lời: từ Java 9, compiler sinh đúng một invokedynamic cho cả expression, và bản viết tay đo ra còn chậm hơn một chút. Bạn viết dài hơn mà chẳng được gì.
FAQ
StringBuilder có nhanh hơn + trên một dòng không?
Không — đo trên hai triệu lần tính một expression năm toán hạng thì nó còn chậm hơn chút ít, và bytecode giải thích vì sao. javac compile cả expression thành đúng một lời gọi invokedynamic tới StringConcatFactory, chỗ đó cấp một mảng vừa khít rồi điền vào. Bản viết tay thì cấp một builder, cho nó lớn lên, rồi copy sang một String. Dùng + trên một dòng, dùng StringBuilder khi trải qua nhiều dòng.
Có nên cấp sẵn capacity cho mọi StringBuilder không?
Chỉ khi bạn đặt được một con số. Phần tiết kiệm đo được trên năm mươi nghìn lần append là mười hai lần cấp lại mảng và 73.686 ký tự bị copy — đáng kể trong một vòng lặp nóng, và vô hình ở mọi chỗ khác. Truyền capacity khi kích thước là hiển nhiên — một bộ field cố định, số dòng, hay bội số độ dài input — còn lại cứ để mặc định. Một capacity quá rộng chỉ là đổi vấn đề này lấy vấn đề khác.
Có khi nào StringBuffer là lựa chọn đúng không?
Hiếm, và thường vì lý do không nằm ở thiết kế của bạn: một API cũ trả về nó, hoặc một field thật sự bị chia sẻ mà hôm nay bạn chưa sửa được cấu trúc code. Khi bạn làm chủ thiết kế, hãy cho mỗi thread một StringBuilder riêng rồi gộp kết quả, hoặc dùng một collection concurrent. Nhớ rằng StringBuffer chỉ bảo đảm không hỏng dữ liệu chứ không bảo đảm thứ tự có ý nghĩa — hai thread append dưới lock vẫn xen kẽ nhau.
Vì sao sb.equals(other) trả về false dù nội dung giống nhau?
Vì StringBuilder không override equals, nên nó thừa hưởng cách so sánh identity từ Object. Hãy so sb.toString().equals(other.toString()). compareTo là ngoại lệ mâu thuẫn — thêm từ Java 11, nó có so sánh nội dung — nên một builder mà compareTo báo bằng nhau vẫn trượt equals, và nó không bao giờ dùng được làm key của HashMap.
Có thể tái sử dụng một StringBuilder thay vì tạo mới không?
Được: sb.setLength(0) làm rỗng builder mà vẫn giữ nguyên mảng, đây là cách reset rẻ nhất và bỏ qua luôn phần grow ở vòng sau. Trong một vòng lặp chặt mà mỗi vòng dựng một chuỗi, đó là khoản lời có thật. Ngoài vòng lặp nóng thì một new StringBuilder() mới cho mỗi vòng lại rõ ràng hơn, và JVM xử lý object sống ngắn rất tốt, nên đừng bẻ cong code chỉ vì việc này.
Kết luận
String là immutable nên + luôn tạo ra object mới — nhưng từ Java 9, javac compile cả một expression nối chuỗi thành đúng một lời gọi invokedynamic tới StringConcatFactory, và đó là lý do nối chuỗi trên một dòng hoàn toàn ổn còn viết tay lại không phải là tối ưu. Bên trong vòng lặp thì mỗi vòng là một expression riêng nên công việc bị làm lại từ đầu mỗi lượt: năm mươi nghìn phép + đã cấp phát năm mươi nghìn object và copy 1,25 tỉ ký tự, so với một StringBuilder chỉ copy 123.686. StringBuilder giữ một mảng char khởi đầu ở 16 rồi nhân đôi cộng hai khi cần, và cấp sẵn capacity thì xóa sạch mười hai lần cấp lại mảng đó. StringBuffer là cùng một buffer đó với synchronized gắn trên bốn mươi bảy method của nó; bản không synchronized thật sự mất ký tự và ném ArrayIndexOutOfBoundsException dưới hai thread, nhưng việc chia sẻ một buffer giữa các thread là vấn đề thiết kế mà cái lock không giải quyết được.
Bài tiếp theo nói về method: một khai báo gồm những phần nào, gọi method ra sao, static thay đổi điều gì trong lời gọi, return và void nghĩa là gì, và call stack diễn ra thế nào khi một method chạy.