String là kiểu ai cũng dùng hàng ngày, nhưng ít người biết nó là gì ở tầng bộ nhớ. Vì sao không sửa được s[0]? Vì sao []byte(s) bỗng tốn thời gian và bộ nhớ? Vì sao cắt chuỗi s[a:b] lại miễn phí? Mọi câu trả lời nằm ở hai đặc điểm: string là một header 16 byte và dữ liệu của nó bất biến. Bài này đo trực tiếp cấu trúc string và chi phí ẩn của việc chuyển đổi.
String header: hai từ máy, dữ liệu bất biến
String trên máy 64-bit là một struct 16 byte gồm hai từ máy:
ptr: con trỏ tới byte đầu tiên của dữ liệu chuỗi.len: số byte.
Chú ý: không có cap (khác slice có ba trường). Vì string bất biến — nó không bao giờ tăng trưởng tại chỗ, nên không cần cap. Dữ liệu của chuỗi literal nằm trong vùng nhớ chỉ-đọc (rodata) của chương trình, đó là lý do string bất biến ở tầng vật lý: bộ nhớ đó không cho ghi.

Hình 1: String header 16 byte {ptr, len}, không có cap. Dữ liệu bất biến ở vùng chỉ-đọc. Substring chia sẻ backing; chuyển string↔[]byte phải copy.
Đo thật: substring miễn phí, chuyển đổi tốn kém
Đo cấu trúc và chi phí ba thao tác: substring, string→[]byte, []byte→string.

Hình 2: Header = 16 byte. Substring s[7:13] có ptr lệch đúng 7 byte (chia sẻ backing). Substring: 0,73 ns, 0 cấp phát. Chuyển string→[]byte: 21,43 ns, 1 cấp phát; []byte→string: 13,75 ns, 1 cấp phát.
Kết quả cho thấy hai hành vi trái ngược:
- Substring miễn phí:
s[7:13]="Golang"cóptrlệch đúng 7 byte so vớis, chia sẻ cùng dữ liệu — chỉ tạo header 16 byte mới. Benchmark: 0,73 ns, 0 cấp phát. Cắt chuỗi không copy vì cả hai đều bất biến, chia sẻ an toàn. - Chuyển string↔[]byte phải copy:
[]byte(s)tốn 21,43 ns và 1 cấp phát,string(b)tốn 13,75 ns và 1 cấp phát. Mỗi lần chuyển phải cấp mảng mới và copy toàn bộ dữ liệu.
Vì sao chuyển đổi phải copy
Đây là điểm mấu chốt. string bất biến, []byte sửa được. Nếu []byte(s) chia sẻ dữ liệu với string thay vì copy, thì sửa []byte sẽ làm thay đổi string bất biến — phá vỡ đảm bảo an toàn cốt lõi của Go (string không bao giờ đổi). Nên Go luôn copy khi chuyển giữa hai kiểu, đảm bảo mỗi bên có bản riêng. Đây là chi phí ẩn của mọi lần []byte(s) hay string(b) — một cấp phát + một lần copy toàn bộ.
Ứng dụng thực tế
Tránh chuyển string↔[]byte trong vòng lặp nóng. Nếu bạn xử lý chuỗi trong vòng lặp và mỗi vòng làm []byte(s), đó là một cấp phát + copy mỗi vòng. Với đường nóng, giữ nguyên một kiểu (làm việc với []byte từ đầu, hoặc string từ đầu) tránh chuyển qua lại.
Cắt chuỗi thoải mái — nó miễn phí. s[a:b], strings.Split, và các thao tác cắt chuỗi chỉ tạo header mới, không copy. Xử lý văn bản bằng cách cắt substring rất hiệu quả. (Nhưng nhớ: substring giữ cả backing lớn sống — như slice, một substring nhỏ của chuỗi 1GB giữ cả 1GB.)
Nhiều thao tác chuỗi nhận string để dùng được cả literal. Vì string bất biến và literal là string, API nhận string (như strings.Contains) dùng được trực tiếp với "...". API nhận []byte (như bytes.Contains) cần chuyển. Chọn kiểu theo nguồn dữ liệu chính của bạn.
Đánh đổi cần cân nhắc
Bất biến đổi lấy an toàn và chia sẻ, trả giá bằng copy khi cần sửa. String bất biến nên chia sẻ vô tư (nhiều biến trỏ cùng dữ liệu, không lo ai sửa), truyền rẻ (16 byte), và an toàn đồng thời (đọc string từ nhiều goroutine không cần khóa). Đổi lại, khi cần sửa nội dung, phải chuyển sang []byte (copy), sửa, rồi chuyển lại (copy nữa).
Copy có thể tránh bằng unsafe — nhưng nguy hiểm. Có kỹ thuật chuyển string↔[]byte không copy bằng unsafe (chủ đề bài sau). Nó nhanh (0 cấp phát) nhưng phá vỡ đảm bảo bất biến — nếu sửa []byte "chia sẻ" với string, bạn làm hỏng string, gây bug khủng khiếp. Chỉ dùng khi chắc chắn không sửa.
String không phải lúc nào cũng ở rodata. Chuỗi literal ở vùng chỉ-đọc, nhưng string tạo lúc chạy (string(b), nối chuỗi) nằm trên heap. Chúng vẫn bất biến (Go đảm bảo qua ngôn ngữ), nhưng không phải vật lý read-only. Đừng dựa vào việc string luôn ở rodata.
Ba ý mang về
- String là header 16 byte {ptr, len} trỏ vào dữ liệu bất biến: không có cap vì string không tăng trưởng — dữ liệu literal ở vùng chỉ-đọc, đó là lý do vật lý của bất biến; không sửa được
s[0](lỗi biên dịch). - Substring miễn phí, chuyển string↔[]byte tốn kém: đo thật
s[7:13]chỉ tạo header mới chia sẻ backing (0,73 ns, 0 cấp phát), nhưng[]byte(s)/string(b)phải copy toàn bộ (21/14 ns, 1 cấp phát mỗi lần). - Chuyển đổi copy vì bất biến vs sửa được: string không đổi, []byte sửa được — chia sẻ sẽ để sửa []byte làm hỏng string, nên Go luôn copy; tránh chuyển đổi trong vòng lặp nóng, và cắt chuỗi thoải mái vì nó không copy.
Phần sau ta xét chính kỹ thuật vừa cảnh báo: Phần sau mổ xẻ cách chuyển string ↔ []byte không copy bằng unsafe — khi nào an toàn dùng, đo mức tiết kiệm thật, và những cạm bẫy khiến nó phá hỏng chương trình.