You own a class wrapping an off-heap buffer. How do you design its resource cleanup using AutoCloseable and a Cleaner backstop, and what are the pitfalls?
answer
- AutoCloseable primary, Cleaner backstop
- Static State class, never capture the wrapper
- clean() at most once + guard address (no double free)
- Cleaner timing non-deterministic; off-heap can outrun GC
- reachabilityFence for long methods; FFM Arena for new code
basics
~20 sMake the class AutoCloseable so callers free the buffer promptly with try-with-resources. Also register it with a Cleaner as a safety net if they forget. Put the buffer handle in a separate state object the cleanup doesn't tie back to the wrapper, and make close() and the Cleaner path idempotent.
solid answer
~50 sThe primary mechanism is deterministic: implement AutoCloseable so callers free the off-heap buffer immediately via try-with-resources, never relying on GC timing. Add a Cleaner only as a backstop for forgotten closes. Keep the native handle in a separate static state class (implementing Runnable) holding just the raw address — crucially it must not reference the wrapper, or the wrapper never becomes phantom-reachable and the backstop never fires. register() returns a Cleanable; close() calls cleanable.clean(), which is idempotent and runs the action at most once, so the explicit and GC paths can't double-free. Pitfalls: capturing the outer instance (leak), double-free races (guard the address, null it after freeing), assuming Cleaner runs promptly (it doesn't), and forgetting thread-safety if close() can race with the cleaner thread. Consider whether the newer Foreign Function & Memory API's Arena is a better fit for new code.
code
java · 32 linesimport java.lang.ref.Cleaner;
import java.lang.ref.Reference;
public final class OffHeapBuffer implements AutoCloseable {
private static final Cleaner CLEANER = Cleaner.create();
private static final class State implements Runnable {
private long address; // 0 == freed
State(long address) { this.address = address; }
@Override public synchronized void run() {
if (address != 0) { freeNative(address); address = 0; }
}
}
private final State state;
private final Cleaner.Cleanable cleanable;
public OffHeapBuffer(long size) {
this.state = new State(allocNative(size));
this.cleanable = CLEANER.register(this, state); // backstop
}
public void writeAll() {
// ... long-running work using state.address ...
Reference.reachabilityFence(this); // keep 'this' alive till here
}
@Override public void close() { cleanable.clean(); } // primary, idempotent
private static native long allocNative(long size);
private static native void freeNative(long address);
}go deeper
Knows you should free native buffers and that try-with-resources helps; may not know the Cleaner backstop or pitfalls.
Implements AutoCloseable and registers a Cleaner, and knows the state must not reference the wrapper.
Designs the static-state + idempotent clean() pattern, handles close/cleaner races and use-after-close.
Reasons about non-deterministic timing causing off-heap exhaustion, reachabilityFence, thread-safety of cleanup, and weighs FFM Arena as a modern alternative.
## The scenario You have a class that owns an **off-heap buffer** — memory allocated outside the Java heap (e.g. via `Unsafe`, a native `malloc`, or a direct `ByteBuffer`). The GC does **not** track off-heap bytes, so if your wrapper object is collected without freeing that memory, you leak native memory that can crash the process under load. You need a robust release strategy. ## Layer 1 (primary): deterministic release via AutoCloseable The **correct primary** mechanism is **explicit, deterministic** cleanup. Implement `AutoCloseable` and free in `close()`, and document try-with-resources: ```java try (OffHeapBuffer buf = new OffHeapBuffer(1 << 20)) { buf.put(...); } // close() runs here, deterministically, regardless of exceptions ``` This releases memory **at a known point**, independent of GC behavior. Never make correctness depend on GC timing. ## Layer 2 (backstop): Cleaner for forgotten closes Humans forget `close()`. A `Cleaner` is the **safety net**: if the wrapper becomes unreachable without `close()`, the Cleaner eventually frees the buffer. Design: ```java public final class OffHeapBuffer implements AutoCloseable { private static final Cleaner CLEANER = Cleaner.create(); // Separate state: holds ONLY the raw handle, never OffHeapBuffer. private static final class State implements Runnable { private long address; // 0 == freed State(long address) { this.address = address; } @Override public synchronized void run() { if (address != 0) { freeNative(address); address = 0; } } } private final State state; private final Cleaner.Cleanable cleanable; public OffHeapBuffer(long size) { this.state = new State(allocNative(size)); this.cleanable = CLEANER.register(this, state); } @Override public void close() { cleanable.clean(); } // prompt + idempotent private static native long allocNative(long size); private static native void freeNative(long address); } ``` `Cleanable.clean()` runs the action **at most once**: whichever of `close()` or the GC-triggered path happens first wins, so the buffer is never double-freed. ## Pitfall 1: capturing the wrapper (the cardinal sin) The cleanup `State` **must not** reference the `OffHeapBuffer` instance — not directly, and not via a **lambda or non-static inner class**, which capture the enclosing instance implicitly. If it did, the Cleaner's internal phantom reference would keep the wrapper reachable, so it would **never** become phantom-reachable, the backstop would **never** fire, and you'd leak. Hence `State` is a **static** nested class holding only the `long address`. ## Pitfall 2: double-free / use-after-free Guard the handle: store `0` after freeing and check before freeing (`if (address != 0)`). This makes `run()` idempotent at the resource level too, surviving any path that triggers cleanup twice. ## Pitfall 3: races between close() and the cleaner thread `close()` runs on the caller's thread; the GC-triggered cleanup runs on the **Cleaner's daemon thread**. If both can occur, synchronize the `run()` (as above) so freeing is atomic. Also beware **use-after-close**: methods must not touch the buffer after `close()` (check `address == 0` and throw `IllegalStateException`). ## Pitfall 4: assuming Cleaner is timely The Cleaner fires **whenever the GC decides** the object is phantom-reachable — possibly long after the last use, possibly only under memory pressure (which off-heap allocations don't even create!). So **off-heap exhaustion can occur even though heap is fine**, because the GC sees no pressure to collect the small wrapper. This is exactly why the AutoCloseable path must be primary. ## Pitfall 5: reachability of the wrapper during a long method Subtle JIT issue: if a long-running instance method only reads the off-heap handle early, the GC may consider `this` unreachable mid-method and the Cleaner could free the buffer **while the method is still running**. Use `Reference.reachabilityFence(this)` at the end of such methods to keep `this` alive until the work completes. ## Modern alternative: Foreign Function & Memory API For new code targeting recent JDKs, the **Foreign Function & Memory (FFM) API** offers `Arena` / `MemorySegment` with **scoped, deterministic** native-memory management and bounds checking — often a cleaner fit than hand-rolled Cleaner plumbing. Evaluate it before reaching for Unsafe + Cleaner. ## Key takeaways - AutoCloseable + try-with-resources is the **primary**, deterministic path; Cleaner is the **backstop**. - State object must be static and never reference the wrapper. - Make freeing idempotent (`clean()` + guarded address) and thread-safe. - Cleaner timing is non-deterministic; off-heap leaks can outpace the GC. - Consider `reachabilityFence` for long methods, and FFM `Arena` for new designs.
- Why can off-heap memory be exhausted even though the Java heap is healthy and the GC isn't running?The GC's collection schedule is driven by heap pressure, but off-heap bytes are invisible to it. A small wrapper object may stay uncollected for a long time, so its Cleaner-based free never runs in time, letting native memory pile up. That's why explicit close() is mandatory for correctness.
- What does Reference.reachabilityFence(this) prevent, and when do you need it?The JIT may treat 'this' as unreachable once a method stops using its fields, letting the Cleaner free the resource mid-method. reachabilityFence(this) keeps the object strongly reachable up to that point, preventing premature cleanup. Needed in long-running instance methods that use a Cleaner-managed native handle.
saying these in an interview costs you the question
- Using a Cleaner as the only/primary cleanup and skipping AutoCloseable.
- Cleanup state (lambda/inner class) capturing the wrapper, so the backstop never fires.
- Not guarding against double-free or use-after-close.
- Assuming Cleaner runs promptly enough to bound off-heap memory.
- Ignoring reachabilityFence and risking premature cleanup during a long method.