📁 Sangam Dhakal - Portfolio v2.0 - Netscape Navigator 4.0
_
□
×

📚 Interview question

What is GC pressure in .NET, how do you measure it, and how do you reduce it (including GC.AddMemoryPressure for native memory)?

Allocation rate and time in GC as pressure metrics, practical reduction techniques, GCConserveMemory, AddMemoryPressure for native allocations, and RefreshMemoryLimit.

Senior Theory Advanced allocation gc gc-pressure native-memory performance

🎯 Key points

  • GC pressure comes from allocation rate, survival and live heap size
  • Measure with % time in GC, allocation rate, generation counts and heap sizes
  • Fix the top allocating stacks in hot paths with spans, streaming and selective pooling
  • Shorten or stabilise object lifetimes and shrink long-lived graphs
  • GCConserveMemory trades CPU for a smaller heap; RefreshMemoryLimit re-reads limits (.NET 8+)
  • GC.AddMemoryPressure informs the GC about native memory; always balance with RemoveMemoryPressure

⏱ 30-second answer

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.

🗣 Full answer (senior)

How I would answer this in an interview

"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.AddMemoryPressure tells the GC that a small managed object owns a lot of native memory, so it gets collected in time. It must always be balanced with RemoveMemoryPressure, and it's a safety net, not a substitute for Dispose."


1. The three drivers

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.

2. Diagnosing which driver dominates

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

3. Measurement workflow

  1. dotnet-counters monitor -p <pid> --counters System.Runtime under realistic load.
  2. dotnet-trace collect --profile gc-verbose (allocation sampling) or Visual Studio allocation tracking → top allocating call stacks.
  3. BenchmarkDotNet with [MemoryDiagnoser] on the hot method, before and after.
  4. Re-run the load test; compare time in GC, allocation rate and p99 latency.

4. Reduction techniques, by typical impact

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

5. Runtime settings (memory/CPU trade-offs)

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.

6. Native memory: GC.AddMemoryPressure

Problem: 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.

Production example

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.

🔍 What the interviewer is checking

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.

📚 Learn in depth

1. Why each driver costs what it costs

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:

  • a service allocating 1 GB/s at 2% time in GC (almost nothing survives), and
  • a service allocating 50 MB/s at 15% (a huge, reference-heavy live heap with frequent Gen 2 GCs).

2. Graph shape matters: references cost marking work

// 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.

3. Reading GC pressure in-process

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.

4. ObjectPool done right

var 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.

5. How AddMemoryPressure influences the GC

The 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:

  • Unbalanced Add without Remove → the GC thinks memory is permanently high → excessive GCs.
  • Many tiny calls → overhead for no benefit.
  • It doesn't free anything by itself: the native memory is released only by your Dispose/SafeHandle.

6. GCConserveMemory in practice

  • Range 0 (default) to 9; higher means the GC compacts Gen 2 more readily when fragmentation is high.
  • Useful for memory-dense deployments (many pods per node) where a few percent more GC CPU is acceptable.
  • Set via System.GC.ConserveMemory in runtimeconfig.json or DOTNET_GCConserveMemory (hex).

7. Version notes

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

8. Try it yourself

  1. Run the code example; compare allocated MB and GC pause time for the LINQ and span tokenisers.
  2. Put both tokenisers in BenchmarkDotNet with [MemoryDiagnoser].
  3. Build a 1-million-element List<PointClass> and PointStruct[], keep them alive, run GC.Collect() several times, and compare GC.GetTotalPauseDuration() deltas.
  4. Allocate 200 MB of native memory per wrapper in a loop without Dispose or AddMemoryPressure, and watch working set; then add AddMemoryPressure plus a finaliser-backed SafeHandle and compare.
  5. Call AddMemoryPressure without RemoveMemoryPressure repeatedly and watch GC counts climb.
  6. Export PauseTimePercentage and allocation rate from a sample API to dotnet-counters or OpenTelemetry and compare before/after an optimisation.

💻 Code

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.

⚠ Common mistakes

1. Optimising allocations everywhere

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.

2. Unbalanced AddMemoryPressure

What 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.

3. Treating AddMemoryPressure as memory management

What 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.

4. Pooling small, cheap objects

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.

5. Looking only at allocation rate

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.

6. Using GC.Collect to relieve pressure

What 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.

7. Tuning GC settings before fixing code

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.

8. Reference-heavy long-lived data structures

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.

9. Measuring with micro-benchmarks only

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.

10. Not tracking allocations over time

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.

🏛 Senior insight: trade-offs

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.

🔗 Follow-ups the interviewer may ask

  1. When: Candidate mentions allocation rate Why: Checks Gen 0 allocation mechanics
    How to answer

    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.

  2. When: Candidate recommends pooling Why: Checks ArrayPool usage
    How to answer

    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.

  3. When: Candidate mentions large buffers Why: Checks LOH behaviour
    How to answer

    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.

🛠 Real-world problems: how you handle them

Search API spent 18% of CPU in GC because of LINQ and string splitting
Situation
A search API tokenised queries and documents for scoring on every request.
Signals
% Time in GC around [18%], high Gen 0 rate, p99 latency over SLO under load.
Investigation
Allocation profiling showed `string.Split`, intermediate lists from LINQ chains and boxed comparers as the top allocators.
Resolution
Rewrote the tokeniser with spans and a preallocated result buffer; replaced LINQ in the scoring loop with plain loops.
Prevention
Added allocation-per-request to performance tests with a budget.

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%].

↩ Usually asked after

More in Garbage Collection

Last updated 31 Oct 2026.

🌐 Sangam's Webring

⏪ Previous 🏠 Main Next ⏩
💬 Chat with me