Bài size classes cho thấy Go làm tròn cấp phát lên các lớp kích thước cố định, lớp nhỏ nhất là 8 byte. Vậy một object 1 byte — như *bool, *int8, hay một chuỗi một ký tự — sẽ tốn 8 byte? Nếu đúng thế thì lãng phí 87%. Go có một tối ưu riêng cho tình huống này: tiny allocator, gộp nhiều object tí hon vào chung một khối. Bài này đo chính xác cơ chế đó và ba điều kiện để một object được hưởng.
Vấn đề: object tí hon quá phổ biến
Trong code thực, object cực nhỏ xuất hiện khắp nơi: con trỏ tới một bool, một byte được lấy địa chỉ, phần tử nhỏ của cấu trúc dữ liệu. Nếu mỗi cái chiếm một slot của lớp kích thước nhỏ nhất (8 byte), thì cấp một triệu object 1-byte tốn 8MB thay vì ~1MB. Tiny allocator giải quyết bằng cách gộp nhiều object nhỏ liên tiếp vào chung một khối 16 byte, mỗi object dùng một phần của khối.
Có ba điều kiện để một object được gộp:
- Kích thước nhỏ hơn 16 byte.
- Không chứa con trỏ nào bên trong (noscan).
- Các cấp phát liên tiếp còn vừa chỗ trong khối 16-byte hiện tại.

Hình 1: Ba điều kiện để object được gộp: nhỏ hơn 16B, không con trỏ, và các cấp phát liên tiếp vừa chỗ. Đọc /gc/heap/tiny/allocs:objects để biết object có qua tiny không.
Đo thật: một byte chỉ tốn một byte
Cấp 1 triệu object của bốn kiểu khác nhau, đọc metric /gc/heap/tiny/allocs:objects để biết object có đi qua tiny không, và đo HeapAlloc để biết bộ nhớ thực mỗi object:
func tinyCount() uint64 {
s := []metrics.Sample{{Name: "/gc/heap/tiny/allocs:objects"}}
metrics.Read(s)
return s[0].Value.Uint64()
}

Hình 2: Object 1-byte không con trỏ → qua tiny, 1.0 B/object (gộp 16 cái/khối). 8B không con trỏ → tiny, 8 B/obj. 8B có con trỏ → KHÔNG tiny (size class 8). 16B → bằng ngưỡng, không tiny.
Kết quả xác nhận từng điều kiện:
- 1-byte không con trỏ: qua tiny, 1,0 byte mỗi object. 16 object 1-byte gộp vào một khối 16 byte. Không có tiny, mỗi cái phải chiếm slot lớp 8-byte → 8 byte. Tiny tiết kiệm 8 lần cho object 1-byte.
- 8-byte không con trỏ: qua tiny, 8 byte/object — hai cái gộp vào một khối 16 byte.
- 8-byte CÓ con trỏ: không qua tiny (metric = 0), phải dùng lớp kích thước 8 riêng. Cùng kích thước 8 byte với trường hợp trên, nhưng vì chứa con trỏ nên bị loại khỏi tiny.
- 16-byte không con trỏ: không qua tiny — 16 byte đã bằng ngưỡng (điều kiện là nhỏ hơn 16), nên dùng lớp kích thước 16.
Vì sao cấm con trỏ
Điều kiện "không con trỏ" là điểm tinh tế nhất. Bộ thu gom rác của Go phải quét các con trỏ để lần theo object nào còn sống. Nếu gộp nhiều object có con trỏ vào chung một khối, GC sẽ phải theo dõi con trỏ nằm ở đâu trong khối gộp — phức tạp và dễ sai. Ngược lại, object không con trỏ (gọi là noscan) được GC bỏ qua hoàn toàn khi quét, nên gộp chúng lại là an toàn tuyệt đối. Đây là lý do một struct 8-byte chỉ chứa số thì được gộp, còn một struct 8-byte chứa một con trỏ thì không.
Ứng dụng thực tế
Struct nhỏ không con trỏ hưởng lợi tự động. Nếu bạn cấp phát nhiều struct nhỏ chỉ chứa số/bool (như điểm dữ liệu, cờ, chỉ số), chúng được gộp mà bạn không phải làm gì. Đây là lý do các cấu trúc dữ liệu gọn (không nhét con trỏ vào mọi node) tốn ít bộ nhớ hơn nhiều.
Tránh con trỏ trong object nhỏ nếu muốn tiny. Nếu một struct nhỏ của bạn chứa con trỏ, nó mất quyền vào tiny allocator. Đôi khi có thể thiết kế lại: thay *int bằng int (nếu không cần chia sẻ), hoặc thay con trỏ bằng chỉ số (index) vào một slice — chỉ số là int không con trỏ, giữ object trong diện tiny và cũng thân thiện với cache.
Tiny giúp giảm áp lực GC gấp đôi. Object không con trỏ vừa được gộp (ít bộ nhớ), vừa được GC bỏ qua khi quét (ít việc cho GC). Với workload cấp phát nhiều object nhỏ, giữ chúng noscan là một trong những tối ưu bộ nhớ hiệu quả nhất.
Đánh đổi cần cân nhắc
Tiny chỉ gộp các cấp phát liên tiếp vừa chỗ. Khối tiny hiện tại được chia sẻ giữa các tiny alloc nối nhau; nếu một cấp phát không vừa phần còn lại của khối, một khối mới bắt đầu và phần đuôi khối cũ bị bỏ phí. Nên hiệu quả gộp thực tế phụ thuộc mẫu cấp phát — object 1-byte đều nhau gộp gần hoàn hảo (1,0 B/obj như đo được), nhưng cấp phát kích thước lẫn lộn có thể để lại lỗ hổng.
Đừng cố "ép" mọi thứ vào tiny. Lợi ích lớn nhất của tiny là tự động — bạn không cần và không nên vặn vẹo code để lách vào nó. Chỉ khi profiler cho thấy bộ nhớ từ object nhỏ là nút thắt thì mới cân nhắc bỏ con trỏ khỏi struct nhỏ.
Alignment vẫn được tôn trọng. Tiny allocator không nhồi bừa: nó căn lề mỗi object theo yêu cầu (một int64 8-byte cần căn 8-byte). Nên với các cỡ không chia hết 16, có thể có chút khoảng trống căn lề trong khối — vẫn tốt hơn nhiều so với mỗi cái một slot riêng.
Ba ý mang về
- Tiny allocator gộp object dưới 16 byte không con trỏ vào chung khối 16B: đo thật, object 1-byte nén xuống đúng 1,0 byte mỗi cái (16 cái/khối) — tiết kiệm 8 lần so với phải chiếm slot lớp 8-byte.
- Ba điều kiện để được gộp: nhỏ hơn 16 byte, không chứa con trỏ (noscan), và cấp phát liên tiếp vừa chỗ — đo thật, cùng cỡ 8 byte nhưng object có con trỏ thì bị loại (tiny=không), và object 16 byte đã bằng ngưỡng nên cũng không qua tiny.
- Cấm con trỏ vì GC: object không con trỏ được GC bỏ qua khi quét nên gộp an toàn — giữ struct nhỏ noscan (thay con trỏ bằng chỉ số khi hợp) vừa được gộp tiết kiệm bộ nhớ vừa giảm việc cho GC.
Phần sau ta rời cơ chế cấp phát để học công cụ đo nó cho đúng: Phần sau mổ xẻ cách đo cấp phát bằng -benchmem — đọc B/op và allocs/op cho chuẩn, các bẫy khiến benchmark nói dối, và cách viết benchmark cấp phát đáng tin.