string vs stringbuilder vs stringbuffer
Tung Bui Thanh (TungDaDev)8 mins read

Dev Java nào cũng thuộc câu: 'nối chuỗi trong vòng lặp thì dùng StringBuilder/StringBuffer'. Nhưng hỏi từ Java 9 JVM lưu String trong Heap ra sao, tại sao toán tử '+' không còn dịch ra StringBuilder, hay khi nào JIT Compiler tự bỏ lock (Lock Elision) của StringBuffer qua Escape Analysis thì phần lớn đều ấp úng. Theo số liệu dẫn trong JEP 192, ở các ứng dụng Java lớn, object String chiếm khoảng 25% dữ liệu sống trên Heap. Hiểu rõ String vận hành thế nào là đáng bỏ thời gian.
Từ Java 1.0 đến giờ, cách Java xử lý chuỗi đã đổi khá nhiều. Dưới đây là cách vận hành của bộ ba String, StringBuilder, StringBuffer, cơ chế Compact Strings và mấy cách tối ưu bộ nhớ ở tầng JVM.
# compact strings (jep 254)
Trước Java 9, mọi đối tượng String trong Java đều lưu trữ dữ liệu dưới dạng mảng các ký tự 16-bit:
// Cấu trúc String trước Java 9:
private final char[] value; // Luôn tốn 2 bytes cho MỌI ký tự!Theo JEP 254, dữ liệu thu từ nhiều ứng dụng cho thấy String chiếm phần lớn heap, và đa số chuỗi chỉ chứa ký tự Latin-1 (bảng mã ISO-8859-1, mỗi ký tự 1 byte là đủ). Vậy mà mỗi ký tự Latin-1 vẫn bị cấp 2 bytes, tức là một nửa mảng char[] của các chuỗi đó bỏ trống.
# kiến trúc compact strings từ java 9+
Nếu chuỗi chỉ có ký tự ASCII/Latin-1 ("Hello World"), JVM cấp 1 byte cho mỗi ký tự. Chỉ cần có một ký tự nằm ngoài Latin-1 (như chữ ệ trong "Việt Nam" hoặc Emoji 🚀) là cả chuỗi chuyển sang UTF-16 (2 bytes/char, emoji còn tốn 2 char). Có một chi tiết vui: "Xin chào" vẫn là Latin-1, vì à, á, â, ê, ô... có sẵn trong ISO-8859-1; còn ạ, ệ, ơ, ư, đ thì không. Nên văn bản tiếng Việt thực tế gần như luôn là UTF-16.
# toán tử cộng chuỗi: từ stringbuilder sang invokedynamic
Nhiều bạn vẫn nghĩ trình biên dịch Java luôn dịch toán tử + thành StringBuilder:
String fullName = lastName + " " + firstName;(Lưu ý: nếu mọi vế đều là hằng số, như "Tran " + "Thi " + "B", thì javac gộp luôn thành một hằng "Tran Thi B" lúc compile, chẳng có StringBuilder hay invokedynamic nào cả.)
# java 8 trở về trước: bytecode tạo stringbuilder cứng nhắc
Trước Java 9, biểu thức a + b + c được javac dịch thẳng thành:
new StringBuilder().append(a).append(b).append(c).toString();Cách này có mấy điểm yếu về hiệu năng:
- Không ước lượng trước được kích thước buffer nên mảng bị resize liên tục.
- Bytecode bị gắn cứng vào một cách triển khai, JVM không thể tự tối ưu dần theo thời gian.
# java 9+: invokedynamic & stringconcatfactory (jep 280)
Từ Java 9, javac không sinh new StringBuilder() nữa mà phát ra một chỉ lệnh bytecode invokedynamic trỏ tới StringConcatFactory.makeConcatWithConstants():
// Bytecode dịch từ Java 9+:
0: aload_1
1: aload_2
2: invokedynamic #7, 0 // InvokeDynamic #0:makeConcatWithConstants:(Ljava/lang/String;Ljava/lang/String;)Ljava/lang/String;Phần hằng " " không nằm trong lệnh aload nào: nó được gói vào "recipe" truyền cho bootstrap method. StringConcatFactory để đến Runtime mới quyết định cách nối:
- JDK 9 có vài chiến lược khác nhau (chọn bằng
-Djava.lang.invoke.stringConcat), từ JDK 15 chỉ còn giữ chiến lược tốt nhất: tính trước chính xác độ dài và coder của kết quả, cấp đúng một mảngbyte[]đủ kích thước, rồi chép từng phần vào, không resize, khôngStringBuildertrung gian. - Lợi ích lớn nhất là cách nối nằm trong JDK chứ không bị đóng cứng vào bytecode của bạn: lên JDK mới là được hưởng cải tiến mà không phải compile lại.
# so sánh string vs stringbuilder vs stringbuffer
| Đặc tính | String | StringBuilder | StringBuffer |
|---|---|---|---|
| Tính bất biến (Mutability) | Immutable (Bất biến) | Mutable (Khả biến) | Mutable (Khả biến) |
| Thread-Safety | Thread-safe tuyệt đối (do bất biến) | Không an toàn đa luồng | Thread-safe (Đồng bộ qua synchronized) |
| Tốc độ thực thi | Chậm nhất khi nối chuỗi nhiều lần | Nhanh nhất trong môi trường đơn luồng | Chậm hơn StringBuilder do chi phí khóa Lock |
| Vùng nhớ lưu trữ | String Constant Pool / Heap | Heap Memory thông thường | Heap Memory thông thường |
| Use Case chuẩn | Dữ liệu định danh, DTO, Config | Xử lý nối chuỗi nội bộ hàm | Rất hiếm dùng trong Java hiện đại |
# phân tích c2 jit: escape analysis & lock elision
Có một câu hỏi khá hóc: "StringBuffer dùng synchronized trên mọi method, sao trong một số benchmark đơn luồng nó vẫn nhanh ngang StringBuilder?"
Câu trả lời nằm chủ yếu ở Escape Analysis (phân tích thoát) và Lock Elision của C2 JIT Compiler. Trên JDK cũ còn có thêm biased locking làm khoá không tranh chấp gần như miễn phí, nhưng tính năng này đã bị tắt mặc định từ JDK 15 và gỡ hẳn ở JDK 18:
Nếu một StringBuffer được tạo cục bộ trong method và không bị trả ra ngoài hay gán vào biến static (tức là không "thoát" sang luồng khác), JIT sẽ tự bỏ toàn bộ code lock (monitorenter/monitorexit).
WARNING
Đừng trông chờ vào JIT: Escape Analysis có thể thất bại nếu code quá phức tạp hoặc method vượt ngưỡng Inlining (JIT inlining threshold). Đơn luồng thì cứ dùng StringBuilder cho chắc.
# kỹ thuật tối ưu bộ nhớ
# xác định trước kích thước buffer (pre-sizing)
Mặc định new StringBuilder() chỉ cấp mảng 16 ký tự. Vượt quá thì nó tăng dung lượng theo công thức:
newCapacity = (oldCapacity * 2) + 2Mỗi lần tăng lại gọi System.arraycopy() chép toàn bộ dữ liệu cũ sang mảng mới, và mảng cũ thành rác trên heap.
// TỐI ƯU HÓA: Ước lượng trước dung lượng (tính theo số ký tự) để tránh resize mảng
int estimatedLength = items.size() * AVERAGE_ITEM_LENGTH;
StringBuilder sb = new StringBuilder(estimatedLength);# kỹ thuật threadlocal reusable buffer
Các framework chịu tải cao như Netty hay Log4j2 (Garbage-Free Logging) không tạo StringBuilder mới cho mỗi log message. Họ dùng một ThreadLocal<StringBuilder> và reset độ dài (sb.setLength(0)) sau mỗi lần dùng, để không phải cấp phát thêm bộ nhớ nào (Zero Memory Allocation).
Hai cái bẫy khi tự làm theo: buffer từng phình to vì một message rất dài sẽ giữ nguyên dung lượng đó mãi (Log4j2 có giới hạn log4j2.maxReusableMsgSize rồi tự thu nhỏ lại), và với virtual thread (Java 21) thì mỗi task là một thread mới, ThreadLocal gần như không được dùng lại nên kỹ thuật này mất tác dụng.
# checklist trước khi lên production
- 1. Không cộng chuỗi trong vòng lặp: Quét code bằng SonarQube, đảm bảo không còn
+=bên trong vòng lặpfor,while. - 2. Định cỡ dung lượng ban đầu (Capacity Planning): Biết trước số phần tử thì truyền kích thước ước lượng vào constructor của
StringBuilder. - 3. Thay StringBuffer cũ: Rà code legacy và đổi
StringBuffersangStringBuilder, trừ khi biến đó thật sự được nhiều luồng dùng chung và sửa đổi. - 4. Bật Compact Strings: Kiểm tra production không lỡ tắt bằng cờ
-XX:-CompactStrings. - 5. String Deduplication: Hệ thống lớn có nhiều String trùng (như catalog E-commerce) thì cân nhắc bật
-XX:+UseStringDeduplication(G1 từ 8u20; từ JDK 18 chạy được với cả Serial, Parallel, ZGC).
# kết luận
Phân biệt String, StringBuilder và StringBuffer không chỉ để trả lời phỏng vấn. Nó còn là bước đầu để hiểu Mechanical Sympathy, tức là hiểu cách JVM và phần cứng chạy để viết code hợp với chúng.
Việc cụ thể nên làm thì nằm trong checklist ở trên; muốn biết chuỗi đang chiếm bao nhiêu heap trong app của bạn thì mở một heap dump bằng Eclipse MAT hoặc chạy jcmd <pid> GC.class_histogram xem dòng java.lang.String và [B.
Chỉ là những ghi chép cá nhân với hy vọng mang lại chút giá trị. Nếu thấy hữu ích, đừng ngại chia sẻ cho bạn bè & đồng nghiệp nhé!
Happy coding 😎 👍🏻 🚀 🔥.