Java Cleaner API: Replace finalize() with Cleaner

Learn the Java Cleaner API with a native memory example, the mistakes that stop cleanup, and how to migrate deprecated finalize() code in Java 25.

Two paths that run a Cleaner action, close() calling clean() and the garbage collector finding the object phantom reachable

The Java Cleaner API (java.lang.ref.Cleaner, added in Java 9) registers a cleanup action for an object and runs that action once, either when our code calls clean() or after the garbage collector finds that nothing uses the object anymore. It is the replacement for finalize(), which is deprecated for removal since Java 18.

We use a Cleaner for resources that the garbage collector does not free on its own, such as native memory, file handles and sockets. The class that owns the resource still offers close() for try-with-resources, and the Cleaner is the safety net for callers who forget to call it.

The following example registers a cleanup action that decrements a counter of open handles and runs it through clean().

Cleaner cleaner = Cleaner.create();
AtomicInteger openHandles = new AtomicInteger(1);
Object owner = new Object();
Cleaner.Cleanable cleanable = cleaner.register(owner, openHandles::decrementAndGet);

cleanable.clean();                             // runs the action on this thread
int afterClean = openHandles.get();            // 0
cleanable.clean();                             // second call does nothing
int afterSecond = openHandles.get();           // 0

Notice that the second clean() call has no effect, because a cleaning action runs at most once. We start with how a Cleaner decides when to run, build a class that frees native memory, look at the mistakes that stop the cleanup, and finish with the reasons finalize() is going away and how to migrate old code.

1. What Is the Java Cleaner API?

A Cleaner keeps a list of registered objects, each paired with a Runnable that releases the object’s resources. The call register(object, action) returns a Cleaner.Cleanable, and that handle is the only way our code triggers the action itself.

Two paths that run a Cleaner action, close() calling clean() and the garbage collector finding the object phantom reachable
An explicit close() runs the cleaning action at once; the garbage collector path runs it later, or never if the JVM exits first

The action runs on one of two paths. When our code calls clean(), the action runs at once on the calling thread. When nobody calls clean(), the action runs on the Cleaner‘s own daemon thread after the garbage collector finds the object phantom reachable, which means no strong, soft or weak reference leads to it anymore and it is ready to be reclaimed.

Only the explicit clean() path is predictable, so a Cleaner is a backup for close() and never the main way to release a resource. The garbage collector runs when the heap needs space, which can be seconds, hours or never for an app with plenty of free memory.

Every cleaning action also has to follow a few rules of the Cleaner contract, and each rule changes how we write the action.

RuleWhat it means for our code
Each Cleaner starts one daemon threadAll actions of that Cleaner run one after another on this thread, so we share one Cleaner per library instead of creating one per object.
The action runs at most onceA second clean() call, or a garbage collection after clean(), does nothing.
Exceptions in the action are ignoredA failing action does not stop the Cleaner, but nobody sees the error unless the action logs it.
No guarantee at System.exit()Pending actions can be skipped when the JVM shuts down.
The action must not reference the objectIf it does, the object never becomes phantom reachable, and the action never runs on the garbage collector path.

2. Writing a Class That Uses a Cleaner

Say a photo editing service decodes uploaded images into native memory, outside the Java heap, because an image processing library reads pixels from there. The garbage collector frees the small ImageBuffer object but not the 8 MB of native memory behind it. If a request handler forgets close(), the process grows until the operating system kills it.

The following example is an ImageBuffer class that allocates its pixels with the Foreign Function and Memory API (final since Java 22). The cleanup state is kept in a nested Release record that holds the Arena to close, never the ImageBuffer itself, and close() calls clean() so try-with-resources works.

final class ImageBuffer implements AutoCloseable {

    private static final Cleaner CLEANER = Cleaner.create();   // one per library
    static final AtomicInteger OPEN = new AtomicInteger();

    // Holds only what the cleanup needs, never the ImageBuffer
    private record Release(Arena arena) implements Runnable {
        @Override
        public void run() {
            arena.close();                     // frees the native memory
            OPEN.decrementAndGet();
        }
    }

    private final MemorySegment pixels;
    private final Cleaner.Cleanable cleanable;

    ImageBuffer(int width, int height) {
        Arena arena = Arena.ofShared();
        this.pixels = arena.allocate((long) width * height * 4);
        this.cleanable = CLEANER.register(this, new Release(arena));
        OPEN.incrementAndGet();
    }

    long sizeInBytes() {
        return pixels.byteSize();
    }

    @Override
    public void close() {
        cleanable.clean();
    }
}

A careful caller opens the buffer in a try-with-resources block, so the native memory is freed as soon as the block ends, even when the code inside throws an exception.

try (ImageBuffer photo = new ImageBuffer(1920, 1080)) {
    long bytes = photo.sizeInBytes();          // 8294400
}
int stillOpen = ImageBuffer.OPEN.get();        // 0

A careless caller drops the reference without closing it. The memory stays allocated until a garbage collection finds the buffer, and the Cleaner thread runs Release. We added a print statement with the thread name to Release.run() and ran both cases on JDK 25.

ImageBuffer forgotten = new ImageBuffer(640, 480);   // never closed
forgotten = null;
System.gc();                                         // a request, not a command
Released 8294400 bytes on main
Open buffers: 1
Released 1228800 bytes on Cleaner-0
Open buffers after GC: 0

We can see that the closed buffer was released on the main thread and the forgotten one on the thread named Cleaner-0. The third line appears only because System.gc() happened to trigger a collection in a small demo; in a real app the forgotten buffer can stay allocated for a long time.

3. Mistakes That Stop the Cleanup

Most broken Cleaner code compiles and passes tests, because the explicit close() path still works. The garbage collector path fails without any error, and the leak shows up only in production when somebody forgets close().

3.1. A Lambda or Inner Class That Captures this

A lambda that reads an instance field captures this, and an inner or anonymous class keeps a hidden reference to its outer object. The Cleaner holds the action strongly, the action holds the object, so the object never becomes phantom reachable.

final class LeakyBuffer {

    private static final Cleaner CLEANER = Cleaner.create();
    private final Arena arena = Arena.ofShared();

    LeakyBuffer() {
        // Wrong: arena means this.arena, so the lambda captures this
        CLEANER.register(this, () -> arena.close());
    }
}

The fix is to copy what the action needs into a local variable, or to use a static nested class or record as in ImageBuffer. A lambda that uses only local variables is safe, although a named record makes the intent easier to see in a code review.

final class SafeBuffer {

    private static final Cleaner CLEANER = Cleaner.create();
    private final Cleaner.Cleanable cleanable;

    SafeBuffer() {
        Arena arena = Arena.ofShared();                 // local, not a field
        this.cleanable = CLEANER.register(this, arena::close);
    }
}

3.2. Other Mistakes

A few more problems come up in code reviews of classes that use a Cleaner.

  • Creating a Cleaner per object starts a new thread for every instance. Keep one static final Cleaner per library or per module.
  • A slow or blocking action, such as a network call, delays every other action on the same Cleaner thread. Keep actions short and move slow work to an executor.
  • An exception inside the action disappears. Catch it inside run() and log it if a failed cleanup matters.
  • Calling System.gc() in production code to force cleanup does not work reliably, and it can be disabled with -XX:+DisableExplicitGC. Fix the missing close() call instead.

4. Why finalize() Is Deprecated for Removal

Before Java 9, the standard hook for cleanup was Object.finalize(). A class overrode it, and the JVM called it on a finalizer thread some time after the object became unreachable. JEP 421 deprecated finalization for removal in Java 18, and the method is still deprecated for removal in Java 25.

The design has problems that no amount of careful coding fixes.

  • There is no guarantee that finalize() runs at all. A program that exits before the next garbage collection never calls it.
  • The method can make the object reachable again (resurrection) by storing this in a field, so the garbage collector has to keep every finalizable object for at least one extra collection cycle.
  • Every subclass inherits the finalizer, and a subclass that overrides finalize() without calling super.finalize() skips the parent’s cleanup.
  • Exceptions thrown by finalize() are ignored, and the finalizer thread can run the method while other threads still use objects that the finalizable object refers to.

A Cleaner fixes the parts that matter. The action cannot resurrect the object because it has no reference to it, the action is not inherited, and the owning class decides which thread runs it by offering close().

4.1. Finalization Timeline

Finalization has been on its way out for several releases, and some old advice, such as forcing finalizers on exit, refers to methods that no longer exist.

Java versionChange
Java 9Object.finalize() deprecated, Cleaner added
Java 11Runtime.runFinalizersOnExit() and System.runFinalizersOnExit() removed
Java 18JEP 421 marks finalization for removal, adds –finalization=disabled, and deprecates Runtime.runFinalization() for removal
Java 25Finalization still works by default; javac warns about every finalize() override

Compiling a class that overrides finalize() on JDK 25 prints a removal warning even without -Xlint.

ImageCache.java:6: warning: [removal] finalize() in Object has been deprecated and marked for removal
    protected void finalize() {
                   ^
1 warning

4.2. Finding Finalizers in an Existing App

A text search finds finalizers in our own code, but they can also exist in third-party jars. JEP 421 gives two tools to find them before a future JDK removes the feature.

The first tool is the –finalization=disabled option. The JVM starts normally but never calls any finalize() method, so a test run with it shows whether the app depends on finalizers, for example through a growing file handle count.

java --finalization=disabled -jar photo-service.jar

The second tool is the JDK Flight Recorder event jdk.FinalizerStatistics, which lists every loaded class that overrides finalize() and the jar it came from.

java -XX:StartFlightRecording:filename=rec.jfr -jar photo-service.jar
jfr print --events jdk.FinalizerStatistics rec.jfr
jdk.FinalizerStatistics {
  startTime = 15:34:40.560 (2026-10-10)
  finalizableClass = ImageCache (classLoader = app)
  codeSource = "file:///opt/app/photo-service.jar"
  objects = 0
  totalFinalizersRun = 1
}

4.3. Migrating finalize() to a Cleaner

The following example is a typical old class that frees a native image handle in finalize(). The NativeImages helper stands for any library call that allocates and frees native resources.

final class NativeImages {
    static final AtomicInteger HANDLES = new AtomicInteger();
    static long open() { HANDLES.incrementAndGet(); return 42L; }
    static void free(long handle) { HANDLES.decrementAndGet(); }
}
// Before: deprecated for removal
class ImageCache {
    private final long handle = NativeImages.open();

    @Override
    protected void finalize() {
        NativeImages.free(handle);
    }
}

The migration takes four steps. We implement AutoCloseable, move the body of finalize() into a static nested record, register that record with a shared Cleaner in the constructor, and call clean() from close().

// After: explicit close() plus a Cleaner as the safety net
final class ImageCacheV2 implements AutoCloseable {

    private static final Cleaner CLEANER = Cleaner.create();

    private record FreeHandle(long handle) implements Runnable {
        @Override
        public void run() {
            NativeImages.free(handle);
        }
    }

    private final Cleaner.Cleanable cleanable;

    ImageCacheV2() {
        long handle = NativeImages.open();
        this.cleanable = CLEANER.register(this, new FreeHandle(handle));
    }

    @Override
    public void close() {
        cleanable.clean();
    }
}
try (ImageCacheV2 cache = new ImageCacheV2()) {
    int inUse = NativeImages.HANDLES.get();    // 1
}
int afterClose = NativeImages.HANDLES.get();   // 0

After the migration, we also update the callers to use try-with-resources. If a class only wraps other AutoCloseable objects, such as streams or JDBC connections, it does not need a Cleaner at all; closing the wrapped objects in close() is enough.

5. Cleaner vs try-with-resources vs PhantomReference

Java has four ways to run cleanup code, and they differ in who triggers the cleanup and when it happens. The keyword-level differences between final, finally and finalize() are covered in final vs finally vs finalize.

OptionWho triggers itWhen it runsUse it for
try-with-resources and close()Our codeAt the end of the block, alwaysThe main cleanup path for every resource
CleanerOur code through clean(), or the garbage collectorAt once, or after a collection, or neverA safety net behind close() for native resources
PhantomReference with a ReferenceQueueThe garbage collectorAfter a collection, when our own thread polls the queueCustom reference handling; Cleaner is built on it
finalize()The garbage collectorMaybe, on the finalizer threadNothing in new code; deprecated for removal

The JDK follows the same pattern. The class java.util.zip.Inflater asks callers to call end(), and it also registers its native zlib memory with an internal Cleaner whose thread is named Common-Cleaner, so a forgotten Inflater does not leak native memory forever. Background on when the collector runs is in Java garbage collection algorithms, and the rest of the language basics are in the Java tutorial.

6. Java Cleaner FAQs

Anyone who replaces a finalizer with a Cleaner runs into the same doubts about threads, shutdown and lambdas.

6.1. Does finalize() still work in Java 25?

Yes. In Java 25, finalize() is deprecated for removal but still works by default. A future release is expected to remove it, so code that depends on it should move to try-with-resources or a Cleaner before that happens. Running the app with –finalization=disabled shows what breaks without it.

6.2. Does a Cleaner run its actions when the JVM exits?

Not reliably. The behavior during System.exit() is implementation-specific, and the Cleaner thread is a daemon thread that does not keep the JVM alive. Cleanup that must happen before shutdown belongs in close() or in a shutdown hook registered with Runtime.addShutdownHook().

6.3. Can the cleaning action be a lambda?

Yes, as long as the lambda does not use this or any instance field. Copy the values into local variables first, as in SafeBuffer in section 3. A named static nested class or record is safer because the compiler stops it from referring to the outer object.

6.4. How many threads does a Cleaner use?

One. Each Cleaner.create() call starts one daemon thread that runs all actions of that Cleaner in sequence. The overload Cleaner.create(ThreadFactory) lets us name the thread or set its priority.

6.5. What is the difference between Cleaner and PhantomReference?

A Cleaner is a ready-made wrapper around PhantomReference. With a bare PhantomReference, we create the ReferenceQueue, keep the references reachable and run our own polling thread. The Cleaner does all of that and adds the explicit clean() path.

6.6. Is clean() thread-safe?

Yes. The action runs at most once even when two threads call clean() at the same time, or when clean() races with the garbage collector path.

7. Conclusion

The Java Cleaner API runs a cleanup action once, either when close() calls clean() or after the garbage collector finds the object phantom reachable. The explicit path is the one we rely on, and the garbage collector path catches callers who forget close().

A working Cleaner needs one shared instance, an action that holds only the resource and never the owning object, and short actions that log their own errors. With those rules, it replaces every legitimate use of finalize(), which is deprecated for removal and can be switched off today with –finalization=disabled.

8. References

Happy Learning !!

Source Code on Github

About Us

HowToDoInJava provides tutorials and how-to guides on Java and related technologies.

It also shares the best practices, algorithms & solutions and frequently asked interview questions.