Thêm một câu hỏi kinh điển không kém, có phần nâng cao hơn chút vì ít được nhắc đến trong trường học nên đánh giá được khả năng hiểu sâu của thí sinh. Đó là so sánh StringBuilder với StringBuffer.

Tất nhiên, không như tiêu đề. StringBuilderStringBuffer khác nhau rất nhiều, ta có thể xem qua bảng so sánh dưới

StringBuilder StringBuffer
Cả 2 đều lưu trữ giá trị trong vùng nhớ Heap. Đều có thể thay đổi được giá trị
Không synchronized Có synchronized
Fail Safe Fail Fast
  • Heap là 1 cấu trúc lưu trữ dữ liệu trên Ram được sử dụng khi chương trình run-time. Heap cung cấp vùng nhớ cho các yêu cầu cấp phát động
  • Cả 2 đều có thể thay đổi giá trị? Đúng! Còn String thì không thay đổi được giá trị. Mình sẽ giải thích thêm phần này ở mục sau.
  • Synchronized là cơ chế khóa hoặc đồng bộ dữ liệu, tránh việc mất mát dữ liệu khi có nhiều thread cùng sử dụng 1 dữ liệu chung. Bản chất StringBuffer vẫn kế thừa từ AbstractStringBuilder nhưng được trang bị thêm Synchronized. Kiểm tra rất đơn giản bằng cách decompile file StringBuffer.class trong JDK ra sẽ thấy hầu hết các method của StringBuffer đều có thêm từ khóa synchronized, còn StringBuilder thì không có.
  • Khi có nhiều thread cùng đọc/ghi 1 vùng dữ liệu thì cơ chế này sẽ ưu tiên thread nào tác động trước sẽ được thực hiện, thread đến sau sẽ phải chờ. Viêc này giúp StringBuffer đảm bảo an toàn cho dữ liệu nhưng lại làm chậm quá trình xử lý (Fail Fast)
  • Với StringBuilder đến bản Java 5 mới ra đời (sau StringBuffer) do không được trang bị Synchronized nên khắc phục được nhược điểm của StringBuffer là thao tác xử lý nhanh hơn so với StringBuffer, nhưng lại dễ xảy ra mất mát dữ liệu khi có sự tranh chấp trong đa luồng (Fail Safe).

Để thấy rõ hơn về vấn đề nhanh-chậm và mất mát hay xung đột dữ liệu thì các bạn theo dõi 2 ví dụ dưới đây

Ví dụ 1: Kiểm tra tốc độ xử lý của String, StringBuilder, StringBuffer khi thực hiện thao tác thêm chuỗi 100000 lần. Kết hợp thử thêm chuỗi 1000000 với StringBuilder và StringBuffer để so sánh với ví dụ 2

String str ="";
long time = System.currentTimeMillis();
for (int i = 0 ; i < 100000; i++){
    str+="add string ";
}
System.out.println("String: " + (System.currentTimeMillis() - time) + " ms");

time = System.currentTimeMillis();
StringBuilder stringBuilder = new StringBuilder();
for (int i = 0 ; i < 100000; i++){
    stringBuilder.append("append string");
}
System.out.println("StringBuilder 100K: "+(System.currentTimeMillis() - time) + " ms");

time = System.currentTimeMillis();
StringBuffer stringBuffer = new StringBuffer();
for (int i = 0 ; i < 100000; i++){
    stringBuffer.append("append string ");
}
System.out.println("StringBuffer 100K: "+(System.currentTimeMillis() - time) + " ms");
time = System.currentTimeMillis();
StringBuilder stringBuilder1 = new StringBuilder();
for (int i = 0 ; i < 1000000; i++){
    stringBuilder1.append("append string");
}
System.out.println("StringBuilder 1M: "+(System.currentTimeMillis() - time) + " ms");

time = System.currentTimeMillis();
StringBuffer stringBuffer1 = new StringBuffer();
for (int i = 0 ; i < 1000000; i++){
    stringBuffer1.append("append string ");
}
System.out.println("StringBuffer 1M: "+(System.currentTimeMillis() - time) + " ms");

Kết quả cho thấy sự khác biệt, nhưng sự khác biệt này không lớn vì đã được JVM tối ưu cho StringBuffer trong trường hợp chạy đơn luồng, nên mặc dù có chậm cũng không đáng kể so với StringBuilder

String: 87809 ms
StringBuilder 100K: 3 ms
StringBuffer 100K: 5 ms
StringBuilder 1M: 23 ms
StringBuffer 1M: 26 ms

Chắc cũng sẽ có 1 số bạn thắc mắc tại sao String lại quá chậm so với StringBuilder và StringBuffer. Cái này mình sẽ giải thích cùng với phần String không thay đổi được giá trị.

Ví dụ 2: Kiểm tra vấn đề bảo toàn dữ liệu khi xử lý đa luồng. Ở đây mình sử dụng tối đa 10 thread để thực hiện 10 thao tác thêm chuỗi cùng lúc, mỗi thao tác thêm 100000 lần chuỗi mới.

Trong ví dụ này không thể đưa String vào vì nó quá chậm rồi 😀

import java.io.IOException;
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;
import java.util.concurrent.ThreadPoolExecutor;
import java.util.concurrent.TimeUnit;

public class Test {

    public static void main(String[] args) {
        ThreadPoolExecutor executorService = (ThreadPoolExecutor) Executors.newFixedThreadPool(10);
        StringBuilder stringBuilder = new StringBuilder();
        for (int i = 0; i < 10; i++) {
            executorService.execute(new AppendableRunnable(stringBuilder));
        }
        shutdownAndAwaitTermination(executorService);
        System.out.println("Thread Builder: " + AppendableRunnable.time + " ms | Length: " + stringBuilder.length());

        AppendableRunnable.time = 0;
        executorService = (ThreadPoolExecutor) Executors.newFixedThreadPool(10);
        StringBuffer stringBuffer = new StringBuffer();
        for (int i = 0; i < 10; i++) {
            executorService.execute(new AppendableRunnable(stringBuffer));
        }
        shutdownAndAwaitTermination(executorService);
        System.out.println("Thread Buffer: " + AppendableRunnable.time + " ms | Length: " + stringBuffer.length());

    }

    static void shutdownAndAwaitTermination(ExecutorService pool) {
        pool.shutdown();
        try {
            if (!pool.awaitTermination(60, TimeUnit.SECONDS)) {
                pool.shutdownNow();
                if (!pool.awaitTermination(60, TimeUnit.SECONDS))
                    System.err.println("Pool did not terminate");
            }
        } catch (Exception e) {
        }
    }
}

class AppendableRunnable <T extends Appendable> implements Runnable {
    static long time = 0;
    T appendable;

    public AppendableRunnable(T appendable) {
        this.appendable = appendable;
    }

    @Override
    public void run() {
        long time = System.currentTimeMillis();
        for (int j = 0; j < 100000; j++) {
            try {
                appendable.append("append string ");
            } catch (IOException e) {
            }
        }
        AppendableRunnable.time += (System.currentTimeMillis() - time);
    }
}

Kết quả cho thấy StringBuffer đã chậm hơn hẳn so với StringBuilder (~50 lần), đồng thời cũng chậm hơn nhiều so với ví dụ 1 mặc dù cùng số lần thêm chuỗi, bởi vì StringBuffer có synchronized phải mất thêm thời gian cho việc lock và wait.

Exception in thread "pool-1-thread-1" java.lang.ArrayIndexOutOfBoundsException
at java.lang.String.getChars(String.java:826)
at java.lang.AbstractStringBuilder.append(AbstractStringBuilder.java:422)
at java.lang.StringBuilder.append(StringBuilder.java:136)
at java.lang.StringBuilder.append(StringBuilder.java:76)
at java.lang.AbstractStringBuilder.append(AbstractStringBuilder.java:457)
at java.lang.StringBuilder.append(StringBuilder.java:166)
at java.lang.StringBuilder.append(StringBuilder.java:76)
at AppendableRunnable.run(Test.java:90)
at java.util.concurrent.ThreadPoolExecutor.runWorker(ThreadPoolExecutor.java:1142)
at java.util.concurrent.ThreadPoolExecutor$Worker.run(ThreadPoolExecutor.java:617)
at java.lang.Thread.run(Thread.java:745)
Exception in thread "pool-1-thread-7" java.lang.ArrayIndexOutOfBoundsException
at java.lang.String.getChars(String.java:826)
at java.lang.AbstractStringBuilder.append(AbstractStringBuilder.java:422)
at java.lang.StringBuilder.append(StringBuilder.java:136)
at java.lang.StringBuilder.append(StringBuilder.java:76)
at java.lang.AbstractStringBuilder.append(AbstractStringBuilder.java:457)
at java.lang.StringBuilder.append(StringBuilder.java:166)
at java.lang.StringBuilder.append(StringBuilder.java:76)
at AppendableRunnable.run(Test.java:90)
at java.util.concurrent.ThreadPoolExecutor.runWorker(ThreadPoolExecutor.java:1142)
at java.util.concurrent.ThreadPoolExecutor$Worker.run(ThreadPoolExecutor.java:617)
at java.lang.Thread.run(Thread.java:745)
Exception in thread "pool-1-thread-4" java.lang.ArrayIndexOutOfBoundsException
at java.lang.String.getChars(String.java:826)
at java.lang.AbstractStringBuilder.append(AbstractStringBuilder.java:422)
at java.lang.StringBuilder.append(StringBuilder.java:136)
at java.lang.StringBuilder.append(StringBuilder.java:76)
at java.lang.AbstractStringBuilder.append(AbstractStringBuilder.java:457)
at java.lang.StringBuilder.append(StringBuilder.java:166)
at java.lang.StringBuilder.append(StringBuilder.java:76)
at AppendableRunnable.run(Test.java:90)
at java.util.concurrent.ThreadPoolExecutor.runWorker(ThreadPoolExecutor.java:1142)
at java.util.concurrent.ThreadPoolExecutor$Worker.run(ThreadPoolExecutor.java:617)
at java.lang.Thread.run(Thread.java:745)

Thread Builder: 151 ms | Length: 9491776
Thread Buffer: 1128 ms | Length: 14000000

Còn StringBuilder, có vẻ vẫn bị chậm hơn chút nhưng không đáng kể gì so với StringBuffer vì không synchronized, không phải check lock. Nhưng ngược lại, độ dài chuỗi kết quả không đủ do bị mất mát dữ liệu trong mấy lần gặp ArrayIndexOutOfBoundsException, đây chính là thời điểm các thread xung đột nhau về dữ liệu.

String không thay đổi được giá trị, String là immutable

Đúng thế, String không thể thay đổi được giá trị, bản chất của việc cộng chuỗi trong String là tạo ra 1 String mới với giá trị là ghép của 2 chuỗi ban đầu. Ta có thể test đơn giản bằng cách debug đoạn code ngắn dưới đây.

  • Bước 1: Khai báo 1 chuỗi String s1 = “Abc”; lúc này debug thấy địa chỉ của chuỗi s1@497
  • Bước 2: Cộng thêm 1 chuỗi “Xyz” vào chuỗi s1 ban đầu, giá trị đã được thay đổi và địa chỉ của chuỗi cũng được trỏ sang @499

Đây chính là nguyên nhân của việc cộng chuỗi quá chậm so với StringBuider và StringBuffer khi liên tục phải thực hiện các thao tác tạo chuỗi mới trên vùng nhớ mới, trỏ biến hiện tại sang vùng nhớ mới. Để chắc chắn hơn, ta kiểm tra thao tác tương tự với StringBuilder hoặc StringBuffer

Có thể thấy cả trước và sau khi append thì địa chỉ vẫn là @498 mặc dù giá trị đã được thay đổi. Bởi vì trong thao tác cộng chuỗi, StringBuilder và StringBuffer đã được cấp phát thêm bộ nhớ để nối chuỗi mới vào chính chuỗi hiện tại, việc này làm giảm khá nhiều thời gian vì không phải tạo mới và trỏ lại địa chỉ cho biến.

Vậy tại sao String lại phải mất công như thế?

Cái này liên quan đến tiết kiệm tài nguyên hệ thống. Giá trị của String cũng được lưu trong Heap nhưng nó không nằm rải rác trong Heap như StringBuilderStringBuffer, mà nó được lưu trong 1 cấu trúc có tên gọi Constant String Pool. Cái tên Constant đã thể hiện 1 cái gì đó mang tính cố định và bất biến.

Mỗi khi có 1 String được tạo ra, giá trị của nó sẽ được đẩy vào table của Constant String Pool và biến String sẽ trỏ vào vùng nhớ này, nếu có 1 String mới khác được tạo ra nhưng lại mang giá trị đúng bằng String đã có thì nó sẽ duyệt thấy trong Constant String Pool. Cái này làm cho 2 biến s1s2 khởi tạo riêng biệt nhưng lại cùng trỏ vào vùng nhớ @502 (xem ảnh dưới), bớt được 1 ô nhớ trùng lặp giá trị. Và nếu có 1 vùng nhớ nào đó không có biến trỏ vào thì sau 1 thời gian nhất định GC sẽ quét và free vùng nhớ đó sử dụng cho việc khác.

Còn StringBuilder mặc dù giá trị giống nhau nhưng nó vẫn tạo ra 2 giá trị riêng biệt và trỏ đến 2 vùng nhớ khác nhau.

Cũng vì việc sử dụng chung này của String mà việc cộng chuỗi không thể thay đổi giá trị hiện tại vì có thể nó sẽ gây ảnh hưởng đến nhiều biến khác nếu đang có nhiều biến cùng trỏ vào, thế nên cần phải tạo 1 vùng nhớ mới lưu giá trị mới là kết quả của việc cộng chuỗi và trỏ biến gốc vào vùng nhớ mới.

Khi nào nên sử dụng StringBuilder, khi nào nên sử dụng StringBuffer?

So sánh xong thấy sự khác biệt là phải thắc mắc nó khác nhau như thế thì dùng trong trường hợp nào để chương trình trở nên hiệu quả 😀

  • String: Sử dụng trong các trường hợp thông thường khi cần thao tác với chuỗi, việc thay đổi giá trị không lặp lại quá nhiều lần.
  • StringBuilder: Sử dụng khi cần thực hiện liên tục việc cộng chuỗi, thêm/sửa/xóa ký tự trong chuỗi. Các đoạn code xử lý tạo câu truy vấn SQL dài rất hay phải sử dụng cái này.
  • StringBuffer: Đúng theo thế mạnh của nó là bảo toàn dữ liệu, khi nào cần làm việc trong tình huống đa luồng thì nên sử dụng StringBuffer để tránh mất mát dữ liệu

Hy vọng sau khi đọc xong bài này thì các bạn sẽ lựa chọn thông thái hơn cho từng đoạn code của mình, tránh các lỗi tiềm ẩn khi run-time.

Chúc các bạn học tập và làm việc tốt!

4 Replies to “[Java] Phần 5: StringBuilder giống StringBuffer?”

  1. Chào ad, em muốn hỏi nếu trong môi trường đơn luồng thì tại sao stringbuilder lại chạy nhanh hơn stringbuffer ạ? Có phải do fail safe vs fail fast nên dẫn đến có sự chênh lệch thời gian xử lý của cả 2 loại không ạ?

    1. Đúng rồi bạn. Như trong bài viết mình đã đề cập. StringBuffer luôn mất 1 nỗ lực thời gian cho việc lock và wait để đảm bảo an toàn cho dữ liệu (Safe – Fail Fast), trong khi StringBuilder lại không quan tâm đến việc an toàn nên sẽ nhanh hơn StringBuffer (Fast – Fail Safe). Tuy nhiên, trong môi trường đơn luồng, mặc dù StringBuffer vẫn chậm hơn StringBuilder nhưng không đáng kể bởi vì JVM đã tối ưu lại case này.

Bình luận