HashMap踩坑实录——谁动了我的奶酪
说到HashMap
,hashCode
和 equals
,想必绝大多数人都不会陌生,然而你真的了解这它们的机制么?本文将通过一个简单的Demo还原我自己前不久在 HashMap
上导致的线上问题,看看我是怎么跳进这个坑中去的。
起因
在重构一段旧代码的时候发现有个 HashMap
的key对象没有重写 hashCode
和 equals
方法,使用IDEA自动重构工具生成后引发线上问题,因为实际重构的旧代码复杂,所以我抽出了一个关于奶酪(Cheese)的Demo还原踩坑场景,看看究竟谁动了我的奶酪
。
一个奶酪的例子
-
首先,我们有一个奶酪(Cheese)类
/** * @author nauyus */ public class Cheese { /** * 大小 */ private Integer size; /** * 价格 */ private BigDecimal price; /** * 制造者 */ private String creator; //节约篇幅省略get/set/构造方法 }
-
然后,我们制造一个奶酪并且把它放到 HashMap
中去
Cheese cheese = new Cheese(7, new BigDecimal(20), "nauyus"); Map<Cheese, String> map = new HashMap<>(); map.put(cheese, "something not important");
好了,这时候我收到了阿里代码扫描插件的严正警告:如果自定义对象做为Map的键,那么必须重写hashCode和equals。
看到此警告,加上自己从前的经验,那当然就是改啊,打开Cheese类 Command+N
迅速生成代码然后add,commit,push一气呵成,然后,发布后线上出现了一个大BUG……
HashMap原理浅析
抛开BUG原因,我们先想一想为什么编程规约中强制要求了关于 hashCode
和 equals
的如下规则?
这要简单说下 HashMap
原理, HashMap
底层数据结构为在 jdk1.7 中为数组+链表, jdk1.8 中为数组+链表+红黑树,大概就长这个样子:
然后我们看看 HashMap
如何将数据存入又如何取出的。
首先看下 put
方法
/** * Implements Map.put and related methods. * * @param hash hash for key * @param key the key * @param value the value to put * @param onlyIfAbsent if true, don't change existing value * @param evict if false, the table is in creation mode. * @return previous value, or null if none */ final V putVal(int hash, K key, V value, boolean onlyIfAbsent, boolean evict) { Node<K,V>[] tab; Node<K,V> p; int n, i; if ((tab = table) == null || (n = tab.length) == 0) n = (tab = resize()).length; if ((p = tab[i = (n - 1) & hash]) == null) tab[i] = newNode(hash, key, value, null); else { Node<K,V> e; K k; if (p.hash == hash && ((k = p.key) == key || (key != null && key.equals(k)))) e = p; else if (p instanceof TreeNode) e = ((TreeNode<K,V>)p).putTreeVal(this, tab, hash, key, value); else { for (int binCount = 0; ; ++binCount) { if ((e = p.next) == null) { p.next = newNode(hash, key, value, null); if (binCount >= TREEIFY_THRESHOLD - 1) // -1 for 1st treeifyBin(tab, hash); break; } if (e.hash == hash && ((k = e.key) == key || (key != null && key.equals(k)))) break; p = e; } } if (e != null) { // existing mapping for key V oldValue = e.value; if (!onlyIfAbsent || oldValue == null) e.value = value; afterNodeAccess(e); return oldValue; } } ++modCount; if (++size > threshold) resize(); afterNodeInsertion(evict); return null; }
具体细节可以仔细阅读源码,简单说来,就是首先对 key 进行 hash
计算,hash
是一个 int 类型的本地方法,也就将 key 的 hashCode
无符号右移16位然后与 hashCode
异或从而得到 hash
值,在 putVal
方法中 (n - 1)& hash
计算得到数组的索引位置
,如果位置无冲突,则直接将 value 放入数组中对应位置,如果存在冲突,则使用 equals
方法判断 key 是否为同一对象,同一对象则覆盖,不同对象则将 value 挂到链表或红黑树上。
然后再看看 get
方法
/** * Implements Map.get and related methods. * * @param hash hash for key * @param key the key * @return the node, or null if none */ final Node<K,V> getNode(int hash, Object key) { Node<K,V>[] tab; Node<K,V> first, e; int n; K k; if ((tab = table) != null && (n = tab.length) > 0 && (first = tab[(n - 1) & hash]) != null) { if (first.hash == hash && // always check first node ((k = first.key) == key || (key != null && key.equals(k)))) return first; if ((e = first.next) != null) { if (first instanceof TreeNode) return ((TreeNode<K,V>)first).getTreeNode(hash, key); do { if (e.hash == hash && ((k = e.key) == key || (key != null && key.equals(k)))) return e; } while ((e = e.next) != null); } } return null; }
同样的,get
也是通过 first = tab[(n - 1) & hash]
计算出位置然后再决定是否从链表或红黑树中进行查找,过程中同样用到了 equals
方法。
总结一下,put
方法使用基于 hashCode
的 hash
方法得到下标位置,但是不同对象 hash
可能相同,即存在 hash碰撞
的可能,所以需要 equals
方法进一步判断是否为同一对象,get
方法同样使用 hash
方法得到下标位置,再根据 equals
方法确定是否取出该对象。
谁动了我的奶酪
如果我们的自定义对象没有覆写 hashCode
和 equals
,则会使用父类Object的方法,源码如下:
public native int hashCode; public boolean equals(Object obj) { return (this == obj); }
hashCode
是个本地方法,和内存地址有关系,而默认的 equals
内部实现就是 "=="
运算符,这就会导致一个结果,值相同对象的 hashCode
并不同,并且 equals
方法返回 false
。所以编程规约强制要求如果自定义对象做为Map的键,那么必须重写hashCode和equals。
(敲黑板,这段话第二次出现),没毛病!
如果没有覆写父类方法,下面的程序 cheese 值虽相同,但 put
奶酪后无法 get
到,奶酪被动了
!
Cheese cheese= new Cheese(7, new BigDecimal(20), "nauyus"); Map<Cheese, String> map = new HashMap<>(); map.put(cheese, "something not important"); cheese = new Cheese(7, new BigDecimal(20), "nauyus"); //没有覆写hashCode和equals时虽然cheese值相同,但输出为null System.out.println(map.get(cheese));
谁又动了我的奶酪
好了,现在我们知道了如果自定义对象做为Map的键,那么必须重写hashCode和equals。
(重要的事情说三遍!),那有人问我重写后的产生的BUG后是怎么回事? 还原了下场景应该是这样的,我重写了 hashCode
和 equals
,但是千不该万不该忽略了原有代码很多行后还有一行代码,做成Demo后大概是这样的:
Cheese cheese= new Cheese(7, new BigDecimal(20), "nauyus"); Map<Cheese, String> map = new HashMap<>(); map.put(cheese, "something not important"); //一段被我忽略的代码 cheese.setCreator("tom"); System.out.println(map.get(cheese));
在 put
到 HashMap
后,作为 key 的 cheese 对象再次被 set 了值,导致 hashCode
返回结果有了变更,put
奶酪后无法 get
到,奶酪再一次被动了
!
总结
总结一下踩坑经历,可以得出以下结论:
-
如果自定义对象做为Map的键,那么必须重写hashCode和equals。 -
尽量使用 不可变对象
作为map的键,如String。 -
即使万分自信的代码,还是跑一下单元测试为好。(血的教训)
还有啊,没事还是少瞎改别人代码吧!
感谢阅读,原创不易,如有启发,点个赞吧!这将是我写作的最强动力!本文不同步发布于不止于技术的技术公众号
Nauyus
,主要分享一些编程语言,架构设计,思维认知类文章, 2019年12月起开启周更模式,每周一篇原创文章,欢迎关注,共同学习成长!