Memory & Garbage Collection · Lesson 21

Minor collections and old-to-young references

Trace a write barrier, distinguish a collection set from the full graph, and test an old owner with a young child.

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 ↗
.NET 10 SDK / desktop CoreCLR

Read or download the complete SDK project, then follow its setup on your computer. The separate C# companion practice below runs in SharpRune’s .NET 9 console environment.

A collection has a boundary

A generational collector exploits a workload tendency: many allocations become unreachable quickly, while some graphs remain alive for much longer. A minor collection limits the region it tries to reclaim. It is therefore incorrect to draw the whole heap, remove one root, and assume every disconnected object disappears during the next Gen 0 collection. An older object may remain allocated until a collection that includes its generation. Any references it contains still matter to younger objects. Start every investigation by naming both the ownership graph and the collection set.

The interactive lab makes this distinction visible. Choose Old → young references, remove the cache root, and collect only Gen 0. Then collect Gen 2. The different results are consequences of the selected collection boundary, not proof that an unreachable old cache became an application root. The model conservatively scans older objects as remembered sources; it is not a visualization of an actual runtime card table.

Why reference writes need bookkeeping

Suppose a long-lived cache object stores a newly allocated payload. Following only stack roots and references inside the young collection area would miss that payload. CoreCLR maintains bookkeeping around relevant managed reference writes so a young collection can discover references coming from older storage. The write barrier is runtime machinery attached to reference updates; ordinary application code should use managed assignments rather than trying to maintain collector metadata itself. A remembered reference is evidence that a young object might still be needed, not evidence that the containing cache is useful to the application forever.

The cost of a collection is also not determined only by the number of bytes allocated since the previous one. Surviving objects, scanned references, promotion and remembered locations all contribute. A program that creates many short-lived independent byte arrays can behave differently from one that continually inserts small reference-rich nodes into an old collection. Compare the same request shape and retention policy before interpreting a change in collection frequency as a regression. A lower collection count can coexist with a larger retained heap.

Promotion is an observation, not an age clock

In the ordinary teaching model, surviving small objects advance toward older generations when their generation participates in collection. Real CoreCLR policies have additional details, including pinning-related behavior and collection decisions based on budgets and pressure. Generation does not tell you an object's wall-clock age, business importance, or remaining lifetime. A freshly created large object can be reported as generation 2, and an object that has lived for some time can remain young if the relevant collection has not occurred.

The external experiment deliberately promotes an owner before attaching a small child. A helper marked NoInlining creates the child without returning another retained local to the caller. A forced Gen 0 collection then observes the child through the owner. These calls are bounded educational instrumentation in a new process. They are not a recipe for calling GC.Collect after each request, nor a performance benchmark. Record the runtime and output rather than teaching one observed generation number as a language guarantee.

Trace the portable ownership change

The portable program gives the first payload two possible owners over its lifetime. Initially cache.Entry refers to first. Copying that reference into previous creates another strong path to the same instance. Replacing cache.Entry with second changes only the cache field; previous still refers to first. The first two output lines therefore identify second and first respectively. Setting cache.Entry to null makes the field empty, producing cache-empty:True, while GC.KeepAlive explicitly requires previous to remain live through its call.

Nothing in those three lines measures collection, promotion, object addresses or freed bytes. The program executes real reference assignments, but it does not introspect the collector. If you remove KeepAlive, source-level scope alone still does not establish exactly when the runtime stops reporting the local as live. Explain the result in terms of observable ownership and use a diagnostic experiment for generation claims.

Observe two different experiments

Run the portable program first and predict all three outputs. In the interactive model, separately remove the old-to-young edge while leaving the old root enabled. A Gen 0 collection can now reclaim the young entry because that path is absent; the old cache remains outside the reclamation set. Undo the change and inspect the retaining path. This separates a reference-write question from a root-removal question.

For the SDK experiment, save the effective runtime, GC mode and generation output. Repeat in a clean process. If a profiler reports unexpected promotion, inspect surviving types and roots under the actual workload. Do not explain a service-wide memory rise using only one object's generation. That object may be representative, incidental, or held alive by the diagnostic code that was added to inspect it.

Design a cache investigation

Define a maximum count or byte budget and an explicit eviction boundary before choosing a different GC mode. Measure incoming allocations, retained entries after an idle interval, and the ownership of entries currently being processed. A cache entry removed from the dictionary can remain live through an active request. Conversely, retaining a key does not necessarily mean retaining every historical value if updates replace references correctly. Check both paths and the shape of the stored values.

For an interview, explain why the old cache is not searched from scratch in exactly the same way during every minor collection, then explain why bookkeeping must still preserve young targets. A strong answer separates reachability, the current collection set and reclamation timing. Avoid claims that a write barrier makes the reference thread-safe or publishes a compound business invariant: collector bookkeeping and application synchronization solve different problems.

Practice and review

Modify the portable program so previous points to second after the cache update. Predict the changed name without adding a collection. Then clear previous as well and explain which ownership facts can still be observed and which physical-memory claims remain unproven. Add a second cache field and show why clearing only one field does not remove every path.

In the lab, compare Gen 0, Gen 1 and Gen 2 with the same graph. Write down the candidate set before each run. Your final explanation should distinguish an application root, an older remembered source, an ordinary surviving object and an object outside the selected collection. That vocabulary makes later LOH, pinning and diagnostic lessons much easier to reason about.

.NET 10 SDK / desktop CoreCLR

Minor collections and old-to-young references — CoreCLR experiment

Set up the SDK project

  1. Install .NET 10 SDK and unzip the project into a new directory.
  2. Read README.md and run dotnet run -c Release --project MemoryLab.csproj in this isolated process.
  3. Record the runtime, effective configuration and full output. Repeat the same workload; numeric observations are not universal expected values.

Download complete SDK project ↗

Project files

MemoryLab.csprojDownload file ↗
<Project Sdk="Microsoft.NET.Sdk"><PropertyGroup><OutputType>Exe</OutputType><TargetFramework>net10.0</TargetFramework><ImplicitUsings>enable</ImplicitUsings><Nullable>enable</Nullable></PropertyGroup></Project>
Program.csDownload file ↗
using System.Runtime.CompilerServices;
var owner = new Holder();
GC.Collect(2, GCCollectionMode.Forced, blocking: true, compacting: true);
Console.WriteLine($"owner-before:{GC.GetGeneration(owner)}");
Install(owner);
GC.Collect(0, GCCollectionMode.Forced, blocking: true);
Console.WriteLine($"child-alive:{owner.Child is not null};child-generation:{GC.GetGeneration(owner.Child!)}");
owner.Child = null;
Console.WriteLine($"owner-empty:{owner.Child is null}");
GC.KeepAlive(owner);
[MethodImpl(MethodImplOptions.NoInlining)] static void Install(Holder h) => h.Child = new byte[1024];
sealed class Holder { public byte[]? Child; }
README.mdDownload file ↗
# Minor collections and old-to-young references

Environment: .NET 10 SDK / desktop CoreCLR

Run `dotnet run -c Release --project MemoryLab.csproj` with .NET 10 SDK.

This is an isolated desktop CoreCLR experiment. It may deliberately request bounded collections for observation. Never add these forced collection calls to a production request path. Numeric counters, generations, addresses and RSS vary. The portable SharpRune program is separate.

Primary references:
https://learn.microsoft.com/en-us/dotnet/standard/garbage-collection/fundamentals
https://learn.microsoft.com/en-us/dotnet/api/system.gc.collect?view=net-10.0
https://learn.microsoft.com/en-us/dotnet/api/system.gc.keepalive?view=net-10.0

Expected result

The bounded program exits successfully and prints genuine CoreCLR observations. Counts, generations, heap sizes and timings vary; compare ownership invariants and matching observation windows rather than hardcoded measurements.

Use the listed SDK and dependencies for this project. Open C# practice runs the separate portable exercise.

C# companion practice

var cache = new Cache();
cache.Entry = new Payload("first");
var previous = cache.Entry;
cache.Entry = new Payload("second");
Console.WriteLine($"current:{cache.Entry.Name}");
Console.WriteLine($"previous:{previous.Name}");
cache.Entry = null;
Console.WriteLine($"cache-empty:{cache.Entry is null}");
GC.KeepAlive(previous);
sealed class Cache { public Payload? Entry; }
sealed record Payload(string Name);
Open C# practice

C# practice output

current:second
previous:first
cache-empty:True
Try this
Trace ownership before inferring collection. Expected portable output: current:second previous:first cache-empty:True

Further reading

Interview questions for this lesson