Systems Programming · Resource Management
Finalizers and the Gap Between Reclaimable and Released
Why a finalizer runs at an unpredictable time, how resurrection and finalizer ordering break cleanup, and what a disposal protocol adds that collection cannot.
A finalizer is a function the runtime calls after it has determined that an object is unreachable. The word “after” carries the whole problem. The determination happens during a collection, the collection happens when the allocator needs memory, and the need for memory has no fixed relationship to the lifetime of any particular resource. A file handle wrapped in a finalizable object is therefore closed at a time determined by heap pressure rather than by the program’s own logic, and a program that holds many such objects without allocating much may never collect them at all.
That is not a defect in any particular implementation. It is what finalization means. Everything else about finalizers follows from it.
The reachability window
Consider the sequence a runtime performs when it decides an object is garbage. During marking, the object is found unreachable and placed on a queue of objects whose finalizers must run. The object is not freed at that point, because the finalizer needs it. It stays alive until the finalizer has run, and only then becomes collectable in a later cycle.
That two-cycle structure has a visible consequence: an object with a finalizer requires at least two collections to be freed, and the second one happens whenever the runtime next runs. In a generational collector the object may also have been promoted to an older generation before it died, so the second collection is a full or old-generation collection, which is the expensive kind.
The window between unreachability and finalization is where a resource stays held. A descriptor in that state is still open; a lock in that state is still held. The process is not wrong to hold them, but the program’s own view of its resource usage is now out of step with reality. A program that opens a thousand files, drops the wrappers, and then opens a thousand more will exceed a descriptor limit of 1024 even though no more than a few wrappers are reachable at any moment, because the dropped ones have not been finalized.
This is why the standard advice for resources with a hard limit is to release them explicitly. Finalization is a backstop for the case where the programmer forgot, not a mechanism to be relied on for correct operation under load.
Resurrection and the order problem
A finalizer receives a reference to the object it is finalizing, so it can store that reference somewhere reachable. The object is then reachable again, and the runtime will not finalize it a second time in most designs. This is resurrection, and it converts a resource that was about to be released into one that is held indefinitely by a finalizer that ran at an arbitrary moment.
The legitimate use is a cache that revives entries, and the illegitimate use is a finalizer that logs the object into a registry “for later inspection”, which turns every collected object into a permanent one. Implementations differ in whether they allow resurrection at all: the JVM permits it, and the object is not re-finalized; Go’s documentation for runtime.SetFinalizer states that the object is not reachable again from the finalizer except through the argument, and that a finalizer must not make the object reachable again, because doing so prevents it from being collected and the finalizer will not run a second time.
Ordering is the second structural problem. Finalizers run in an order the runtime chooses, which may be arbitrary or may follow the order objects were queued. If two finalizable objects refer to each other, each may see the other in a finalized state. The JVM specification says finalizers are not guaranteed to run in any particular order, and that a finalizer may observe an object whose finalizer has already run. Cleanup code that assumes its collaborators are intact is therefore wrong by construction.
The sound pattern is to make each finalizer act only on state it owns exclusively and to make the release idempotent, so that a second release attempt, whether from a finalizer or from an explicit close, does nothing.
Disposal as an explicit protocol
The alternative to finalization is to give the object a method that releases the resource, and to require the caller to invoke it. That is the Close of Go’s io.Closer, the close of Python’s context managers, the Dispose of the .NET pattern, and the Drop of Rust. The method is deterministic: it runs when the caller says so.
The design question is what happens if the caller does not. Three answers exist, and they differ in where the burden sits.
The first is to do nothing and document the requirement. This is Rust’s position: Drop runs at scope exit, so the compiler enforces the common case, and mem::forget is safe and explicitly documented as not running the destructor. Leaking is not a memory-safety violation, so the language does not prevent it.
The second is to pair the explicit release with a finalizer as a backstop, which is the .NET and Java Cleaner arrangement. The object holds a reference to a cleanup action that does not itself reference the object, so the action is reachable while the object is alive and can be run either by Dispose or by the collector. The separation of the action from the object is what avoids resurrection: the finalizer closure must not capture this, or the object becomes reachable from its own finalizer and is never collected. Java’s Cleaner documents this restriction and the PhantomReference machinery underneath enforces it by design.
The third is to make the resource a separate object that the operating system reclaims at process exit, and to accept that the in-process accounting is approximate. This is the position of many short-lived command-line tools.
What the runtime can and cannot know
A finalizer cannot safely perform arbitrary cleanup. It runs on a runtime thread at a time the program does not control, so it cannot take locks that other threads may hold, and it cannot assume the program is in a consistent state. In a language with a global interpreter lock the constraint is milder but still present: the finalizer may run while the program is in the middle of a data structure update.
The practical consequence is that finalizers should only do work that is safe in any state: closing a descriptor, releasing a native allocation, decrementing a count. They should not flush buffers that other threads may be writing, and they should not perform I/O that can block, because a blocked finalizer thread stops all finalization.
Even the descriptor close is not entirely safe, because the descriptor number may have been reused if the wrapper was closed explicitly and then resurrected. A correct finalizer records that the explicit close happened, and the flag is checked before the close in the finalizer. That flag is exactly the idempotence the previous section required, and it is why the disposal path and the finalizer path must share the same release routine.
There is one more asymmetry worth naming. A finalizer does not run at process exit in most runtimes, or runs only partially. The JVM does not guarantee that finalizers run at shutdown, and Go’s documentation notes that finalizers are not guaranteed to run when the program exits, including when it exits via os.Exit. So a program that relies on finalizers to flush data will lose the data at exit regardless of how long it ran.
The interaction with a real resource limit
The reason this matters is that resources are finite and their limits are enforced by something other than the collector. A descriptor table of 1024 entries, a lock, a memory mapping, a database connection slot on a server that counts them: each has an owner outside the runtime, and none of them knows anything about reachability.
The design that holds up under those limits is to make the release explicit on the paths that matter and to keep the finalizer only for the paths that are already broken. That means the success path closes deterministically, the error path closes deterministically, and the finalizer catches the case where a caller ignored both. The finalizer’s presence should not change the code on the first two paths, because the whole point is that it is not part of the contract.
Where the explicit path is hard to arrange, the alternative is to restructure the ownership so that it is easy. A per-request arena that is released in one operation removes the need for a finalizer on every object in the request, and the arena itself can be released by a scope guard. That trade is described in arena allocation, and it is often the right answer when the number of finalizable objects would be large. The complementary case, where the object must outlive any single scope, is where shared ownership becomes relevant, and it has the same property: the release happens at a point the program controls, not at a point the collector chooses.
The last resort, when a resource cannot be released before the process ends, is the exit path itself. That path has its own ordering rules and its own guarantees, and it is the subject of shutdown ordering and kernel reclamation.
References
- Java Platform, Standard Edition — java.lang.ref.Cleaner — Oracle
- Go Package runtime — SetFinalizer — The Go Project
- The Garbage Collection Handbook — Jones, Hosking & Moss, CRC Press
- ECMAScript 2023 Language Specification — WeakRef and FinalizationRegistry — Ecma International