Object Lifetime, Garbage Collection and Resource Management

Concept

Stack, Heap and References Value types are stored inline where they are declared: locals (usually on the stack), fields (inside their containing object), array elements (inside the array).

Reference types are allocated on the managed heap; variables hold references to them.

An object stays alive while it is reachable from a root (local variables, static fields, CPU registers, GC handles). When nothing references it, it becomes garbage.

The Garbage Collector.NET uses a generational, compacting GC:

Newly allocated objects — collected very often and cheaply Objects that survived one collection — a buffer between young and old

Long-lived objects (caches, singletons) — collected rarely Large Object Heap (LOH)

Objects ≥ 85 000 bytes — logically gen 2, not compacted by default Pinned Object Heap

Objects that must not move (interop buffers) Workstation GC (default for desktop/console apps) vs Server GC (one heap per core; default for ASP.NET Core) — set with <ServerGarbageCollection>.

Most apps never call GC.Collect(). The GC class is useful for diagnostics: GetGeneration, CollectionCount, GetAllocatedBytesForCurrentThread, GetGCMemoryInfo.

GC pressure: many short-lived allocations cause frequent gen 0 collections; reduce allocations in hot paths (Module 41, Module 57).

Memory leaks in managed code are really *retained references*: static collections that only grow, event subscriptions that are never removed (Module 19), caches without eviction.

Deterministic Cleanup: IDisposable The GC reclaims memory, but not other resources (file handles, sockets, database connections, hardware sessions). Types that own such resources implement IDisposable, and callers dispose them:

using (var x =...) { } The end of the block using var x =...; (C# 8) The end of the enclosing scope await using var x =...;

End of scope, calling IAsyncDisposable.DisposeAsync() using compiles to try {... } finally { x.Dispose(); }, so disposal happens even when an exception is thrown.

Finalizers and the Dispose Pattern A finalizer (~NativeHandle()) runs on a background thread at some point after the object becomes unreachable. It is a safety net for unmanaged resources only. The full pattern: Dispose() calls Dispose(true) and GC.SuppressFinalize(this); the finalizer calls Dispose(false). Modern code wraps OS handles in SafeHandle instead of writing finalizers.

Why It Matters

Leaked file handles, connections and hardware sessions exhaust resources long before memory runs out. Understanding generations explains performance behaviour (why short-lived objects are cheap and long-lived caches are not).

Syntax

using var file = File.OpenRead(path);
await using var conn = await OpenConnectionAsync();
public sealed class Session : IDisposable { public void Dispose() { ... } }

Example

30.1 Generations, collection and disposal 30.2 Measuring allocations

using System.Runtime.CompilerServices;

// ---- Generations: new objects start in gen 0 and are promoted when they survive collections ----
var survivor = new byte[1000];
Console.WriteLine($"new object generation: {GC.GetGeneration(survivor)}");
GC.Collect();
Console.WriteLine($"after 1 collection:    {GC.GetGeneration(survivor)}");
GC.Collect();
Console.WriteLine($"after 2 collections:   {GC.GetGeneration(survivor)}");
var big = new byte[100_000];                  // >= 85,000 bytes -> Large Object Heap (logically gen 2)
Console.WriteLine($"large array generation: {GC.GetGeneration(big)}");

// ---- Unreachable objects are collected (observed with a WeakReference) ----
WeakReference weak = CreateGarbage();
Console.WriteLine($"before GC: target alive = {weak.IsAlive}");
GC.Collect();
GC.WaitForPendingFinalizers();
Console.WriteLine($"after GC:  target alive = {weak.IsAlive}");

// ---- Deterministic cleanup with IDisposable / using ----
using (var session = new DiagnosticSession("VCU"))
{
    session.Send("10 03");
}                                             // Dispose runs here, even on exceptions
using var second = new DiagnosticSession("BMS");   // using declaration: disposed 
…
using System.Runtime;

// Allocation reduction: measure what an operation allocates
long before = GC.GetAllocatedBytesForCurrentThread();
string s = "";
for (int i = 0; i < 100; i++) s += i;                 // creates 100 intermediate strings
long concat = GC.GetAllocatedBytesForCurrentThread() - before;

before = GC.GetAllocatedBytesForCurrentThread();
var sb = new System.Text.StringBuilder();
for (int i = 0; i < 100; i++) sb.Append(i);
string s2 = sb.ToString();
long builder = GC.GetAllocatedBytesForCurrentThread() - before;
Console.WriteLine($"same result: {s == s2}; string += allocated more than StringBuilder: {concat > builder}");
Console.WriteLine($"StringBuilder allocated less than half of +=: {builder * 2 < concat}");

// GC settings and collection counts
Console.WriteLine($"Server GC: {GCSettings.IsServerGC}, latency mode: {GCSettings.LatencyMode}");
int gen0 = GC.CollectionCount(0);
for (int i = 0; i < 200_000; i++) _ = new byte[256];  // lots of short-lived garbage
Console.WriteLine($"gen0 collections happened during the loop: {GC.CollectionCount(0) > gen0}");
GCMemoryInfo info = GC.GetGCMemoryInfo();
Console.WriteLine($"GC memory info available: {info.Index > 0}, max ge
…

Expected Result

Notice the order at the end: the finalizer ran because Dispose was never called on NativeHandle, and the using var second declaration disposed the BMS session only when the top-level scope ended — after end of program.

new object generation: 0
after 1 collection:    1
after 2 collections:   2
large array generation: 2
before GC: target alive = True
after GC:  target alive = False
[VCU] -> 10 03
[VCU] session closed (Dispose)
[BMS] -> 22 F1 90
[ABS] session closed (Dispose)
caught 'bus error' (ABS session was already disposed)
log: test started
log flushed and closed (DisposeAsync)
NativeHandle released by FINALIZER (Dispose was forgotten)
end of program
[BMS] session closed (Dispose)
same result: True; string += allocated more than StringBuilder: True
StringBuilder allocated less than half of +=: True
Server GC: False, latency mode: Interactive
gen0 collections happened during the loop: True
GC memory info available: True, max generation 2

Common Mistakes

Forgetting to dispose (new SqlConnection(...) without using) — connection pool exhaustion.

Calling GC.Collect() to "fix" memory problems.

Writing finalizers for classes that only own managed objects.

Keeping large objects alive through static fields, caches or event handlers.

Using an object after disposing it — throw ObjectDisposedException (as DiagnosticSession.Send does).

Best Practices

Dispose everything that implements IDisposable, preferably with using declarations.

Make classes that own disposables IDisposable themselves; seal them to avoid the full pattern.

Use SafeHandle for OS handles.

Measure allocations (GetAllocatedBytesForCurrentThread, profilers) before optimizing.

Interview Relevance

Very high: "how does the GC work?", "what are generations?", "what is the LOH?", "Dispose vs finalizer", "what does using compile to?", "can .NET leak memory?".

Practice Questions

Write a TempFile class that deletes its file in Dispose.

Show that a static List<byte[]> keeps arrays alive across GC.Collect().

Implement IAsyncDisposable for a class that flushes a buffer to disk.

Compare allocations of string.Concat in a loop vs StringBuilder for 10 000 items.

Quick Quiz

Q1. (Multiple choice) An array of 100 000 bytes is allocated in: A) Gen 0 B) Gen 1 C) The Large Object Heap D) The stack Answer: C (verified: generation 2).

Q2. (True/False) The GC closes open files automatically as soon as they are unreachable.

Answer: False. At best a finalizer closes them later; dispose them deterministically.

Q3. (Predict the output) What happens to a using var resource declared in top-level statements?

Answer: It is disposed when the top-level scope ends — after the last statement (verified: BMS closed after "end of program").

Q4. (Find the bug) var s = new DiagnosticSession("VCU"); s.Send("10 03"); in a loop of 10 000 iterations.

Answer: Sessions are never disposed; wrap them in using.

Coding Challenge

Write a sealed class TempFile : IDisposable that creates a temp file in its constructor, exposes Path, and deletes the file in Dispose; prove it with File.Exists before and after.

string path;
using (var temp = new TempFile())
{
    path = temp.Path;
    File.WriteAllText(path, "calibration data");
    Console.WriteLine($"exists inside using: {File.Exists(path)}");
}
Console.WriteLine($"exists after dispose: {File.Exists(path)}");

sealed class TempFile : IDisposable
{
    public string Path { get; } = System.IO.Path.GetTempFileName();
    public void Dispose()
    {
        if (File.Exists(Path)) File.Delete(Path);
    }
}