Memory & Garbage Collection · Lesson 9

Finalization versus deterministic disposal

End owned resource work explicitly without pretending that Dispose frees managed object memory.

Explore the interactive Memory Lab

Change owners, step through generations, pinning and finalization, or compare memory under load. The model explains concepts; the SDK examples measure real runtime observations.

Open the Memory Lab ↗

The memory contract

Garbage collection reclaims eligible managed storage; IDisposable defines an explicit cleanup protocol for an object's owned work or resources. They are not the same operation. A using statement calls Dispose when its managed scope is left normally or through ordinary exception unwinding. Finalization is a runtime-managed fallback mechanism with nondeterministic scheduling and additional lifetime costs, not a substitute for timely disposal. The sample has no finalizer and owns no native resource; a visible in-memory lease makes the deterministic protocol observable without manufacturing a finalizer timetable.

Trace the actual program

The lease starts active. Inside the using body, the program prints working. Leaving that body calls Dispose, setting IsDisposed and incrementing ReleaseCount. The next output is True. Calling Dispose again returns without another release, so the final count is 1. This demonstrates an idempotent cleanup policy for the sample. The lease variable still references the managed object afterward. Nothing in those outputs proves that its object storage was collected, that all managed references disappeared, or that operating-system memory was returned.

How the mechanism works

A type wrapping a real unmanaged resource needs reliable ownership of the corresponding release operation. Current .NET guidance generally favors an appropriate SafeHandle wrapper rather than writing a custom finalizer in every owning class. Objects awaiting finalization can remain involved in additional collector work, and finalizer code must not depend on other managed objects being finalized in a convenient order. Deterministic callers still dispose promptly even when a fallback exists. A finalizer is not guaranteed to run before an abrupt host shutdown, and browser execution boundaries require particular care about such assumptions.

Boundaries and misconceptions

Do not call Dispose on a borrowed shared dependency merely because it implements the interface. The component that owns the lifetime should end it according to the contract. Conversely, clearing a reference to an owned resource without disposing can leave scarce non-memory resources waiting for a fallback that may run too late. A no-op Dispose is acceptable only when it honestly matches the resource model, not as a way to conceal missing cleanup. This lease's counter is a test observation of its own protocol, not a universal resource-release metric.

Practice: change one ownership path

First add another explicit Dispose call after the existing one. Predict that ReleaseCount remains one. Hint: the IsDisposed guard makes the sample idempotent. Then throw a controlled exception inside the using body and handle it outside. Verify the disposed state after the handler, keeping the changed message order separate from the original baseline. This exercise concerns ordinary managed unwinding; do not use a forcibly terminated worker as a supposed proof that every cleanup path is guaranteed to execute.

Practice: test a second boundary

Next write a helper that borrows a Lease, reads its state, and returns without disposing it. Let the caller's using scope remain the owner. Hint: the parameter does not imply ownership transfer. Compare that design with a factory returning a newly owned lease and document who must dispose the result. Finally describe which parts would change if the lease wrapped an actual SafeHandle, while keeping native platform code out of this portable lesson and avoiding a finalizer printout as a timing assertion.

Review the evidence

A clear lifetime contract answers who creates, owns, borrows, and disposes each resource. The unchanged outputs show work, a disposed flag, and a single release count. They do not claim that disposal forces garbage collection. When reviewing a real cleanup implementation, separate managed retention from unmanaged resource ownership and verify both using appropriate evidence. This distinction prevents a memory investigation from recommending GC.Collect to solve a missing Close or Dispose boundary.

  • The original output is working, True, and release count one.
  • Dispose ends this protocol but the lease object can still be referenced.
  • Finalization timing is not a deterministic substitute for owned cleanup.

A working example

using System;
using System.Linq;
using System.Collections.Generic;
using System.Text;
using System.Buffers;
using System.Runtime.InteropServices;
using System.Runtime.CompilerServices;

var lease = new Lease();
using (lease) { Console.WriteLine("working"); }
Console.WriteLine(lease.IsDisposed);
lease.Dispose();
Console.WriteLine(lease.ReleaseCount);
sealed class Lease : IDisposable
{
    public bool IsDisposed { get; private set; }
    public int ReleaseCount { get; private set; }
    public void Dispose()
    {
        if (IsDisposed) return;
        IsDisposed = true;
        ReleaseCount++;
    }
}

Expected output

working
True
1
Try this
The original output is working, True, and release count one.

Further reading