Memory & Garbage Collection · Lesson 1

Reachability and object graphs

Follow references from an owner instead of equating a missing variable name with reclaimed memory.

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 ↗

The memory contract

Managed garbage collection starts from reachability, not from whether a programmer has stopped thinking about an object. A live owner can retain a reference to another object, which can retain further objects. Following those edges produces an object graph. Runtime roots include references in active execution and other runtime-managed locations; an application collection becomes part of a retaining path when a root can reach that collection. Removing one reference does not necessarily remove every path to the target, and losing a path is not the same as observing an immediate collection.

A root reaches the cycle A to B to A. A separate cycle C to D to C has no path from a root.View full size ↗
Tracing reachability explains why an object cycle can be retained or become eligible for collection.
Read the diagram

In the left panel, a strong root points to A. A refers to B, and B refers back to A. Both objects are reachable from the root and are retained through that path.

In the right panel, C and D refer to one another, but neither can be reached from any root. A cycle alone does not keep these managed objects alive in a tracing collector.

Eligible for collection does not mean immediately reclaimed. Finalization and other runtime rules can affect reclamation; this conceptual graph is not a timing trace or a stack-versus-heap layout.

Illustration created with ImageGen for SharpRune.

Trace the actual program

The program constructs a child Node and a parent whose Child property refers to it. The owners list stores the parent. Setting the local child variable to null removes only that local edge; the parent's property still reaches the child, so the first output is child. Clearing the list produces the second output, 0, for its item count. The local parent remains explicitly live through GC.KeepAlive. Consequently this example does not claim that clearing the list makes either object unreachable or that any bytes were reclaimed.

How the mechanism works

Draw separate boxes for local reference slots, the list, and the two Node objects. The list's element is a reference to the parent, not a deep copy of it. The parent holds another reference to the child. Garbage collectors reason about paths supported by their runtime, not source-level variable names alone. Cycles are not automatically leaks in a tracing collector: an unreachable cycle can be reclaimed. The important question is whether a root still reaches the cycle, which is why ownership diagrams help explain retention more accurately than counting assignments to null.

Boundaries and misconceptions

A variable's textual scope is not a precise lifetime timer. JIT and interpreter behavior can differ, and a runtime may determine that a local is no longer needed before the closing brace or keep temporary references longer. KeepAlive deliberately extends the observable liveness requirement to its call. Do not infer physical heap layout, a collection count, or resident-memory changes from this program's list count. The same reachability reasoning is useful across CoreCLR and managed browser runtimes, but their collection algorithms and reporting tools are separate implementation questions.

Practice: change one ownership path

First remove only child = null; and predict that both original output lines remain unchanged. The parent-to-child edge was already sufficient. Next add a second list containing the same parent, clear owners, and print the second list's Count. Predict one retained entry. Hint: draw each container edge separately; clearing one container does not edit the other. State exactly which references remain before discussing eligibility, and do not label a zero collection count as proof that the runtime freed a Node.

Practice: test a second boundary

For a second exercise, set parent.Child = null; before the first WriteLine and replace the null-forgiving access with a null-safe display that prints no child. Predict that new label while the parent and owners still exist. Hint: removing the parent-to-child edge changes graph connectivity, whereas clearing owners changes a different edge. Then remove every intentional owner in a separate helper and describe the child as potentially collectible after the helper returns. Do not assert the exact instant collection occurs, and do not add a forced finalizer demonstration to manufacture a timetable.

Review the evidence

A sound explanation names roots, owners, objects, and reference edges individually. For the unchanged program, the observed facts are a reachable child and an emptied list, not a collector measurement. When investigating a real retention problem, identify the complete path that keeps the unwanted object alive and the component responsible for ending that relationship. Fixing ownership is more reliable than scattering null assignments throughout unrelated code, because it addresses the actual path through which long-lived application state retains the data.

  • The original output is child, followed by the owners count 0.
  • The local parent still reaches the child after the list is cleared.
  • Eligibility, collection timing, and operating-system memory are distinct observations.

A working example

using System;
using System.Linq;
using System.Collections.Generic;
using System.Text;
using System.Buffers;
using System.Runtime.InteropServices;
using System.Runtime.CompilerServices;

var child = new Node("child");
var parent = new Node("parent") { Child = child };
var owners = new List<Node> { parent };
child = null;
Console.WriteLine(owners[0].Child!.Name);
owners.Clear();
Console.WriteLine(owners.Count);
GC.KeepAlive(parent);
sealed class Node
{
    public string Name { get; }
    public Node? Child { get; set; }
    public Node(string name) { Name = name; }
}

Expected output

child
0
Try this
The original output is `child`, followed by the owners count `0`.

Further reading