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.

builds onEncapsulation as Invariant ProtectionGC vs Manual Memory Management

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:

java
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.

rust
{
    let file = File::create("out.txt")?;   // acquire
    write_all(&file, data)?;               // an early return here still closes the file
}                                          // Drop runs — deterministically

Rust'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.

java
try (var conn = pool.get(); var st = conn.prepareStatement(sql)) {
    ...
}   // closed in reverse order, even on exception
csharp
using var stream = File.OpenRead(path);   // Dispose at end of scope

Finalizers, 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 EMFILE under load while heap usage looks fine.
  • Resurrection. A finalizer can store this somewhere 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 Connection that 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 stacked defer give you for free and what a hand-rolled shutdown sequence usually gets wrong.

Sources

  1. docsJEP 421: Deprecate Finalization for Removal (JDK 18)
  2. docsJava API — java.lang.ref.Cleaner
  3. docs.NET docs — Implement a Dispose method
  4. docsRust Book — Running code on cleanup with the Drop trait
  5. bookEffective Java, 3rd Edition

Searches titles, aliases, headings, summaries, and full text.↑↓ move ↵ open esc close