JDK1.8开始HashMap的源码有了一些改变,这篇博客主要分别介绍下JDK1.7和JDK1.8中HashMap的底层实现,并比较一下二者的不同。
JDK1.7中的HashMap
HashMap是我们很常用的集合框架,对它的用法我们都很熟悉。但是如果要更好的使用它,我们可能需要理解HashMap的内部实现,同时这也是面试比较常问的一个问题。下面的源码使用的是Java SE Development Kit 7u80,主要介绍下HashMap的底层存储结构,扩容机制和一些常用的方法。
JDK1.7中的HashMap的存储结构
在JDk1.7中或者之前的一些版本中,HashMap的底层存储结构都是采用“数组+链表”的形式。数组的每一节被称为桶(bucket),每个bucket存储这一个或多个entry(Map中的每一个键值对),如果有冲突,冲突的地方开一个链表,链式存储这些entry,所以性能差的HashMap最终会退化成一个链表。其中Entry的结构如下:
1 | static class Entry<K,V> implements Map.Entry<K,V> { |
key,value存储相应的值,next指向bucket中下一个entry。
JDK1.7中HashMap的存储结构大致可表述为下图:
table数组中的索引通过key的hashCode再取模获得。获取hashCode的hash算法如下(对hashCode进行rehash–高位参与运算,尽量减少冲突):
1 | final int hash(Object k) { |
获取数组的索引的方法如下:
1 | static int indexFor(int h, int length) { |
扩容
HashMap的成员变量中有一个负载因子loadFactor,值为0.75f。还有一个阈值变量threshold,这个变量的值为capacity * loadFactor。源码中定义如下:
1 | /** |
当table数组中所存储的bucket数大于等于这个值,并且当前位置的bucket已经有值时就会发生扩容。源码如下:
1 | void addEntry(int hash, K key, V value, int bucketIndex) { |
1 | void resize(int newCapacity) { |
扩容时会新建一个原先数组两倍的新数组,将原先数组的数据重新hash到新的数组,如果原先数组的某个bucket是链表,并且重新hash的位置仍然相同,则链表顺序会反转。rehash后重设table引用和threshold的值。源码如下:
1 | /** |
同时我们要知道上面的addEntry方法是在put方法中调用的,HashMap在初始化时并没有创建table数组(只是初始化一些基本的属性),而是在真正放入数据才开始创建,并在这里判断是否需要扩容。
HashMap的一些常用方法
get方法
我们是如何通过key找到所需的value值的呢?当我们调用HashMap的get方法时,首先我们会判断key是否为null,如果为空,则定位到table[0](所有的key为null的entry都会放在这里);如果不为空并且table的size不为0,此时会根据key的hashcode找到entry所在的bucket(因此HashMap的key最好为不可变对象),并遍历链表通过key的equals方法找到最终的entry。返回它的value值。相关代码如下:
1 | public V get(Object key) { |
1 | private V getForNullKey() { |
1 | final Entry<K,V> getEntry(Object key) { |
put方法
在调用put方法时,会首先判断table数组是否已经初始化,如果没有,则初始化table表;如果已经初始化则判断key是否为null,如果为null,则将它放在table[0]中(替换之前的旧值);如果key不为null,则取key的hashCode值找到具体的bucket位置,如果该位置没有值(即为null),则新建entry(调用上面列出的addEntry方法);如果该位置已有值,即发生了hash冲突,则遍历链表,通过key的equals方法找到具体的entry;如果没有找到这样的entry,则在尾部新增一个entry节点。相关代码如下:
1 | public V put(K key, V value) { |
初始化:
1 | private void inflateTable(int toSize) { |
放入空key对象:
1 | /** |
JDK1.8中HashMap
从JDK1.8开始,HashMap的底层实现做了一些改变。首先增加了一个Node类替换之前的Entry类(实际上就是改了个命名,具体实现没有变,这个命名比之前的确实要更加贴切)。同时新增了一个TreeNode类,这是因为1.8中的HashMap当冲突的链表节点大于等于8时,就会转化为红黑树,以优化链表查询。Node和TreeNode的结构如下:
1 | static class Node<K,V> implements Map.Entry<K,V> { |
1 | /** |
HashMap的存储结构可表述如下:
扩容
JDK1.8中扩容操作比1.7中做了些优化,首先它将初始化操作也整合在了扩容方法里面,当然这不是重点;重点是在扩容时它不需要再调用hash方法重新计算新表中索引位置。它采用二次幂的扩展,使得扩展后的bucket位置要么是原位置,要么是原位置加上OldCap(即老表的长度)(这个具体是如何实现的,笔者也不是很明白,看的云里雾里。参考了网上的博客,原因可能是源码中优化了hash方法中的高位运算,使得在扩容时,它的mask范围在高位多了1个bit位,而这个bit位如果为0,则扩展后的元素位置为原位置,若这个bit为1,则新位置为原位置加上OldCap。因此在扩容时不需要再重新hash,只需要观察原先的hash值新增的bit位是0还是1即可。同时因为新增的bit为是0还是1是随机的,因此可以很好的将之前的冲突重新离散。)。还有一点优化是如果之前冲突的节点在扩展后仍然冲突,与1.7中不同的是,1.8中的链表不会反转。源码如下:
1 | /** |
HashMap的一些方法
JDK1.8中get和put方法的逻辑和1.7中的变化也不大。
get方法
get方法在1.7和1.8中改变不大,多了个逻辑,当索引到的bucket类型为TreeNode时,查找红黑树。get方法源码如下:
1 | public V get(Object key) { |
1 | final Node<K,V> getNode(int hash, Object key) { |
put方法
逻辑变化不大。多了个当链表的节点数大于等于8时,转化为红黑树。源码如下:
1 | public V put(K key, V value) { |
1 | final V putVal(int hash, K key, V value, boolean onlyIfAbsent, |
结束语
HashMap可以说是是使用最频繁的一个集合之一,了解它的底层实现有助于我们更好的使用它,在map中的数据很大的情况下,恰当的使用可能比不当的使用具有很大的性能提升。本博客是笔者通过研读JDk中的源码和翻看相关的博客和书籍整合而成。受笔者自身水平限制,可能会有些错谬之处,读者不可全信,当多查询相关资料或者自己阅读源码验证一下。最后,如有不当之处,敬请斧正。