Garbage collection and resources
How .NET manages memory and resources: how the GC finds garbage, why it has generations, how Dispose differs from a finalizer, where leaks come from and which GC mode to pick. In interviews this topic checks whether you understand the cost of allocations and object lifetimes.
Before this topic: Types and memory.
How does the garbage collector work in .NET?
Section titled “How does the garbage collector work in .NET?”What it is
The garbage collector (GC) is the part of .NET that frees object memory for you. You don’t call free or delete like in C++.
Why
Freeing memory by hand leads to leaks and to using memory that has already been freed. The GC removes both kinds of bugs.
How it decides what to delete
- Roots are the local variables of running methods and static fields.
- The GC follows references from the roots and marks every reachable object.
- Unmarked objects are garbage, even if they reference each other in a cycle.
The GC starts on its own, usually during a new when there’s no room left for new objects.
Common mistake
Calling GC.Collect() “to tidy up”. A manual call usually just slows the program down.
Takeaway: the GC frees objects that can’t be reached from the roots and decides on its own when.
How it works
The heap is split into generations by object age: generation 0 holds new objects, generation 2 holds long-lived ones. Usually only generation 0 is collected.
- Allocation. A new object is allocated by bumping a pointer in generation 0 — almost as cheap as the stack. Memory comes back already zeroed.
- Suspension. Before a blocking GC the runtime suspends all managed threads except the one that triggered it.
- Marking. The GC walks the graph from the roots: thread locals and registers, static fields, GC handles, objects waiting for finalization.
- Reclaiming. Dead space is either removed by compaction or added to a free list (sweep).
- Resuming. Threads continue running.
When to choose what
After marking, the GC picks the reclaiming strategy itself:
| Compaction | Sweep | |
|---|---|---|
| What it does | slides live objects, updates references | puts dead space on a free list |
| Upside | no fragmentation | faster |
| Downside | copying costs more | holes stay in the heap |
Pitfalls
- A GC is triggered when the generation 0 or large object budget runs out, when the OS signals low memory, or by
GC.Collect. The runtime tunes the thresholds as it goes. GC.Collect()in application code hurts: live objects get promoted to older generations and the self-tuning is thrown off.
Rule of thumb
Don’t manage the GC by hand: cut the number of allocations and the lifetime of objects.
Takeaway: the GC marks what is reachable from the roots and reclaims the rest by compacting or sweeping.
Under the hood
- Precise collector. For every method the JIT records GC info — where references live at each point in the code.
- Early death. In optimized code a variable stops being a root after its last use. That’s why you need
GC.KeepAlivewhen passing a rawIntPtrto native code. - Debug vs Release. In Debug the JIT extends locals to the end of the method, so behavior under the debugger differs from production.
- Safe points. A blocking GC waits until threads reach a safe point. A thread running native code isn’t suspended, but it can’t return to managed code until the GC finishes.
- Card table. To collect young objects without scanning old ones, a write barrier marks a memory range when a reference is stored into a field. Marked ranges become extra roots.
- Regions. Since .NET 7 the heap on 64-bit Windows and Linux is made of regions: 4 MB by default, 32 MB for large and pinned objects.
Cost
- Marking is proportional to the number of live objects and references, compaction to the volume of survivors.
- Dead objects are almost free: they are neither traversed nor copied.
Trade-offs
The generation 0 budget balances collection frequency against memory. A bigger budget means fewer GCs and higher throughput, but more memory. The GC tunes budgets itself, and GC modes shift this balance.
How to check in production
dotnet-counters(.NET 9+ names):dotnet.gc.pause.timeanddotnet.gc.collectionsper generation.- PerfView (GCStats): duration and trigger reason of every GC.
Takeaway: you pay for live objects and allocation rate, not for garbage.
var cache = new List<byte[]> { new byte[1024] };var reachable = new WeakReference(cache[0]);var garbage = MakeGarbage();
GC.Collect(); // for the demo only, not needed in application code
Console.WriteLine(reachable.IsAlive); // True: reachable from the local cacheConsole.WriteLine(garbage.IsAlive); // False: no strong references to the array leftGC.KeepAlive(cache); // cache counts as alive up to this line
static WeakReference MakeGarbage(){ var data = new byte[1024]; // referenced only by a local variable return new WeakReference(data); // a weak reference is not a root}- Why don’t circular references prevent collection? — The GC checks reachability from roots instead of counting references.
- Can an object be collected while its method is still running? — Yes, in optimized code if
thisisn’t used afterwards;GC.KeepAlivehelps. - How does compaction differ from sweeping? — Compaction moves objects and fixes references; sweeping leaves holes on a free list.
- Why is allocation in .NET cheap? — It’s a pointer bump inside a region handed to the thread in advance, with no free-block search.
- When is
GC.Collectjustified? — In tests, benchmarks and diagnostics; almost never in application code.
Why do we need IDisposable and using?
Section titled “Why do we need IDisposable and using?”What it is
IDisposable is an interface with a single Dispose() method. It’s implemented by classes that hold OS resources: an open file, a socket, a database connection.
Why
The GC frees only memory, and only when it wants to. A file or connection must be closed right away, or nobody else can use it.
How to use
using (var reader = new StreamReader(path)) { ... }—Disposeat the end of the block.using var reader = new StreamReader(path);— a using declaration (C# 8):Disposeat the end of the method or block.await using var stream = ...;— for types withIAsyncDisposable, whose cleanup is asynchronous.
Common mistake
Forgetting using. The file stays locked and the connection doesn’t go back to the pool until the GC gets to the object someday.
Takeaway: using guarantees the Dispose call, and Dispose gives the resource back immediately.
How it works
- The expression in
usingis evaluated and stored in a read-only variable. - The body runs inside
try. finallycallsDisposeif the variable isn’tnull.
Several objects in one using, and consecutive using declarations, are disposed in reverse order.
When to choose what
| Form | When |
|---|---|
using (...) { } |
the resource is needed in part of a method |
using var x = ...; |
the resource is needed until the end of the method or block |
await using |
closing does I/O: Stream, DbConnection |
Pitfalls
- A type with only
IAsyncDisposablecan’t be disposed with a plainusing. That’s why it’s good practice to implement both interfaces. HttpClientisn’t created and disposed per request: connections don’t close immediately, and under load you run out of ports. Reuse it or get it fromIHttpClientFactory.- Dependencies from DI aren’t disposed by hand — the container does it at the end of the scope.
Rule of thumb
The owner disposes — whoever created the object or received ownership. A class that owns an IDisposable field implements IDisposable itself. Dispose is idempotent, and other methods throw ObjectDisposedException afterwards — ObjectDisposedException.ThrowIf (.NET 7+) makes this easy.
Takeaway: using is try/finally with Dispose, and the owner is the one who calls Dispose.
Under the hood
Disposereleases the resource, not the memory: the object lives as long as something references it.- If nobody calls
Dispose, an unmanaged resource is released only by a finalizer — a method the GC calls before destroying the object. That happens eventually, and in .NET 5+ finalizers don’t run at process exit. - That’s why a handle is wrapped in
SafeHandle— a ready-made wrapper with its own finalizer.DisposecallsGC.SuppressFinalizeto cancel the finalization that is no longer needed.
Cost
- Undisposed resources show up not on the heap but in handles, the connection pool and native memory.
- A transient service with
IDisposableresolved from the root DI provider is held by the container until the container itself is disposed. That’s a leak.
Trade-offs
- Order. Dispose a
StreamWriterbefore the underlyingStream, or the buffer won’t be flushed. Using declarations dispose in reverse order, which is exactly right. - Ownership in APIs. If a method takes a
Stream, agree on who closes it.StreamReaderandStreamWriterhave aleaveOpenparameter for this. - Pre-built instances. DI doesn’t dispose objects registered as ready instances:
AddSingleton(new X()).
How to check in production
- Analyzers: CA2000 — object not disposed before losing scope, CA2213 — owned field not disposed, CA1816 — incorrect
GC.SuppressFinalizecall. - Metrics: open handle count of the process, connection pool usage, working set growth while the heap is stable.
Takeaway: Dispose is about owning a resource, and its leaks show up in handles and pools, not on the heap.
// using declaration (C# 8): Dispose at the end of the method, even on exceptionstatic string ReadFirstLine(string path){ using var reader = new StreamReader(path); return reader.ReadLine() ?? "";}
// await using: asynchronous cleanup via DisposeAsyncstatic async Task WriteAsync(string path, string text){ await using var stream = new FileStream(path, FileMode.Create); await using var writer = new StreamWriter(stream); await writer.WriteAsync(text);} // writer first (flushes the buffer), then stream
// What the compiler turns using (var r = new StreamReader("a.txt")) { ... } intovar r = new StreamReader("a.txt");try{ Console.WriteLine(r.ReadLine());}finally{ r?.Dispose();}- What happens if you don’t call
Disposeon aFileStream? — TheSafeFileHandlefinalizer closes the file eventually; until then it may stay locked. - Can
Disposebe called twice? — Yes, a correct implementation is idempotent. - Why
IAsyncDisposableif there’sIDisposable? — To avoid blocking a thread on I/O while closing, for example when flushing a buffer. - Should you call
Disposeon a service you got from DI? — No, the container manages its lifetime. - Why isn’t
HttpClientwrapped inusingfor every request? — Connections don’t close immediately, and under load you run out of ports.
Why does the GC have generations, and what are the LOH and POH?
Section titled “Why does the GC have generations, and what are the LOH and POH?”What it is
The heap is split into three generations by object age:
- Generation 0 — new objects, collected most often.
- Generation 1 — objects that survived one GC.
- Generation 2 — long-lived objects: caches, singletons, static data.
An object that survives a GC moves to the next generation. GC.GetGeneration(obj) tells you its generation.
Why
Most objects live briefly. Collecting the small generation 0 is faster than collecting the whole heap.
Separate heaps
| Heap | What’s in it |
|---|---|
| LOH (large object heap) | objects of 85,000 bytes or more; collected only during a full GC |
| POH (pinned object heap, .NET 5+) | arrays that must not move, such as buffers for native code |
Common mistake
Allocating a large temporary array on every request: each one lands in the LOH and brings an expensive full GC closer.
Takeaway: young objects are collected often and cheaply, old and large ones rarely and expensively.
How it works
- Collecting generation N also collects all younger generations. A generation 2 GC is a full GC.
- Each generation has an allocation budget; when it runs out, a GC starts. If many objects survive, the GC raises the budget.
- The LOH is logically part of generation 2. The 85,000-byte threshold can be raised with
GCLOHThreshold. - The LOH usually isn’t compacted but swept into a free list, so it fragments.
- The POH is also collected with generation 2, and objects in it never move. You allocate there with
GC.AllocateArray<T>(length, pinned: true)orGC.AllocateUninitializedArray<T>(length, pinned: true).
When to choose what
| Task | Solution |
|---|---|
| Pin an object for a single call | fixed: the object stays in generation 0 |
| Long-lived buffer for I/O and interop | an array in the POH |
| Temporary large buffer | ArrayPool<T>.Shared instead of new |
Pitfalls
- Zeroing the memory of a large array is expensive by itself.
- A one-time compaction fixes LOH fragmentation:
GCSettings.LargeObjectHeapCompactionMode = GCLargeObjectHeapCompactionMode.CompactOnce. With a hard heap limit, such as a container with a memory limit, the LOH is compacted automatically. - Objects that live “a bit longer than a request” reach generation 2 and make full GCs frequent.
Rule of thumb
Don’t allocate temporary large arrays on hot paths, and don’t hold objects longer than needed.
Takeaway: the LOH and POH are part of generation 2, so large and pinned objects are cheaper to reuse than to allocate.
Under the hood
- A reference from generation 2 to generation 0 is also a root for a young-generation GC. When a reference is stored into a field or array element, the JIT emits a write barrier that marks the memory range in the card table.
- When collecting generations 0 and 1 (the ephemeral ones), the GC scans only the marked ranges of older generations.
- Since .NET 7 generations are made of regions: 4 MB by default for small objects and 32 MB for the LOH and POH.
- A pinned object in an ephemeral generation prevents compacting the memory around it. The POH moves long-lived pinned buffers out of the way.
Cost
- The write barrier runs on every reference store into a field. If old objects massively reference new ones, like a large cache full of fresh entries, an ephemeral GC scans many ranges.
- Mid-life objects that die in generation 2 are the main source of expensive full GCs.
- The LOH has its own budget: temporary large arrays trigger full GCs.
Trade-offs
- Pools (
ArrayPool<T>) remove allocations, but pooled buffers live in generation 2, and returning them takes discipline. - The POH removes fragmentation caused by pinning, but an object there lives until a full GC. For short-lived buffers
fixedin generation 0 is better.
How to check in production
dotnet-counters(.NET 9+ names):dotnet.gc.collectionsper generation,dotnet.gc.last_collection.heap.sizeanddotnet.gc.last_collection.heap.fragmentation.sizebroken down by gen0, gen1, gen2, loh, poh.- PerfView (GCStats): the
AllocLargetrigger reason means full GCs are caused by the LOH. - From code —
GC.GetGCMemoryInfo().
Takeaway: what costs you is what survives to generation 2 and dies there, temporary large arrays included.
using System.Runtime;
var obj = new object();Console.WriteLine(GC.GetGeneration(obj)); // 0: a new object
GC.Collect(0); // collect generation 0 onlyConsole.WriteLine(GC.GetGeneration(obj)); // 1: survived and got promotedGC.Collect(); // full GCConsole.WriteLine(GC.GetGeneration(obj)); // 2: nowhere further to go
var large = new byte[85_000]; // 85,000 bytes or more — straight to the LOHvar buffer = GC.AllocateArray<byte>(4096, pinned: true); // POH, .NET 5+
// Compact the LOH once during the next blocking full GCGCSettings.LargeObjectHeapCompactionMode = GCLargeObjectHeapCompactionMode.CompactOnce;GC.Collect();
Console.WriteLine($"gen0: {GC.CollectionCount(0)}, gen2: {GC.CollectionCount(2)}"); // usually gen0: 3, gen2: 2GC.KeepAlive(large);GC.KeepAlive(buffer);- Why is the LOH threshold 85,000 bytes? — It was chosen by performance tuning;
GCLOHThresholdcan change it, but only upwards. - How do you fight LOH fragmentation? — Pools (
ArrayPool<T>), splitting data into chunks below the threshold, a one-timeCompactOnce. - How is the POH better than
GCHandle.Alloc(obj, GCHandleType.Pinned)? — Long-lived pinned buffers don’t fragment the ephemeral generations. - What is a write barrier? — Code the JIT emits on a reference store into a field to mark the range for ephemeral GCs.
- What does “full GC” mean? — A generation 2 GC together with all younger generations, the LOH and the POH.
What are a finalizer and the Dispose pattern, and where does SafeHandle fit in?
Section titled “What are a finalizer and the Dispose pattern, and where does SafeHandle fit in?”What it is
A finalizer is declared as ~ClassName(), and the compiler turns it into an override of Object.Finalize. The GC calls it, not you, at an unpredictable moment.
Why
It’s a safety net. If a class directly holds an unmanaged resource, such as a pointer to native memory, and Dispose was forgotten, the resource still gets released.
How to use
- Dispose pattern — the standard scheme:
Dispose()and the finalizer both call a sharedDispose(bool disposing)method. - SafeHandle — a ready-made wrapper over an OS handle with its own finalizer (
SafeFileHandleand others). With it you don’t need your own finalizer.
Common mistake
An empty finalizer “just in case”. It only slows the program down. Structs can’t have finalizers at all.
Takeaway: a finalizer is a safety net for unmanaged resources, and SafeHandle usually provides it for you.
How it works
- When created, an object with a finalizer is registered in the finalization queue.
- When the GC finds the object unreachable, the entry moves to the f-reachable queue. The object and everything it references become reachable again for a while.
- The finalizer thread calls
Finalize. - The next GC of that generation frees the memory.
That’s at least two GCs plus a promotion to an older generation.
When to choose what
| The class owns | What to implement |
|---|---|
only managed IDisposables, class is sealed |
a plain Dispose() |
| managed resources, class open for inheritance | Dispose() and protected virtual Dispose(bool) |
a raw IntPtr |
wrap it in a SafeHandle subclass |
Pitfalls
- In
Dispose(false), that is when called from the finalizer, don’t touch other managed objects: they may already be finalized. - A derived class doesn’t reimplement
IDisposable; it overridesDispose(bool)and callsbase.Dispose(disposing). Dispose(bool)is guarded by a flag against repeated calls.
Rule of thumb
Dispose() calls Dispose(true) and GC.SuppressFinalize(this), and the finalizer, if there is one, calls Dispose(false). SafeHandle guarantees that ReleaseHandle runs exactly once.
Takeaway: instead of your own finalizer — SafeHandle and a plain Dispose.
Under the hood
- A finalizable object is registered in the queue at creation, so its allocation is more expensive.
- CoreCLR runs finalizers on a single thread. An exception in a finalizer terminates the process.
SafeHandlederives fromCriticalFinalizerObject: its finalizer runs after the normal ones from the same GC. So a normal finalizer, for example one that flushes a buffer, can still use the handle.- P/Invoke increments the
SafeHandlereference count for the duration of the call. The handle can’t be closed mid-call or recycled by the OS for another resource (a recycle attack).
Cost
- The object and its whole graph survive an extra GC and get promoted. Thousands of such objects per second load generations 1 and 2.
- A finalizer blocked on a lock, the network or synchronous I/O stalls the queue, and memory stops being freed.
Trade-offs
- Finalization order is undefined, and in .NET 5+ finalizers don’t run at process exit. Guaranteed cleanup goes through
Dispose,IHostApplicationLifetimeorAppDomain.ProcessExit. - A class holding a raw
IntPtrwithoutSafeHandlerisks early finalization: after the last use ofthis, the object can be finalized during a native call. Then you needGC.KeepAlive(this).
How to check in production
- The SOS command
finalizequeueindotnet-dump analyzeshows how many objects are waiting for finalization. - The finalizer thread’s stack in the dump shows what it’s stuck on.
Takeaway: write your own finalizer only in a SafeHandle subclass; every other class owns one through Dispose.
using System.Runtime.InteropServices;
public class LegacyNativeBuffer : IDisposable{ private IntPtr _ptr = Marshal.AllocHGlobal(1024); // unmanaged memory private readonly MemoryStream _log = new(); // managed resource private bool _disposed;
public void Dispose() { Dispose(disposing: true); GC.SuppressFinalize(this); // the finalizer is no longer needed }
protected virtual void Dispose(bool disposing) { if (_disposed) return; if (disposing) _log.Dispose(); // managed — only from Dispose() Marshal.FreeHGlobal(_ptr); // unmanaged — always _ptr = IntPtr.Zero; _disposed = true; }
~LegacyNativeBuffer() => Dispose(disposing: false); // safety net if Dispose was forgotten}using System.Runtime.InteropServices;using Microsoft.Win32.SafeHandles;
// SafeHandle already has a finalizer; ReleaseHandle runs exactly oncesealed class HGlobalHandle : SafeHandleZeroOrMinusOneIsInvalid{ public HGlobalHandle(int size) : base(ownsHandle: true) => SetHandle(Marshal.AllocHGlobal(size));
protected override bool ReleaseHandle() { Marshal.FreeHGlobal(handle); return true; }}
// The owner: no finalizer and no Dispose(bool)public sealed class NativeBuffer : IDisposable{ private readonly HGlobalHandle _handle = new(1024);
public void Dispose() => _handle.Dispose(); // calling it twice is safe}- Why can’t you touch managed fields in
Dispose(false)? — They may already be finalized: finalization order is undefined. - Why call
GC.SuppressFinalizeif there’s no finalizer? — For a derived class that adds one; without a finalizer the call has no effect. - What happens if a finalizer throws? — The process terminates.
- Do finalizers run when the application exits? — In .NET Framework yes, in .NET 5+ no.
- How does
SafeHandleprotect against a recycle attack? — During P/Invoke the reference count prevents closing the handle and handing its number to another resource.
How do memory leaks happen in managed code, and how do you find them?
Section titled “How do memory leaks happen in managed code, and how do you find them?”What it is
A memory leak is unneeded objects that are still referenced. The GC removes only unreachable objects, so that memory is never freed.
Where leaks come from
- an event subscription without unsubscribing: the publisher holds a reference to the subscriber;
- a
staticlist or dictionary that nothing is ever removed from; - a cache with no size or lifetime limit;
- timers without
Dispose.
How to spot it
- Process memory grows and doesn’t drop even when idle.
- In Visual Studio (Diagnostic Tools → Memory Usage), take a heap snapshot.
- Repeat the action, take a second snapshot and compare which objects increased.
Common mistake
Thinking Dispose removes an object from memory. If an event or a collection references it, it stays on the heap.
Takeaway: a leak in .NET is a forgotten reference, and you need to find who holds it.
How it works
- Events. A delegate holds a reference to the subscriber. If the publisher lives longer (a singleton, a static event), the subscriber is never collected.
- Statics and caches. A
static DictionaryorConcurrentDictionaryused as a cache without eviction grows forever. - Timers. An active timer is registered in the runtime’s timer queue, and its callback holds the owner object. If the owner keeps the timer in a field, both live until the timer is disposed.
- Closures. A lambda holds captured variables and
this. Stored in an event, a cache or a static field, it keeps the whole graph alive. - DI. A transient
IDisposableservice resolved from the root provider is held by the container until the container is disposed.
When to choose what
| Tool | What it gives you |
|---|---|
dotnet-counters |
whether the heap grows after GCs |
dotnet-gcdump |
the object graph: comparison by type, path to root |
dotnet-dump |
a full dump: dumpheap -stat, gcroot |
Pitfalls
Disposedoesn’t remove references to the object from collections and events.- You need to unsubscribe from events of a long-lived publisher. A reference cycle inside an unreachable graph is collected by the GC anyway.
Rule of thumb
Search step by step:
dotnet-counters monitor -p <pid>— make sure the heap size after GCs keeps growing.- Two
dotnet-gcdump collect -p <pid>snapshots some time apart; open them in Visual Studio or PerfView and compare by type. - Find who holds the growing type: Paths to Root in Visual Studio or
gcrootindotnet-dump analyze.
Takeaway: a leak is reachability that lasts longer than needed, and the tools find the root it comes from.
Under the hood
- Shared closure. The compiler creates one closure class per scope. If two lambdas in a method capture different variables, both variables live in one object, and the long-lived lambda keeps someone else’s large object alive.
ExecutionContext. Timers and thread pool work capture the execution context, and with it the values ofAsyncLocal<T>. A timer created inside a request holds that request’s data.- Roots.
gcrootends the chain at a root: a static field, a strong or pinned GC handle, the finalization queue.
Cost
dotnet-gcdumptriggers a full GC, which can pause the process for a long time on a large heap.dotnet-dumpcontains all process memory with field values, and it’s just as big.
Trade-offs
A .gcdump is lighter and safer for production, but it has no field values. A full dump is needed when the data matters, for example which cache key keeps growing.
How to check in production
- Compare
dotnet.gc.last_collection.heap.sizewithdotnet.process.memory.working_set(.NET 9+ names). If only the working set grows, the leak is native. - Apply load, wait for a steady state, take a dump; repeat a few minutes later.
- Compare
dumpheap -stator diff two.gcdumpfiles: you want the type whose instance count keeps growing. - Run
gcrooton an instance of that type and find the root.
In tests a leak is caught like this: create the object in a separate method, keep a WeakReference to it, call GC.Collect() and check IsAlive.
Takeaway: first separate a native leak from a managed one, then find the root that holds the growing type.
public sealed class PriceFeed // a singleton, lives for the whole run{ public event EventHandler<decimal>? PriceChanged; public void Publish(decimal price) => PriceChanged?.Invoke(this, price);}
public sealed class PriceWidget : IDisposable // created for every screen{ private readonly PriceFeed _feed; private readonly byte[] _cache = new byte[100_000]; // leaks together with the widget
public PriceWidget(PriceFeed feed) { _feed = feed; _feed.PriceChanged += OnPrice; // feed → delegate → this }
private void OnPrice(object? sender, decimal price) => Console.WriteLine(price);
// Without unsubscribing, every closed widget stays in memory forever public void Dispose() => _feed.PriceChanged -= OnPrice;}static void Subscribe(PriceFeed feed){ var report = new byte[10_000_000]; // a large temporary object var name = "EURUSD";
Task.Run(() => Console.WriteLine(report.Length)); // a short-lived lambda feed.PriceChanged += (_, p) => Console.WriteLine($"{name}: {p}");
// Both lambdas share one closure class: the long-lived // event handler keeps both name and report (10 MB) alive}- Why unsubscribe from a singleton’s events but not from events of your own field? — The subscriber of a long-lived publisher leaks; a cycle inside an unreachable graph is collected.
- How does
dotnet-gcdumpdiffer fromdotnet-dump? — A gcdump is just the object graph, light but with a full GC; a dump is all process memory with values. - How do you tell a leak from a simply large Server GC heap? — Watch the heap size after full GCs under steady load: with a leak it grows with no plateau.
- What is a weak event? — A subscription through a
WeakReferencethat doesn’t prevent collecting the subscriber. - Can memory leak even with a correct
Dispose? — Yes:Disposedoesn’t remove references to the object from collections and events.
What GC modes are there, and how do you choose?
Section titled “What GC modes are there, and how do you choose?”What it is
- Workstation GC — one heap, the GC runs right on the thread that was allocating. The default for console and desktop apps.
- Server GC — its own heap and GC thread per processor. The default in ASP.NET Core.
- Background GC is on in both modes: the long generation 2 collection runs concurrently with the app.
Why
Server GC collects faster and gives more throughput, but uses more memory. Workstation is more frugal.
How to use
Set the mode in .csproj: <ServerGarbageCollection>true</ServerGarbageCollection>.
Common mistake
Switching GC mode “to make it faster” without measuring. The defaults suit most applications.
Takeaway: Server GC for servers, Workstation for clients, and background GC is on everywhere.
How it works
| Workstation | Server | |
|---|---|---|
| Heaps | one | one per logical processor |
| Where the GC runs | on the thread that triggered it | on dedicated GC threads (THREAD_PRIORITY_HIGHEST on Windows) |
| Memory | less | more |
- On a machine with a single logical processor, Workstation is always used.
- Background GC (
System.GC.Concurrent,trueby default) applies only to generation 2: a dedicated thread marks and sweeps while the app runs. Background GC doesn’t compact the heap. - If generation 0 fills up during a background GC, a regular ephemeral GC runs with threads suspended.
- DATAS arrived in .NET 8 as opt-in and has been on by default for Server GC since .NET 9. It starts with one heap, adds and removes heaps based on load, and caps the generation 0 budget based on the amount of long-lived data.
When to choose what
| Scenario | Mode |
|---|---|
| Web service, containers, variable load | Server GC with DATAS (the default) |
| Dedicated machine, startup and peak throughput matter | Server GC without DATAS |
| Desktop, console tool | Workstation with background GC |
| Hundreds of small processes on one machine | Workstation without background GC |
Pitfalls
- Settings are read once at startup: changing environment variables of a running process does nothing.
- An explicit
GCHeapCountdisables DATAS.
Rule of thumb
Set the mode with the ServerGarbageCollection and ConcurrentGarbageCollection properties in .csproj (or System.GC.Server and System.GC.Concurrent in runtimeconfig.json), and change it only based on measurements.
Takeaway: Server GC with DATAS is a sensible default for services, and measurements decide the rest.
Under the hood
- DATAS targets the throughput cost percentage (TCP) — the share lost to GC pauses and allocation waits. The default target is 2% (
GCDTargetTCP). - The upper bound of the generation 0 budget is derived from the application size. .NET 10 added settings for it:
GCDGen0GrowthPercent,GCDGen0GrowthMinFactorandGCDGen0GrowthMaxFactor. - In a container the GC treats the container limit as physical memory. Without settings,
GCHeapHardLimitis the larger of 20 MB and 75% of the limit, and with a hard limit the LOH is compacted automatically.
Cost
- With DATAS there’s one heap at first, so startup is slower, and threads wait on allocations when load spikes.
- Without DATAS, Server GC sizes budgets for throughput, and on a machine with many cores the heap balloons.
- Once
GCHeapHardLimitis reached, allocation fails withOutOfMemoryException.
Trade-offs
- DATAS is turned off when startup is critical, even a small throughput loss is unacceptable, or most GCs are full ones. Ways to do it:
<GarbageCollectionAdaptationMode>0</GarbageCollectionAdaptationMode>,System.GC.DynamicAdaptationMode= 0 orDOTNET_GCDynamicAdaptationMode=0. GCConserveMemory(0–9) keeps the heap smaller at the cost of more frequent GCs.GCSettings.LatencyMode = GCLatencyMode.SustainedLowLatencysuppresses blocking full GCs during critical work, but the heap grows.
How to check in production
- A load test with a realistic profile:
dotnet.gc.pause.time, GC counts, working set, RPS and p99. - PerfView GCStats: pause share and generation 0 budget for each GC.
- For many teams .NET 10 is the first LTS with DATAS on by default, so compare metrics after migrating from .NET 8. Change one setting at a time.
Takeaway: pick the GC mode by pause, memory and throughput metrics, not by its name.
using System.Runtime;
// Mode from the project: <ServerGarbageCollection>true</ServerGarbageCollection>Console.WriteLine($"Server GC: {GCSettings.IsServerGC}"); // Server GC: TrueConsole.WriteLine($"Latency mode: {GCSettings.LatencyMode}"); // Interactive when background GC is on
var info = GC.GetGCMemoryInfo();Console.WriteLine($"Available to GC: {info.TotalAvailableMemoryBytes / 1024 / 1024} MB"); // derived from the limit in a containerConsole.WriteLine($"High memory load threshold: {info.HighMemoryLoadThresholdBytes / 1024 / 1024} MB");
// Effective GC settings (.NET 7+): heap count, limits, DATAS, etc.foreach (var (key, value) in GC.GetConfigurationVariables()) Console.WriteLine($"{key} = {value}");- Why does Server GC without DATAS bloat memory in a small container on a many-core machine? — Heaps and budgets are sized from the core count and throughput, not from the amount of data.
- What does background GC do if generation 0 fills up during it? — It pauses, and a regular blocking ephemeral GC runs.
- How do you turn off DATAS, and when is it justified? —
GCDynamicAdaptationMode=0; when startup and peak throughput matter and memory isn’t scarce. - What happens when
GCHeapHardLimitis reached? — Allocation fails withOutOfMemoryException, even if the machine has free memory. - When do you need
SustainedLowLatency? — During latency-sensitive operations, if there’s enough memory: the heap grows and fragments.