Contents
- Why Cleaner Instead of finalize()
- Creating a Cleaner and Registering Objects
- Combining with AutoCloseable
- Native Resource Example
- Pitfalls and Best Practices
- finalize() is deprecated (Java 9) and removed in Java 18+. It has serious problems: unpredictable timing, can prevent GC, can resurrect objects, and runs on a single finalizer thread that blocks on errors.
- Cleaner advantages: runs on a dedicated thread that doesn't block other finalization; cleaning action runs in its own thread with no reference to the object; can be triggered explicitly; supports multiple cleaners for different resource types.
- Primary use: safety net cleanup for native memory, file handles, or other OS resources when close() is not called explicitly.
// finalize() — don't use this
public class OldResource {
private long nativeHandle;
@Override
@Deprecated
protected void finalize() throws Throwable {
// Problems: unpredictable, can block GC, can resurrect 'this'
freeNative(nativeHandle);
}
}
Cleaner.create() returns a Cleaner instance backed by a daemon thread that processes cleaning actions. Passing an object and a Runnable to register() returns a Cleaner.Cleanable handle; the Runnable will be executed when the registered object becomes phantom-reachable (i.e., the GC determines nothing else can reach it through strong, soft, or weak references). The single most important constraint is that the cleaning action must not hold a reference back to the object being cleaned — if it does, the object can never become phantom-reachable and will never be collected, creating a permanent memory leak. The safe approach is a static nested class or a separate standalone class that captures only the primitive handles or resource identifiers needed for cleanup, never this.
import java.lang.ref.Cleaner;
// Create a shared Cleaner (one per subsystem is typical)
// Runs cleanup actions on its own daemon thread
static final Cleaner CLEANER = Cleaner.create();
class Resource {
private final long handle;
private final Cleaner.Cleanable cleanable;
Resource(long handle) {
this.handle = handle;
// Register a cleanup action — it MUST NOT reference 'this'
// Use a static inner class or a lambda that captures only the handle
this.cleanable = CLEANER.register(this, new CleanupAction(handle));
}
// The cleaning action must be a static class (or standalone class)
// to avoid holding a reference back to the enclosing Resource object
private static class CleanupAction implements Runnable {
private final long handle;
CleanupAction(long handle) { this.handle = handle; }
@Override
public void run() {
System.out.println("Cleaning handle: " + handle);
// freeNative(handle);
}
}
public void use() { System.out.println("Using handle: " + handle); }
// Trigger cleanup immediately (optional — also runs automatically after GC)
public void cleanup() { cleanable.clean(); }
}
// Usage
Resource r = new Resource(42L);
r.use();
r.cleanup(); // explicit cleanup — idempotent (safe to call multiple times)
// If cleanup() was not called, CLEANER runs it after r becomes unreachable
The cleaning action must not hold a reference to the object being cleaned. If the lambda or inner class captures this, the object is strongly reachable from the Cleaner and will never be GC'd — creating a memory leak. Always use a static nested class or capture only primitive/immutable values.
The recommended pattern: implement AutoCloseable for explicit resource management, and use Cleaner as a safety net for forgotten close() calls:
public class SafeResource implements AutoCloseable {
private static final Cleaner CLEANER = Cleaner.create();
// Holds state needed for cleanup — no reference to SafeResource
private static class State implements Runnable {
private boolean closed = false;
private final long nativeHandle;
State(long nativeHandle) { this.nativeHandle = nativeHandle; }
@Override
public void run() {
if (!closed) {
System.err.println("WARNING: Resource " + nativeHandle +
" was not explicitly closed!");
// freeNative(nativeHandle);
closed = true;
}
}
}
private final State state;
private final Cleaner.Cleanable cleanable;
public SafeResource(long nativeHandle) {
this.state = new State(nativeHandle);
this.cleanable = CLEANER.register(this, state);
}
@Override
public void close() {
state.closed = true; // mark as closed to suppress warning
cleanable.clean(); // run cleanup immediately and deregister
}
public void doWork() {
if (state.closed) throw new IllegalStateException("Resource is closed");
System.out.println("Working with: " + state.nativeHandle);
}
}
// Correct usage — no leak warning
try (SafeResource r = new SafeResource(99L)) {
r.doWork();
} // close() called automatically
// Forgot close() — Cleaner will print the warning after GC
SafeResource forgotten = new SafeResource(100L);
forgotten.doWork();
// forgotten becomes unreachable → Cleaner prints warning
The canonical pattern for native resource management combines AutoCloseable with Cleaner in two distinct layers. A static inner class (here Deallocator) holds the native memory address and implements Runnable — it has no reference to the outer object and contains only what is needed to free the resource. The outer class registers this state object with the Cleaner at construction time. Calling close() explicitly invokes cleanable.clean(), which runs the action immediately and deregisters it so the Cleaner thread will not run it again. If the caller forgets to close the object, the Cleaner catches the omission once the object is GC'd — making it a genuine safety net rather than a primary resource-release mechanism. The volatile closed flag prevents use-after-close bugs.
// Wrapping a native library handle (C library loaded via JNI/Panama)
public class NativeBuffer implements AutoCloseable {
private static final Cleaner CLEANER = Cleaner.create();
private static final class Deallocator implements Runnable {
private long address;
Deallocator(long address) { this.address = address; }
@Override
public void run() {
if (address != 0) {
freeNativeBuffer(address); // native call
address = 0;
}
}
}
private final long address;
private final int capacity;
private final Cleaner.Cleanable cleanable;
private volatile boolean closed = false;
public NativeBuffer(int capacity) {
this.capacity = capacity;
this.address = allocateNativeBuffer(capacity); // native call
this.cleanable = CLEANER.register(this, new Deallocator(address));
}
public void putByte(int offset, byte value) {
checkOpen();
putNativeByte(address + offset, value); // native call
}
public byte getByte(int offset) {
checkOpen();
return getNativeByte(address + offset);
}
private void checkOpen() {
if (closed) throw new IllegalStateException("Buffer is closed");
}
@Override
public void close() {
closed = true;
cleanable.clean();
}
}
- Cleaner is a safety net, not a primary resource manager. Always implement AutoCloseable and use try-with-resources.
- Timing is not guaranteed. The GC may not run for a long time — don't rely on Cleaner for timely resource release (e.g., database connections, file locks).
- Never capture this in the cleaning action. Use a static nested class capturing only the necessary state.
- Cleaner.clean() is idempotent. Safe to call multiple times — only the first call runs the action.
- One Cleaner per subsystem is the recommended pattern — don't create a new Cleaner per object.
// BAD — captures 'this', prevents GC
class Broken implements AutoCloseable {
private final Cleaner.Cleanable cleanable;
private long handle;
Broken(long handle) {
this.handle = handle;
// Lambda captures 'this' — the object will NEVER be GC'd!
cleanable = CLEANER.register(this, () -> freeNative(this.handle));
}
@Override public void close() { cleanable.clean(); }
}
// GOOD — static class captures only the handle value
class Fixed implements AutoCloseable {
private static final Cleaner CLEANER = Cleaner.create();
private final Cleaner.Cleanable cleanable;
private static class Cleanup implements Runnable {
private final long handle;
Cleanup(long h) { this.handle = h; }
@Override public void run() { freeNative(handle); }
}
Fixed(long handle) {
cleanable = CLEANER.register(this, new Cleanup(handle));
}
@Override public void close() { cleanable.clean(); }
}
The JDK itself uses Cleaner for DirectByteBuffer deallocation and FileDescriptor cleanup. It is the correct, supported way to manage native resources in modern Java — replacing both finalize() and manual PhantomReference patterns.