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 ↗Read or download the complete SDK project, then follow its setup on your computer. The separate C# companion practice below runs in SharpRune’s .NET 9 console environment.
Resource release and memory reclamation differ
A managed wrapper can own a file handle, native allocation or other external resource. The GC can track the wrapper's managed reachability, but it does not automatically understand the resource-specific release protocol. IDisposable provides a deterministic ownership boundary when callers use it correctly. Finalization provides a runtime-controlled fallback for some resource wrappers, not a promise of timely business cleanup or a replacement for using.
The portable example deliberately uses a managed demonstration token rather than pretending to release an operating-system handle. That keeps its exact output deterministic and makes the idempotent ownership rule visible. The separate desktop experiment uses a real finalizer to observe runtime behavior in an isolated process. Neither program claims that a production request should force collection to close resources.
An unreachable finalizable object can survive
When a finalizable object becomes unreachable through ordinary application roots, its finalizer may still need access to its fields. The runtime therefore cannot immediately reclaim its storage as though it were an ordinary dead object. Finalization infrastructure keeps the object and relevant reachable graph available until the finalizer runs. A large managed payload referenced by a small wrapper can consequently survive beyond the application's last ordinary owner.
After finalization finishes without resurrection, the object can become eligible for reclamation in a later collection. This is why one collection and one finalizer execution are not the same event as final physical reclamation. An object can also survive for unrelated reasons, such as another strong root. Do not diagnose every long-lived wrapper as a finalizer backlog without inspecting queue state, object graphs and the actual workload.
Resurrection complicates the graph
A finalizer that publishes this into a static location creates a new strong ownership path. That resurrection changes the lifetime story and can expose partially cleaned-up state. Re-registering for finalization adds further complexity. These mechanisms exist, but ordinary application design should prefer a clear deterministic release protocol rather than relying on resurrection to manage useful work.
Finalizers should avoid depending on another finalizable object's cleanup order, performing unbounded blocking work or allowing exceptions to escape. A single problematic finalizer can delay other cleanup. SafeHandle is the standard building block for many native-handle wrappers because it centralizes the resource-specific release path and integrates with runtime reliability machinery. It does not mean every arbitrary external resource can be represented by a fake integer and considered safely released.
Trace deterministic disposal
The portable resource starts undisposed. Inside using it prints working:True. Leaving the using scope invokes Dispose, which changes Disposed and increments Releases once. Calling Dispose again returns immediately because the object already completed its release boundary. The next line therefore reports disposed:True;releases:1.
The finalizer-timing:not-observed line is intentional: this type has no finalizer and no native resource. The observable claim is idempotent managed state transition, not forced collection. The local variable remains available to inspect its state after disposal, demonstrating that an object can remain reachable while its external-resource lease has ended. Treating reachability as permission to continue using a disposed resource is a separate correctness mistake.
Run the finalization model and SDK lab
In the interactive scenario, disable the request owner, mark and reclaim. The finalizable owner is queued, so its child remains available. Finish the collection, run the queued model finalizer, then collect again. No resurrection is modeled. The explicit finalizer button represents a lifecycle event whose timing the real runtime controls; it is not an application API promising immediate execution.
The desktop helper creates one unrooted finalizable object in a non-inlined method, requests a full collection and waits for this bounded experiment's pending finalizers. Its finalizer only increments a counter and keeps its payload valid until return. A second collection is requested afterward. This controlled program is not evidence that waiting for arbitrary production finalizers is safe: real finalizers can block or depend on other resources, and diagnostic forcing changes the process being observed.
Design deterministic native ownership
Choose the exact native resource and its release operation. Wrap it with a suitable SafeHandle implementation or an equivalent narrowly defined owner, then expose a clear Dispose boundary. Ensure partially constructed objects do not release uninitialized resources incorrectly and repeated disposal cannot release another owner's resource. If async operations are outstanding, define whether disposal waits, cancels and acknowledges, or rejects new operations while existing ones complete.
For an interview, contrast an object surviving because a cache roots it with an object retained for pending finalization. Explain why GC does not simply call Dispose on every unreachable object. Then describe a fallback that protects native resource release without making timely disposal optional. A strong answer keeps finalizer-thread behavior, native release correctness and managed memory reclamation as related but separate concerns.
Practice and review
Add an exception inside the portable using block and confirm disposal still happens. Record the exception separately and verify the release counter remains one after a repeated Dispose. Do not add a finalizer merely to make a deterministic unit test depend on collection timing.
Draw the wrapper and a large child through four states: ordinary root present, ordinary root absent but finalization pending, finalizer completed without resurrection, and a later collection. Explain what a weak observer might report only under its documented weak-reference policy; do not assume all weak-reference flavors have identical finalization behavior. Finish by listing evidence that would distinguish too many live resource owners from a blocked finalizer thread.
Finalizer queues, resurrection and disposal — CoreCLR experiment
Set up the SDK project
- Install .NET 10 SDK and unzip the project into a new directory.
- Read README.md and run dotnet run -c Release --project MemoryLab.csproj in this isolated process.
- Record the runtime, effective configuration and full output. Repeat the same workload; numeric observations are not universal expected values.
Download complete SDK project ↗
Project files
MemoryLab.csproj
Download file ↗<Project Sdk="Microsoft.NET.Sdk"><PropertyGroup><OutputType>Exe</OutputType><TargetFramework>net10.0</TargetFramework><ImplicitUsings>enable</ImplicitUsings><Nullable>enable</Nullable></PropertyGroup></Project>
Program.cs
Download file ↗using System.Runtime.CompilerServices;
CreateUnrooted();
GC.Collect(2, GCCollectionMode.Forced, blocking: true);
GC.WaitForPendingFinalizers();
Console.WriteLine($"finalizers:{Probe.Count}");
GC.Collect(2, GCCollectionMode.Forced, blocking: true);
Console.WriteLine("second-collection-requested;RSS-not-asserted");
[MethodImpl(MethodImplOptions.NoInlining)] static void CreateUnrooted() { _ = new Probe(); }
sealed class Probe { public static int Count; private readonly byte[] payload = new byte[100_000]; ~Probe() { Interlocked.Increment(ref Count); GC.KeepAlive(payload); } }
README.md
Download file ↗# Finalizer queues, resurrection and disposal
Environment: .NET 10 SDK / desktop CoreCLR
Run `dotnet run -c Release --project MemoryLab.csproj` with .NET 10 SDK.
This is an isolated desktop CoreCLR experiment. It may deliberately request bounded collections for observation. Never add these forced collection calls to a production request path. Numeric counters, generations, addresses and RSS vary. The portable SharpRune program is separate.
Primary references:
https://learn.microsoft.com/en-us/dotnet/fundamentals/runtime-libraries/system-object-finalize
https://learn.microsoft.com/en-us/dotnet/standard/garbage-collection/implementing-dispose
https://learn.microsoft.com/en-us/dotnet/fundamentals/runtime-libraries/system-runtime-interopservices-safehandle
Expected result
The bounded program exits successfully and prints genuine CoreCLR observations. Counts, generations, heap sizes and timings vary; compare ownership invariants and matching observation windows rather than hardcoded measurements.
Use the listed SDK and dependencies for this project. Open C# practice runs the separate portable exercise.
C# companion practice
var resource = new OwnedToken();
using (resource) { Console.WriteLine($"working:{!resource.Disposed}"); }
resource.Dispose();
Console.WriteLine($"disposed:{resource.Disposed};releases:{resource.Releases}");
Console.WriteLine("finalizer-timing:not-observed");
sealed class OwnedToken : IDisposable
{
public bool Disposed; public int Releases;
public void Dispose() { if (Disposed) return; Disposed = true; Releases++; }
}
C# practice output
working:True disposed:True;releases:1 finalizer-timing:not-observed
Trace ownership before inferring collection. Expected portable output: working:True disposed:True;releases:1 finalizer-timing:not-observed