Most C# developers never think about the garbage collector, until a service's memory climbs all day, or someone adds GC.Collect() "to be safe". So I ran five experiments to see what it actually does.
Machine: .NET 10.0.10, Windows 11, Release build, workstation GC. Each test ran in its own fresh process, three times; ranges are from those three runs.
1. A hundred million objects of garbage
int gen0 = GC.CollectionCount(0);
int gen1 = GC.CollectionCount(1);
int gen2 = GC.CollectionCount(2);
var timer = Stopwatch.StartNew();
for (var i = 0; i < 100_000_000; i++)
{
var box = new Box(i); // garbage immediately
}
Console.WriteLine($"100,000,000 objects in {timer.ElapsedMilliseconds} ms");
Console.WriteLine($"gen0 collections: {GC.CollectionCount(0) - gen0}");
Console.WriteLine($"gen1 collections: {GC.CollectionCount(1) - gen1}");
Console.WriteLine($"gen2 collections: {GC.CollectionCount(2) - gen2}");
100,000,000 objects in 180 ms
gen0 collections: 183
gen1 collections: 1
gen2 collections: 0
(180–247 ms over three runs.) A hundred million allocations in about a fifth of a second, and the GC ran 183 times without me noticing.
The .NET GC is generational. New objects start in generation 0, and a gen0 collection only has to deal with what's still alive there. Here, almost nothing is still alive, so each collection is tiny. Short-lived garbage is the cheapest kind of memory you can use.
2. Keep everything
Now the opposite: never let anything become garbage. This test runs with the heap capped at 256 MB (DOTNET_GCHeapHardLimit), so it fails quickly instead of eating the machine's RAM.
// launched with DOTNET_GCHeapHardLimit = 256 MB
var keep = new List<Box>();
try
{
while (true) keep.Add(new Box(keep.Count));
}
catch (OutOfMemoryException)
{
var count = keep.Count;
keep = null!;
Console.WriteLine($"Out of memory after {count:N0} objects");
}
Out of memory after 7,874,433 objects
About 7.9 million small objects fill 256 MB (each one plus its slot in the list is roughly 32 bytes). The GC was running the whole time; it just couldn't free anything, because every object was still reachable from keep.
That's what a "memory leak" is in .NET: not memory the GC forgot, but references you forgot. A static list, a cache with no eviction, an event handler that's never unsubscribed.
3. Turning the GC off
.NET lets you ask for a stretch of code with no collections at all:
GC.TryStartNoGCRegion(200_000_000); // "don't collect for 200 MB"
var before = GC.CollectionCount(0);
var keep = new Box[1_000_000];
for (var i = 0; i < keep.Length; i++) keep[i] = new Box(i);
Console.WriteLine($"collections inside region: {GC.CollectionCount(0) - before}");
GC.EndNoGCRegion();
collections inside region: 0
It works, as long as you stay inside the budget. (The counter starts after entering the region, because entering it can trigger one collection first.)
Now give it a 10 MB budget and allocate about 32 MB:
GC.TryStartNoGCRegion(10_000_000); // only 10 MB this time
var keep = new Box[1_000_000]; // ~32 MB of objects
for (var i = 0; i < keep.Length; i++) keep[i] = new Box(i);
GC.EndNoGCRegion();
Unhandled exception. System.InvalidOperationException: NoGCRegion mode must be set.
No warning when the budget ran out. The region quietly ended, the GC collected as usual, and the only sign was the exception at the very end, when there was no region left to end.
4. Small collection vs full collection
With 5 million long-lived objects on the heap:
var timer = Stopwatch.StartNew();
GC.Collect(0);
Console.WriteLine($"gen0 collection: {timer.Elapsed.TotalMilliseconds:0.00} ms");
timer.Restart();
GC.Collect(2);
Console.WriteLine($"full collection: {timer.Elapsed.TotalMilliseconds:0.00} ms");
gen0 collection: 2.30 ms
full collection: 32.60 ms
(2.30–2.68 ms vs 32.60–33.84 ms.) A full collection has to walk every live object, so it took about 14 times longer. The more long-lived data you keep around, the more each full GC costs.
5. Calling GC.Collect() "to be safe"
A thousand small work items, with a realistic heap of a million live objects. Once letting the GC decide, once with GC.Collect() after every item:
var timer = Stopwatch.StartNew();
Work(forceGc: false);
Console.WriteLine($"let the GC decide: {timer.ElapsedMilliseconds} ms");
timer.Restart();
Work(forceGc: true); // GC.Collect() after every item
Console.WriteLine($"GC.Collect() each time: {timer.ElapsedMilliseconds} ms");
let the GC decide: 1 ms
GC.Collect() each time: 6839 ms
(6,839–6,921 ms over three runs.) One millisecond versus about seven seconds. GC.Collect() forces a full, blocking collection every time, which is the expensive kind from part 4, a thousand times over.
Recap
- Short-lived objects are cheap; generational GC is built for them.
- Leaks in .NET are references you kept, not memory the GC missed.
- A no-GC region ends silently when you exceed its budget.
- Full collections cost more the more long-lived data you hold.
- Don't call
GC.Collect(). Let the GC decide.
What's the weirdest memory issue you've had to track down in .NET? Tell me in the comments.
I make short, tested videos about C# and what happens when you measure it. More on the Naze Code YouTube channel.
Sources: Microsoft Learn, Fundamentals of garbage collection, GC.TryStartNoGCRegion, GC.EndNoGCRegion, GC configuration settings. Tested on .NET 10.0.10, Windows 11, Release build, 3 runs each.
Top comments (0)