I’m a big believer in learning from my mistakes, but I’m an even bigger believer in learning from other people’s mistakes. Hopefully someone else will be able to learn from my mistakes.
This post is inspired by an issue that took me a number of days to track down and pin point the root cause. It started with NullPointerExceptions randomly be thrown in one of my applications. I wasn’t able to consistently replicate the issue, so I added an indiscriminate amount of logging to the code to see if I could track down what was going on.
What I found was that when I was attempting to pull a value out of a particular hashmap, the value would sometimes be Null, which was a bit puzzling because after initializing the map with the keys/values, there were no more calls to put(), only calls to get(), so there should have been no opportunity to put a null value in the map.
Below is a code snippet similar (but far more concise) to the one I had been working on.
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
public void runTest() { | |
ProductSummaryBean summaryBean = new ProductSummaryBean(19.95, "MyWidget", "Z332332", new DecimalFormat("$#,###,##0.00")); | |
ProductDetailsBean detailBean = getProductDetailsBean(summaryBean); | |
productMap.put(summaryBean, detailBean); | |
//Load the same summaryBean from the DB | |
summaryBean = loadSummaryBean("Z332332"); | |
//Pull the detailBean from the map for the given summaryBean | |
detailBean = productMap.get(summaryBean); | |
System.out.println("DetailBean is: " + detailBean ); | |
} |
There is a ProductSummaryBean with a short summary of the product, and a ProductDetailBean with further product details. The summary bean is below and contains four properties.
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
package com.lynden.mapdemo; | |
import java.text.DecimalFormat; | |
import java.util.Objects; | |
public class ProductSummaryBean { | |
protected double price; | |
protected String name; | |
protected String upcCode; | |
protected DecimalFormat priceFormatter; | |
public ProductSummaryBean(double price, String name, String upcCode, DecimalFormat priceFormatter) { | |
this.price = price; | |
this.name = name; | |
this.upcCode = upcCode; | |
this.priceFormatter = priceFormatter; | |
} | |
public double getPrice() { | |
return price; | |
} | |
public String getName() { | |
return name; | |
} | |
public String getUpcCode() { | |
return upcCode; | |
} | |
public DecimalFormat getPriceFormatter() { | |
return priceFormatter; | |
} | |
@Override | |
public int hashCode() { | |
int hash = 7; | |
hash = 79 * hash + (int) (Double.doubleToLongBits(this.price) ^ (Double.doubleToLongBits(this.price) >>> 32)); | |
hash = 79 * hash + Objects.hashCode(this.name); | |
hash = 79 * hash + Objects.hashCode(this.upcCode); | |
hash = 79 * hash + Objects.hashCode(this.priceFormatter); | |
return hash; | |
} | |
@Override | |
public boolean equals(Object obj) { | |
if (this == obj) { | |
return true; | |
} | |
if (obj == null) { | |
return false; | |
} | |
if (getClass() != obj.getClass()) { | |
return false; | |
} | |
final ProductSummaryBean other = (ProductSummaryBean) obj; | |
if (Double.doubleToLongBits(this.price) != Double.doubleToLongBits(other.price)) { | |
return false; | |
} | |
if (!Objects.equals(this.name, other.name)) { | |
return false; | |
} | |
if (!Objects.equals(this.upcCode, other.upcCode)) { | |
return false; | |
} | |
if (!Objects.equals(this.priceFormatter, other.priceFormatter)) { | |
return false; | |
} | |
return true; | |
} | |
@Override | |
public String toString() { | |
return "ProductBean{" + "price=" + price + ", name=" + name + ", upcCode=" + upcCode + ", priceFormatter=" + priceFormatter + '}'; | |
} | |
} |
Any guesses what happens when the code above is run?
Exception in thread "main" java.lang.NullPointerException at com.lynden.mapdemo.TestClass.runTest(TestClass.java:34) at com.lynden.mapdemo.TestClass.main(TestClass.java:50)
So what happened? The HashMap stores its keys by using the hashcode of the key objects. If we print out the hashcode when the ProductSummaryBean is first created and also after its read out of the DB we get the following.
SummaryBean hashcode before: -298224643 SummaryBean hashcode after: -298224679
We can see that the hashcode before and after are different, so there must be something different about the two objects.
SummaryBean before: ProductBean{priceFormatter=java.text.DecimalFormat@67500, price=19.95, name=MyWidget, upcCode=Z332332} SummaryBean after: ProductBean{priceFormatter=java.text.DecimalFormat@674dc, price=19.95, name=MyWidget, upcCode=Z332332}
Printing out the entire objects shows that while name, upc code, and price are all the same, the DecimalFormatter used for the price is different. Since the DecimalFormatter is part of the hashcode() calculation for the ProductSummaryBean, the hashcodes between the before and after versions of the bean turned out different. Since the hashcode was modified, the map was not able to find the corresponding ProductDetailBean which in turned caused the NullPointerException.
Now one may ask, should the DecimalFormat object in the bean been used as part of the equals() and hashcode() calculations? In this case, probably not, but this may not be true in your case. The safer way to go for the hashmap key would be to have used the product’s upc code as the HashMap key to avoid the danger of the keys changing unexpectedly.