Memory Deep Dive

By the end of this lesson you will be able to explain what a Dart object actually looks like in memory, correctly predict identical() vs == in tricky cases, trace exactly which objects are "reachable" (and which are garbage) from a diagram, and describe — with appropriate hedging about VM implementation details — how the Dart VM's generational garbage collector actually reclaims memory, plus how to spot and fix real memory leaks.

1. Recap: stack, heap, references, object layout

In lesson D00 you met the cafeteria-tray stack and the shared heap storage room. Now zoom into ONE tray item on the heap: every object is like a labelled parcel — a small header stapled to the front (which says what KIND of parcel this is, plus a little internal bookkeeping) followed by the actual contents (the object's fields, one after another). A variable never holds the parcel itself — it holds a delivery address (a reference) telling you where to find it.

Every value you can name in Dart is, conceptually, an object with a class. Even 5 has a class (int) and could answer 5.runtimeType. When you write var p = Person('Ada', 36);, the Dart VM allocates a block of memory on the heap conceptually laid out as: a small header (current Dart VM designs use it to record which class the object is, plus bookkeeping such as a cached identity hash and bits the garbage collector uses for marking) followed directly by the object's own fields in some fixed order. This exact byte layout is a VM implementation detail — you never see it directly from Dart code — but it explains a lot of behaviour, including why identical() and object headers matter for the rest of this lesson.

In Dart: the parts of an object's layout Dart code CAN observe (its class and its identity hash). Header size, field offsets and addresses are VM internals: you cannot read them from Dart, so this lesson states them from the VM design only and never prints them.

Now look at a slightly richer example: an object whose OWN field is itself a reference to another object.

In Dart: two Person objects sharing one Address, and var b = a copying only the arrow.

Scale: references are one machine word each, so a list of 106 references costs about 8 MB of slots on a typical 64-bit Dart VM (Flutter apps on 64-bit phones use 4-byte compressed pointers, so about 4 MB) however large the objects they point at are.

A common mistake: thinking an object's field that holds another object "contains" that object, the way an int field contains a number. It doesn't — a field of a class type holds a reference, just like a stack variable does. This is why two different objects can end up sharing one sub-object, and why that shared sub-object can outlive the object you originally reached it through.
A variable is a named reference. An object field is also a reference (unless it's a primitive-ish value like int/double/bool, which behave the same way but you never observe the sharing because they're immutable). "Where does this live?" always has the same two possible answers: a stack frame's slot, or a heap object's header+fields block.

2. Identity vs equality

Imagine two identical twin lockers, both containing exactly $5 in exactly the same coins. Identity ("are these the SAME locker?") is a different question from equality ("do these two lockers currently hold equal contents?"). Dart gives you two different tools for these two different questions: identical(a, b) for identity, a == b for equality — and by default, without you writing any code, they ask the SAME question.

identical(a, b) asks "do a and b point at the exact same object in memory?" It can never be fooled and never be customised. a == b calls the == operator, which is a real method your class can override — but if you don't override it, every class inherits Object's default ==, which is defined to do EXACTLY what identical() does.

In Dart: identical versus == on a class without and with an overridden ==/hashCode.

Now override == (and, critically, hashCode alongside it) to give a class its own notion of equality:

The hashCode contract: if a == b, then a.hashCode MUST equal b.hashCode. (The reverse isn't required — two unequal objects are allowed to share a hash code; that's just a "hash collision", which every hash-based structure already handles.) Overriding == without also overriding hashCode consistently is a classic bug: your object might compare equal to another one but land in a different bucket of a HashSet/HashMap, so lookups mysteriously fail.

In Dart: a class that breaks the hashCode contract (equal objects, different hashes) and a key mutated while inside a Set. Both make contains fail even though == says yes.

A few more identity subtleties worth knowing, all hedged appropriately since they touch VM/compiler implementation choices rather than hard language guarantees:

In Dart: numbers, strings and const values. Note that identical on numbers compares by value, so Dart code cannot tell a small inline integer (Smi) from a boxed one: that distinction exists only in the VM design.

"Small integers are typically stored directly inside the reference slot" is a widely-used VM implementation technique (often nicknamed a Smi, "small integer", a term that comes from V8-style VM design and is commonly used when discussing Dart VM internals too). It means the VM doesn't need a separate heap allocation for small, commonly-used integers — which is also why identical(1, 1) reports true. Treat this as "how the current Dart VM happens to be built", not as a general rule you can rely on for arbitrary objects — always use == for value comparisons in real code, and reserve identical() for genuinely identity-sensitive logic (e.g. "is this the exact singleton I expect?").
Rule of thumb: use == almost everywhere (it's what "logically equal" means for your type). Use identical() only when you specifically care about object identity — e.g. checking a cache hit returned the exact cached instance, or writing a custom equality override that (correctly) starts with an identity fast-path: bool operator ==(Object o) => identical(this, o) || (o is MyClass && ...);

3. Reachability: roots, graphs, cycles

Picture the heap as a city map of houses (objects), connected by one-way roads (references). The garbage collector doesn't care how a house LOOKS — it only cares whether you can actually drive to it starting from a fixed set of "town squares": the roots (your currently-running functions' local variables, top-level/static variables, and a handful of isolate-level bookkeeping references). Any house you cannot reach from a town square, no matter how many roads connect it to OTHER unreachable houses, is abandoned — garbage.

Reachability is computed as a graph search starting from the roots. This is exactly the algorithm behind the Medium-tier interview question below — try tracing it yourself before checking the code:

In Dart: the traversal as a function, run on a 7-object heap with a cycle (5, 6), a self-loop (7), and the empty cases.

Input size → what's feasible: this is O(V + E) with an explicit stack, so V = 2·105 objects and E = 106 references (about 106 steps) is instant; a recursive version would overflow the call stack on a 2·105-long chain.

The genuinely subtle case is a cycle: two or more objects that reference each other. A naive "count how many things point at me, and free myself when that count hits zero" strategy (reference counting) gets this wrong.

In Dart: the incoming-reference counts of a cycle before and after its only outside reference is dropped, then a trace from the root.

Reference counting fails on cycles because a cycle's members can keep EACH OTHER'S count above zero forever, even after nothing outside the cycle can reach any of them. Some reference-counted systems bolt on a separate "cycle collector" to catch this (Python does exactly this alongside its refcounting); Dart's GC sidesteps the whole problem by tracing reachability from roots instead of counting references at all.
"Garbage" always means unreachable from any root — never "has a reference count of zero" and never "looks unused". A tracing collector (what Dart uses) walks the graph from roots and marks everything it finds; anything left unmarked, cycle or not, is garbage.

4. Generational GC: the new-space scavenger

Most objects are like a coffee cup at a conference — created, used within seconds, and thrown away almost immediately. Very few objects are like the conference building itself — created once, used the entire time. This observation is called the weak generational hypothesis: in most programs, most objects die young. Dart's VM (current design) is built directly around this observation by splitting the heap into a small, fast new space for young objects and a larger old space for long-lived ones.

New space uses a technique called a semi-space copying collector (often just called "the scavenger"): new space is split into two equal halves, from-space (currently active, where allocations happen) and to-space (currently empty, reserved). When from-space fills up, the scavenger walks from the roots, copies every LIVE object it finds into to-space (recording a forwarding address for each one so any other reference to it can be redirected), and then simply discards the entire from-space in one shot — dead objects are never individually freed, they're just left behind and thrown away wholesale.

A common misconception: thinking a copying collector visits every object in from-space to check if it's alive. It doesn't — it only visits objects REACHABLE from the roots (copying them as it goes); anything it never reaches during that walk is, by definition, garbage, and gets discarded along with the rest of from-space without ever being individually inspected.

Objects that keep surviving scavenge after scavenge are eventually judged "probably long-lived" and promoted straight into old space instead of being copied within new space yet again:

In Dart: a simulated young generation: one minor GC keeps what the roots reach, ages the survivors and promotes them at a threshold of 2. This models the algorithm; it is not the real VM, whose thresholds are internal.

Input size → what's feasible: a scavenge costs O(survivors), so 104 allocations with 100 survivors visit 100 objects (see the first interview question on generational GC for the count).

The exact survivor-count threshold used for promotion, and many other tuning details, are internal to the current Dart VM implementation and can change between releases — the concept (age-based promotion out of a small, fast new space) is the stable, portable idea to remember; the precise number of scavenges before promotion is not something application code should ever depend on.
Because new space is small and most young objects die immediately, a scavenge is typically very fast — it only has to copy the (usually few) survivors, not scan the whole heap. This is the entire point of generational collection: optimise heavily for the common case (short-lived garbage) instead of treating every object equally.

5. Old space: mark-sweep-compact

Old space holds objects expected to live a long time, so copying them around on every collection (like the scavenger does) would be wasteful. Instead, old space is cleaned with mark-sweep: first walk from the roots and put a mental sticky-note ("MARK") on every object you can reach; then walk the WHOLE space slot by slot and reclaim ("SWEEP") anything without a sticky-note.

Watch mark, then sweep, then an optional compaction pass run over a small old-space region:

In Dart: the same mark, sweep and compact on a larger 10-slot region with two roots, printing the largest contiguous free run each time.

Sweeping alone can leave fragmentation: lots of small free gaps scattered between surviving objects, so even though there's plenty of TOTAL free memory, there might not be one contiguous gap big enough for a large new allocation. Compaction fixes this by sliding survivors together — but every remaining reference to a moved object has to be rewritten to its new address, which costs extra work, so not every collector compacts on every cycle.

Two more concepts worth knowing by name, since they come up constantly in GC discussions and interviews:

In Dart: incremental marking where the program moves the only reference to Y into an already-scanned object during a pause. Without the barrier Y would be freed while still reachable.

Stop-the-world (STW) means the entire application is paused while the collector runs, guaranteeing the object graph can't change mid-collection. Many current collectors (including parts of the Dart VM's design) instead try to do as much marking work as possible concurrently — while your program keeps running — to shrink that pause. The catch: if the app is allowed to keep mutating references while marking is in progress, the collector could miss a newly-created reference to something it already decided was garbage. A write barrier is a small piece of extra code the compiler inserts around every pointer write specifically to catch this: whenever the running program stores a reference into an object, the write barrier notices and tells the collector "re-check this", keeping concurrent marking correct. The exact mix of stop-the-world vs. concurrent/parallel phases is an evolving Dart VM implementation detail, not something this lesson pins to one specific version.
New space: copying collector, optimised for "most objects die young". Old space: mark-sweep(-compact), optimised for "most objects here live a long time, so don't bother copying them every cycle". Both are forms of tracing garbage collection — neither counts references.

6. Memory leaks in Dart

Dart has a real garbage collector, so you can never get a C-style "forgot to free this pointer" leak. But you CAN absolutely leak memory a different way: by accidentally keeping something reachable forever when you meant to let it go. If a root (or anything reachable from a root) still points at an object, the GC is doing its job correctly by keeping it alive — even if that's not what you wanted.

The most common real-world Dart/Flutter leak shapes are: forgetting to cancel a StreamSubscription or remove a listener, a static cache/map that only ever grows, a closure that captures a large object (or an entire widget/page) and outlives it via a long-lived Timer or callback list, and a repeating Timer that's never cancelled.

In Dart: five screens subscribe to a bus and are immediately "closed"; the bus still holds, and still runs, all five listeners. The safe bus returns an unsubscribe function.

In Dart: a page that never cancels its StreamSubscription keeps receiving events after it was removed; dispose() stops it.

In Dart: a repeating Timer keeps firing until it is cancelled.

In Dart: a static map that only grows versus one bounded by evicting its oldest key.

The fix is almost always the same shape: whoever REGISTERS a callback/listener/subscription must also be given (and must actually call) a way to UN-register it, and that un-registration must happen at the moment the registering code is done (e.g. a widget's dispose(), or a finally block around a subscription's lifetime).
A "leak" in a garbage-collected language always means the same thing: something is still reachable that shouldn't be. Fixing a leak is never about "freeing memory" by hand — it's about removing the reference that's keeping the unwanted object reachable.

7. WeakReference, Finalizer, Expando

A WeakReference<T> is like writing someone's address on a sticky note without actually inviting them to stay — it lets you find them IF they're still around, but your note itself is never a reason for them to stay. An Expando<T> is a filing cabinet that stores extra notes about objects by their identity, without stapling anything to the object itself and without keeping the object around. A Finalizer<T> is like asking a neighbour to "let me know whenever that house finally gets torn down" — useful, but they might tell you late, out of order, or (if the whole neighbourhood is bulldozed at once) not at all.

In Dart: a WeakReference and a cache of weak references, asserting only what holds no matter when the GC runs.

In Dart: an Expando keyed by identity, the key types it rejects, and equal-but-distinct keys.

In Dart: explicit cleanup in finally with a Finalizer as a detached safety net; finalizer callbacks never run during the synchronous code that dropped the object.

Never use a Finalizer for anything time-critical or correctness-critical (closing a file handle, releasing a lock, flushing data). Dart's own documentation is explicit that finalizer callbacks are not guaranteed to run promptly, in any particular order relative to other finalizers, or even at all — for example, if the isolate exits first, pending finalizer callbacks may simply never run. Use an explicit dispose() method (called from a finally block, or a Flutter widget's own dispose()) for anything that actually matters.
Expando's storage does not keep its keys alive — attaching a value to an object through an Expando never prevents that object from being collected. This is different from a plain Map<Object, T>, whose keys ARE strongly held simply by being in the map, which would itself become a slow leak if you used it the same way. WeakReference has the same restriction (it throws an ArgumentError for numbers, strings, booleans and records — shown in the first panel of this section). Both WeakReference and Expando are ordinary, stable, currently-shipping dart:core APIs — but exactly how quickly the VM notices a target has become unreachable and updates them is, again, GC-timing dependent and not something to test for or depend on in application logic. One concrete restriction worth knowing (verified against the current SDK): an Expando key can't be a value that Dart represents "by value" rather than by a unique identity — strings, numbers, booleans, records (and Pointer/Struct/Union values) are all rejected at runtime with an ArgumentError, while null cannot even be passed: it is a compile-time error under sound null safety. This makes sense once you remember why identity-based attachment exists at all: those values can be canonicalized or boxed/unboxed in ways that would make "the exact same key" an unstable idea, so Expando only accepts keys with genuine, stable object identity (ordinary class instances, including custom ones that override == — Expando still keys on identity, ignoring any custom ==).
You can't reliably unit-test "this object was actually garbage collected" because GC timing is never guaranteed or deterministic. What you CAN test deterministically: the cache/lookup logic itself (does get() return the right thing while the target is alive? does it correctly treat a null target as "not found"?) — see the Expert-tier weak-value-cache question below for exactly this approach.

8. Measuring memory

You rarely need to reason about raw bytes by hand — Dart and Flutter ship real tooling for this. DevTools' Memory view (available for both Dart VM and Flutter apps run in debug/profile mode) shows a live, ticking graph of heap usage (current, external, RSS) over time, which is the fastest way to visually spot a leak: a memory graph that keeps climbing and never comes back down after a garbage collection is exactly the leak shape from section 6. DevTools can also capture a full heap snapshot — a point-in-time dump of every live object, grouped by class, with retained-size and "who's keeping this object reachable" (a retaining path, i.e. the actual chain of references from a root down to the object) information. The standard workflow for hunting a suspected leak is: take a snapshot, perform the suspected-leaky action several times (e.g. open and close the same screen 5 times), force a GC, take a second snapshot, and compare — any object type whose count grew roughly in proportion to how many times you repeated the action is very likely being leaked.

In Dart: ProcessInfo.currentRss and maxRss (resident memory of the whole process, in bytes) around a 64 MB buffer. Dart code can read these two numbers; heap size, old-space size and GC counts come from DevTools or the VM service and are not available to plain Dart code, so the table below describes them without printing any.

In Dart: the generational idea measured from Dart: 3 million short-lived lists barely move RSS, while retaining 3 million lists grows it by hundreds of MB. Only comparisons are checked, because exact megabytes differ per machine and VM version.

Observatory, the older VM web UI, was replaced by DevTools; both read the VM service, which is why neither can be reproduced by a line of plain Dart.

ToolWhat it showsBest for
DevTools Memory viewLive heap-size timelineNoticing THAT there's a leak (a graph that only ever goes up)
Heap snapshot + diffEvery live object, by class, with retaining pathsFinding WHICH class is leaking and WHY it's still reachable
Allocation profilingWhere (which call site) objects of a class were allocatedFinding a hot allocation path causing excessive GC churn

Quiz

Interview questions

Cheat sheet

TermMeaning
HeaderThe small per-object metadata block (class info, GC bits) that precedes an object's fields — a VM implementation detail
identical(a,b)True iff a and b are the exact same object; never overridable
a == bCalls the (possibly overridden) == operator; default is identity
hashCode contractIf a == b then a.hashCode == b.hashCode must hold
ReachableThere is a path of references from some root to this object
GarbageUnreachable from every root — regardless of cycles
Reference countingFrees an object when its incoming-reference count hits 0; fails on cycles
New space / scavengerSmall, fast, copying collector for young objects (most die here)
Old space / mark-sweepLarger space for long-lived objects, cleaned by marking then sweeping
Forwarding addressThe new-space location a copied object moved to, recorded during a scavenge
PromotionMoving a repeatedly-surviving young object into old space
Write barrierCompiler-inserted code that flags pointer writes so concurrent/generational GC stays correct
WeakReference<T>Points at an object without keeping it reachable
Finalizer<T>Runs a callback after a target is collected — not guaranteed prompt or certain
Expando<T>Attaches data to an object by identity, without extending its class or keeping it alive