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
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.
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.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
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:
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.
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?").== 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
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.
4. Generational GC: the new-space scavenger
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.
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).
5. Old space: mark-sweep-compact
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.
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.
6. Memory leaks in Dart
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.
dispose(), or a finally block around a subscription's lifetime).7. WeakReference, Finalizer, Expando
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.
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 ==).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.
| Tool | What it shows | Best for |
|---|---|---|
| DevTools Memory view | Live heap-size timeline | Noticing THAT there's a leak (a graph that only ever goes up) |
| Heap snapshot + diff | Every live object, by class, with retaining paths | Finding WHICH class is leaking and WHY it's still reachable |
| Allocation profiling | Where (which call site) objects of a class were allocated | Finding a hot allocation path causing excessive GC churn |
Quiz
Interview questions
Cheat sheet
| Term | Meaning |
|---|---|
| Header | The 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 == b | Calls the (possibly overridden) == operator; default is identity |
| hashCode contract | If a == b then a.hashCode == b.hashCode must hold |
| Reachable | There is a path of references from some root to this object |
| Garbage | Unreachable from every root — regardless of cycles |
| Reference counting | Frees an object when its incoming-reference count hits 0; fails on cycles |
| New space / scavenger | Small, fast, copying collector for young objects (most die here) |
| Old space / mark-sweep | Larger space for long-lived objects, cleaned by marking then sweeping |
| Forwarding address | The new-space location a copied object moved to, recorded during a scavenge |
| Promotion | Moving a repeatedly-surviving young object into old space |
| Write barrier | Compiler-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 |