Object Lifetime

An object's lifetime runs from its creation until it becomes unreachable and is eventually reclaimed — but that memory lifetime is completely separate from the lifetime of any external resource it might be holding.

In Java, you never explicitly free an object's memory. Instead, an object remains alive as long as it is reachable — reachable from at least one GC root through a chain of references — and becomes eligible for garbage collection once nothing can reach it anymore. This automatic model handles memory well, but it does nothing for non-memory resources like open files, sockets, or database connections, which need their own explicit lifecycle management.

Reachability

An object is reachable if it can be reached by following a chain of references starting from a GC root — active local variables on any thread's stack, static fields, JNI references, and a few other implementation-defined starting points. As soon as no reference chain from any GC root reaches an object, it becomes unreachable.

void method() {
    Object obj = new Object(); // obj is a GC root (local variable) referencing the new object
    // ... object is reachable here, through obj ...
} // obj goes out of scope; if nothing else references the object, it becomes unreachable

Garbage Collection Eligibility

Becoming unreachable makes an object ELIGIBLE for garbage collection — it does not mean the object is destroyed immediately. The JVM decides when (and whether, before the program exits) to actually reclaim an eligible object's memory; this timing is unspecified by the Java Language Specification and is not something correct code should depend on.

Never write logic that depends on exactly when GC runs (e.g. relying on finalize() for correctness, or timing-sensitive cleanup). Treat 'eligible for GC' and 'has been collected' as two different things.

Object o = new Object();
o = null; // the original object is now unreachable, hence GC-eligible
// It is NOT guaranteed to be collected at this exact point, or even before the program ends.

Finalization: Historical and Deprecated

Object.finalize() was Java's original mechanism for running cleanup code before an object's memory was reclaimed. It has always been unreliable in practice (no guarantee it runs promptly, or at all, before JVM shutdown; can run on an unpredictable thread; can resurrect the object by creating a new reference to it) and is deprecated since Java 9, with Object.finalize() slated for eventual removal. It should not be used in new code.

class Legacy {
    @Override
    @Deprecated(since = "9")
    protected void finalize() throws Throwable {
        // historical pattern - unreliable, do not use in new code
        super.finalize();
    }
}

AutoCloseable: The Modern Answer

AutoCloseable (Java 7+) is the modern, deterministic mechanism for cleanup: a class implements it with a close() method, and callers use try-with-resources to guarantee that method runs — even if an exception is thrown — as soon as the resource is no longer needed, independent of garbage collection.

class Connection implements AutoCloseable {
    Connection() { System.out.println("opened"); }
    void query() { System.out.println("querying"); }
    @Override public void close() { System.out.println("closed"); }
}

try (Connection c = new Connection()) {
    c.query();
} // close() runs automatically here, guaranteed, even if query() threw
// Output: opened / querying / closed

Resource Lifetime vs Object Lifetime

It is crucial to separate two different lifetimes: the object's MEMORY lifetime (how long the JVM needs to keep its fields around, ending whenever GC eventually reclaims it) and any RESOURCE lifetime the object is managing on the object's behalf (a file handle, socket, native memory block, database connection). Garbage collection only ever manages the first. An object can become unreachable and eventually be collected while the external resource it opened remains open indefinitely, because closing that resource requires code the GC does not run automatically.

Object memory lifetime:  new -----> unreachable -----> (eventually) GC reclaims memory
Resource lifetime:       open ---------------?---------------> leaked if never closed explicitly
(The two timelines are independent unless you tie them together with close()/try-with-resources.)

Explicit Cleanup for External Resources

Because the garbage collector has no idea a file descriptor, socket, or lock is logically tied to an object's fields, all such resources must be released explicitly — typically via close() in a try-with-resources block, or in extreme legacy cases a finally block. Relying on the object simply becoming unreachable to free these resources is a common source of resource leaks ("too many open files", exhausted connection pools) that can occur well before, or entirely independent of, actual GC.

// Leak-prone: relies on GC (which won't close the resource)
FileInputStream in = new FileInputStream("data.txt");
in.read();
in = null; // file descriptor is NOT released just because the reference is gone

// Correct: deterministic cleanup
try (FileInputStream in2 = new FileInputStream("data.txt")) {
    in2.read();
} // guaranteed close(), regardless of when/if GC would ever run

Reference Counting Is Not How Java Works

Java does not use reference counting to determine object lifetime (unlike some other runtimes). Reachability, not a live reference count, decides GC eligibility — this is what lets Java correctly collect cyclic structures (e.g. two objects referencing each other) that would leak memory forever under naive reference counting, as long as nothing outside the cycle can reach either of them.

class Node { Node other; }
Node a = new Node();
Node b = new Node();
a.other = b;
b.other = a; // a and b reference each other - a cycle
a = null;
b = null;
// Both nodes are now unreachable from any GC root (even though they still reference
// each other) and are eligible for collection - a reachability-based collector handles this correctly.

Practical Usage

The practical discipline: let the garbage collector handle plain memory — don't try to manage it yourself — but explicitly manage anything that represents an external resource, using try-with-resources for anything implementing AutoCloseable. When designing your own classes that wrap a resource, implement AutoCloseable rather than relying on finalize() or hoping callers remember to call a manual dispose() method.

Edge Cases

An object can be reachable purely through a static field for the entire lifetime of the class (effectively as long as the program runs) even if no code ever uses it again — a classic accidental memory leak. Also, holding a reference in a long-lived collection (a cache that's never pruned, a listener list that's never unregistered) keeps every referenced object reachable indefinitely, regardless of whether the logical 'owner' code has moved on.

class Cache {
    static final List<Object> entries = new ArrayList<>(); // static - lives as long as the class
    static void add(Object o) { entries.add(o); } // never removed - unbounded reachable growth
}

Mental Model

Think of reachability like a family tree rooted at the currently-running program state (local variables, statics): anything with a path down from a root is 'alive' in the tree; anything that gets cut off (no path from any root) is eligible to be pruned. Resource lifetime is a completely separate thing you manage yourself — like remembering to hang up a phone call even though the phone itself doesn't need to be thrown away when you're done.

Quick Reference

Object memory lifetime : automatic, governed by reachability, GC-managed
Resource lifetime       : manual, governed by your code

GC roots: local variables (active stack frames), static fields, JNI refs, ...
Reachable    -> alive, not GC-eligible
Unreachable  -> GC-eligible (timing unspecified)

AutoCloseable + try-with-resources -> deterministic resource cleanup
finalize() -> deprecated, unreliable, do not use

Key Takeaways

An object is reachable if a reference chain from a GC root reaches it; unreachable objects become eligible for GC, with no guaranteed timing.

Java's reachability-based collection correctly handles reference cycles, unlike naive reference counting.

'GC-eligible' and 'actually collected' are different things - never write correctness-critical logic that assumes a specific collection time.

Object.finalize() is deprecated and unreliable; do not use it for cleanup in new code.

Object memory lifetime and external resource lifetime (files, sockets, connections) are independent - GC manages only the former.

Use AutoCloseable and try-with-resources for deterministic, guaranteed cleanup of external resources.

FileWriter fw = new FileWriter("log.txt"); fw.write("data"); fw = null; // hoping GC will close the file try (FileWriter fw = new FileWriter("log.txt")) { fw.write("data"); } // close() is guaranteed here

Setting a reference to null only makes the object GC-eligible eventually; it does nothing to release the underlying file handle. Always use try-with-resources for AutoCloseable resources.

class Resource { @Override protected void finalize() { releaseNativeHandle(); } // relying on finalize for cleanup class Resource implements AutoCloseable { @Override public void close() { releaseNativeHandle(); } } // used with try-with-resources finalize() is deprecated, unreliable in timing, and may never run before JVM shutdown. AutoCloseable with try-with-resources gives deterministic, guaranteed cleanup.

static List<Listener> listeners = new ArrayList<>(); void register(Listener l) { listeners.add(l); } // never removed static List<Listener> listeners = new ArrayList<>(); void register(Listener l) { listeners.add(l); } void unregister(Listener l) { listeners.remove(l); } // explicitly called when no longer needed

A static collection that's never pruned keeps every added object reachable forever, causing a memory leak even though the objects are logically no longer in use.

What is the difference between an object being 'unreachable' and being 'garbage collected'?

Unreachable means no GC root can reach the object through any reference chain - this makes it eligible for collection. Garbage collected means the JVM has actually reclaimed its memory. The gap between the two is unspecified in timing and can even be indefinite (e.g. if GC never runs again before the program exits).

Why does Java's reachability-based garbage collection correctly handle reference cycles, unlike simple reference counting?

Reachability asks 'can this object be reached from a GC root', not 'how many references point to it'. Two objects that reference only each other, with no path from any root, are correctly identified as unreachable and collectible, whereas naive reference counting would see each object's count as nonzero and never free either.

Why is Object.finalize() considered unreliable, and what replaced it?

finalize() has no guaranteed timing (may run late or never before shutdown), can run on an unpredictable thread, and can even resurrect the object. AutoCloseable with try-with-resources replaced it for deterministic, guaranteed cleanup.

Explain why garbage collection alone cannot prevent a 'too many open files' error.

GC only reclaims JVM-managed memory. An open file descriptor is an OS-level resource that stays open until something explicitly calls close() on it - becoming unreachable (and even eventually being collected) doesn't trigger that call unless the class does its cleanup deterministically, e.g. via AutoCloseable.

Give an example of an object that stays reachable indefinitely even though the program logically no longer needs it.

An object added to a static collection (like a cache or listener list) that's never removed - the static field keeps a reachable reference alive as long as the class is loaded, regardless of whether that specific object is still logically in use.

What guarantee does try-with-resources give that manually calling close() in ordinary code does not?

try-with-resources guarantees close() runs even if an exception is thrown from within the try block (it's effectively an automatically-generated finally), whereas a manual close() call placed after other code is skipped if an exception propagates past it first.

Why should you never rely on GC timing for correctness-critical behavior?

The JLS does not specify when (or in some cases, whether at all, before program exit) an eligible object is actually collected - it's entirely a JVM implementation decision, so code that depends on a specific collection moment is not portable or reliable.

Are the two A objects eligible for garbage collection after this code runs? Why?

Yes. Even though x and y still reference each other, neither is reachable from any GC root anymore once the local variables x and y are set to null. Java's reachability-based collector correctly identifies the whole unreachable cycle as GC-eligible.

Write a class Session implementing AutoCloseable that prints 'session opened' in its constructor and 'session closed' in close(), then use it in a try-with-resources block that also does some work.

class Session implements AutoCloseable { Session() { System.out.println("session opened"); } void doWork() { System.out.println("working"); } @Override public void close() { System.out.println("session closed"); } } try (Session s = new Session()) { s.doWork(); }

Prints: session opened / working / session closed - close() runs automatically and deterministically at the end of the try block, regardless of whether doWork() throws.

Why does this pattern risk a memory leak even though Java has automatic garbage collection?

Every value stored in the static cache map remains reachable through that static field for as long as the class is loaded, regardless of whether the rest of the program still needs it - GC cannot collect something that's still reachable, so an ever-growing, never-pruned cache leaks memory even with a fully working garbage collector.

Write a class NativeBuffer that wraps a resource needing explicit release, implementing AutoCloseable, and show correct usage with try-with-resources versus an incorrect version that relies on the object simply going out of scope.

class NativeBuffer implements AutoCloseable { private boolean released = false; NativeBuffer() { System.out.println("native buffer allocated"); } void use() { System.out.println("using buffer"); } @Override public void close() { if (!released) { released = true; System.out.println("native buffer released"); } } } // Correct: try (NativeBuffer buf = new NativeBuffer()) { buf.use(); } // guaranteed release // Incorrect (do not do this): NativeBuffer buf2 = new NativeBuffer(); buf2.use(); buf2 = null; // the native resource is NOT released just because the reference is gone

The try-with-resources version guarantees close() (and thus release) runs deterministically. Setting buf2 = null only makes the NativeBuffer object eligible for GC eventually - it does nothing to release whatever external resource the object was wrapping, since GC has no knowledge of that resource.

class A { A other; }
A x = new A();
A y = new A();
x.other = y;
y.other = x;
x = null;
y = null;
// Are the two A objects eligible for garbage collection?
static Map<String, Object> cache = new HashMap<>();
void store(String key, Object value) {
    cache.put(key, value);
}
// value is never removed from cache