Command Palette

Search for a command to run...

[Java Basics] String, StringBuilder và StringBuffer: khi nào dùng cái nào

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.

String, StringBuilder và StringBuffer trong Java

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ạnDùng
Cố định ngay khi tạo ra — hằng số, key của map, một giá trị đã parseString
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ầnStringBuilder
Nhiều thread cùng dựng vào một buffer chungStringBuffer, 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. Stringfinal 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.javajavap -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

Số ký tự bị copy của vòng lặp += so với một buffer StringBuilder

Đọ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.
  • StringBuilder mặ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:

MethodLàm gìGhi chú
append(x)Thêm x vào cuốiCó 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 -1Tìm theo nghĩa đen, không phải regex
length()Số ký tự đang có trong bufferKhông phải capacity
setLength(int)Cắt bớt, hoặc đệm thêm \u0000 nếu bạn tăng lênsetLength(0) là cách rẻ nhất để dùng lại một builder
capacity()Kích thước mảng bên trongLuôn lớn hơn hoặc bằng length()
toString()Copy buffer sang một String mớiChỗ 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

StringBuilder tăng buffer thế nào, với capacity đo thật

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ố + 2 là 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

StringBufferStringBuilder 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 StringBuffersynchronized; 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

Hai thread cùng append vào một StringBuilder so với một StringBuffer

Đâ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 142 chí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 vi OmitStackTraceInFastThrow củ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 toStringCacheStringBuilder 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

StringStringBuilderStringBuffer
Thay đổi đượcKhông
An toàn với threadCó, vì không đổi đượcKhôngCó, mọi method đều synchronized
Chi phí dựng trong vòng lặpBậc haiTuyến tínhTuyến tính
Chi phí thừa khi chạy một threadKhông cóLấy và nhả một monitor ở mỗi lời gọi
Có từJava 1.0Java 5Java 1.0
equals so sánhNội dung ký tựIdentity của objectIdentity của object
Dùng làm key của mapĐượcKhôngKhông
Có cú pháp literalKhôngKhông
Được pool và internKhôngKhông
Dùng khi nàoHằng số, key, giá trị truyền điDựng text từng mảnhCode 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?

Chọn giữa String, +, StringBuilder và StringBuffer

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ì 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.concat nối đúng hai chuỗi, không hơn. Nó từ chối argument null trong khi + sẽ vui vẻ ghi ra chữ null"log".concat(null) ném NullPointerException: 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òn a + "" thì không. Đây là method khá hẹp dùng: mọi chỗ khác thì + rõ ràng hơn.
  • String.join là 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ằng StringBuilder cộng thêm if (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ành 91.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ỗi StringBuilder.

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?

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ó 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, returnvoid nghĩa là gì, và call stack diễn ra thế nào khi một method chạy.

Bài viết liên quan

[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] Đệ 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] Best Practice Code Sạch trong Java cho Người Mới

Best practice code sạch cho người mới học Java trên OpenJDK 21: naming convention mà chính JDK đang theo, warning thật từ javac -Xlint:all, magic number, tách method dài theo từng bước giữ nguyên output, guard clause, == và equals, getter làm lộ state, và những code smell nên nhận ra sớm.

[Java Basics] Interface trong Java và so sánh với abstract class

Interface trong Java trên OpenJDK 21: implements khác extends thế nào, member ngầm định public static final, một class implement nhiều interface, default, static và private method, diamond problem và Interface.super, luật class-wins, functional interface, cùng bảng so sánh chi tiết với abstract class.