skip to content

You own native (off-heap) memory in a Java class. Design a cleanup strategy without finalize(). What are the key pitfalls?

level: principalimportance: nice to knowfreq 22%

answer

  1. AutoCloseable.close() = primary; Cleaner = backstop
  2. Static state-holder: pointer only, NO 'this'
  3. Capturing this -> permanent leak
  4. clean() frees once + de-registers; idempotent free
  5. Cleaner not deterministic; close() stays real path

basics

~20 s

Make the class AutoCloseable and free the native memory in close(); callers use try-with-resources. Add a Cleaner as a safety net for forgotten closes, but make the cleanup action a separate object that holds only the native pointer, never a reference back to your class.

solid answer

~50 s

I'd give the class two cleanup paths. Primary: implement AutoCloseable; close() frees the native pointer and is idempotent, and callers manage it with try-with-resources for deterministic release. Safety net: register a Cleaner action that frees the same pointer if the object is collected before close() is called. The critical design rule is that the cleanup action must NOT reference the owning object — otherwise the Cleaner keeps it permanently reachable and it never gets collected. So I put the native pointer (and the free logic) in a separate static state-holder class and register that with the Cleaner. close() then calls cleanable.clean() to both free and de-register, so cleanup happens exactly once. Pitfalls: capturing 'this' (memory leak), double-free if close() isn't idempotent, races between explicit close() and the Cleaner thread, and assuming the Cleaner runs promptly — it doesn't, so close() must remain the real mechanism. I'd also document that finalize() is never used and is banned in our codebase.

go deeper

for a junior

Can say native memory needs explicit freeing and that try-with-resources/close() is used instead of finalize().

for a middle

Describes implementing AutoCloseable for close() plus a Cleaner backstop, and knows finalize() is avoided.

for a senior

Implements the static state-holder pattern correctly, explains the no-back-reference rule, idempotent free, and that Cleaner is non-deterministic so close() is primary.

for a principal

Owns the full lifecycle/ownership and concurrency design, the double-free/race/leak pitfalls, sets a codebase policy banning finalize(), and plans for finalization's removal from the platform.

## Why native memory is special **Native (off-heap) memory** is memory allocated outside the Java heap — typically by C/C++ code reached through **JNI** or via APIs like the **Foreign Function & Memory API**. The **garbage collector** manages the *Java* heap; it does **not** know about native allocations. So if the Java wrapper object is collected but nothing freed the native block, you get a **native memory leak** the GC can never fix. You must arrange to call the native `free` yourself. The old (broken) way to do this was `finalize()`. We don't use it — it's unpredictable, may never run, can resurrect, swallows exceptions, and is deprecated for removal (**JEP 421**). The modern design uses two complementary mechanisms. ## Primary path: deterministic release via `AutoCloseable` Implement `java.lang.AutoCloseable` so `close()` frees the native block, and have callers use **try-with-resources** so `close()` runs at a known point, even on exception: ```java try (var buf = NativeBuffer.allocate(1 << 20)) { // use buf } // buf.close() runs here, deterministically ``` This is the *real* cleanup mechanism. Everything else is a backstop. ## Safety-net path: `java.lang.ref.Cleaner` Real code sometimes forgets to call `close()`. For native memory, a forgotten close is a true leak, so we add a `Cleaner` (Java 9+) as a backstop: when the wrapper becomes phantom-reachable, the Cleaner runs a registered action that frees the block. ### The cardinal rule: the action must not reference the owning object The `Cleaner` holds the cleanup action reachable. If that action (a lambda or `Runnable`) captures `this` — the wrapper — then the wrapper is reachable through the Cleaner **forever** and can never become phantom-reachable, so the cleanup never fires: a self-inflicted leak. Therefore the cleanup *state* (the native pointer + the free logic) lives in a **separate object** that knows nothing about the wrapper: ```java public final class NativeBuffer implements AutoCloseable { private static final Cleaner CLEANER = Cleaner.create(); // State holder: holds ONLY the native pointer, no reference to NativeBuffer. private static final class State implements Runnable { private long ptr; // 0 means already freed State(long ptr) { this.ptr = ptr; } public void run() { // idempotent free if (ptr != 0) { free(ptr); ptr = 0; } } } private final State state; private final Cleaner.Cleanable cleanable; private NativeBuffer(long ptr) { this.state = new State(ptr); this.cleanable = CLEANER.register(this, state); // 'state' must not capture 'this' } public static NativeBuffer allocate(long bytes) { return new NativeBuffer(malloc(bytes)); } @Override public void close() { cleanable.clean(); // runs state.run() exactly once and de-registers from the Cleaner } private static native long malloc(long bytes); private static native void free(long ptr); } ``` `Cleanable.clean()` both runs the action and de-registers it, and the framework ensures the action runs **at most once** whether triggered by `close()` or by the GC — so there's no double-free as long as the action itself is idempotent. ## Pitfalls a principal must call out 1. **Capturing `this` in the action** — the #1 mistake; it pins the object and defeats the backstop (and leaks). Always use a static state-holder. 2. **Non-idempotent free / double-free** — `close()` and the Cleaner could both try to free. Guard with a sentinel (`ptr == 0`) and rely on `clean()` running the action once. 3. **Assuming promptness** — the Cleaner is GC-driven and **not deterministic**; under low memory pressure it may run much later or, at JVM exit, not at all. So `close()` must stay the primary path; the Cleaner is insurance, not a schedule. 4. **Thread-safety / visibility** — the action may run on a Cleaner thread concurrently with application threads; the native pointer and free logic must be safe to touch from another thread (the `clean()`-once guarantee handles the common race, but any shared mutable state needs care). 5. **Exception handling** — unlike `finalize()`, a throwing Cleaner action won't corrupt the object's state silently, but you still want the native free to be robust and logged. 6. **Lifecycle / ownership** — if the buffer is shared, decide ownership explicitly; reference-counting or a single owner avoids freeing memory another component still uses. 7. **Policy** — ban `finalize()` outright (lint/architecture rule) since it's on the removal path; standardize the AutoCloseable + static-state-holder + Cleaner pattern. ## Why this beats finalize() No resurrection (the action can't reach the object), no at-most-once cleanup trap (clean() de-registers cleanly), no swallowed-exception corruption of object state, deterministic primary path via `close()`, and a real backstop for forgotten closes — all without depending on a deprecated, soon-to-be-removed mechanism.

  • Why must the Cleaner cleanup action be a separate (often static) object rather than a lambda capturing 'this'?
    Because the Cleaner keeps the action reachable. If the action references the owning object, the object stays reachable forever and never becomes phantom-reachable, so the cleanup never runs — a leak. A separate holder captures only the native pointer, so the wrapper can be collected and the backstop fires.
  • If both close() and the Cleaner could free the resource, how do you prevent a double-free?
    Use Cleanable.clean(), which guarantees the registered action runs at most once and de-registers it, and make the native free idempotent with a sentinel (e.g. set the pointer to 0 after freeing). Then whichever path fires first does the free; the other is a no-op.

saying these in an interview costs you the question

  • Registering a Cleaner action that captures the owning object
  • Relying on the Cleaner instead of providing close()
  • Assuming the Cleaner runs promptly or at JVM exit
  • Falling back to finalize() for native cleanup
  • Non-idempotent native free leading to double-free crashes

context