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 ↗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.
Allocation is a flow; retention is a stock
Allocated bytes count storage handed out over an interval or process lifetime. Live bytes describe objects that remain owned at a particular observation boundary. A service can allocate many gigabytes over a minute while returning to a bounded live set after each burst. Another service can allocate slowly but retain each result forever. These are different failure patterns and require different experiments. A single graph labeled memory is insufficient to choose a fix.
Allocation still has costs even when objects die quickly: initialization, copying and collection work can affect throughput and latency. Conversely, eliminating an allocation by retaining a huge reusable graph can worsen the memory budget. Define the desired outcome in terms of the actual workload, including both steady state and bursts, before treating a lower allocation counter as universal progress.
Keep the measurement boundary honest
GC.GetAllocatedBytesForCurrentThread reports allocations attributed to the current thread. It does not measure native allocations, the live heap, or every allocation caused by a request that resumes on a different thread after await. A before/after subtraction across arbitrary asynchronous work can therefore mix unrelated activity and miss some of the operation's allocations. Keep a thread-local experiment synchronous or use an appropriate process-wide measurement and controlled workload.
Warmup, JIT compilation, lazy initialization, logging and result formatting can contaminate the measured interval. Print after taking the final counter. Preserve enough work that optimization does not eliminate the behavior under investigation, but recognize that adding an escape or retaining all results changes the workload. A benchmark that stores every result measures a different lifetime pattern from a production path that discards each result immediately.
Boxing and escape behavior
Boxing a value produces a boxed representation with independent value contents. The language-level identity and mutation behavior can be demonstrated without claiming an exact allocation count for every optimized program. Compilers and runtimes can remove or transform work when observable behavior permits, and different generic, interface and formatting paths can introduce additional operations. Investigate the compiled representative case rather than memorizing that one source construct always costs one fixed number of bytes.
An object array that stores boxes forces those values to remain available through that owner. A generic collection of a value type can avoid those particular boxes while still allocating its own backing array and resizing storage. A string formatting change can alter both temporary objects and final output. Compare complete behavior and verify equivalent results before attributing an improvement solely to a chosen syntax change.
Trace the portable boxed value
The sample starts with value equal to seven and stores a boxed copy in box. Assigning nine to the original value does not change the boxed seven, so value:9;boxed:7 is printed. Comparing box with itself is true. Comparing it with a separately boxed seven is false because those are distinct reference identities in this observable comparison.
The final line explicitly refuses to infer allocated bytes from those identities. The program demonstrates copying and reference identity, not a benchmark or a heap-size census. It does not measure object headers, runtime helper allocations or formatting overhead. If you change the sample to print fewer values or allow the box never to escape, you have changed the observation conditions, which is exactly why a careful allocation experiment needs a defined boundary.
Run the synchronous SDK interval
The desktop lab warms a non-inlined array-creation helper, takes a per-thread allocation counter, creates one thousand arrays retained in an outer array, and takes the counter again before formatting output. It reports the logical payload separately from interval allocation. The outer array, array headers and alignment explain why payload length is not the same quantity as allocated bytes. Exact values are runtime observations rather than portable expected output.
The process-wide total is printed afterward and covers a wider scope. Do not subtract it from the per-thread interval and call the difference a precise native-memory estimate. Both exclude native allocation accounting, and their time windows differ. The retained arrays are kept alive deliberately so the experiment has a defined ownership boundary; a different short-lived workload should be measured separately.
Design a comparison that can fail
Choose two implementations with identical validated results and a fixed input distribution. Record warmup policy, iteration count, runtime, GC mode and concurrency. Measure allocation throughput, completed operations and latency over comparable windows. Check surviving memory after a comparable lifecycle boundary. Include an adversarial input that changes sizes or cache hit rates so the result is not an accidental best case.
For an interview, explain how very high allocation can coexist with a stable live set and why a flat allocation rate can still feed a leak. Discuss synchronous thread-local counters versus async process-wide evidence. A strong answer distinguishes the metric's unit, scope and time interval, notes observer overhead, and avoids reporting one microbenchmark as proof of production tail latency under contention or cluster load.
Practice and review
Change the portable value before boxing rather than afterward and predict the exact output. Then compare a typed value collection with an object collection while preserving equivalent business results. Use a separate measured program rather than adding counters around Console.WriteLine and treating formatting as free.
Extend the SDK experiment with a no-retention variant that performs the same number of payload operations but discards each result after consuming it. Keep the consumption observable and explain how that changes the live set while allocation can remain similar. Finish by writing four separate questions: how much was allocated, how much survived, how much was committed, and how much process memory was resident.
Measure allocations without confusing live memory — CoreCLR experiment
Set up the SDK project
- Install .NET 10 SDK and unzip the project into a new directory.
- Read README.md and run dotnet run -c Release --project MemoryLab.csproj in this isolated process.
- 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.csproj
Download file ↗<Project Sdk="Microsoft.NET.Sdk"><PropertyGroup><OutputType>Exe</OutputType><TargetFramework>net10.0</TargetFramework><ImplicitUsings>enable</ImplicitUsings><Nullable>enable</Nullable></PropertyGroup></Project>
Program.cs
Download file ↗using System.Runtime.CompilerServices;
for (int i = 0; i < 2000; i++) _ = Allocate(32);
long before = GC.GetAllocatedBytesForCurrentThread();
var held = new byte[1000][];
for (int i = 0; i < held.Length; i++) held[i] = Allocate(32);
long allocated = GC.GetAllocatedBytesForCurrentThread() - before;
Console.WriteLine($"interval-allocated:{allocated};payload:{held.Length * 32}");
Console.WriteLine($"process-total:{GC.GetTotalAllocatedBytes(precise: false)}");
GC.KeepAlive(held);
[MethodImpl(MethodImplOptions.NoInlining)] static byte[] Allocate(int n) => new byte[n];
README.md
Download file ↗# Measure allocations without confusing live memory
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/api/system.gc.getallocatedbytesforcurrentthread?view=net-10.0
https://learn.microsoft.com/en-us/dotnet/api/system.gc.gettotalallocatedbytes?view=net-10.0
https://learn.microsoft.com/en-us/dotnet/core/diagnostics/built-in-metrics-runtime
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
int value = 7;
object box = value;
value = 9;
Console.WriteLine($"value:{value};boxed:{box}");
Console.WriteLine($"same-box:{ReferenceEquals(box, box)}");
Console.WriteLine($"new-box:{ReferenceEquals(box, (object)7)}");
Console.WriteLine("allocated-bytes:not-inferred-from-identity");
C# practice output
value:9;boxed:7 same-box:True new-box:False allocated-bytes:not-inferred-from-identity
Trace ownership before inferring collection. Expected portable output: value:9;boxed:7 same-box:True new-box:False allocated-bytes:not-inferred-from-identity