Why does Go's heap allocator use 24 bytes to store a 17-byte object?
answer
- nothing is allocated at exactly your size
- buckets, not bytes
- 8, 16, 24, 32, 48, 64 ...
- one span holds one size only
- 17 rounds up to the next class
basics
~20 sGo's heap allocator rounds every small allocation up to one of about 68 fixed size classes, and 24 is the first class that fits 17 bytes. Fixed sizes let one span hold identical objects with no per-object header.
solid answer
~50 sThe runtime never hands out an arbitrary byte count. Every allocation up to 32 KB is rounded up to the nearest of roughly 68 size classes -- 8, 16, 24, 32, 48, 64 and so on -- so a 17-byte request lands in the 24-byte class and 7 bytes are wasted. That waste buys a lot. Every object in a span (a run of 8 KB pages dedicated to one class) is the same size, so the allocator keeps a simple free list per class, needs no per-object size header, and never suffers external fragmentation: a freed 24-byte slot fits the next 24-byte request exactly. An 8 KB span of the 24-byte class holds 341 objects with 8 bytes of tail waste. Anything larger than 32 KB skips the classes and is served as its own run of whole pages.
code
go · 9 linestype packetID [17]byte // unsafe.Sizeof(packetID{}) is 17
var sink *packetID
func BenchmarkAllocID(b *testing.B) {
for i := 0; i < b.N; i++ {
sink = new(packetID)
}
}go deeper
Be ready to say that small heap allocations are rounded up to a fixed set of sizes, and to name a few of them: 8, 16, 24, 32, 48.
Explain why fixed classes exist at all -- identical objects per span, a simple free list per class, no per-object header, and no external fragmentation.
Connect the rounding to numbers you have actually measured: why bytes per operation exceed the type's size, and when a struct sitting just over a class boundary is worth trimming.
Frame the tradeoff being accepted fleet-wide: bounded per-object waste in exchange for a constant-time allocator, and decide when a workload's object-size mix makes that waste worth designing around.
## What a Go heap allocation actually reserves When `new(T)`, `make([]T, n)`, or a compiler decision puts a value on the heap, the Go runtime does not reserve exactly the number of bytes the type occupies. It rounds the request up to the nearest entry in a fixed table of **size classes**, and that rounded number is what the allocator charges you. ### The table There are roughly 68 size classes, generated into the runtime at build time. The small end steps in units of 8 bytes -- 8, 16, 24, 32 -- then widens: 48, 64, 80, 96, 112, 128, 144, 160, 176, 192, 208, 224, 240, 256, 288 and upward, ending at 32768 bytes. 32 KB is the largest "small" allocation; the spacing is fine where objects are small (so the proportional waste stays bounded) and coarse where they are large (so the table stays short). A 17-byte request therefore lands in the 24-byte class. A 33-byte request lands in the 48-byte class -- which is why trimming a hot struct from 33 bytes to 32 saves a third of its footprint, not one byte. ### Where a class lives: the span A **span** is a run of one or more 8 KB pages dedicated to exactly one size class. The 24-byte class carves an 8 KB span into 341 slots of 24 bytes, leaving 8 bytes of tail waste at the end. Because every slot in the span is the same size, the span itself records the class in its metadata; individual objects carry no header saying how big they are. Given a pointer, the runtime finds the span from the address and reads the size from there. ### Why round at all Three things fall out of uniform slots: 1. **No external fragmentation.** A freed slot is exactly the right shape for the next request of that class. The allocator can never end up with plenty of free bytes but nowhere to put a 24-byte object, which is the classic failure mode of a general-purpose `malloc` handing out arbitrary sizes. 2. **Constant-time allocate and free.** Allocation pops the next free slot from the span; sweeping marks slots free in a bitmap. There is no search for a best fit and no coalescing of neighbours. 3. **No per-object header.** A size header would add 8 bytes to every object -- catastrophic when the median heap object is a few dozen bytes. Class metadata is per span, amortised across hundreds of objects, and the garbage collector's pointer information is likewise a per-span property rather than a per-object one. ### The cost: internal fragmentation The price is wasted bytes inside each slot. Seventeen bytes in a 24-byte slot wastes 7 -- about 29% for that object. The class table is spaced tightly at the bottom precisely to keep this bounded, but it is real, and it means measured memory always exceeds the sum of your `unsafe.Sizeof` values. Note that two different roundings stack. The compiler pads and aligns struct fields first: `struct{ a uint64; b byte }` is already 16 bytes because of alignment, before the allocator sees it. Then the allocator rounds that 16 to the 16-byte class -- here, for free. ### Seeing it from outside A benchmark run with `-benchmem` reports bytes per operation as charged by the allocator, so a type that `unsafe.Sizeof` says is 17 bytes shows up as 24 B/op. The gap between those two numbers is exactly the rounding. The rounding also leaks into slice capacity. When `append` reallocates, the runtime rounds the new backing array up to a size class and reports the resulting capacity, which is why growing a `[]byte` from empty yields capacities like 8, 16, 32 rather than whatever you asked for. An explicit `make([]byte, 0, 17)` still reports `cap` 17, even though 24 bytes were reserved underneath. ### Above 32 KB Large objects skip the class table entirely. They are served directly from the heap's page allocator as their own run of whole 8 KB pages, so a 33 KB buffer occupies five pages -- 40 KB. Rounding is coarser there, but large objects are rarer and the per-object overhead of a dedicated span is acceptable. ### The practical takeaway For anything allocated in the millions, look at where it falls in the table. Shaving a field so a per-request struct drops below a class boundary is one of the cheapest memory wins available, and it costs nothing at runtime.
- What happens to a 40 KB request, which no size class covers?It is a large object. The runtime skips the class table and asks the heap's page allocator for a dedicated span of whole 8 KB pages -- five pages, 40 KB, in this case -- so the rounding is to a page multiple rather than to a class. Large objects also get their own span metadata rather than sharing a span with unrelated objects.
- Does a Go heap object store a header recording its own size?No. The size is a property of the span the object lives in. Every slot in a span belongs to the same size class, so the runtime maps the object's address to its span and reads the size from the span's metadata. That is why you cannot ask a Go pointer how many bytes its allocation reserved, and why the per-object overhead is effectively zero.
- Does size-class rounding show up in a slice's reported capacity?When `append` grows a slice, yes: the runtime rounds the new backing array up to a size class and reports that capacity, which is why growing a `[]byte` from empty gives capacities like 8, 16, 32. An explicit `make([]byte, 0, 17)` still reports `cap` 17, even though the allocator reserved 24 bytes for it.
It is a stockroom of fixed-size boxes. Nobody cuts cardboard to fit your parcel; you take the smallest box it fits in, and shelves hold one box size each so anything you return slots straight back.
saying these in an interview costs you the question
- Claims Go reserves exactly the number of bytes requested
- Treats the wasted bytes as a bug rather than a deliberate tradeoff
- Thinks unsafe.Sizeof reports what the allocator charged
- Believes every heap object carries its own size header
- Assumes a 1 MB buffer also goes through a size class