Object Lifecycle: Construction, Disposal, Finalizers & RAII
Getting an object fully valid before anyone can see it, why garbage collection manages memory but not resources, and the deterministic release mechanisms — RAII, Drop, IDisposable, try-with-resources — plus the finalizer patterns that look like a safety net and are not.
Two moments decide whether an object is trustworthy: the instant it becomes visible to other code, and the instant the resources it holds are released. Managed runtimes solved half of the second problem — memory — and left the other half exactly where it was, which is why "we have a garbage collector" and "we leak file descriptors" are entirely compatible statements.
Construction: valid before visible #
The rule is that a constructor must establish the class invariant and must not let anything observe the object before it does.
Two-phase construction is the common violation. A no-arg constructor followed by setters means every reader must ask "is this one initialized yet?", the invariant cannot be checked anywhere, and fields that should be final cannot be. It exists mostly because frameworks demanded it — and modern ones (Spring constructor injection, Jackson @JsonCreator, JPA's tolerance of a package-private no-arg constructor) no longer do.
Many parameters is a real problem, and telescoping constructors are the wrong fix. A builder keeps construction single-phase while staying readable:
Order order = Order.builder()
.customer(c).currency("EUR").line(item).build(); // validation happens in build()The important detail is that build() — not the setters — performs the cross-field validation and produces an object that is valid from its first instant.
Destruction: deterministic or not #
| Language | When cleanup runs | Guarantee |
|---|---|---|
| C++ | end of scope / delete |
deterministic, exception-safe (RAII) |
| Rust | end of scope / move out | deterministic; Drop runs on unwind too |
| Python | refcount hits zero (CPython) | prompt but not portable; cycles wait for the cycle collector |
| Go | never automatic — defer at function exit |
explicit and scoped |
| Java, C# | GC decides, or explicit close/Dispose |
no timing guarantee at all |
RAII is the design that made this a non-issue in C++ and Rust: the resource is owned by an object, and the language guarantees the destructor runs on every exit path including exceptions.
{
let file = File::create("out.txt")?; // acquire
write_all(&file, data)?; // an early return here still closes the file
} // Drop runs — deterministicallyRust's honest caveat: destructors are not guaranteed in the sense of memory safety — std::mem::forget is a safe function, and reference cycles in Rc leak. Leaking is safe; use-after-free is not. That is a deliberate line.
Managed runtimes: two mechanisms, and only one of them works #
Explicit close, enforced by scope. This is the mechanism that actually releases resources on time.
try (var conn = pool.get(); var st = conn.prepareStatement(sql)) {
...
} // closed in reverse order, even on exceptionusing var stream = File.OpenRead(path); // Dispose at end of scopeFinalizers, which do not. A finalizer or ~Destructor runs when the collector gets around to it, on an unspecified thread, possibly never — the JVM makes no promise that finalizers run before exit at all.
The failure modes are specific:
- Unbounded delay. File descriptors and sockets are a per-process limit; waiting for a GC cycle to release them means
EMFILEunder load while heap usage looks fine. - Resurrection. A finalizer can store
thissomewhere reachable, un-collecting the object. - Cost. A finalizable object needs at least two GC cycles and a registration in a finalizer queue; a single stuck finalizer thread blocks the queue for every other object.
- Exceptions are swallowed, so failures are invisible.
Java deprecated finalization for removal in JDK 18 (JEP 421); as of the JDK 26/27 releases it is still enabled by default but is being progressively removed from the JDK itself, with Cleaner, AutoCloseable, and explicit close as the sanctioned replacements. C#'s dispose pattern still pairs Dispose() with a finalizer, but only for classes that directly own an unmanaged handle, and SafeHandle removes the need in most cases.
Ownership is the design question underneath #
Most lifecycle bugs are ownership bugs: two components each believe the other will close the thing, or neither does.
- State the owner in the API. A method that returns an open resource must document that the caller owns it — or better, take a callback and own it itself (
withConnection(conn -> …)), so there is no way to forget. - Do not close what you did not open. A common bug is a helper closing an injected
Connectionthat its caller intended to reuse. - Pools invert the meaning of
close.close()on a pooled connection returns it to the pool. Code that leaks one there does not lose a file descriptor — it exhausts the pool and the symptom is a hang, not an error. - Cleanup order is not arbitrary. Reverse acquisition order, which is what nested
try-with-resources and stackeddefergive you for free and what a hand-rolled shutdown sequence usually gets wrong.