The Runtime Theory
Runtime & Execution

What Happens When You Allocate an Object

Thread-local allocators, TLABs, bump allocation, and why object allocation is nearly free until the moment it isn't.

The Runtime Theory Team4 min read#memory-allocation#tlab#bump-allocation#heap
On this page

You write new Object() and expect nothing to happen. And in most runtimes, almost nothing does — the fastest allocation paths in computing are measured in nanoseconds, faster than a function call, faster than a cache miss. But the "almost" is doing real work. What actually happens when you ask for memory involves thread-local strategies, size classes, and a deliberate trade between allocation speed and collection cost. Understanding this is the difference between code that allocates gracefully and code that thrashes the garbage collector.

The fast path: bump allocation

In a generational collector, the nursery is a contiguous region of memory. "Allocating" an object means moving a pointer forward by the object's size:

text
nursery:  [obj1][obj2][obj3][   free space   ]
                         ^                    ^
                    bump pointer          end of nursery
c
// the fastest allocator in any runtime
void* bump_allocate(size_t size) {
    void* result = bump_pointer;
    bump_pointer += size;
    if (bump_pointer > nursery_end) {
        return slow_path();  // nursery is full → trigger minor GC
    }
    return result;
}

This is called bump allocation or pointer-bump allocation. It has no free list to search, no lock to acquire (the bump pointer is thread-local), and no per-object metadata to write. The cost is one addition and one comparison — typically 1–2 nanoseconds on modern hardware.

Java, Go, and .NET all use bump allocation in their nursery/young generation. The fast path is so fast that allocation is often faster than the code that initializes the object.

Thread-local allocation buffers (TLABs)

The problem with a shared bump pointer is contention: if multiple threads allocate simultaneously, they all race to update the same pointer. The solution is thread-local allocation buffers — each thread gets its own small chunk of the nursery with its own bump pointer:

text
┌─────────────────────────────────────────┐
│              Nursery                     │
│  ┌──────────┐ ┌──────────┐ ┌──────────┐│
│  │ Thread 1  │ │ Thread 2  │ │ Thread 3  ││
│  │ TLAB     │ │ TLAB     │ │ TLAB     ││
│  │ bump: →  │ │ bump: →  │ │ bump: →  ││
│  └──────────┘ └──────────┘ └──────────┘│
└─────────────────────────────────────────┘

Each thread allocates from its own TLAB without any synchronization. When the TLAB is full, the thread requests a new one from the shared nursery. The handoff is the only point of contention, and it happens rarely — a TLAB is typically 1–64 KB, enough for hundreds or thousands of small objects.

This is why allocation scales linearly with thread count in modern runtimes. There is no global lock, no atomic operation on the fast path. The thread-local TLAB turns a potentially serial operation into a parallel one.

Size classes and slab allocation

Not all objects are the same size, and a bump pointer wastes space when objects vary dramatically. The allocator solves this with size classes — predetermined buckets that round up object sizes:

text
size class    actual size    waste per object
8 bytes       8 bytes        0 bytes
16 bytes      16 bytes       0–7 bytes
32 bytes      32 bytes       0–15 bytes
48 bytes      48 bytes       0–15 bytes
64 bytes      64 bytes       0–15 bytes
...

A 13-byte object fits into the 16-byte class. A 33-byte object fits into the 48-byte class. The waste per object is small, but the benefit is enormous: the allocator can manage free lists per size class, and when a slab of a given size class is exhausted, it allocates a new slab from the OS in one call.

This is why sizeof(Object) often doesn't match what the runtime actually charges you. The runtime rounds up to the nearest size class, plus object header overhead (typically 8–16 bytes for type metadata, GC flags, and hash code storage).

The slow path: when the nursery fills up

The nursery is finite. When the bump pointer reaches the end, the runtime triggers a minor garbage collection — a stop-the-world pause that scans the nursery, copies surviving objects to the old generation, and resets the bump pointer.

text
Before minor GC:
  nursery:  [A][B][C][D][E][F]     ← all allocated, some dead
  old-gen:  [X][Y][Z]
 
After minor GC:
  nursery:  [empty]                 ← bump pointer reset to start
  old-gen:  [X][Y][Z][C][E]        ← C and E survived, promoted

The cost of the slow path is proportional to the number of live objects in the nursery, not the number of allocated ones. This is why the generational hypothesis matters: if 95% of objects die young, the minor GC scans 5% and frees 95%. The pause is short because the work is proportional to survivors, not allocations.

But when many objects survive — when you allocate long-lived data structures in a tight loop — the minor GC promotes them all, the old generation grows, and eventually a major GC is triggered. Major collections scan the entire heap and can pause for milliseconds.

Large objects: the bypass path

Objects above a certain size (typically 8–32 KB, depending on the runtime) skip the nursery entirely. They are allocated directly into the old generation or a dedicated large-object space:

text
┌─────────────────────────────────────────┐
│  Old Generation    │  Large Object Space │
│  [small objects]   │  [big array]        │
│                    │  [huge buffer]      │
└─────────────────────────────────────────┘

Large objects are allocated with a different strategy — often mmap directly from the OS — because bumping through them wastes too much nursery space and they'd be expensive to copy during minor GC. The trade-off: large-object allocation is slower (it's a syscall, not a pointer bump) but avoids the copying overhead.

This is why preallocating arrays matters. A new byte[10000] goes through the slow path. A hundred new byte[100] allocations go through the fast path — but they create a hundred objects the GC must scan, while the single large allocation creates one.

What this means for your code

  1. Small, short-lived objects are free. The bump allocator handles them in nanoseconds, and the minor GC reclaims them in microseconds. Do not prematurely pool objects to avoid "allocation cost" — you are optimizing a path that is already near-optimal.

  2. Large allocations have a real cost. Every new byte[N] for large N hits the slow path. Preallocate buffers, reuse arrays, and avoid repeated large allocations in hot loops.

  3. Object headers are not free. Every object carries 8–16 bytes of metadata. A million small objects carry 8–16 MB of headers. Flattening (struct of arrays vs array of structs) can reduce this overhead dramatically.

  4. The nursery is your friend, but only if objects die there. Cache-heavy patterns that promote objects to old generation are fighting the collector. If you must cache, cache in a way that allows bulk eviction.

  5. Allocation scales with threads, collection does not. Modern allocators make allocation embarrassingly parallel. Collection — especially major collections — is serialized by the stop-the-world pause. Optimize for the pause, not the allocation.