IDisposable, using, and what the GC does not do
Why the garbage collector frees memory but not files or connections, how using guarantees Dispose, and the generational GC in a few facts.
After this lesson you can
- Explain what the garbage collector reclaims and what it leaves to Dispose
- Rewrite a using statement or declaration as the try/finally it compiles to
- Describe generations, and why a finalizer is a last resort rather than a cleanup plan
What the garbage collector handles
.NET memory is managed: objects are allocated on the heap and the garbage collector (GC) reclaims the ones nothing references any more. It is generational, built on the observation that most objects die young:
- New objects start in generation 0, which is collected often and cheaply.
- An object that survives a collection is promoted, to gen 1 and then gen 2, which are collected less often.
- Objects of 85,000 bytes or more go on the large object heap, which is collected together with gen 2.
public static class Solution{ public static string Describe() { var survivor = new byte[100]; var gens = new List<int> { GC.GetGeneration(survivor) }; GC.Collect(); gens.Add(GC.GetGeneration(survivor)); GC.Collect(); gens.Add(GC.GetGeneration(survivor)); GC.Collect(); gens.Add(GC.GetGeneration(survivor)); var big = new byte[100_000]; GC.KeepAlive(survivor); return $"small array generation after 0,1,2,3 collections: {string.Join(",", gens)} | " + $"100 KB array starts in generation {GC.GetGeneration(big)} (MaxGeneration={GC.MaxGeneration})"; }}The small array was promoted one generation per collection until it
reached the top, gen 2; the 100 KB array reported gen 2 from the start.
(GC.Collect() here is for the demonstration only; production code
almost never needs it.)
What the GC does not know about is anything outside managed
memory: file handles, sockets, database connections, locks. It also
does not run at a predictable moment. A FileStream you stopped using
may keep the file locked until some unknown future collection. That is
what IDisposable is for: a Dispose() method that releases the
resource now, called by your code.
using guarantees Dispose
using (var file = File.OpenRead(path))
{
// read
}
// compiles to the equivalent of:
var file = File.OpenRead(path);
try { /* read */ }
finally { file?.Dispose(); }
The C# 8 using declaration, using var file = ...;, does the same,
with Dispose running when the enclosing scope ends. Several in one
scope are disposed in reverse order of declaration, and the finally
means it happens even when an exception leaves the scope:
public sealed class Tracked : IDisposable{ private readonly string _name; private readonly List<string> _log; public Tracked(string name, List<string> log) { _name = name; _log = log; log.Add("open " + name); } public void Dispose() => _log.Add("dispose " + _name);} public static class Solution{ static void Work(List<string> log) { using var a = new Tracked("a", log); using var b = new Tracked("b", log); log.Add("working"); throw new InvalidOperationException("failed mid-way"); } public static string Describe() { var log = new List<string>(); try { Work(log); } catch (InvalidOperationException e) { log.Add("caught: " + e.Message); } return string.Join(" | ", log); }}Both resources were disposed, newest first, before the catch outside
ever ran.
Finalizers
A class that directly owns an unmanaged handle can declare a finalizer
(~MyType()), which the GC calls at some point after the object
becomes unreachable. It is a safety net, not a cleanup plan: it runs
late, on a separate thread, in no particular order, and objects with
finalizers survive at least one extra collection. The standard
pattern is for Dispose() to release the resource and call
GC.SuppressFinalize(this); in practice, wrap raw handles in a
SafeHandle and you rarely write a finalizer at all.
Common mistakes
- Relying on the GC to close a file or connection. It frees memory whenever it likes and knows nothing about the file lock.
- Calling
Disposewithouttry/finally. An exception before that line skips it. Useusing. - A
Disposethat throws on the second call. Callers may dispose twice (ausingplus an explicit call); make it idempotent with a_disposedflag. - Committing work in
Dispose.Disposealso runs after an exception, so it must not assume the work succeeded. Commit explicitly, and let undo what was not committed, which is what the exercise builds.
Exercise · Pro
A transaction that rolls back unless completed
Exercises are free in the first 3 lessons of every course. From here on they are part of Pro and Sprint; the lesson and its runnable examples stay open.
Upgrade