You write make([]byte, 100), and those 100 bytes just appear. But have you ever wondered where they come from? Who gave them to you? And why is it so fast?
Go’s memory allocator is like a warehouse manager. Your program constantly needs boxes of different sizes—sometimes a tiny box for coins, sometimes a huge one for a refrigerator—and it needs them urgently. The warehouse manager’s job is to hand you those boxes in milliseconds, keep the warehouse organized, and work with the cleaning crew to reclaim boxes nobody’s using anymore.
Let’s peek behind the curtain and see how this warehouse manager gets the job done.
When Do You Need the Memory Allocator?
Not every variable in your program goes through the warehouse manager. Go has two places to store things: the stack and the heap.
The stack is like sticky notes on your desk. Every time you call a function, you put a new note on the desk, and temporary variables get written on it. When the function finishes, you just tear up the note and throw it away. No warehouse manager needed. Fast and simple.
But some things can’t go on sticky notes. Like when you create an object in a function and return a pointer to it. That object can’t be torn up with the note—it needs to stay alive because someone outside is using it. This data goes on the heap, which is like permanent shelving in the warehouse for long-term storage.
The Go compiler automatically figures out what belongs on the stack versus the heap through escape analysis. Only things that end up on the heap actually involve the memory allocator.
Why Not Just Ask the OS for Memory?
You might wonder: doesn’t the operating system manage memory? Why not just ask the OS directly?
The problem is that asking the OS for memory is slow. Every request involves a system call, switching from user space to kernel space, the OS doing its bookkeeping, and switching back. That round-trip could have been spent doing actual work.
Worse yet, Go programs routinely run thousands of goroutines simultaneously. If every goroutine had to queue up to ask the OS for memory, the line would stretch to the parking lot.
So the Go runtime takes a different approach: ask the OS for large chunks of memory upfront, then distribute it internally. Your program needs 100 bytes? Carve it out from memory we already have. No need to bother the OS. Only when we run out do we go back for more.
That’s the memory allocator’s core value: act as a middleman between your program and the OS, making allocation fast.

Warehouse Structure: Arena and Page
The large chunks of memory Go requests from the OS are called Arenas. On 64-bit systems, each Arena is 64MB.
But don’t panic—Go doesn’t grab 64MB of physical RAM right away. It only reserves 64MB of address space, like staking a claim on a plot of land without building anything yet. The actual physical memory shows up on demand when you write to those addresses. That’s the beauty of virtual memory.
64MB is still too big to hand out directly. So Go divides each Arena into 8KB chunks called Pages. Note that this is Go’s own page size, not the OS’s 4KB pages—it’s Go’s internal management unit.
A 64MB Arena contains 8192 Pages (64MB / 8KB). Go tracks each Page’s state: which are in use, which are free.
But 8KB is still too large for most allocations. You need 32 bytes, and giving you 8KB would be wasteful. That’s where Spans come in.
Span: The Shelves with Compartments
A Span is one or more contiguous Pages dedicated to holding objects of a single size.
Let’s make this concrete. Say your program needs lots of 32-byte objects. The allocator takes one 8KB Page, turns it into a Span for 32-byte objects, and slices it into 256 slots (8192 / 32 = 256). Each slot is exactly 32 bytes.
When you request 32 bytes, the allocator finds an empty slot in that Span and returns it. Next request gets the next empty slot. Simple and efficient.
Why is it fast? All slots in a Span are the same size. No searching for a block that fits, no worrying about fragmentation, no merging adjacent free blocks. Just find the next empty slot and you’re done.
Each Span uses a bitmap to track which slots are occupied. One bit per slot—1 means in use, 0 means free. Finding an empty slot is just scanning for a 0 bit.
Size Class: 68 Standard Sizes
If each Span only holds one size of object, wouldn’t we need many different Spans?
Indeed, but Go can’t create a Span for every possible byte count—that would be unmanageable. Instead, Go defines 68 size classes called Size Classes, ranging from 8 bytes to 32KB.
When you allocate 20 bytes, Go rounds up to 24 bytes and uses the 24-byte Size Class. You waste 4 bytes, but gain simplicity and speed.
Here are some typical Size Classes:
| Size Class | Object Size | Pages per Span | Objects per Span |
|---|---|---|---|
| 1 | 8B | 1 | 1024 |
| 4 | 32B | 1 | 256 |
| 10 | 128B | 1 | 64 |
| 32 | 1024B | 1 | 8 |
| 51 | 8192B | 1 | 1 |
| 67 | 32768B | 4 | 1 |
You’ll notice some Size Classes only fit one object per Span, like the 8KB Size Class. This isn’t a design mistake—it’s a tradeoff. Large objects are rare, so there’s no point making the Span huge just to fit more objects, tying up memory that won’t be used.
Special Handling for Large and Tiny Objects
Size Classes range from 8 bytes to 32KB, but there are special cases at both ends.
Objects larger than 32KB go to Size Class 0. It has no fixed size—it gets exactly the Pages needed, with one object per Span. These large objects skip the cache layers entirely and go straight to the global allocator.
What about objects smaller than 8 bytes? Like a bool or int8 that’s only 1 byte. Giving it an 8-byte slot would be wasteful.
Go has a Tiny allocator specifically for objects under 16 bytes that don’t contain pointers. It packs multiple small objects into a single 16-byte slot. One bool takes 1 byte, the next bool goes right after, no wasted space.

Scan and NoScan: Does It Have Pointers?
Size Class is just about size. Go also cares about something else: does the object contain pointers?
Why does this matter? The garbage collector needs to scan objects with pointers to follow references. Objects without pointers (like a [100]byte array) can be skipped during GC, saving time.
So Go divides each Size Class into two: scan (needs scanning) and noscan (no scanning needed). 68 Size Classes times 2 gives us 136 Span Classes.
Keeping them separate makes garbage collection more efficient.
mcache, mcentral, mheap: Three-Level Cache for Lock Contention
Now we have the structure, but there’s a big problem: concurrency.
Go programs have thousands of goroutines running simultaneously, all allocating memory. If there was a single global list of Spans, every allocation would require grabbing a lock. The queue would be a disaster.
Go’s solution is a three-level hierarchy, inspired by Google’s tcmalloc design.
Level 1: mcache, one per P, no locks
Go’s scheduler has a concept called P (Processor), typically one per CPU core. Each P has its own mcache—a private collection of Spans, one for each Span Class.
When a goroutine needs memory, it runs on some P and grabs a slot directly from that P’s mcache. Since only one goroutine runs on a P at a time, no lock is needed. This is the hot path—most allocations happen here.
Level 2: mcentral, one per Span Class, brief locks
When an mcache’s Span for a particular Span Class is full, it needs a new one. That’s where mcentral comes in. There’s one mcentral for each of the 136 Span Classes, managing a shared pool of Spans.
The mcache returns its full Span to mcentral and swaps for one with free slots. This requires a lock, but it’s brief—just swapping one Span for another. And since each Span Class has its own mcentral, goroutines allocating different sizes don’t compete with each other.
Level 3: mheap, globally unique, highest cost
When mcentral runs out of Spans, it asks mheap for fresh Pages to create a new Span. mheap is the global page allocator—there’s only one, and accessing it requires a global lock. This is the slow path, involving searching for free pages, potentially requesting a new Arena from the OS, and initializing a new Span.
But this path is rarely taken because the upper levels absorb most demand.
The whole design is like a cache chain: mcache caches mcentral, mcentral caches mheap. The hot path is lock-free, the medium path uses brief locks, and the slow path is rare enough that its cost doesn’t matter.

The Allocation Flow Decoded
All allocations go through a single entry point: the mallocgc() function. Depending on size, it takes different paths.
Zero-sized Objects
For zero-sized objects like struct{}{}, Go just returns the address of a global variable called zerobase. No actual allocation—you can’t read or write anything through a zero-sized object anyway.
Tiny Objects (<16B, no pointers)
These go through the Tiny allocator. Check if the current 16-byte block has room; if so, pack it in. If not, grab a new 16-byte slot from mcache.
There’s a neat detail: when the current block is full, the allocator gets a new slot, then compares which has more remaining space—the old block or the new one. The one with more space becomes the current block for future tiny allocations. Nothing wasted.
Small Objects (16B to 32KB)
This is the most common case and what the whole architecture optimizes for.
- Round up to nearest Size Class, determine Span Class
- Find the Span for that Span Class in mcache, use bitmap to find next free slot
- Free slot available? Return it, lock-free
- Span full? Swap with mcentral for a new one
- mcentral empty? Ask mheap for fresh pages
- mheap out of pages? Request new Arena from OS
Most allocations finish at step 2. Blazing fast.

Large Objects (>32KB)
These skip mcache and mcentral entirely, going straight to mheap for exactly the pages needed.
Working with the Garbage Collector
The memory allocator doesn’t work alone—it’s tightly integrated with the garbage collector.
Each Span has two bitmaps: allocBits tracks which slots are allocated, and gcmarkBits tracks which objects the GC found to be live during marking.
During a GC cycle, the collector marks live objects in gcmarkBits. When marking is done, Go swaps the two bitmaps. The new allocBits only contains live objects—anything unmarked is garbage, and those slots can be reused.
This is why mcentral sometimes needs to sweep a Span before handing it to mcache. Sweeping means looking at the bitmaps to figure out which slots are free after a GC cycle. Go does this lazily—sweeping on demand rather than all at once, spreading the cost across allocations.
If a Span is completely empty after sweeping (all objects were garbage), its pages return to mheap and can be reused for different Span Classes.
Can Memory Go Back to the OS?
When the GC frees objects, it just marks slots as reusable. The pages stay with the runtime—from the OS’s perspective, your program is still using all that memory.
But what if your program had a memory spike and now most of it is garbage? Should it just sit there unused?
Go has a background goroutine called the scavenger. It periodically finds pages that have been free for a while and tells the OS “I don’t need this memory right now, take it back.”
On Linux, this uses MADV_DONTNEED. The pages stay mapped in your program’s address space (usable later without a system call), but the kernel can reclaim the physical memory for other processes.
It’s a balancing act: returning memory too eagerly hurts performance (you’ll need to fault it back in), but holding onto too much wastes system resources. The scavenger finds the right balance.
Summary
Go memory allocator’s design philosophy:
- Batch memory requests from OS, distribute internally, avoid system call overhead
- Arena divides into Pages, Pages form Spans, Spans contain slots—clean hierarchy
- 68 Size Classes with scan/noscan distinction for precise matching
- Three-level cache mcache-mcentral-mheap handles most allocations lock-free
- Tiny allocator gives small objects extreme optimization
- Works with GC using dual bitmaps for efficient reclamation
- Scavenger reclaims memory in the background without wasting resources
If you’re curious about the source code, src/runtime/malloc.go, mheap.go, mcache.go, and mcentral.go are well-commented and worth reading.
Next time you write make([]byte, 100), remember that warehouse manager is working frantically just to get those 100 bytes to you as fast as possible.
FAQ
Q: How is Go’s memory allocator different from Java’s?
A: Go uses tcmalloc-style multi-level caching where each P has its own mcache for lock-free allocation. Java’s TLAB (Thread Local Allocation Buffer) is similar, but Go’s design is more aggressive about reducing lock contention, especially suited for high-concurrency goroutine scenarios.
Q: How can I see escape analysis results?
A: Add -gcflags='-m' at compile time. For example, go build -gcflags='-m' main.go will show you which variables escape to the heap.
Q: When does mcache memory get released?
A: When a Span in mcache becomes completely empty, it’s returned to mcentral. Empty Spans in mcentral go back to mheap. Free pages in mheap may be returned to the OS by the scavenger. It’s a layered reclamation process.
Q: Why do large object allocations skip the cache?
A: Large objects are uncommon and use many pages. Caching them in mcache would waste space. Going directly to mheap is more reasonable—large object allocation is already a slow path anyway.
