This is part 4 of a multi-part series on memory leaks in Java. If you missed any of the last posts, you can find them here:
- Part 1 – Overview of memory leaks
- Part 2 – The static modifier and anonymous inner classes
- Part 3 – String interning and string concatenation
Last post we looked at how string interning can cause memory leaks. We also looked at the effect of string concatenation on performance. In this post, we’ll look at how incorrect or missing equals()
and hashCode()
methods can cause memory leaks.
Revision Of equals() And hashCode() Methods
The Java documentation says that the equals()
method “implements an equivalence relation on non-null object references”. In other words, we use it to test whether two objects are “equal to” each other.
What “equal to” actually means is determined by the business rules of our application. These rules are generally different for each class in every application. We generally only override the equals()
method for model/entity classes. The equals()
method is used by collections that cannot contain duplicate elements. These include HashSet
, HashMap
, TreeSet
and TreeMap
.
The hashCode()
method is another canonical method that we usually only override for model/entity classes. It should return an int
value that uniquely identifies an object. The general contract for hashCode()
says that equal objects must have equal hash codes. This implies that the same object fields used in the equals()
method must be used in the hashCode()
method. We must override the hashCode()
method if we want to use hash tables in our application. Hash table implementations in Java include HashMap
and HashSet
.
We don’t need to return different hash code values if two objects are not equal according to the equals()
method. However, if we do return different values, the hash table performance will improve.
If we override the equals()
method in a class, we must also override the hashCode()
method. If we don’t, our class will violate the general contract for hashCode()
. If we then add objects of this class to a hash table, it won’t function properly.
Default Object Implementations
The Object
implementations of these two methods are not suitable for business applications. The hashCode()
method typically returns the physical address of the object in memory. The equals()
method checks if two object references refer to the same object, i.e. it also checks the addresses. These are good default implementations, but not particularly useful in everyday applications.
Let’s say that we correctly override the equals()
method in our class. This will mean that two different objects with the same contents would be logically equal to each other according to the equals()
method.
Now let’s say that we didn’t override the hashCode()
method, but used the inherited hashCode()
method. The previous two equal objects would just be two different objects at different addresses. From the perspective of the hashCode()
method, they would have nothing in common. Calling the hashCode()
method would then return two apparently random values. Two equal values should be returned according to the contract.
Code Example Without equals() And hashCode() Methods
Let’s create a very simple Key
class to use as a key in a Map
. We will not override the equals()
and hashCode()
methods:
/** A very simple class to be used as a key when inserting values into
* a HashMap. This class has no equals() or hashCode() methods. This
* will easily trigger a memory leak when inserting values into a
* HashMap, because whenever a new Key object is created, the HashMap
* sees the object as a unique object, even if the string used in the
* constructor is the same.
*/
import java.util.Objects;
public class Key {
private final String value;
public Key(String value) {
// simple null check
this.value = Objects.requireNonNull(value);
}
// no equals() and no hashCode() methods
} // end of class
Now let’s use this Key
class as a key when putting values into a HashMap
. A HashMap
can’t contain duplicate keys. Because we haven’t implemented the equals()
method, the HashMap
has no way of knowing if we create duplicate Key
objects:
import java.util.Map;
import java.util.HashMap;
public class MemoryLeakTest {
public static void main(String args[]) {
final int NUMBER = 100;
Map<Object, String> map = new HashMap<>();
for(int i=0; i<NUMBER; i++) {
map.put(new Key("key"), "some value");
}
// Checking how many objects were inserted.
// Without the equals() and hashCode(), NUMBER are inserted.
// With the equals() and hashCode(), only 1 object is inserted.
System.out.println("map.size() = " + map.size());
// Searching for the object with get()
String value = map.get(new Key("key"));
if (value == null)
System.out.println("No objects found with this key");
else
System.out.println("Object found, value = |" + value +"|");
// Searching for the object with containsKey()
boolean isFound = map.containsKey(new Key(null));
if (isFound)
System.out.println("Object found!");
else
System.out.println("No objects found with this key");
}
} // end of class
The program prints out the following:
map.size() = 100
No objects found with this key
No objects found with this key
Since the HashMap
doesn’t allow duplicate keys, the many duplicate Key
objects that we used as keys shouldn’t increase the memory. But because we didn’t define the equals()
and hashCode()
methods, these duplicate objects are inserted. This is why we see more than one object in the HashMap
. This will increase the memory usage, and could cause a memory leak.
The HashMap::put()
method calls both the equals()
and the hashCode()
methods of the Key
class when inserting objects. If either or both methods are missing, every Key
object will be inserted in the loop. This will result in a high probability of memory leaks.
The HashMap::get()
method also uses the equals()
and hashCode()
methods of the Key
class. If either or both methods are missing, then objects can’t be found. This is because the Key
object used to search for an entry is a unique object, and not one contained in the map.
Implementing equals() And hashCode() Methods
Now let’s implement the equals()
and hashCode()
methods in the Key
class:
public class Key {
private final String value;
public Key(String value) {
// simple null check
this.value = Objects.requireNonNull(value);
}
@Override
public int hashCode() {
return value.hashCode();
}
@Override
public boolean equals(Object o) {
if (o == this) return true;
if (!(o instanceof Key)) {
return false;
}
Key tmp = (Key) o;
return tmp.value.equals(value);
}
} // end of class
Re-running the test program will print the following:
map.size() = 1
Object found, value = |some value|
Object found!
Now the HashMap
works properly, and rejects the duplicate keys. This shows the importance of correctly implementing the canonical equals()
and hashCode()
methods.
Bonus Code Example
To see what can happen with uncontrolled growth of a Map
, comment out the equals()
and hashCode()
methods in the Key
class, and change the main()
method to:
public static void main(String args[]) {
int counter = 0;
try {
Map<Object, Object> map = System.getProperties();
while (true) {
++counter;
map.put(new Key("key"), "some value");
}
}
catch (Throwable e) {
System.out.println("Counter = " + counter);
e.printStackTrace();
}
}
Start up the JVM with a very small heap, else you’ll wait forever as it fills up memory.
Running with Java 8:
c:> java -Xmx2m -Xms2m MemoryLeakTest
Counter = 18376
java.lang.OutOfMemoryError: Java heap space
at java.util.Hashtable.rehash(Hashtable.java:401)
at java.util.Hashtable.addEntry(Hashtable.java:425)
at java.util.Hashtable.put(Hashtable.java:476)
at MemoryLeakTest.main(MemoryLeakTest.java:103)
Running with Java 17:
C:> java -Xmx2m -Xms2m MemoryLeakTest
Exception: java.lang.OutOfMemoryError thrown from
the UncaughtExceptionHandler in thread "main"
Conclusion
For revision on canonical classes, see the blog post.
In the next post, we’ll look at some of the tools we can use to identify leaks. This includes profilers and verbose garbage collection messages.
Please share your comments.
Stay safe and keep learning!