1. Introduction
In this tutorial, we’ll discuss Java’s attempt to deprecate Object finalization for removal with JEP 421 of the Java 18 release. We’ll also talk about potential replacements and better alternatives for finalization.
2. Finalization in Java
2.1. Resource Leaks
The JVM comes with Garbage Collection (GC) mechanisms to reclaim memory of objects that are no longer in use by the application, or no more references that are pointing to the object. However, some object references use and represent other underlying resources, such as OS-level resources, native memory blocks, and open file descriptors. These objects should call the close() method while shutting down to release the underlying resource back to the OS.
*If the GC cleans up the objects prematurely before the object has had a chance to call the close(), the OS considers the object to be in use. This is a resource leak.*
A very common example of this is when we are trying to read a file and wrap our code in a try-catch block to handle exceptions. We wrap the graceful closure of resources in the traditional finally block. This is not a completely foolproof solution, as exceptions can happen even in the finally block, leading to resource leaks:
public void copyFileOperation() throws IOException {
try {
fis = new FileInputStream("input.txt");
// perform operation on the file
fis.close();
} finally {
if (fis != null) {
fis.close();
}
}
}
2.2. Object’s finalize() Method
Java introduced the idea of finalization to deal with resource leaks. The finalize() method, also called the finalizer, is a protected void method in the Object class whose purpose is to release any resource the object uses. We override the method in our class to perform the closure of resources to help the GC:
public class MyFinalizableResourceClass {
FileInputStream fis = null;
public MyFinalizableResourceClass() throws FileNotFoundException {
this.fis = new FileInputStream("file.txt");
}
public int getByteLength() throws IOException {
return this.fis.readAllBytes().length;
}
@Override
protected void finalize() throws Throwable {
fis.close();
}
}
When the object is eligible for garbage collection, the garbage collector calls the finalize() method of the object if it is overridden. Although having a finalize() method in a class to perform all resource cleanup work looks like a good way to handle resource leaks, it has been stated for deprecation itself since Java 9. Finalization in itself has a couple of fundamental flaws.
3. Flaws of Finalization
3.1. Unpredictable Execution
There is no guarantee that the object’s finalize() will be called even when the object is eligible for garbage collection. Similarly, there can be an unpredictable latency for the GC to call the object’s finalizer after the object is eligible for garbage collection.
The finalizer is scheduled to be run by the GC, however, garbage collection happens based on parameters including the system’s current memory needs. If GC is paused because of ample free memory, many objects will wait on the heap for their finalizer to be called. This may lead to resource shortages.
3.2. Unconstrained Finalizer Code
Even though the intention of the finalize() method is defined, the code is still something that a developer puts and it can take any action. This lack of control can defeat the purpose of the finalizer. This also introduces a security threat to the application. Malicious code can sit in the finalizer and cause unexpected errors or lead to the application misbehaving in various ways.
If we omit the finalizer altogether, a subclass can still define a finalize() for itself and gain access to ill-formed or deserialized objects. The subclass may also choose to override the parent’s finalizer and inject malicious code.
3.3. Performance Overhead
The presence of an overridden finalize() in classes adds a performance penalty, as the GC needs to track all such classes with finalizers. The GC also needs to perform additional steps in such an object’s lifecycle, especially during object creation and finalization.
There are some garbage collectors that are throughput-oriented and perform best by minimizing overall pause times of garbage collection. For such garbage collectors, the finalizer leads to a disadvantage as it increases pause times.
Additionally, the finalize() method is always enabled, and GC will call the finalizer even if it is not required. The finalize() action cannot be canceled even if the requirement of closing the resources is already handled.
This leads to a performance penalty, as it is always called regardless of its requirement.
3.4. No Thread Guarantee
The JVM does not guarantee which thread will invoke the object’s finalizer, nor does it guarantee the order. There can be an unspecified number of finalizer threads. In case the application threads allocate resources to objects more frequently than finalizer threads can relinquish the resources, it can lead to resource shortages as well.
3.5. Ensuring the Correctness of the Finalizer Code
It is generally difficult to write a correct finalize() implementation. It is also very easy to write code that breaks the application because of an improperly implemented finalizer. An object’s finalize() method must remember to invoke the finalize() of its parent class with super.finalize(), and it is not supplied by the JVM inherently.
As finalizers are run on an unspecified number of threads, it can lead to issues that are common to a multithreaded environment, such as deadlocks and any other threading problem. Moreover, when there are several classes with finalizers, it leads to increased coupling in the system. There can arise interdependencies in the finalization of these objects, and some objects might stay in the heap more, waiting for a dependent object to be finalized.
4. try-with-resources as an Alternative Technique
One of the ways we can guarantee that a resource’s close() method is called is by using the try-with resource construct that Java 7 introduced. This framework is an improvement over the try-catch-finally construct as it makes sure all exceptions are handled properly, thereby removing the requirement of finalization:
public void readFileOperationWithTryWith() throws IOException {
try (FileOutputStream fis = new FileOutputStream("input.txt")) {
// perform operations
}
}
We can put any number of resource initializations inside the try-with block. Let’s rewrite our class without a finalizer:
public class MyCloseableResourceClass implements AutoCloseable {
private FileInputStream fis;
public MyCloseableResourceClass() throws FileNotFoundException {
this.fis = new FileInputStream("file.txt");
}
public int getByteLength() throws IOException {
return this.fis.readAllBytes().length;
}
@Override
public void close() throws IOException {
this.fis.close();
}
}
The only difference here is the AutoCloseable interface and the overridden close() method. Now we can safely use our resource object inside a try-with block and not worry about resource leak:
@Test
public void givenCloseableResource_whenUsingTryWith_thenShouldClose() throws IOException{
int length = 0;
try (MyCloseableResourceClass mcr = new MyCloseableResourceClass()) {
length = mcr.getByteLength();
}
Assert.assertEquals(20, length);
}
5. Cleaner API in Java
5.1. Creating a Resource Class With the Cleaner API
Java 9 introduced the idea of a Cleaner API for releasing long-lived resources. Cleaners implement the Cleanable interface and allow us to define and register cleanup actions against objects.
There are three steps to implementing a cleaner for our resource class:
- fetching a Cleaner instance
- registering a cleaning action
- perform the cleaning
We define our resource class, which will use the Cleaner API to help us clean the resource after use:
public class MyCleanerResourceClass implements AutoCloseable {
private static Resource resource;
}
To obtain a cleaner instance, we call the static create() method on the Cleaner class:
private static final Cleaner cleaner = Cleaner.create();
private final Cleaner.Cleanable cleanable;
We also create a Cleanable instance, which will help us register the cleaning actions against my object:
public MyCleanerResourceClass() {
resource = new Resource();
this.cleanable = cleaner.register(this, new CleaningState());
}
The register() method of a cleaner takes two arguments, the object it is supposed to monitor for cleaning, and the action to perform for cleaning. We pass a lambda of type java.lang.Runnable here for the cleaning action, which is defined in the CleaningState class:
static class CleaningState implements Runnable {
CleaningState() {
// constructor
}
@Override
public void run() {
// some cleanup action
System.out.println("Cleanup done");
}
}
We also override the close() method as we have implemented the AutoCloseable interface. In the close() method, we invoke the clean() method on the cleanable and perform the third and final step.
@Override
public void close() {
// perform actions to close all underlying resources
this.cleanable.clean();
}
5.2. Testing the Cleaner Implementation
Now that we have implemented a cleaner API for our resource class, let’s validate it by writing a small test:
@Test
public void givenMyCleanerResource_whenUsingCleanerAPI_thenShouldClean() {
assertDoesNotThrow(() -> {
try (MyCleanerResourceClass myCleanerResourceClass = new MyCleanerResourceClass()) {
myCleanerResourceClass.useResource();
}
});
}
Notice that we are wrapping our resource class inside a try-with block. On running the test, we can see in the console, the two statements:
Using the resource
Cleanup done
5.3. Advantages of the Cleaner API
When the object is eligible for cleanup, the cleaner API performs automatic cleaning up of the resources. The cleaner API attempts to solve most of the drawbacks of finalization that are mentioned above. In finalize(), we could write code that would resurrect the object and make it unworthy for collection. This issue is not there in the cleaner API, as the CleaningState object cannot access the original object.
Additionally, the cleaner API requires proper registration of the cleaning action on the object, which is done after the object creation is complete. Therefore, the cleaning action can’t process improperly initialized objects. Moreover, this sort of cleaning action is cancellable, unlike finalization.
Finally, cleaning actions run on separate threads and are hence non-interfering, and exceptions thrown by the cleaning action are auto-ignored by the JVM.
6. Conclusion
In this article, we talked about the reason behind Java’s decision to deprecate finalization for removal. We looked at the problems with finalization and explored two alternative solutions that help in resource cleanup.
As always, the source code for this tutorial can be found over on GitHub.