Welcome to MikeDaDev's Blog – notes on Java, backend & system design.

Explore
MikeDaDev's Blog
in java

java string pool

Tung Bui Thanh (TungDaDev)10 mins read

Shallow focus photography of potted plants

Nhiều người chỉ biết String Pool qua câu hỏi phỏng vấn mẹo: s1 == s2 ra true hay false. Nhưng nhìn từ góc người tối ưu JVM, String Pool là một cấu trúc dữ liệu C++ native (StringTable) khá phức tạp nằm ngay trong HotSpot runtime. Gọi String.intern() bừa bãi trên dữ liệu động có thể làm GC khổ sở, làm phình bảng băm native, và trên JDK cũ còn kéo tốc độ tra cứu từ O(1) xuống O(N).

Theo số đo được 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, và khoảng một nửa trong số đó là bản trùng của nhau.

Vì vậy, hiểu cách Java String Pool lưu trữ, biết cấu hình bảng băm StringTableSize, và phân biệt được String Interning thủ công với G1GC String Deduplication tự động là mấy thứ rất đáng nắm nếu muốn tiết kiệm tài nguyên phần cứng.


# String Pool dời từ PermGen sang heap

Muốn hiểu kiến trúc bây giờ thì phải nhìn lại JVM đã thay đổi thế nào qua các đời:

# sao lại dời String Pool ra khỏi PermGen?

Ở Java 6, PermGen có kích thước cố định và khá nhỏ. Ứng dụng nào nạp hàng triệu chuỗi vào String Pool bằng String.intern() là PermGen tràn, ném OutOfMemoryError: PermGen space.

Từ Java 7 (rồi tới Java 8 thì bỏ hẳn PermGen, thay bằng Metaspace), String Pool được đưa về Java Heap bình thường. Nghĩa là: chuỗi trong String Pool hoàn toàn có thể bị Garbage Collector dọn khi không còn biến nào tham chiếu tới. StringTable chỉ giữ tham chiếu yếu (weak) tới các chuỗi đã intern, nên nó không tự giữ chuỗi sống.


# cấu trúc native StringTable trong HotSpot

Nhiều bạn tưởng String Pool là một class Java nào đó. Thật ra nó là một bảng băm (HashTable) viết bằng C++ nằm bên trong HotSpot JVM:

# cách băm và xử lý xung đột

  • Tới JDK 10, StringTable có số bucket cố định, chốt ngay lúc JVM khởi động qua cờ -XX:StringTableSize=N.
  • Mỗi lần intern một chuỗi, JVM tính hash code của chuỗi rồi chia lấy dư cho số bucket để biết nó rơi vào bucket nào.
  • Nếu bị collision, các chuỗi được nối với nhau thành linked list.
  • Từ JDK 11, StringTable được viết lại trên ConcurrentHashTable của HotSpot: số bucket luôn là lũy thừa của 2 và bảng tự giãn khi số phần tử trung bình mỗi bucket vượt ngưỡng. -XX:StringTableSize lúc này chỉ là kích thước khởi đầu.

# bảng băm quá nhỏ thì hiệu năng tụt thảm

  • Ở Java 6 và các bản Java 7 đầu (trước 7u40), StringTableSize mặc định chỉ có 1.009 bucket, và không tự giãn.
  • Nếu bạn intern 1.000.000 chuỗi, trung bình mỗi bucket sẽ ôm một linked list dài cỡ:
    1,000,000 / 1,009 ≈ 991 phần tử / bucket!
  • Lúc đó, mỗi lần kiểm tra một chuỗi mới, JVM phải duyệt lần lượt gần 1.000 node. O(1) của bảng băm tụt thành O(N), CPU lên 100% chỉ để đi duyệt danh sách chuỗi.

Giá trị mặc định bây giờ: Từ 7u40 tới JDK 10, mặc định là 60.013 bucket, vẫn cố định nên intern vài triệu chuỗi thì phải tự chỉnh. Từ JDK 11 mặc định là 65.536 và bảng tự giãn, nên chuyện tụt về O(N) gần như không còn; cái giá còn lại của intern bừa bãi là bộ nhớ và công GC phải dọn bảng.


# cái bẫy của String.intern

# ví dụ: xử lý giao dịch ngân hàng

Giả sử bạn có 100 triệu object Transaction trong bộ nhớ, object nào cũng có trường currency:

  • Không intern (chuỗi được parse ra từ file, JSON, kết quả query...): bạn có 100 triệu object String "VND" riêng rẽ trên heap. Với compressed oops, mỗi chuỗi gồm object String 24 bytes cộng mảng byte[] 24 bytes (16 bytes header + 3 bytes dữ liệu, làm tròn), tức là tốn cỡ 100,000,000 * 48 bytes ≈ 4.8 GB RAM chỉ để chứa chữ "VND".
  • Có intern (transaction.setCurrency(curr.intern())): cả 100 triệu transaction cùng trỏ về đúng 1 instance String "VND" trong String Pool, tiết kiệm được gần như toàn bộ 4.8 GB đó (mỗi transaction vẫn giữ một reference 4 bytes, nhưng cái đó có hay không intern cũng phải giữ).

Chuỗi viết thẳng trong code (literal "VND") thì JVM đã tự intern sẵn rồi, vấn đề chỉ nằm ở chuỗi sinh ra lúc runtime. Với tập giá trị nhỏ và cố định như mã tiền tệ, một Map<String, String> tự quản hoặc enum (xem checklist bên dưới) thường dễ kiểm soát hơn intern().


# g1gc string deduplication

Thấy giao intern() cho dev tự gọi vừa nguy hiểm vừa khó kiểm soát, đội JVM đã thêm vào G1GC một tính năng: String Deduplication tự động (JEP 192, có từ 8u20). Ban đầu chỉ G1 hỗ trợ; từ JDK 18 tính năng này được viết lại để chạy với cả Serial, Parallel và ZGC (Shenandoah đã có từ trước).

# String.intern vs string deduplication

Tiêu chíString.intern()G1GC String Deduplication (-XX:+UseStringDeduplication)
Ai làmDev phải tự gọi trong codeTự động 100%, chạy ngầm trong lúc GC
Dùng chung cái gìDùng chung cả instance StringCác instance String vẫn tách riêng, chỉ dùng chung mảng dữ liệu byte[] value
Ảnh hưởng ==s1 == s2 trả về trues1 == s2 vẫn trả về false, ngữ nghĩa code không đổi
Rủi ro rò rỉ bộ nhớCó thể làm phình StringTable C++Không có, mảng byte tự được dọn khi không còn ai trỏ tới

# bật trên production

Chỉ cần thêm mấy cờ JVM này khi chạy ứng dụng:

java -XX:+UseG1GC -XX:+UseStringDeduplication -XX:StringDeduplicationAgeThreshold=3 -jar app.jar

(AgeThreshold=3: chỉ chuỗi nào sống qua 3 lần Minor GC mới được đưa vào hàng đợi kiểm tra trùng, đỡ phí CPU cho mấy chuỗi tạm.)

TIP

Nên làm trên production: Thay vì tự quản String.intern() trong code rồi lo phình bộ nhớ, cách an toàn cho ứng dụng chạy JDK 17/21 là bật -XX:+UseStringDeduplication (mặc định đang tắt). Tính năng này trong suốt (transparent), không đổi ngữ nghĩa code, và tự giải phóng bộ nhớ khi chuỗi không còn dùng. Đổi lại nó tốn thêm một ít CPU chạy nền, và chỉ tiết kiệm phần mảng byte[], còn mỗi object String 24 bytes vẫn nằm đó. Bật rồi thì đo lại heap trước/sau để biết có đáng không.


# theo dõi và tinh chỉnh StringTable

Muốn biết String Pool đang khoẻ hay đang nghẽn thì bật cờ in thông số chẩn đoán:

-XX:+PrintStringTableStatistics

Khi ứng dụng tắt, hoặc khi gọi jcmd <pid> VM.stringtable, JVM sẽ in ra báo cáo (mẫu dưới từ JDK 8, các bản mới định dạng gần giống):

StringTable statistics:
Number of buckets       :     60013 =    480104 bytes, avg   8.000
Number of entries       :    245102 =   3921632 bytes, avg  16.000
Number of literals      :    245102 =  12856400 bytes, avg  52.453
Total footprint         :           =  17258136 bytes
Average bucket size     :     4.084
Variance of bucket size :     4.120
Std. dev. of bucket size:     2.030
Maximum bucket size     :        18

# đọc báo cáo thế nào

  1. Average bucket size: đẹp nhất là trong khoảng 1.0 đến 4.0.
  2. Maximum bucket size: nếu số này vượt 50 hay 100 thì hash đang phân bố lệch nặng (collision skew) hoặc số bucket quá ít.
  3. Cách chỉnh: nếu ứng dụng intern hơn 1 triệu chuỗi, nâng số bucket khởi đầu lên:
-XX:StringTableSize=1000003

(Trên JDK 7/8, số nguyên tố giúp hash rải đều hơn ra các bucket. Từ JDK 11, JVM làm tròn giá trị này lên lũy thừa của 2 gần nhất, ví dụ 1000003 thành 1048576, và bảng vẫn tự giãn, nên chỉnh cờ này chủ yếu để tránh vài lần giãn bảng lúc khởi động.)


# checklist tối ưu bộ nhớ cho chuỗi

  • 1. Không intern dữ liệu tự do: rà code, đảm bảo không gọi intern() trên chuỗi người dùng nhập vào (user-generated content), UUID hay token.
  • 2. Bật String Deduplication: ứng dụng doanh nghiệp chạy JDK 17/21 với heap lớn (vài GB trở lên) thì nên cân nhắc bật -XX:+UseStringDeduplication.
  • 3. Chỉnh StringTableSize khi cần: trên JDK 8 mà buộc phải intern hàng triệu mã danh mục tĩnh thì cấu hình -XX:StringTableSize=1000003 để giữ được tra cứu O(1). JDK 11+ bảng tự giãn, ít khi phải đụng.
  • 4. Theo dõi Native Memory Tracking (NMT): bật -XX:NativeMemoryTracking=summary rồi xem bằng jcmd <pid> VM.native_memory summary để biết bộ nhớ ngoài heap mà các bảng băm C++ đang ăn (StringTable nằm trong mục Symbol, bảng dedup nằm ở mục String Deduplication).
  • 5. Tập giá trị cố định thì dùng Enum: thay vì intern chuỗi trạng thái ("ACTIVE", "INACTIVE"), dùng luôn Java Enum, bản thân enum đã là singleton instance trong bộ nhớ rồi.

# tổng kết

Java String Pool không phải hộp đen bí ẩn gì. Nó là một bảng băm C++ native, kết hợp với cách JVM quản lý heap và GC.

Việc nên làm thì không nhiều: đo trước bằng PrintStringTableStatistics và NMT, đừng intern dữ liệu tự do, cân nhắc bật String Deduplication, và với tập giá trị cố định thì dùng enum. Heap càng nhiều chuỗi trùng thì phần tiết kiệm được càng lớn.


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 😎 👍🏻 🚀 🔥.