Memory & Garbage Collection · Lesson 22

Retaining paths, cycles and dominators

Build an ownership graph, separate shallow from retained size, and identify independent roots.

Explore the interactive Memory Lab

Change owners, step through generations, pinning and finalization, or compare memory under load. The model explains concepts; the SDK examples measure real runtime observations.

Open the Memory Lab ↗

Count owners before counting leaks

A managed leak is often a lifetime policy that retains objects longer than intended. The collector can be working correctly while the application keeps an enormous graph reachable. A small dictionary entry, event delegate or pending work item may be the only visible top-level owner of a much larger payload. The useful question is therefore not simply which type has the most instances. Ask which strong root paths reach those instances, whether those paths are intentional, and when their ownership should end.

The cycle experiment distinguishes internal references from external ownership. Two objects referring to each other are not automatically permanent in a tracing collector. If no strong root reaches the pair, the cycle can become collectible. Reference counting and tracing have different failure modes; importing the rule that cycles always leak from another memory-management scheme produces the wrong diagnosis for ordinary managed objects.

Paths, shallow size and retained size

Shallow size describes the storage of an object itself, with the exact accounting depending on the tool and runtime. Retained size asks a graph question: how much would become unreachable if a particular ownership boundary were removed? Those quantities can differ dramatically. A small view can retain a large array. A large shared array can have multiple independent owners, so attributing all of its bytes independently to every owner overcounts memory.

A dominator lies on every path from a synthetic graph root to an object. If a cache entry dominates a payload, removing that entry's ownership can cut all those paths. If an active request also owns the payload, the cache no longer dominates it. An immediate dominator is a graph relationship, not necessarily a business object named Owner. Tools may use synthetic roots and groupings. Read their definitions before treating a retained-size column as a sum of physical resident pages.

Strong and weak observations

A weak reference offers optional access without establishing an ordinary strong retaining edge to its target. If TryGetTarget succeeds, the returned local reference is strong for its actual runtime-reported lifetime. That access can change the experiment: repeatedly polling a weak reference can keep acquiring a temporary strong target, and a debugger can introduce its own observations. A failed lookup should be handled as a normal state rather than as an exceptional corruption.

Do not use weak references to replace an explicit cache capacity policy indiscriminately. Collection timing depends on pressure and runtime decisions, so a weak cache has different hit-rate and lifetime behavior from a bounded strong cache. It is useful when recomputation is acceptable and optional presence is the intended contract. It is not a deterministic timer, an authorization store, or a way to make returned pooled memory safe after its lease ends.

Trace the two registered roots

The portable program constructs left and right, creates a cycle, and registers two owners in a dictionary. Removing request leaves cache as the one registered owner, so owners:1 is printed. Following cache to left, then Next to right, then Next back to left returns the same instance, producing cycle:True. Clearing the dictionary changes its membership count to zero.

Both local variables are deliberately kept alive through explicit KeepAlive calls. Consequently registered-owners:0 does not establish that the cycle is unreachable in this execution. The program demonstrates dictionary ownership and identity, not a collection event. In the interactive scenario, the root set is explicitly defined by the controls, so a disconnected cycle can be reclaimed by that model. Keep those two forms of evidence separate when explaining the lesson.

Prove a path in a real process

Start with a representative workload and a precise time window. Record the live type distribution after comparable lifecycle boundaries, then inspect roots of selected representative instances using a matching diagnostic tool. In dotnet-dump analysis, dumpheap can locate candidates and gcroot can explain why a particular object is still reachable. An address is valid in that dump's context; do not reuse it as a stable object identifier across later collections or different processes.

A root path is a clue to ownership, not a complete business verdict. A static singleton might intentionally retain configuration. A request task might legitimately retain a buffer until a slow client completes. Compare the observed lifetime with the intended contract. Capture enough surrounding context to distinguish a real cache policy failure from a snapshot taken during ordinary peak work.

Design an ownership-removal experiment

Choose one ownership change at a time. Remove an event subscription, evict a specific cache entry, or complete a pending request, then rerun the same workload. Verify the expected application state and compare a later heap at a comparable boundary. If the payload stays live, find another path rather than adding forced collections until a graph seems to disappear. The second root might be a task continuation, a diagnostic local, an interop handle or a different cache.

For an interview, distinguish type counts, shallow bytes, retained bytes and resident memory. Explain why a disconnected cycle is eligible while a single small reachable owner can retain a large subgraph. Then discuss shared ownership: two roots to one array mean removing either alone is insufficient. A convincing answer specifies the edge to remove and the evidence that would confirm all unintended paths ended.

Practice and review

Add a third dictionary owner to the portable program and remove the first two. Predict both membership and identity results. Then replace one dictionary value with a fresh unrelated node and explain why the old cycle can still be held by the locals. Avoid claiming collection based only on dictionary Count.

In the weak-reference lab, remove the strong target owner while leaving the weak observer alive. Step through mark and reclaim, inspect the dotted edge, then reset. Compare that with the disconnected cycle. Write a short diagnostic plan for a twelve-byte cache entry retaining a one-megabyte buffer, including how you would avoid double counting when an active request also holds the same backing array.

A working example

var roots = new Dictionary<string, Node>();
var left = new Node("left"); var right = new Node("right");
left.Next = right; right.Next = left;
roots["cache"] = left; roots["request"] = right;
roots.Remove("request");
Console.WriteLine($"owners:{roots.Count}");
Console.WriteLine($"cycle:{ReferenceEquals(roots["cache"].Next!.Next, left)}");
roots.Clear();
Console.WriteLine($"registered-owners:{roots.Count}");
GC.KeepAlive(left); GC.KeepAlive(right);
sealed class Node(string name) { public string Name = name; public Node? Next; }

Expected output

owners:1
cycle:True
registered-owners:0
Try this
Trace ownership before inferring collection. Expected portable output: owners:1 cycle:True registered-owners:0

Further reading

Interview questions for this lesson