Over the years, both Java finalizers and phantom references have been recommended as good practice. Both have the same primary purpose: cleaning up limited resources such as file handlers and database connections when they’re no longer needed. However, finalizers were soon found to cause unexpected inconsistencies, and they have, in fact, been deprecated since Java 9.
Phantom references were a little better, but they’re tricky to use and can cause problems if they’re not used carefully. Cleaners were introduced in Java 9, and gave us a much simpler, neater model for cleaning tasks.
Even so, there are still obscure, hard-to-find problems, where occasionally the clean-up model can unexpectedly result in OutOfMemory errors related to the heap. How can this happen, since the clean-up is primarily concerned with external resources?
In this article, we’ll look closely at how the various clean-up models work so we can understand where they can go wrong. We’ll look at how to go about troubleshooting this type of problem, and look at best practices for handling clean-up tasks.
JVM Memory Cleanup: Garbage Collection, Java Finalizers and Phantom References
Before we look at how these techniques can cause OutOfMemoryErrors, let’s look a bit closer at how they’re dealt with by the garbage collector (GC). Later in the article, we’ll go into more depth about what finalizers, phantom references, reference queues, and cleaners actually are.
GC is triggered when memory usage reaches a given percentage of the memory allocated. It searches through memory, checking whether each object is still reachable from the GC roots (the stack, static variables, etc.) Any objects that are still reachable by live references are marked as being in use. Note that phantom references don’t cause an object to be marked as reachable.
Any unmarked objects then go into the collection cycle. Let’s see how this actually works with finalizers and phantom references. Cleaners, in fact, use phantom references as their underlying mechanism, so they are treated in the same way.

Fig: Garbage Collection, Java Finalizers and Phantom References
Notice that:
- If an object has a finalize() method and hasn’t previously been finalized, it is added to the finalizer queue and becomes reachable again because the finalizer queue now holds a reference to it. It is not cleaned from memory.
- There is only one single-threaded finalizer queue within the JVM. If any objects in the queue are slow to process or hang waiting for resources, every other object in the queue is held up and may not be finalized for some time. It’s also possible, though not good programming practice, to resurrect objects within their finalizer methods.
- Even in the best-case scenario, objects with finalizers won’t be removed from memory in the current GC cycle. They will only be removed in some future GC cycle, after the finalize() method has executed.
- If the object has a phantom reference that is registered with a reference queue, it will be added to the queue. The object is then cleaned from memory in the current cycle. The object itself is not added to the queue, but only an object registered as its cleaner. Objects registered with a cleaner using the Cleaner API behave in the same way as phantom references.
- If a phantom-reachable object also has a finalizer method and hasn’t been finalized, it won’t be added to its reference queue on this cycle. Only on a later cycle, after the finalizer has been run, will it be queued for cleaning.
Cleaners vs Java Phantom References vs Finalizers: A Comparison
As we mentioned earlier, these are all models used for cleaning up resources when an object becomes eligible for GC. Let’s look at each of them in turn.
1. Finalizers
Every class in Java ultimately descends from java.lang.Object. The Object class contains a method named finalize(), which does nothing. Any class in Java can override this method with its own code. If it’s overridden, the GC will make sure this method is executed before the object is actually disposed of. It does this by adding the object to a finalizer queue. Objects that don’t have a finalizer method will be collected within the current cycle.
The method may look like this:
@Override protected void finalize() throws Throwable { try { // Cleanup code goes here } finally { // Call this in case the superclass also needs cleanup super.finalize(); } }
This often caused unpredictable results. It might be some time before an object reaches the front of the queue and is finalized. Until then, the object can’t be garbage collected, and neither can any other object that it may hold references to. This sometimes results in significant memory leaks in the heap, and can cause heap-related OutOfMemoryErrors. Resources such as file handles aren’t released until the finalize method reaches the front of the queue, resulting in other JVM errors.
It’s not even guaranteed that the method will ever run. If the JVM is shut down, the finalizer queue is shut down immediately, and any objects still in the queue won’t be finalized. This means it’s not suitable for any essential cleaning up such as:
- Database commits;
- Flushing a buffer to a file;
- Cleaning up temporary files and temporary tables.
Some JVM platforms allow the finalize method to be disabled, which makes it even more unpredictable.
It’s therefore now deprecated, and will eventually be removed from the Java language. It should never be implemented in new code, and should gradually be factored out of existing applications.
For more information, and an example of a problematic finalizer, see Memory Leak Due To Time-Taking finalize() Method.
2. Phantom References
Phantom references were introduced very early on in Java’s history as a more reliable alternative to finalizers. They are part of the java.lang.ref API, which consists of the class Reference and three subclasses: PhantomReference, SoftReference and WeakReference, as well as the class ReferenceQueue, which defines a queue that Reference objects can be added to.
Let’s summarize the four different ways objects can be referenced in Java.
| Types of Reference in Java | |||
| Defined by: | Purpose | Commonly Used For | |
| Strong | Normal assignments | Allow objects to be found in the heap:- “I need this reference” | Normal references |
| Weak | WeakReference Class | Retain a reference for only as long as the object is referenced elsewhere:- “I only need this reference if someone else is also using it” | Registering listeners and callbacks, so they will not retain the reference if the listening object ‘dies’. |
| Soft | SoftReference Class | Retained, but released if the JVM runs short of memory:- “I would like to keep this reference, but if the heap is full it can be released” | Caching |
| Phantom | PhantomReference Class | Allow objects to be associated with a reference queue: “I don’t need this object, but I want to know when it’s garbage collected” | Take action when GC happens; either to clean up, or as a signal a large block of memory has been released to trigger loading a new object |
In this article, we’re not concerned with soft and weak references, but it’s worth mentioning that they can also be associated with a reference queue.
We’ll look at the use case where an object holds resources such as file handles and database connections, which must be released when the object is garbage collected.
The example consists of:
- An object which holds the resources that must be cleaned when it’s garbage collected. We’ll create this from a custom class called ResourceHogger.
- An object of class java.lang,ref,ReferenceQueue, which we’ll call queue. This lets us register references with it so we can take action on garbage collection.
- A queue poller, which we’ll define in a class called QueuePoller. This has the job of polling the reference queue. It must implement the Runnable interface. We would usually run this in a separate thread so it can continuously poll the reference queue in the background. It’s up to the programmer how to handle the queue on program shutdown. If it’s simply releasing resources, it doesn’t matter too much whether all items in the queue have been processed, since they will be released anyway when the program shuts down. On the other hand, if it contains essential tasks such as removing temporary files, the programmer must ensure all tasks are complete before shutdown.
- A phantom reference. For this, we extend the class PhantomReference to a new class ResourceHoggerRef. The reason we need to extend this class with one of our own is that the phantom reference must be able to hold references to the resources that need cleaning. The original class, ResourceHogger, is no longer available when the cleaning action takes place. It will have been garbage collected. The PhantomReference class takes the object it will refer to and the name of the queue to attach it to as parameters to its constructor. In the constructor, we use ResourceHogger’s methods to get the information we need. It must have a method called cleanup(), which does the actual release of the resources.
The sample program PhantomCleanupDemo shows how this fits together. It creates queue, and submits a QueuePoller object to an executor service so a thread can be created for it. The QueuePoller loops until it’s interrupted, processing references in the queue by calling the reference’s cleanup method.
We create a ResourceHogger object, and create a ResourceHoggerRef for it, registering it with queue.
To test that it is, in fact, doing the clean-up, we then set the ResourceHogger to null so it will be garbage collected.
Here’s the sample program, with its supporting classes:\
import java.io.BufferedWriter;import java.io.FileWriter;import java.io.IOException;import java.lang.ref.PhantomReference;import java.lang.ref.Reference;import java.lang.ref.ReferenceQueue;import java.util.concurrent.ExecutorService;import java.util.concurrent.Executors;public class PhantomCleanupDemo {// ------------------------------------------------------------// Main// ------------------------------------------------------------ public static void main(String[] args) throws Exception { // Create a reference queue ReferenceQueue<ResourceHogger> queue = new ReferenceQueue<>(); // Thread executor try (ExecutorService executor = Executors.newVirtualThreadPerTaskExecutor()) { // Start queue polling on a virtual thread so it can run in the background executor.submit(new QueuePoller(queue)); // This is the object that has a resource that must be cleaned up ResourceHogger hogger = new ResourceHogger("demo.txt"); // Create a phantom reference for the class that needs cleaning // and register with queue ResourceHoggerRef ref = new ResourceHoggerRef(hogger, queue); // Release the object for garbage collection hogger = null; // Suggest garbage collection (Don't do this in live situations!!!!) System.gc(); // Give GC + cleaner thread time to execute Thread.sleep(3000); System.out.println("Main program ending"); // Shut down executor service executor.shutdownNow(); System.out.println("Executor service shut down"); } }}// ===============================================================================// ---------------------------------------------------------------// Class that defines an object holding a resource (a file writer)// ---------------------------------------------------------------class ResourceHogger { private final String fileName; private final BufferedWriter writer; ResourceHogger(String fileName) throws IOException { this.fileName = fileName; this.writer = new BufferedWriter(new FileWriter(fileName)); writer.write("Resource opened\n"); writer.flush(); System.out.println("Opened file: " + fileName); } public String getFileName() { return fileName; } public BufferedWriter getWriter() { return writer; }}// ===============================================================================// ---------------------------------------------------------------// Class extending PhantomReference, whose purpose is to hold // links to items that must be cleaned up. This is needed because// the actual object will have been GCd by the time the cleanup is // executed by the queue.// It must NEVER hold a reference to the actual object, since this// will prevent it ever being GCd// ---------------------------------------------------------------class ResourceHoggerRef extends PhantomReference<ResourceHogger> { // These are the variables we'll need for the cleanup private final String fileName; private final BufferedWriter writer; ResourceHoggerRef( ResourceHogger referent, ReferenceQueue<ResourceHogger> queue) { super(referent, queue); // Store required cleanup info separately this.fileName = referent.getFileName(); this.writer = referent.getWriter(); } // Do the actual cleanup public void cleanup() { System.out.println( "Cleaning up resource for file: " + fileName); try { writer.close(); System.out.println( "Closed file: " + fileName); } catch (IOException e) { e.printStackTrace(); } } }// ====================================================================// ------------------------------------------------------------// Queue polling task// ------------------------------------------------------------class QueuePoller implements Runnable { private final ReferenceQueue<ResourceHogger> queue; private boolean wasInterrupted =false; QueuePoller(ReferenceQueue<ResourceHogger> queue) { this.queue = queue; } @Override public void run() { try { while (!wasInterrupted) { Reference ref = queue.remove(); // Get a reference from the queue, // remove from queue, block until // reference available if (ref instanceof ResourceHoggerRef hoggerRef) { // Invoke the cleaning action hoggerRef.cleanup(); } } } catch (InterruptedException e) { System.out.println( "Queue polling thread interrupted"); wasInterrupted=true; } } }
The advantages of this approach rather than using a finalizer are:
- We have our own queue that we control ourselves. We know what is in it, and can make sure the cleanup process doesn’t block and cause a backlog;
- If the situation warrants it, we could create more than one queue so cleanups could happen in parallel;
- If we need to ensure the cleanup does take place even on shutdown, we can add logic to enforce this;
- The object itself is cleared from memory as soon as the reference has been enqueued, releasing heap memory immediately, regardless of when the cleanup runs.
- It is up to us to ensure the PhantomReference is tiny, so it doesn’t fill up the heap while waiting to be processed.
This can work well, but it’s complex to implement, and if we’re not careful it can lead to hard-to-find bugs. The PhantomReference must not hold a reference to the actual object to be cleaned, as this would prevent it ever being garbage collected. Using lambdas to define the PhantomReference is not safe.
For more information on phantom references, see PhantomReference Class in Java.
3. Cleaners
Cleaners, introduced in Java 9, give us a much simpler, neater way of achieving the same thing. Internally, they actually use phantom references, so they behave identically when an object to be cleaned is garbage collected.
To create a demo class that uses the Cleaner API, we need:
- An object of class Cleaner, which will manage the cleaning queue in its own thread. Ideally, unless the queue is very busy, there should only be one cleaner per application. More could be created if the cleaning proves to be a bottleneck. It should always be static, and it is often defined in a central utility class. It’s created using the static Cleaner.create() method.
- An object that does the actual cleaning. We’ll name this ResourceHoggerCleaner. This must implement the Runnable interface, and include variables that link to the resources to be cleaned. These variables can be set in the constructor. The actual cleaning code goes in the run() method.
- An object which holds the resources that must be cleaned when it’s garbage collected. We’ll create this from a custom class called ResourceHogger. Within this class, in addition to its normal work, it will need to:
- Create the ResourceHoggerCleaner.
- Register itself and its cleaner with the Cleaner object.
To test this, the calling class only needs to create the ResourceHogger, then release it for garbage collection.
Here’s the code:
import java.io.BufferedWriter;import java.io.FileWriter;import java.io.IOException;import java.lang.ref.Cleaner;public class CleanerDemo { // -------------------------------------------------------------------- // Shared cleaner (IMPORTANT: usually only one per application, // although if cleaning is a bottleneck, more than one can be created) // May often be defined in a utility class. The create method creates // a cleaner in its own thread // -------------------------------------------------------------------- public static final Cleaner CLEANER = Cleaner.create(); // ------------------------------------------------------------ // Main // ------------------------------------------------------------ public static void main(String[] args) throws Exception { ResourceHogger hogger = new ResourceHogger("demo.txt"); // Release for GC hogger = null; // Encourage GC System.gc(); // Don't do this in live programs! // Give cleaner time to run Thread.sleep(3000); System.out.println("Worker task finished"); System.out.println("Main program ending"); }}// ================================================================// ==================// Supporting Classes// ================== // ------------------------------------------------------------ // Class to implement cleanup logic // ------------------------------------------------------------class ResourceHoggerCleaner implements Runnable {// Uses these variables to store information about the resources to be cleaned// private final String fileName; private final BufferedWriter writer;// They are supplied in the constructor ResourceHoggerCleaner(String fileName, BufferedWriter writer) { this.fileName = fileName; this.writer = writer; }// Overrides the run() method with the clean-up code @Override public void run() { System.out.println( "Cleaning up resource for file: " + fileName); try { writer.close(); System.out.println( "Closed file: " + fileName); } catch (IOException e) { e.printStackTrace(); } } }//-================================================================// ------------------------------------------------------------// Resource holder// ------------------------------------------------------------class ResourceHogger { private final Cleaner.Cleanable cleanable; ResourceHogger(String fileName) throws IOException {// Resource to be cleaned is created BufferedWriter writer = new BufferedWriter(new FileWriter(fileName));// Create the cleaner class, passing the link to the cleanable item ResourceHoggerCleaner myCleaner = new ResourceHoggerCleaner(fileName, writer);// Register cleanup action cleanable = CleanerDemo.CLEANER.register(this, myCleaner);// Normal tasks carried out by this class writer.write("Resource opened\n"); writer.flush(); System.out.println("Opened file: " + fileName); }}
As you can see, this is very much simpler than the phantom reference model. The entire queue management is done by the API. On normal shutdown, all tasks currently in the cleaner queue will be executed. This makes it more reliable.
As with phantom references, the GC will remove the object from memory within the same cycle, regardless of whether the cleaning action has completed, meaning that it’s unlikely to cause memory leaks in the heap.
However, there may still be a delay between garbage collection and actually releasing the resources, which can cause problems such as running out of file handles.
For more information on cleaners, see Clean your Memory: From Finalize to Cleaner
4. The Best Way to Clean Up Native Resources in Java Without Finalizers
We’ve seen that finalizers can cause several different problems. Phantom references solve most of them, and the Cleaner API has the same advantages with a simpler model. However, we’re still left with the possible time delay between GC and cleaning, and it’s still possible for cleaning queues to have backlogs.
So, is there a better way to handle resource cleaning, without resorting to Java finalizers and phantom references?
Yes, but it puts the onus on the programmer to follow good coding practices. A simple close method within the class that holds the resources is far easier and safer, and doesn’t have the time lapse involved in queueing cleaners.
Since programmers may easily forget to call the close() method, this can to some extent be enforced by using the AutoCloseable interface together with the try-with-resources construct. AutoCloseable has only one mandatory method: close(). This should be used to release any resources held by the class, as well as dropping any large chunks of memory it may use.The code for the CleanerDemo example above would then look like this:
import java.io.BufferedWriter;import java.io.FileWriter;import java.io.IOException;public class AutoCloseableDemo { // ------------------------------------------------------------ // Main // ------------------------------------------------------------ public static void main(String[] args) throws Exception { try(ResourceHogger hogger = new ResourceHogger("demo.txt")) {System.out.println("Hogger created");} // Will be eligible for GC since the scope has terminated System.out.println("Main program ending"); }}// ==================// Supporting Classes// ==================// ------------------------------------------------------------// Resource holder// ------------------------------------------------------------class ResourceHogger implements AutoCloseable{ String fileName; BufferedWriter writer; ResourceHogger(String name) throws IOException {// Resource to be cleaned is created fileName=name; writer = new BufferedWriter(new FileWriter(fileName));// Normal tasks carried out by this class writer.write("Resource opened\n"); writer.flush(); System.out.println("Opened file: " + fileName); }public void close() { System.out.println( "Cleaning up resource for file: " + fileName); try { writer.close(); System.out.println( "Closed file: " + fileName); } catch (IOException e) { e.printStackTrace(); } }}
The only snag with this is, if you’re writing re-usable code, there’s a fair chance that some developer down the line will forget to use try-with-resources with your class. This is certainly something we should always look out for when doing code reviews. It’s also possible that poor exception handling can bypass the close().
Because of this, critical systems often use the Cleaner interface in addition to the try-with-resources, as a belt-and-braces technique.
Cleaning Up Resources: A Quick Comparison
Let’s summarize this in a table.
| Timing | Code Simplicity | Disadvantages | |
| Finalizer | Unpredictable | Simple | Error prone; potential memory leaks |
| Phantom References | Developer- controlled Queue | Complex | Complex and bug-prone; queues may have a backlog |
| Cleaner | API-controlled Queue | Fairly simple | Cleaners may have a backlog |
| AutoCloseable | Immediate | Simple | Developers may forget to close |
Troubleshooting Memory Retention in Java Clean-up Mechanisms
As we’ve discussed, it’s possible for mechanisms such as Java finalizers and phantom References to result in OutOfMemoryErrors in the heap, as well as cause problems with finite resources such as file handles. This type of problem can be very hard to diagnose.
The first step in troubleshooting is to take a heap dump, and use a tool such as HeapHero or Eclipse MAT to explore the contents.
Here are a couple of things to look out for.
Good heap dump analyzers, such as HeapHero display an interactive list of objects awaiting finalization.

Fig: HeapHero List of Objects Awaiting Finalization
If the list is long, or if the amount of memory wasted is significant, then it’s highly likely that finalizers are the problem. If so, take a second dump after a short interval, and see if the queue is moving. If not, the item at the top of the list is probably blocked, causing a memory leak. A thread dump can be useful in this case to find out what’s blocking it.
If possible, finalizers should be phased out, since they will eventually not be supported.
Phantom references and cleaners seldom cause heap-related problems, but it is possible. Two reasons this may happen are:
- The queue is not moving fast enough, and cleaners are backing up;
- The phantom reference object, or the runnable used with a cleaner, is holding unnecessary variables. These objects should be very small.
To investigate whether the queue has a backlog or the objects are too large, use a heap dump analyzer such as HeapHero or Eclipse MAT. The reference queue (and therefore the cleaner queue) has its own internal structure, so it’s not easy to see what’s happening inside it. However, by searching for classes with names ending in $PhantomCleanableRef in the class histogram, we can get a fair idea of what’s going on. If there are too many of them, the queue has a backlog. We can also see if they are too large. See the highlighted entry in the image below.

Fig: Class Histogram Showing Reference Objects
Here we have 32 instances of this object, which tells us the queue is not moving fast enough. We can also see how much heap space the phantom references are retaining.
We would first investigate why they are running slowly or blocking. As a last resort, we could create a second queue, with an algorithm to allocate objects to each queue alternately.
Container Considerations
When running in containers, or in container management environments such as Kubernetes, we need to be especially aware of memory-related issues.
Poor memory management can cause applications to crash with Java OutOfMemoryErrors. Even worse than this, memory-hungry code can cause the container to be terminated by the silent Out Of Memory killer, which safeguards against bringing down a whole cluster because one application is using too much memory. A program that is continually requesting more memory is likely to be seen as a rogue process, and terminated.
Conclusion
It’s essential to clean up resources such as file handles and network sockets in long-running or heavily concurrent applications. Java finalizers and phantom references were frequently used for this purpose in older applications.
Finalizers have now been deprecated, since they are unreliable. Phantom references are tricky to program successfully, so they have largely been replaced by the Cleaner API in newer systems.A simpler method is to ensure classes that hold resources implement the AutoCloseable interface, and encapsulate their use in a try-with-resources block. However, this only works if developers use it consistently. In critical applications, this construct is sometimes paired with a Cleaner as a safety net.

Share your Thoughts!