Memory & Garbage Collection · Lesson 32

A complete memory incident investigation

Correlate counters, surviving types, retaining paths, native memory and latency before choosing a fix.

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.

Begin with a falsifiable incident question

Memory investigations go faster when they begin with a concrete observation and a hypothesis that can be disproved. For example: after each identical batch completes, retained Order payloads increase and the cache count never falls. That is more actionable than memory seems high. Record runtime, process identity, deployment, quota, workload phase and timestamps before collecting more evidence. Different replicas or restarts can otherwise make unrelated samples appear to form one trend.

Separate an immediate capacity response from the root-cause experiment. Reducing admission can preserve service while you investigate, but it does not prove which graph retained memory. A restart can restore capacity temporarily while destroying useful evidence. Choose the least disruptive diagnostic that answers the current question, and preserve the conditions needed to reproduce the issue in a controlled environment.

Align counters before interpreting them

Process working set, GC heap snapshots, cumulative allocation counters and pause-duration counters have different scopes and update semantics. A last-collection heap value may remain unchanged while current process memory rises. A cumulative allocated-byte counter rises even when all recent temporary objects die. Compare rates or deltas over a clearly defined interval and record the collection index where relevant.

Current .NET runtime metrics expose separate observations for heap size including fragmentation, fragmentation by generation, committed GC memory and process working set. Those values cannot be substituted for one another. If resident process memory grows while comparable managed live evidence remains flat, investigate native and other process allocations rather than declaring the collector defective. Conversely, a working set plateau can hide a growing managed graph that has not yet exhausted available capacity.

Use graphs to explain retained types

Start with type distribution and representative instances, then inspect retaining paths. A large byte-array total might belong to active requests, a pool, a cache or a native interop workflow. Those owners imply different fixes. A type histogram tells you what exists, not why it exists or whether its lifetime is wrong. A root path can establish ownership but still needs comparison with the intended business boundary.

Use tools compatible with the target runtime and platform. dotnet-counters provides low-friction metrics, dotnet-dump and SOS support deeper object and root analysis, and dotnet-gcdump reconstructs managed graph evidence using a collection-triggering process. Tool availability, overhead and exact commands vary. A collection-triggering diagnostic changes the process being observed, so label that intervention rather than presenting it as a passive continuous snapshot.

Trace the synthetic incident fixture

The portable program compares two deliberately synthetic snapshots. Process memory rises from eighty to one hundred forty MiB, a delta of sixty. Managed live memory rises from forty to forty-two MiB, a delta of two. The fragmented value is unchanged in this fixture. The output suggests inspecting native and other process memory while confirming a matching observation window.

That suggestion is a next investigation step, not a definitive diagnosis from three numbers. The fixture does not subtract managed live bytes from working set and claim the remainder is exactly native allocations. Shared pages, GC committed space, sampling differences and other categories complicate that accounting. Its final line explicitly labels the inputs as synthetic so they cannot be mistaken for measurements of the user's device or this application's current health.

Capture a real bounded observation

The SDK lab retains eight large arrays, requests a bounded educational collection and prints a JSON record containing timestamp, runtime, PID, working set, GC index, heap size, fragmentation, committed bytes and cumulative allocation. The arrays remain deliberately live through the observation. Exact numbers are not asserted, and the explanation distinguishes process-resident memory from last-collection GC fields.

For a real incident, collect evidence at comparable lifecycle points before and after a controlled workload. If the target is containerized, include quota and neighboring process activity where relevant. Diagnostic artifacts can contain application data, so use the project's approved storage and access practices. A dump from the wrong replica or after a restart can be technically valid yet answer the wrong incident question.

Design the fix and its verification

Choose the observed ownership defect or allocation bottleneck explicitly. An unbounded cache needs a retention policy; a slow finalizer needs a resource-release investigation; excessive temporary copies may need a pipeline change; native growth needs native evidence. Apply one meaningful change and rerun the same workload. Verify correctness, peak memory, recovery after idle, throughput and latency rather than selecting only the chart that improved.

For an interview, present your evidence chain: symptom, aligned measurements, representative retained types, root path, intended lifetime, intervention and repeatable result. Explain alternative hypotheses and which observation would distinguish them. A strong answer admits when managed evidence is insufficient to explain a process-memory delta, and it does not use a local microbenchmark as proof that a clustered production system has been load-tested.

Practice and review

Change the synthetic fixture so managed live memory rises almost as much as process memory. Explain why retained managed graphs become a more plausible next target without asserting causation from correlation alone. Then keep managed live flat and increase only the process measurement, listing at least three nonmanaged or accounting explanations worth checking.

Write an incident worksheet for two replicas with different traffic partitions. Include deployment identity, timestamps, workload counts, GC indexes, allocation deltas, retained-type evidence and the exact ownership edge you plan to change. Finish by defining a passing recovery condition after an idle interval and a failing condition under renewed load, so the investigation ends with a testable improvement rather than a reassuring screenshot.

.NET 10 SDK / desktop CoreCLR

A complete memory incident investigation — 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.Diagnostics;
using System.Text.Json;
var held = Enumerable.Range(0, 8).Select(_ => new byte[100_000]).ToArray();
GC.Collect(2, GCCollectionMode.Forced, blocking: true);
var info = GC.GetGCMemoryInfo();
using var process = Process.GetCurrentProcess();
Console.WriteLine(JsonSerializer.Serialize(new { observedAt = DateTimeOffset.UtcNow, runtime = System.Runtime.InteropServices.RuntimeInformation.FrameworkDescription, pid = Environment.ProcessId, workingSet = process.WorkingSet64, gcIndex = info.Index, heap = info.HeapSizeBytes, fragmented = info.FragmentedBytes, committed = info.TotalCommittedBytes, allocated = GC.GetTotalAllocatedBytes(false) }));
Console.WriteLine("Different scopes: working set is process resident memory; GC fields describe the latest collection.");
GC.KeepAlive(held);
README.mdDownload file ↗
# A complete memory incident investigation

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/core/diagnostics/debug-memory-leak
https://learn.microsoft.com/en-us/dotnet/core/diagnostics/dotnet-counters
https://learn.microsoft.com/en-us/dotnet/core/diagnostics/dotnet-dump
https://learn.microsoft.com/en-us/dotnet/core/diagnostics/dotnet-gcdump

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 baseline = new Snapshot(80, 40, 16);
var current = new Snapshot(140, 42, 16);
Console.WriteLine($"process-delta-MiB:{current.Process - baseline.Process}");
Console.WriteLine($"managed-live-delta-MiB:{current.Live - baseline.Live}");
Console.WriteLine("next:inspect-native-and-other-process-memory;confirm-matching-time-window");
Console.WriteLine("values:synthetic-incident-fixture");
record Snapshot(int Process, int Live, int Fragmented);
Open C# practice

C# practice output

process-delta-MiB:60
managed-live-delta-MiB:2
next:inspect-native-and-other-process-memory;confirm-matching-time-window
values:synthetic-incident-fixture
Try this
Trace ownership before inferring collection. Expected portable output: process-delta-MiB:60 managed-live-delta-MiB:2 next:inspect-native-and-other-process-memory;confirm-matching-time-window values:synthetic-incident-fixture

Further reading

Interview questions for this lesson