Loading content...
Objects are born through constructors. They live, performing their duties, being called upon by the system. And eventually, inevitably, they must die. But how an object dies matters enormously.
An object that simply disappears—abandoning open files, unreleased database connections, unfreed memory, or locked resources—poisons the system it leaves behind. Resource leaks accumulate silently until the system crashes, runs out of connections, or corrupts data. The cleanup phase of an object's lifecycle is every bit as critical as its construction.
By the end of this page, you will understand why explicit cleanup is necessary even in garbage-collected languages, how destructors and finalizers work (and why to avoid them), and modern patterns like try-with-resources that ensure clean object death. You'll learn to design objects that release resources reliably.
Many developers, especially those who learned on garbage-collected languages, have a dangerous misconception: "The garbage collector handles cleanup; I don't need to worry about it."
This is only partially true—and the false part can destroy your system.
What garbage collectors DO handle:
What garbage collectors DON'T handle:
| Resource Type | Consequence of Not Cleaning | Typical Limit |
|---|---|---|
| File handles | OS refuses to open new files | ~65,000 per process (Linux) |
| Database connections | Connection pool exhausted; DB overloaded | ~100-500 per pool |
| Network sockets | Port exhaustion; connection failures | ~65,000 per host |
| Memory-mapped files | Address space exhaustion | Limited by virtual memory |
| Thread handles | Cannot create new threads | OS-dependent (thousands) |
| Locks/Mutexes | Deadlocks; resource starvation | Logical limit: 1 holder |
Resource leaks are insidious because they don't cause immediate failure. The system works fine for days or weeks. Then, suddenly, it crashes—but not near the leaking code. The crash happens wherever the exhausted resource is next requested, making diagnosis extremely difficult.
The fundamental rule:
If you acquire a resource, you are responsible for releasing it. The garbage collector only handles memory. Everything else is on you.
This is why understanding object cleanup is essential, even in modern, garbage-collected languages.
In languages without garbage collection (C++) or with deterministic cleanup (Rust), destructors provide automatic, predictable cleanup when objects go out of scope.
Key characteristics of destructors:
1234567891011121314151617181920212223242526272829303132333435363738394041424344
class FileHandler {private: FILE* file_; std::string path_; public: // Constructor acquires resource FileHandler(const std::string& path) : path_(path) { file_ = fopen(path.c_str(), "r"); if (!file_) { throw std::runtime_error("Cannot open file: " + path); } std::cout << "Opened: " << path << std::endl; } // Destructor releases resource ~FileHandler() { if (file_) { fclose(file_); std::cout << "Closed: " << path_ << std::endl; } } // Prevent copying (or implement deep copy) FileHandler(const FileHandler&) = delete; FileHandler& operator=(const FileHandler&) = delete; std::string readLine() { // ... read from file_ ... }}; void processFile(const std::string& path) { FileHandler file(path); // Constructor opens file // Use the file... std::string line = file.readLine(); // Even if an exception occurs here, destructor runs! process(line); } // <-- Destructor automatically called here, file closed // This pattern is called RAII: Resource Acquisition Is InitializationRAII (Resource Acquisition Is Initialization):
This C++ pattern ties resource lifetime to object lifetime:
RAII is considered the gold standard for resource management because it's automatic, exception-safe, and deterministic.
Garbage-collected languages can't have true destructors because the GC runs at unpredictable times. You might create an object, lose all references to it, but it could linger in memory for seconds, minutes, or hours before the GC collects it. For resources that must be released promptly, this unpredictability is unacceptable.
Java and other GC languages offer finalizers (or similar mechanisms like Python's __del__)—methods called when the garbage collector decides to reclaim an object. These might seem like destructors, but they are fundamentally different and dangerous.
Why finalizers are problematic:
finalize() method is officially deprecated, signaling you shouldn't use it.12345678910111213141516171819202122232425262728293031323334353637
// ❌ BAD: Using finalizers for resource cleanuppublic class ResourceHolder { private Connection connection; public ResourceHolder() { this.connection = Database.connect(); } // DON'T DO THIS @Override protected void finalize() throws Throwable { try { if (connection != null) { connection.close(); // Might never be called! } } finally { super.finalize(); } }} // Problems:// 1. Under heavy load, GC might not run for minutes/hours// 2. Database connection pool exhausted before finalizers run// 3. System crashes, but finalizers for leaked resources show no issue// 4. Post-mortem: "All finalizers were written correctly..." // What happens in practice:void createResources() { for (int i = 0; i < 1000; i++) { new ResourceHolder(); // Connections opened // Objects unreachable, but finalizers haven't run! // 1000 connections open, pool exhausted } // Program proceeds normally, unaware of the leak // Later: "Connection pool exhausted" in unrelated code}In modern Java development, the rule is simple: DON'T USE FINALIZERS. They are deprecated, unreliable, and dangerous. The only legitimate use (as a safety net for native resources) is better handled by Cleaner. Use try-with-resources and explicit close() methods instead.
Since finalizers are unreliable, garbage-collected languages use the Disposable pattern: objects that hold resources implement an interface declaring a cleanup method that users must call explicitly.
Common interfaces:
| Language | Interface | Method |
|---|---|---|
| Java | AutoCloseable / Closeable | close() |
| C# | IDisposable | Dispose() |
| Python | Context Manager | __exit__() |
| Go | (convention) | Close() |
| Rust | Drop trait | drop() (automatic) |
12345678910111213141516171819202122232425262728293031323334353637383940414243444546474849505152535455
// A properly implemented auto-closeable resourcepublic class DatabaseSession implements AutoCloseable { private final Connection connection; private final String sessionId; private boolean closed = false; public DatabaseSession(String connectionUrl) throws SQLException { this.sessionId = UUID.randomUUID().toString(); this.connection = DriverManager.getConnection(connectionUrl); this.connection.setAutoCommit(false); System.out.println("Session " + sessionId + " opened"); } public ResultSet query(String sql) throws SQLException { checkNotClosed(); return connection.createStatement().executeQuery(sql); } public void commit() throws SQLException { checkNotClosed(); connection.commit(); } public void rollback() throws SQLException { checkNotClosed(); connection.rollback(); } private void checkNotClosed() { if (closed) { throw new IllegalStateException( "Session " + sessionId + " is already closed" ); } } @Override public void close() throws SQLException { if (!closed) { closed = true; try { // Rollback any uncommitted transaction if (!connection.getAutoCommit()) { connection.rollback(); } } finally { connection.close(); System.out.println("Session " + sessionId + " closed"); } } // Multiple close() calls are safe - idempotent }}The Disposable pattern requires manual calling of close(). This is still error-prone—developers forget, exception paths skip cleanup, code gets complex. Try-with-resources (Java) and with statements (Python) solve this by automatically calling cleanup methods.
This pattern ensures resources are cleaned up even if exceptions occur, without complex try-finally blocks.
12345678910111213141516171819202122232425262728293031323334353637383940
// ❌ Manual cleanup - verbose, error-proneConnection conn = null;Statement stmt = null;ResultSet rs = null; try { conn = getConnection(); stmt = conn.createStatement(); rs = stmt.executeQuery(sql); while (rs.next()) { process(rs); }} catch (SQLException e) { log.error("Query failed", e);} finally { // Must close in reverse order // Must handle each close exception if (rs != null) { try { rs.close(); } catch (SQLException e) { log.warn("Failed to close ResultSet", e); } } if (stmt != null) { try { stmt.close(); } catch (SQLException e) { log.warn("Failed to close Statement", e); } } if (conn != null) { try { conn.close(); } catch (SQLException e) { log.warn("Failed to close Connection", e); } }}12345678910111213141516171819202122
// ✅ Try-with-resources - clean, safetry ( Connection conn = getConnection(); Statement stmt = conn.createStatement(); ResultSet rs = stmt.executeQuery(sql)) { while (rs.next()) { process(rs); }} catch (SQLException e) { log.error("Query failed", e);} // close() called automatically on:// - rs (first)// - stmt (second)// - conn (last)// Even if exceptions occur inside the try block// Even if close() throws an exception // Exceptions from close() are "suppressed"// and attached to the primary exception12345678910111213141516171819
# Python's 'with' statement provides the same guaranteewith open('data.txt', 'r') as file: content = file.read() process(content)# file.close() called automatically when exiting the 'with' block # Multiple resourceswith open('input.txt') as infile, open('output.txt', 'w') as outfile: for line in infile: outfile.write(transform(line))# Both files closed automatically # Works with any context managerwith DatabaseSession(url) as session: results = session.query("SELECT * FROM users") for row in results: process(row) session.commit()# session.close() called automaticallyFor any resource that implements AutoCloseable or Closeable, always use try-with-resources. There is no reason to manually manage resource cleanup in modern Java. The same applies to Python's 'with' statement for context managers.
Real-world cleanup often involves complications: resources with dependencies, partial initialization, cleanup that can itself fail, and resources that must be cleaned up in specific orders.
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100
// Pattern: Composite resource with ordered cleanuppublic class TransactionContext implements AutoCloseable { private final Connection connection; private final Session session; private final Lock lock; private boolean committed = false; public TransactionContext(String dbUrl, String sessionId, Lock lock) throws SQLException { // Acquire resources in order, tracking progress for rollback Connection conn = null; Session sess = null; boolean lockAcquired = false; try { // Step 1: Acquire lock lock.lock(); lockAcquired = true; // Step 2: Open connection conn = DriverManager.getConnection(dbUrl); conn.setAutoCommit(false); // Step 3: Create session sess = new Session(conn, sessionId); // All resources acquired successfully this.connection = conn; this.session = sess; this.lock = lock; } catch (Exception e) { // Cleanup in reverse order of what we acquired if (sess != null) { try { sess.close(); } catch (Exception ignored) {} } if (conn != null) { try { conn.close(); } catch (Exception ignored) {} } if (lockAcquired) { lock.unlock(); } throw e; } } public void commit() throws SQLException { connection.commit(); committed = true; } @Override public void close() throws SQLException { SQLException suppressed = null; try { // Step 1: Handle transaction state if (!committed) { try { connection.rollback(); } catch (SQLException e) { suppressed = e; } } } finally { try { // Step 2: Close session session.close(); } catch (SQLException e) { if (suppressed != null) { e.addSuppressed(suppressed); } suppressed = e; } finally { try { // Step 3: Close connection connection.close(); } catch (SQLException e) { if (suppressed != null) { e.addSuppressed(suppressed); } suppressed = e; } finally { // Step 4: Release lock (always, no matter what) lock.unlock(); } } } if (suppressed != null) { throw suppressed; } }} // Usage - despite complexity inside, usage is simpletry (TransactionContext ctx = new TransactionContext(url, id, lock)) { doWork(ctx); ctx.commit();}When cleanup fails, you must either throw an exception or log a prominent warning. Silent failure during cleanup leads to resource leaks that are extremely difficult to diagnose. Use suppressed exceptions to avoid losing error information.
Java 9 introduced java.lang.ref.Cleaner as a replacement for finalizers. It provides a safer (though still unreliable) way to clean up resources when an object becomes phantom-reachable.
When to use Cleaner:
12345678910111213141516171819202122232425262728293031323334353637383940414243444546474849505152535455565758
public class NativeResource implements AutoCloseable { // Shared Cleaner instance private static final Cleaner CLEANER = Cleaner.create(); // The actual resource handle private final long nativeHandle; // The cleanable registered with the Cleaner private final Cleaner.Cleanable cleanable; // State held separately for the cleaning action // IMPORTANT: This must NOT reference the NativeResource instance! private static class CleaningAction implements Runnable { private long handle; CleaningAction(long handle) { this.handle = handle; } @Override public void run() { if (handle != 0) { freeNativeResource(handle); // Native call handle = 0; System.out.println("Safety net: cleaned native resource"); } } } public NativeResource() { this.nativeHandle = allocateNativeResource(); // Native call // Register cleaning action as safety net CleaningAction action = new CleaningAction(nativeHandle); this.cleanable = CLEANER.register(this, action); } // Public methods use nativeHandle... @Override public void close() { // Explicit cleanup - this is the preferred path cleanable.clean(); // Runs the CleaningAction and deregisters System.out.println("Explicitly closed native resource"); } // Native methods (would be implemented in C/C++) private static native long allocateNativeResource(); private static native void freeNativeResource(long handle);} // Preferred usage: try-with-resourcestry (NativeResource resource = new NativeResource()) { useResource(resource);} // close() called, native resource freed immediately // If someone forgets try-with-resources, Cleaner eventually runs// (but with unpredictable timing - this is the safety net, not the plan)We've completed our journey through the object lifecycle—from construction through cleanup. Let's consolidate the key principles:
What's next:
With constructors and object lifecycle mastered, we're now prepared to explore access modifiers and visibility—how we control what parts of our objects are exposed to the outside world and protect encapsulation.
You have completed the Constructors and Object Lifecycle module. You now understand how to design objects that are born valid through proper constructor design and die gracefully through disciplined resource cleanup. This foundation is essential for building robust, professional-quality software.