Allocation rate and time in GC as pressure metrics, practical reduction techniques, GCConserveMemory, AddMemoryPressure for native allocations, and RefreshMemoryLimit.
GC pressure is how much work your code forces the GC to do: driven by allocation rate (how fast you allocate), survival (how much of that stays alive and is promoted) and heap size (how much there is to mark). Measure it with dotnet-counters (% Time in GC, allocation rate, Gen 0/1/2 counts, heap sizes) and GC traces. Reduce it by allocating less in hot paths (spans, pooling, streaming, avoiding boxing and LINQ in tight loops), shortening or stabilising lifetimes, and shrinking long-lived object graphs. Separately, GC.AddMemoryPressure tells the GC about native memory owned by small managed objects so it collects them in time; pair it with RemoveMemoryPressure.
"GC pressure is how much work my code forces the garbage collector to do. It has three drivers: allocation rate (how often Gen 0 fills up), survival (how much of that is promoted, which drives Gen 1/Gen 2 work), and the size and shape of the live heap (how much there is to mark in a full GC). I measure it with
dotnet-counters(% time in GC, allocation rate, collections per generation, heap sizes) and GC traces, and I find the source with allocation profiling. To reduce it I fix the top allocating stacks in hot paths: spans instead of substrings, streaming instead of buffering, pooling only where it pays, no LINQ or boxing in tight loops. I also shorten or stabilise lifetimes and shrink long-lived graphs. Separately,GC.AddMemoryPressuretells the GC that a small managed object owns a lot of native memory, so it gets collected in time. It must always be balanced withRemoveMemoryPressure, and it's a safety net, not a substitute forDispose."
| Driver | Metric | What it causes |
|---|---|---|
| Allocation rate | Bytes allocated/sec | Gen 0 GC frequency |
| Survival / promotion | Promoted bytes per GC, Gen 1/Gen 2 counts | Cost per GC, Gen 2 frequency |
| Live heap size and graph shape | Gen 2 size, object and reference count | Cost of full GCs, memory footprint |
% time in GC (or GCMemoryInfo.PauseTimePercentage) summarises them: low single digits is typical; double digits on a hot service deserves investigation.
| Signal | Interpretation | First action |
|---|---|---|
| Time in GC low (< ~5%) | Healthy | Leave it |
| High time in GC + high allocation rate | Churn | Allocation profiling of hot paths |
| High time in GC + low allocation rate | Big live heap or many Gen 2 GCs | Heap snapshots, lifetimes, GC reasons |
| Memory high, time in GC low | Memory cost problem, not CPU | GC mode, DATAS, GCConserveMemory, smaller graphs |
Frequent Gen 2 with reason AllocLarge |
LOH churn | Pool/stream large buffers |
Frequent Induced GCs |
Someone calls GC.Collect or unbalanced AddMemoryPressure |
Find the caller |
dotnet-counters monitor -p <pid> --counters System.Runtime under realistic load.dotnet-trace collect --profile gc-verbose (allocation sampling) or Visual Studio allocation tracking → top allocating call stacks.[MemoryDiagnoser] on the hot method, before and after.| Technique | Example |
|---|---|
| Fix the top few allocation sites | Usually 3–5 call stacks account for most bytes |
| Stream instead of buffer | JsonSerializer.SerializeAsync(stream), PipeReader, IAsyncEnumerable<T> |
Spans and stackalloc |
Tokenising without Split/Substring |
| Pool where it pays | ArrayPool<T>, RecyclableMemoryStream, ObjectPool<StringBuilder> |
| Avoid boxing and closures | Generic APIs, static lambdas, concrete collection types |
| No LINQ in the hottest loops | Plain loops in scoring/serialisation code |
| Pre-size collections | new List<T>(capacity) |
| Better logging | Message templates, LoggerMessage source generator |
| Shrink long-lived graphs | Arrays of structs, frozen collections, IDs instead of object references |
| Bounded, compact caches | MemoryCache with SizeLimit and small entries |
| Setting | Effect |
|---|---|
| GC mode (Workstation / Server / DATAS) | Memory vs throughput (see Server GC question) |
GCConserveMemory (0–9) |
Compacts more aggressively to reduce fragmentation and heap size, using more GC CPU |
GCHeapHardLimit / ...Percent |
Caps the managed heap |
GC.RefreshMemoryLimit() (.NET 8+) |
Re-reads limits when a container's memory limit changes at run time |
These change how the GC spends effort; they don't reduce the work your code creates.
GC.AddMemoryPressureProblem: a 100-byte managed wrapper owns 50 MB of native memory. The GC sees 100 bytes, so it has no reason to collect the wrapper soon, and native memory grows until the process is killed.
| Do | Don't |
|---|---|
AddMemoryPressure(bytes) when the native allocation is made |
Use it for small allocations |
RemoveMemoryPressure(bytes) with the same amount when freed |
Forget to remove it (the GC then over-collects) |
Still dispose deterministically (SafeHandle) |
Treat it as a replacement for Dispose |
For scarce handles rather than bytes, HandleCollector plays a similar role.
Our [search API] spent [about 18%] of CPU in GC, with a high Gen 0 rate and p99 latency over the SLO under load. An allocation trace showed the top allocators were string.Split in the query and document tokeniser, intermediate List<T>s from LINQ chains in the scoring loop, and boxed comparers. I rewrote the tokeniser with ReadOnlySpan<char> and MemoryExtensions.Split into ranges, replaced LINQ in the scoring loop with plain loops over a preallocated result buffer, and made the comparer a generic struct. Allocation per request dropped by [about 80%], time in GC went below [4%], and p99 fell by [about 30%]. I added an allocation-per-request budget to our BenchmarkDotNet suite so regressions fail CI.
The interviewer checks practical performance engineering around the GC.
Green flags: allocation rate, survival and heap size as pressure components, time in GC, a measurement workflow, targeted techniques, GCConserveMemory, correct use of AddMemoryPressure, RefreshMemoryLimit.
Red flags: only knows GC.Collect; pools everything; confuses AddMemoryPressure with allocating memory.
Gen 0 GCs per second ≈ allocation rate / Gen 0 budget
cost per young GC ≈ roots + dirty cards + survivors copied
Gen 2 inflow ≈ allocation rate × Gen 0 survival × Gen 1 survival
full GC cost ≈ live objects + references in the whole heap
This is why you can have:
// 1 million objects, 1 million references to mark and possibly update
List<Point> points; // class Point { double X, Y; }
// 1 object, no references inside: marking is trivial
Point[] points; // struct Point { double X, Y; }
For large long-lived datasets (lookup tables, in-memory indexes), arrays of structs, FrozenDictionary, or storing integer IDs instead of object references reduce full GC cost and card-marking work.
GCMemoryInfo info = GC.GetGCMemoryInfo();
Console.WriteLine($"Pause time %: {info.PauseTimePercentage:N2}");
Console.WriteLine($"Heap: {info.HeapSizeBytes / 1_048_576} MB, fragmented {info.FragmentedBytes / 1_048_576} MB");
Console.WriteLine($"Promoted last GC: {info.PromotedBytes / 1024} KB");
Console.WriteLine($"Total allocated: {GC.GetTotalAllocatedBytes() / 1_048_576} MB");
Console.WriteLine($"Total pause: {GC.GetTotalPauseDuration().TotalMilliseconds:N0} ms");
Exporting these per process to your metrics system makes regressions visible after deploys.
ObjectPool done rightvar provider = new DefaultObjectPoolProvider();
ObjectPool<StringBuilder> pool = provider.CreateStringBuilderPool(initialCapacity: 256, maximumRetainedCapacity: 4096);
StringBuilder sb = pool.Get();
try
{
sb.Append("order:").Append(orderId);
return sb.ToString();
}
finally
{
pool.Return(sb); // the policy clears it and drops oversized builders
}
Pool expensive or large objects. Pooled objects live in Gen 2; references from them to new objects dirty cards. Pooling tiny objects usually costs more than it saves.
AddMemoryPressure influences the GCThe runtime tracks added and removed pressure. When added pressure grows significantly relative to the managed heap and to what has been removed, the GC may trigger collections itself so that wrappers owning native memory get finalised or noticed sooner. Consequences:
Add without Remove → the GC thinks memory is permanently high → excessive GCs.Dispose/SafeHandle.GCConserveMemory in practiceSystem.GC.ConserveMemory in runtimeconfig.json or DOTNET_GCConserveMemory (hex).| Version | Change |
|---|---|
| .NET Framework 2.0 | GC.AddMemoryPressure, HandleCollector |
| .NET Core 3.0 | GC.GetGCMemoryInfo, container-aware limits |
| .NET 5 | PauseTimePercentage, PromotedBytes and more on GCMemoryInfo |
| .NET 6 | GCConserveMemory |
| .NET 7 | GC.GetTotalPauseDuration() |
| .NET 8 | GC.RefreshMemoryLimit(), frozen collections, DATAS opt-in |
| .NET 9 | MemoryExtensions.Split enumerator over spans; DATAS default for Server GC |
| .NET 10 | Further JIT escape analysis reduces some allocations automatically |
[MemoryDiagnoser].List<PointClass> and PointStruct[], keep them alive, run GC.Collect() several times, and compare GC.GetTotalPauseDuration() deltas.Dispose or AddMemoryPressure, and watch working set; then add AddMemoryPressure plus a finaliser-backed SafeHandle and compare.AddMemoryPressure without RemoveMemoryPressure repeatedly and watch GC counts climb.PauseTimePercentage and allocation rate from a sample API to dotnet-counters or OpenTelemetry and compare before/after an optimisation.using System.Diagnostics;
using System.Runtime.InteropServices;
string text = string.Join(' ', Enumerable.Range(0, 2_000).Select(i => $"word{i % 50}"));
Run("Split + LINQ", () => text.Split(' ').Where(w => w.EndsWith('7')).Select(w => w.Length).Sum());
Run("Span tokeniser", () =>
{
int sum = 0;
foreach (Range r in text.AsSpan().Split(' ')) // .NET 9+: enumerates ranges without allocating
{
ReadOnlySpan<char> word = text.AsSpan()[r];
if (word.Length > 0 && word[^1] == '7') sum += word.Length;
}
return sum;
});
// Native memory owned by a small managed object: tell the GC about it
using (var image = new NativeImage(50 * 1024 * 1024))
Console.WriteLine($"Native image of {image.Size / 1024 / 1024} MB allocated");
static void Run(string name, Func<int> work)
{
work();
TimeSpan pauseBefore = GC.GetTotalPauseDuration();
long allocBefore = GC.GetTotalAllocatedBytes();
var sw = Stopwatch.StartNew();
long total = 0;
for (int i = 0; i < 5_000; i++) total += work();
sw.Stop();
Console.WriteLine($"{name,-15} {sw.ElapsedMilliseconds,5} ms, allocated {(GC.GetTotalAllocatedBytes() - allocBefore) / 1024 / 1024,5} MB, " +
$"GC pauses {(GC.GetTotalPauseDuration() - pauseBefore).TotalMilliseconds:N0} ms (sum {total})");
}
public sealed unsafe class NativeImage : IDisposable
{
private void* _buffer;
public long Size { get; }
public NativeImage(long size)
{
Size = size;
_buffer = NativeMemory.Alloc((nuint)size);
GC.AddMemoryPressure(size); // GC now knows this small object is expensive
}
public void Dispose()
{
if (_buffer == null) return;
NativeMemory.Free(_buffer);
_buffer = null;
GC.RemoveMemoryPressure(Size); // always balance
}
}
Requires <AllowUnsafeBlocks>true</AllowUnsafeBlocks> for the pointer field. In production, wrap native memory in a SafeHandle so it is released even if Dispose is missed.
What happens: the whole codebase is rewritten with spans and pools. Why it is wrong: most code isn't hot; readability and maintainability suffer for no gain. Do instead: profile and fix the top allocating stacks in hot paths only.
AddMemoryPressureWhat happens: pressure is added in the constructor but never removed. Why it is wrong: the GC believes native memory is still in use and over-collects. Do instead: remove exactly the same amount when the native memory is freed.
AddMemoryPressure as memory managementWhat happens: a wrapper relies on the GC to free native memory because pressure was added.
Why it is wrong: it only influences scheduling; memory is freed only by your release code.
Do instead: dispose deterministically and use SafeHandle as the safety net.
What happens: DTOs and small lists come from custom pools. Why it is wrong: pooled objects live in Gen 2, dirty cards, and invite use-after-return bugs. Do instead: pool large buffers and expensive objects only.
What happens: allocation is cut but time in GC barely changes. Why it is wrong: the cost may come from a huge live heap or frequent Gen 2 GCs. Do instead: check survival, Gen 2 rate and heap shape too.
GC.Collect to relieve pressureWhat happens: forced collections after heavy operations. Why it is wrong: adds blocking full GCs and promotes live data. Do instead: reduce allocations and lifetimes; let the GC schedule itself.
What happens: GCgen0size, ConserveMemory or modes are changed first.
Why it is wrong: settings move cost around; the work your code creates is unchanged.
Do instead: fix hot paths first, tune settings with evidence after.
What happens: millions of small class instances kept in memory as a lookup table. Why it is wrong: every full GC has to mark them all. Do instead: arrays of structs, frozen collections, IDs instead of references.
What happens: a benchmark shows fewer bytes per op, so the change ships. Why it is wrong: real impact depends on concurrency, lifetimes and request mix. Do instead: confirm with load tests: time in GC, allocation rate and p99.
What happens: a regression adds 50% more allocation per request unnoticed. Why it is wrong: GC pressure creeps up release by release. Do instead: track allocation per request in CI benchmarks and production dashboards.
GC pressure is a budget to manage, like CPU or I/O.
| Signal | Interpretation | First action |
|---|---|---|
| Time in GC under ~5% | Healthy | None |
| High time in GC, high allocation rate | Churn | Allocation profiling of hot paths |
| High time in GC, low allocation rate | Big live heap or Gen 2 pressure | Heap snapshots, lifetimes |
| Memory high but GC time low | Memory cost problem | GC mode, GCConserveMemory, smaller graphs |
Seniors treat allocation rate per request as a performance KPI and track it across releases.
Each thread allocates small objects from its own allocation context, a chunk of Gen 0 memory, by bumping a pointer: allocation is a few instructions and needs no lock. When the context is used up the thread gets a new chunk; when the total allocated since the last GC exceeds the Gen 0 budget (sized dynamically, related to CPU cache sizes and GC mode), a Gen 0 GC runs. It marks survivors, promotes them to Gen 1 and frees the rest. Gen 0 GCs are short, but they still pause threads, so a high allocation rate means many pauses. Reduce them by allocating less: spans, pooling, avoiding LINQ and boxing in hot paths, and reusing buffers.
First I measure: dotnet-counters for allocation rate and LOH size, then an allocation profiler to see which arrays dominate. Short-lived large arrays are the worst case, because anything of 85,000 bytes or more lands on the Large Object Heap, which is only collected in gen 2. The fixes are to reuse buffers with ArrayPool<T>.Shared (always return them, use only the requested length, clear sensitive data), use spans instead of copying slices, and stream data instead of building giant arrays. For speed I keep data in contiguous arrays of structs for cache locality and use the vectorised CopyTo/Fill/TensorPrimitives APIs instead of hand-written loops. Then I prove it with BenchmarkDotNet and compare production counters.
Objects of 85,000 bytes or more (in practice large arrays and strings) are allocated on the LOH. It is collected only during Gen 2 GCs and, by default, is swept, not compacted, so freed space becomes free-list gaps. Two problems follow: churn (frequent large temporary allocations exceed the LOH budget and trigger Gen 2 GCs) and fragmentation (free gaps too small for new large objects, so the heap grows). Fix with `ArrayPool<T>`, RecyclableMemoryStream, streaming instead of buffering, chunked processing, and sizing buffers to stay under the threshold. One-off compaction is available through GCSettings.LargeObjectHeapCompactionMode.
How to tell it (STAR)
Our [search API] spent [18%] of CPU in GC. Allocation profiling pointed at splitting and LINQ in the tokeniser. After switching to spans and loops, time in GC dropped below [4%] and p99 improved by [30%].
Last updated 31 Oct 2026.