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
A memory investigation should connect an application symptom to a particular lifetime and a testable hypothesis. Start by reproducing the workload and defining correct output. Then distinguish allocation churn, unwanted retention, collector pauses, and unmanaged-resource exhaustion. Inspect the owners capable of retaining the data, fix the responsible relationship, and measure the target again without changing the workload. This capstone uses real managed cache and callback ownership, not a simulated collector or an invented physical heap map. Its counters describe only its own collections.
Trace the actual program
WorkSession adds two Payload objects, each referenced by the cache and by a callback closure. The first Describe output is cache:2;callbacks:2. End clears both owner collections, so the next output is cache:0;callbacks:0. Calling End again preserves those zeros, demonstrating an idempotent release-of-membership boundary. The session and empty collection containers still exist, and this code does not print collection counts or reclaimed bytes. End also does not permanently forbid later Add calls; that would be an additional state-machine rule, not a guarantee shown by this sample.
How the mechanism works
Draw two retaining routes for each payload: session to cache to payload, and session to callback list to delegate capture to payload. Clearing only one route can leave the other intact. This explains why a superficially plausible cache fix can fail to end retention. Runtime roots may reach the session through a screen, service, static field, or active operation in a larger application. Verify that route with supported target evidence when necessary, but first make the application ownership contract and its end transition explicit enough to test directly.
Boundaries and misconceptions
A zero owner count is not a byte measurement, and a lower allocation counter does not prove every unwanted root disappeared. Instrumentation can itself hold references, and a profiler snapshot is tied to its runtime and capture time. Do not force collections merely to make a screenshot look better, or suppress required report rows to reduce memory. A valid correction preserves the intended result, releases the unwanted relationship, and shows appropriate target-specific improvement under a comparable workload with limits and variability recorded.
Practice: change one ownership path
First change End to clear only the cache in a separate copy and predict cache:0;callbacks:2 afterward. Hint: callback closures still retain their payload references even though no cache key remains. Restore the original, then clear only callbacks and predict the opposite membership counts. Use those two failing lifecycle assertions to explain why the complete boundary needs both actions. Avoid calling the collector to compensate for a route that the application still owns deliberately.
Practice: test a second boundary
Next add a state flag that rejects Add after the session has ended, if that matches your chosen domain contract. Test normal ending, repeated ending, and a rejected later add. Hint: validate the state before allocating and storing a new payload. Then create a bounded repeated scenario with correctness and owner-count checks at every end boundary. On the actual target, measure the symptom that motivated the work, keeping allocation traffic, retained objects, and process memory as separate reported observations rather than interchangeable pass criteria.
Review the evidence
Finish the investigation with a concise record of runtime, workload, expected result, suspected owner path, lifecycle correction, verification, and measurement limitations. The exact baseline lines prove that both collections are cleared and repeated End is safe; they do not certify immediate collection. Apply the same disciplined separation to static caches, events, pooled buffers, and external handles. Good memory work makes ownership and evidence more precise instead of replacing every uncertainty with another GC.Collect call.
- Two independent application paths retain each payload before End.
- Clearing both memberships is a lifecycle assertion, not a collector simulation.
- The final claim must preserve correctness and name the runtime and metric limitations.
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 session = new WorkSession();
session.Add("A");
session.Add("B");
Console.WriteLine(session.Describe());
session.End();
Console.WriteLine(session.Describe());
session.End();
Console.WriteLine(session.Describe());
sealed class WorkSession
{
private readonly Dictionary<string, Payload> cache = new(StringComparer.Ordinal);
private readonly List<Action> callbacks = new();
public void Add(string name)
{
var payload = new Payload(name, new byte[32]);
cache[name] = payload;
callbacks.Add(() => Console.WriteLine(payload.Name));
}
public void End() { callbacks.Clear(); cache.Clear(); }
public string Describe() => $"cache:{cache.Count};callbacks:{callbacks.Count}";
}
record Payload(string Name, byte[] Data);
Expected output
cache:2;callbacks:2 cache:0;callbacks:0 cache:0;callbacks:0
Two independent application paths retain each payload before End.