skip to content

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?

level: principalimportance: nice to knowfreq 18%

answer

  1. AutoCloseable primary, Cleaner backstop
  2. Static State class, never capture the wrapper
  3. clean() at most once + guard address (no double free)
  4. Cleaner timing non-deterministic; off-heap can outrun GC
  5. reachabilityFence for long methods; FFM Arena for new code

basics

~20 s

Make 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 s

The 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 lines
java
import 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

for a junior

Knows you should free native buffers and that try-with-resources helps; may not know the Cleaner backstop or pitfalls.

for a middle

Implements AutoCloseable and registers a Cleaner, and knows the state must not reference the wrapper.

for a senior

Designs the static-state + idempotent clean() pattern, handles close/cleaner races and use-after-close.

for a principal

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.

context