Dart 100 🎯 Dart Internals Master Class: Hand-Tracing Memory Lifecycle from Allocator to GC Sweeper
This chapter traces a single object from the moment it is allocated to the moment its memory is reclaimed. The path is not a straight line. An object begins in the new space, allocated through a thread-local buffer with a bump pointer. If it survives a scavenge, it is promoted to the old space. If it survives long enough there, it is eventually marked during a concurrent mark-sweep cycle and swept. Each stage has its own data structures, its own synchronization rules, and its own failure modes. The goal of this chapter is to make the path visible: to show what happens at each step and why.
Key point: The Dart VM’s generational collector treats the new space as part of the root set during old-space marking. This means old-space marking does not need to dereference new-space objects, which avoids data races with the concurrent marker. The trade-off is that unreachable objects referenced only by unreachable new-space objects are not collected until the new space is scavenged .
a. Allocation: The TLAB and the Bump Pointer
When a Dart program allocates an object, the mutator does not call the heap allocator for every object. It allocates a thread-local allocation buffer (TLAB)—a contiguous block of new-space memory—and then bump-allocates objects into that buffer without locking . The bump pointer is a simple cursor: the object is placed at the current position, and the cursor advances by the object’s size.
The allocation path has a specific set of fallbacks. If the object fits in the remaining buffer space, the bump pointer allocates it. If the object is larger than the entire buffer, it goes directly to the old space. If the object is not larger than the buffer but cannot fit in the remaining space, the current buffer is made iterable by the GC, a new buffer is requested, and the object is allocated into the new buffer. If the mutator cannot get a new buffer, it triggers a GC and tries again. If the GC does not free enough space, the object is allocated in the old space .
The TLAB is the reason Dart’s new-space allocation is fast. The mutator never competes for a global lock. The buffer is private to the thread, and the bump pointer is a local variable. The only synchronization points are when the buffer is refilled or abandoned, and those are rare relative to the number of allocations.
The heap’s Allocate method enforces the new/old distinction. If the requested size is not page-allocatable in the new space, it calls AllocateOld directly . This is the mechanism that sends large arrays and other oversized objects to the old space without attempting a new-space allocation that would fail.
b. Scavenge: Copying Survivors and Promoting
When the new space fills, the scavenger runs. The new space is divided into two semispaces: the active space where allocation happens, and the inactive space that serves as the destination for surviving objects . The scavenger traverses the object graph from the roots, copies each live object from the active space to the inactive space, and then swaps the roles of the two spaces.
An object that survives a scavenge is not necessarily promoted. Promotion to the old space happens based on the object’s age and the scavenger’s policy. Objects that have survived a certain number of scavenges, or that are large enough, are moved to the old space instead of being copied within the new space. The old space is larger and is collected less frequently, which makes it suitable for objects with longer lifetimes .
The scavenger’s root set includes the new space itself. During an old-space mark-sweep, the new space is treated as part of the root set, and pointers to new-space objects are ignored . This is a deliberate design choice. The concurrent marker never needs to dereference new-space memory, which avoids the data races that would otherwise occur if the marker read an object that the mutator was simultaneously modifying. The disadvantage is that an unreachable old-space object referenced by an unreachable new-space object is not collected until the new space is scavenged .
c. Mark-Sweep: Concurrent Marking and the Write Barrier
When the old space fills, the mark-sweep collector runs. The marking phase is concurrent with the mutator for most of its duration, which reduces the pause time . The collector starts from the roots and traverses the object graph, marking each reachable object. Objects that remain unmarked at the end of the marking phase are unreachable and are swept.
The concurrent marker cannot traverse the graph without cooperation from the mutator. When the mutator stores a pointer into an old-space object, the write barrier checks whether the store creates a reference that the collector needs to know about. The generational write barrier checks whether the container is old and not in the remembered set, and the value is new. If so, the container is inserted into the remembered set . The incremental marking write barrier checks whether the value is old and not marked, and marking is in progress. If so, the value is inserted into the marking worklist .
The write barrier is the mechanism that keeps the concurrent marker correct. Without it, the mutator could store a pointer to an unmarked object into a marked object, and the marker would never visit the unmarked object, causing it to be swept while still reachable. The barrier ensures that the marker is informed of every store that could create such a reference.
The compiler eliminates the write barrier when it can prove the store cannot create a problematic reference. Constants are always old and are marked via constant pools. Smis are not heap objects. A self-reference cannot cause a reachable object to be freed. An object that is the result of an allocation and has not yet triggered a GC is known to be either new-space or preemptively added to the remembered set . These eliminations are what make the barrier affordable in hot code.
The sweeping phase reclaims the memory of unmarked objects. The memory is returned to the free lists, and the objects are no longer part of the heap. The cycle is complete: the object that was allocated through a TLAB, survived a scavenge, was promoted to the old space, and was marked during a concurrent marking cycle has now been swept.
Complete Example Session
// ============================================
// PART 1: ALLOCATION ENTRY POINT
// ============================================
// A Dart allocation becomes a call to Heap::Allocate
// The size determines the space
// Large objects go directly to old space
// Small objects go to new space via TLAB
// ============================================
// PART 2: TLAB BUMP ALLOCATION
// ============================================
// Mutator gets a buffer
// Object is placed at the bump pointer
// Cursor advances by object size
// No lock, no global heap access
// ============================================
// PART 3: TLAB FALLBACKS
// ============================================
// Object fits → bump allocate
// Object larger than buffer → old space
// Object cannot fit → new buffer, GC, retry
// Still cannot fit → old space
// ============================================
// PART 4: SCAVENGE TRIGGER
// ============================================
// New space fills
// Scavenger traverses from roots
// Copies live objects to inactive semispace
// Swaps active and inactive
// ============================================
// PART 5: PROMOTION
// ============================================
// Objects that survive a threshold of scavenges
// are moved to old space instead of being copied
// Old space is larger, collected less often
// ============================================
// PART 6: OLD-SPACE MARKING
// ============================================
// Old space fills
// Concurrent marker starts from roots
// New space treated as root set
// New-space pointers ignored by marker
// ============================================
// PART 7: WRITE BARRIER
// ============================================
// Mutator stores container.slot = value
// Generational barrier: old container + new value → remembered set
// Incremental barrier: value old + unmarked + marking → worklist
// ============================================
// PART 8: BARRIER ELIMINATION
// ============================================
// Compiler skips barrier when:
// - value is constant
// - value is Smi
// - container == value (self-reference)
// - container is freshly allocated, no GC between alloc and store
// ============================================
// PART 9: SWEEPING
// ============================================
// Marking completes
// Unmarked objects are swept
// Memory returned to free lists
// ============================================
// PART 10: THE FULL CYCLE
// ============================================
// Allocate → TLAB → survive scavenge → promote
// → marked concurrently → swept
The ten parts trace the lifecycle from allocation to sweeping. Parts 2 and 3 show the TLAB and its fallbacks. Part 6 shows the root-set treatment of the new space. Part 7 shows the write barrier that keeps concurrent marking correct.
Quick Reference
Space and Collector
| Space | Collector | Algorithm | Pause |
|---|---|---|---|
| New space | Scavenger | Copying (semispaces) | Stop-the-world |
| Old space | Mark-sweep | Mark + sweep | Mostly concurrent |
TLAB Fallbacks
| Condition | Action |
|---|---|
| Object fits in buffer | Bump allocate |
| Object larger than buffer | Allocate in old space |
| Object cannot fit, buffer full | New buffer, GC if needed |
| Still cannot allocate | Old space |
Write Barrier Checks
| Barrier | Condition | Action |
|---|---|---|
| Generational | Old container, new value | Add container to remembered set |
| Incremental | Old value, unmarked, marking | Add value to marking worklist |
Barrier Elimination
| Condition | Reason |
|---|---|
| Value is constant | Marked via constant pool |
| Value is Smi | Not a heap object |
| Container == value | Self-reference cannot free reachable object |
| Fresh allocation, no GC | Preemptively remembered or new-space |
Best Practices
✅ Do This:
// Keep hot-path allocations small for TLAB bump allocation
final buffer = List<int>.filled(16, 0); // ✅
// Be aware that large objects bypass the new space
final large = List<int>.filled(1000000, 0); // goes to old space // ✅
// Use finalizable classes correctly for native resources
// The Finalizable marker keeps the object alive until scope ends // ✅
❌ Don’t Do This:
// Assume the new space collects everything
// Old-space cycles require mark-sweep // ❌
// Rely on write barriers being eliminated in all cases
// The compiler eliminates only when provable // ❌
// Create generation-crossing cycles that delay collection
// An old object referenced by a new object is not collected until scavenge // ❌
Common Pitfalls
| Pitfall | Why It Happens | Fix |
|---|---|---|
| OOM despite GC | Old-space garbage referenced by new-space garbage | Scavenge before old-space GC |
| Large arrays retained | Allocated directly in old space | Ensure new-space scavenge runs |
| Concurrent marking race | Mutator modified slot during marking | Write barrier adds to worklist |
| Finalizer runs too early | Resource extracted from Finalizable variable | Keep Finalizable in scope |
Real-World Examples
1. TLAB Allocation
// Small object bump-allocated in thread-local buffer
final point = Point(1, 2);
2. Large Object to Old Space
// Bypasses TLAB, allocated directly in old space
final bigArray = List<int>.filled(500000, 0);
3. Scavenge Copy
// Live object copied to inactive semispace
// Dead object left behind
4. Promotion
// Object survives N scavenges, moved to old space
5. Concurrent Mark
// Marker traverses old space while mutator runs
// Write barrier records new references
6. Remembered Set
// Old container with new value is added to remembered set
7. Marking Worklist
// Unmarked old value stored during marking is added to worklist
8. Sweep
// Unmarked objects reclaimed
9. Finalizable Scope
void useResource() {
final finalizable = MyFinalizable(ptr);
nativeCall(finalizable.resource);
} // finalizable alive until here
10. Generation-Crossing Cycle
// Old object → New object → Old object
// Old object not collected until scavenge
Visual
The Object Lifecycle
┌──────────────────────────────────────────────────────────────┐
│ ALLOCATE │
│ │ │
│ ▼ │
│ ┌─────────────────┐ │
│ │ TLAB bump │ Small object │
│ │ pointer │ │
│ └────────┬────────┘ │
│ │ buffer full │
│ ▼ │
│ ┌─────────────────┐ │
│ │ Scavenge │ Copy live objects │
│ │ (new space) │ Swap semispaces │
│ └────────┬────────┘ │
│ │ survives threshold │
│ ▼ │
│ ┌─────────────────┐ │
│ │ PROMOTE │ Move to old space │
│ └────────┬────────┘ │
│ │ old space fills │
│ ▼ │
│ ┌─────────────────┐ │
│ │ Concurrent mark │ Write barrier keeps marker correct │
│ └────────┬────────┘ │
│ │ marking complete │
│ ▼ │
│ ┌─────────────────┐ │
│ │ Sweep │ Reclaim unmarked objects │
│ └─────────────────┘ │
│ │
│ Large objects bypass TLAB and go directly to old space. │
│ │
└──────────────────────────────────────────────────────────────┘
TLAB Allocation
┌──────────────────────────────────────────────────────────────┐
│ THREAD-LOCAL ALLOCATION BUFFER │
│ │
│ ┌─────────────────────────────────────────────────────────┐│
│ │ [obj1][obj2][obj3] [free space] ││
│ │ ▲ ││
│ │ │ ││
│ │ bump pointer ││
│ └─────────────────────────────────────────────────────────┘│
│ │
│ Allocation: place object at bump, advance bump │
│ No lock. No global heap access. │
│ │
│ Buffer full → get new buffer (may trigger GC) │
│ Object too large → old space │
│ │
└──────────────────────────────────────────────────────────────┘
New Space as Root Set
┌──────────────────────────────────────────────────────────────┐
│ OLD-SPACE MARKING │
│ │
│ ┌─────────────────────────────────────────────────────────┐│
│ │ ROOTS ││
│ │ ┌─────────────┐ ││
│ │ │ Stack │ ││
│ │ │ Globals │ ││
│ │ │ New space │ ← entire new space is a root ││
│ │ └─────────────┘ ││
│ └──────────────────────────┬──────────────────────────────┘│
│ │ │
│ ▼ │
│ ┌─────────────────────────────────────────────────────────┐│
│ │ OLD SPACE ││
│ │ Mark reachable objects from roots ││
│ │ Ignore pointers into new space ││
│ └─────────────────────────────────────────────────────────┘│
│ │
│ Marker never dereferences new-space memory. │
│ Avoids data races with mutator. │
│ Cost: generation-crossing garbage not collected until │
│ scavenge. │
│ │
└──────────────────────────────────────────────────────────────┘
Write Barrier
┌──────────────────────────────────────────────────────────────┐
│ MUTATOR STORE: container.slot = value │
│ │
│ ┌─────────────────┐ │
│ │ container old? │────► NO: skip barrier │
│ └────────┬────────┘ │
│ │ YES │
│ ▼ │
│ ┌─────────────────┐ │
│ │ value new? │────► YES: add container to │
│ │ │ remembered set │
│ └────────┬────────┘ │
│ │ NO │
│ ▼ │
│ ┌─────────────────┐ │
│ │ marking? │────► YES: add value to │
│ │ │ marking worklist │
│ └─────────────────┘ │
│ │
│ The barrier keeps the concurrent marker correct. │
│ Compiler eliminates it when provable safe. │
│ │
└──────────────────────────────────────────────────────────────┘
Summary
| Item | Value |
|---|---|
| New-space allocation | TLAB bump pointer, no lock |
| Large object | Allocated directly in old space |
| New-space GC | Scavenger, copying, stop-the-world |
| Promotion | Surviving objects moved to old space |
| Old-space GC | Mark-sweep, concurrent marking |
| New-space as root | Treated as root set during old-space marking |
| Generational barrier | Old container + new value → remembered set |
| Incremental barrier | Unmarked old value during marking → worklist |
| Barrier elimination | Constants, Smis, self-references, fresh allocations |
| Sweeping | Unmarked objects reclaimed |
Key takeaways:
- Allocation is a bump pointer in a thread-local buffer. The mutator does not lock the heap for each object. It allocates a TLAB, bump-allocates into it, and only requests a new buffer when the current one is full. Large objects bypass the TLAB and go directly to the old space .
- Scavenge copies survivors and promotes based on age. The new space is divided into two semispaces. Live objects are copied from the active one to the inactive one, and the roles are swapped. Objects that survive enough scavenges are promoted to the old space .
- Old-space marking treats the new space as a root. The concurrent marker never dereferences new-space memory. This avoids data races with the mutator at the cost of delaying the collection of generation-crossing garbage until the next scavenge .
- The write barrier is what makes concurrent marking correct. When the mutator stores a pointer into an old-space object, the barrier checks whether the store creates a reference the marker needs to know about. If so, it adds the container to the remembered set or the value to the marking worklist .
- Barrier elimination is a compiler optimization. The compiler skips the barrier when it can prove the store cannot create a problematic reference: constants, Smis, self-references, and fresh allocations with no intervening GC .
- The cycle ends with sweeping. After marking completes, unmarked objects are reclaimed. The object that began as a TLAB allocation is either still live and marked, or it has been swept and its memory returned to the free lists.
Remember: The Dart VM’s memory lifecycle is a pipeline with distinct stages, and each stage has a specific mechanism. The TLAB makes allocation fast by avoiding locks. The scavenger makes new-space collection fast by copying only live objects. The concurrent marker makes old-space collection fast by running alongside the mutator. The write barrier makes the concurrent marker correct by recording stores that could otherwise be missed. The compiler makes the write barrier affordable by eliminating it where it is provably unnecessary. None of these mechanisms is independent; the correctness of the concurrent marker depends on the write barrier, and the performance of the mutator depends on the TLAB and the barrier elimination. The object’s journey from allocation to sweeping is the journey through this pipeline, and understanding the pipeline is understanding how Dart manages memory.
Stop using slow, ad-bloated tool sites! 🤮
🔎 Search “KandZ Tools” on Google to use many professional utilities for free.
KandZ.me is the ultimate minimalist hub for:
✅ Finance (Mortgage, Interest, Inflation)
✅ Tech (Base64, JSON, Dev Suite, IP)
✅ Health (BMI, BMR, TDEE)
✅ Productivity (Timer, Workspace, QR)
⚡️ Fast & Private
🔒 No data leaves your device
💎 100% Free
🔗 Use it now: https://tools.kandz.me
🔖 Bookmark it—you’ll need it later!