Identify and eliminate bottlenecks in your application for optimized performance.
Java's automatic garbage collection process is a fundamental feature that distinguishes it from its predecessors like C++ and C. The Java Virtual Machine (JVM) uses this process for memory management, so you can focus on developing the core business logic. Despite being an efficient tool, it's not without flaws, leading to significant memory-hogging issues.
The garbage collector automatically deletes unused objects or objects without references in the stack and heap memory, clearing up space for subsequent operations. However, in some situations, the garbage collector fails to remove objects that are no longer used from the heap memory, thus maintaining them unnecessarily. This is known as memory leak (https://en.wikipedia.org/wiki/Memory_leak). It can obstruct the allocation of memory resources to programs that need them and eventually lead to a java.lang.OutOfMemoryError
. Over time, this degrades the performance of your system.
There can be a variety of reasons for memory hogging but memory leaks are the most common. This article explains the most common situations that lead to memory leaks and also provides some coding best practices to mitigate or avoid them.
This article focuses on the following five reasons for memory leaks:
equals()
and hashCode()
implementationsThreadLocal
sfinalize()
methodsEach section first explains the problem and then offers some coding best practices for dealing with it.
In any Java application, the static variables declared will remain in the heap memory throughout the lifecycle of that application, that is, while it is still running. The more static fields you declare, the more heap memory you consume in the application lifecycle. Using static fields excessively can expose your Java program to a potential memory leak. Consider the following code:
import java.util.ArrayList;
import java.util.List;
import java.util.Random;
public class StaticFieldMemoryLeak {
public static Listlist = new ArrayList<>();
public void generateRandomValues() {
Random random = new Random();
for (int i = 0; i < 10000000; i++) {
list.add(random.nextLong());
}
}
public static void main(String[] args) {
new StaticFieldMemoryLeak().generateRandomValues();
// Further implementations
}
}
When you run this code, the static variable list
grows within the scope of the for
loop in the generateRandomValues()
method. Each element in the list will occupy some portion of the heap memory. Ordinarily, the garbage collector should remove the list after the application exits the generateRandomValues()
method, but this isn't the case here.
As static fields will remain in memory throughout the lifecycle of the application, the list and all of its elements will retain their respective spaces in memory even though they are not needed in subsequent operations of the application. Any other program implementations will have to utilize whatever space is left.
Keep static field usage to a minimum and only declare static variables when needed in the application context, especially with collections or large objects—for example, when you need only one copy of these values.
Opt for lazily loading objects rather than eager loading (https://developer.mozilla.org/en-US/docs/Web/Performance/Lazy_loading). Lazily loading objects that are not needed for the initial operations of the program can limit static field usage.
equals()
and hashCode()
ImplementationsObjects are usually distinguished from one another by their equals()
and hashCode()
values. When you create a custom class, it's important that you override and define its equals()
and hashCode()
methods. This is because collections that contain unique elements depend on the implementation of these methods.
When these methods are not properly overridden and the class is declared as the key in a HashMap
, the collection will probably contain duplicate keys. Similarly, if objects of this class are inserted into a HashSet
, there will be duplicate elements in the collection. Not only does this go against the principles of these collections but these duplicate elements will also consume unnecessary space in the heap memory and may affect the application's performance in the long run.
Here is an example of a class that demonstrates the above scenario:
public class Product {
private String productName;
public Product(String productName) {
this.productName = productName;
}
}
There are no overridden implementations of the equals()
and hashCode()
methods in the above Product
class. So, at this point, the Java program will recognize different instances of this class as separate objects even if they have the same productName
value.
Here is an example that illustrates this issue:
import org.junit.jupiter.api.Assertions;
import org.junit.jupiter.api.Test;
import java.util.HashMap;
public class ProductUnitTests {
@Test
void testThatMemoryLeakThroughHashMapWhenHashCodeAndEqualsNotOverridden(){
Mapmap = new HashMap<>();
for (long i = 0; i < 10; i++) {
map.put(new Product("Shirt"), i);
}
Assertions.assertNotEquals(1, map.size());
}
}
The above test shows that a HashMap
with a Product
class key contains duplicate keys with the same productName
value as the assert statement. The duplicate keys in the above example occurred because there was no custom equals()
method to distinguish Product
objects with the same productName
as duplicates. As a result, these duplicate objects will cause a memory leak in the heap memory.
equals()
and hashCode()
ImplementationsBy correctly overriding the equals()
and hashCode()
methods, you prevent occurrences of duplicate objects in the collection, ensuring the space in the memory is well utilized.
To do this, add the following implementations to the Product
class:
@Override
public boolean equals(Object o) {
if (o == this) return true;
if (!(o instanceof Product product)) {
return false;
}
return product.productName.equals(productName);
}
@Override
public int hashCode() {
int hash = 17;
hash = productName != null ? 31 * hash + productName.hashCode() : hash;
return hash;
}
Use the following test to confirm that you have successfully overridden the equals()
and hashCode()
methods:
HashMapmap = new HashMap<>();
for (long i = 0; i < 10; i++) {
map.put(new Product("Shirt"), i);
}
Assertions.assertEquals(1, map.size());
When you open a new connection or stream, memory is allocated to that resource. As long as it's open, that resource will continue to be allocated memory even if you're not using it. If these resources are not properly implemented—for example, if an exception is thrown before the resource is closed—the garbage collector could lose access to them, thereby leading to a memory leak, as you can see in the following example:
import java.io.FileWriter;
import java.io.IOException;
import java.io.PrintWriter;
public class Resources {
public void writeToFile() {
FileWriter fileWriter;
PrintWriter printWriter;
try {
fileWriter = new FileWriter("test.txt");
printWriter = new PrintWriter(fileWriter);
printWriter.println("test");
} catch (IOException e) {
e.printStackTrace();
}
}
}
The writeToFile()
method above opens a connection to write to the test.txt
file, and the JVM allocates memory to this connection. This method is prone to memory leaks because the connection is not closed after writing to the file. JVM assumes that the connection is still needed, so it maintains the memory allocation.
Ensure all opened connections and streams are closed after completing their operations. You should close the connection in the above example by invoking the close()
method on the printWriter
object, as shown below:
try {
fileWriter = new FileWriter("test.txt");
printWriter = new PrintWriter(fileWriter);
printWriter.println("test");
} catch (IOException e) {
e.printStackTrace();
} finally {
if(printWriter != null){
printWriter.close();
}
}
Sometimes, an exception may be thrown after the connection has been opened but before it gets to the close()
statement. Closing the connection within the finally
(https://docs.oracle.com/javase/tutorial/essential/exceptions/finally.html) block ensures that the connection is closed whether or not the try
block throws an unexpected exception.
If your application runs on Java 8 or above, you can use the try
-with-resources(https://docs.oracle.com/javase/tutorial/essential/exceptions/tryResourceClose.html) statement to ensure every AutoCloseable
(https://docs.oracle.com/javase/8/docs/api/java/lang/AutoCloseable.html) is cleaned up after the try
block has completed its operation:
public void writeToFile() {
try (PrintWriter printWriter = new PrintWriter(new FileWriter("test.txt"))){
printWriter.println("test");
} catch (IOException e) {
e.printStackTrace();
}
}
A ThreadLocal
(https://docs.oracle.com/javase/7/docs/api/java/lang/ThreadLocal.html) is a Java object that allows you to wrap other objects that are accessible only by specific threads. Each thread has a reference to the ThreadLocal
instance throughout its lifecycle.
Most web servers today maintain a thread pool(https://docs.oracle.com/javase/tutorial/essential/concurrency/pools.html), or a collection of multiple threads, each of which processes individual web requests. Threads in the thread pool are not removed after they complete their operation; instead, they are recycled for the next web request. When a web request creates a ThreadLocal
instance and fails to remove it after it completes its operation, the object remains in the thread even during subsequent requests. Eventually, this blocks the garbage collector from cleaning up the unused object.
ThreadLocal
sEvery ThreadLocal
object has a remove()
method that you can invoke to explicitly clean up the object after completing its operation:
ThreadLocalthreadLocal = new ThreadLocal();
threadLocal.set(10);
threadLocal.remove();
You can also place threadLocal.remove()
within a finally
scope to ensure that the ThreadLocal
object is removed in a situation where an unexpected exception may have prevented the removal of the object.
finalize()
MethodsWhenever the garbage collector decides that an object should be cleaned up, it invokes the finalize()
method on the object. This method releases the resources allocated to the object before finally removing it from memory.
Sometimes, you might override the finalize()
method to perform custom operations before the object is garbage collected. However, objects with overridden finalizers are not immediately garbage collected, even if you invoke System.gc()
. They are sent to a queue and cleaned up each time the garbage collector automatically sweeps for unreferenced objects.
When the rate at which objects with overridden finalize()
methods are sent to the queue is higher than what the queue can accommodate, memory is depleted, which will eventually lead to an OutOfMemoryError
.
finalize()
MethodsTo prevent an unnecessary buildup of objects for garbage collection, you should avoid overriding the finalize()
method.
Alternatively, you can implement the previously mentioned Java Closeable
(https://docs.oracle.com/javase/8/docs/api/java/io/Closeable.html) or AutoCloseable
(https://docs.oracle.com/javase/8/docs/api/java/lang/AutoCloseable.html) interfaces. Both of these interfaces expect you to provide a concrete implementation of the close()
method that runs when an instance of the class goes out of scope, as shown below:
public class Product implements AutoCloseable{
private String productName;
public Product(String productName) {
this.productName = productName;
}
@Override
public void close() throws Exception {
// Perform any custom operation
}
}
This also allows you to use objects of your class in the try
-with-resources](https://docs.oracle.com/javase/tutorial/essential/exceptions/tryResourceClose.html) statement:
try(Product product = new Product("Shirt")){
}
Monitoring your Java application is an important part of the modern software development process. It helps you identify and troubleshoot memory leaks and inefficient code, optimize the application, and maintain optimal application performance. Memory leaks can reduce application performance, thereby leading to poor user experience. They could also cause potential security risks, such as denial of service (DOS). If an attacker is able to control the pace at which memory leaks occur in a program, they can potentially exhaust the available memory and eventually crash the program.
By improving the memory usage of an application, developers can ensure that the application runs as efficiently as possible. This can reduce the amount of memory the application uses and improve overall performance. So, it's important to monitor memory usage and identify issues before they become a significant problem.
Despite the benefits of having insights into the metrics of your application, it's still a tedious task to monitor your application metrics without the right tools. Site24x7 is an application-monitoring service that provides you with tools to inspect the performance of your application on various platforms, like web applications, the cloud, web servers, networking, and so on. You can monitor your Java applications with Site24x7's Java application monitoring tool, which provides visuals and metrics on how your application responds to specific conditions.
This article introduced JVM's garbage collection method for handling memory allocation. It also explored the top five reasons for memory leaks, which cause memory hogging, and some best practices to prevent each of them.
The examples illustrated in this article are available on GitHub(https://github.com/olu-damilare/MemoryHoggingExamples).
Damilare Jolayemi
Write for Site24x7 is a special writing program that supports writers who create content for Site24x7 “Learn” portal. Get paid for your writing.
Apply Now