【数据结构】Java 常用集合类 HashMap(JDK 1.8)
整体设计
HashMap 实现了 Map 接口,存储 key-value 的散列表。与 HashTable 类似相似,区别是:
线程不安全;
允许键或值为 null;
HashMap 采用“数组+链表/红黑树”的结构存储,数组可以扩容,链表可以转化为红黑树。能提供常量时间性能的基本操作 get()
和 put()
。迭代器遍历的效率与容量和存储KV数量有关。HashMap 不保证存入的KV的次序。
HashMap 属性
初始容量大小和载入因子会影响 HashMap 的性能。初始容量指的是桶的数量。载入因子是来衡量 HashMap 在其容量增加之前允许达到的完整度。当 HashMap 中的 key 数量超过了载入因子和当前容量,HashMap 就会扩容(resize),新的 HashMap 桶的数量变为原来的两倍。
通常载入因子设置成 0.75,超过这个值会减少空间开销,但是会增加查询开销。设置初始容量的时候需要考虑 HashMap 将容纳的数量和载入因子,这样可以减少 resize 次数,从而减少性能损耗。
HashMap 在 JDK8 与 JDK7 实现上比较大的不同在于,当桶上结点的数量过大的时候,会将结构改变成树结构。树结构在数量过多的时候查找效率会得到提升。
HashMap 构造函数
tableSizeFor(initialCapacity)
返回大于等于初始容量的的最小的二次幂数值,threshold
作为判断 resize
的阈值。
从构造函数可以看出,这时候并没有初始化桶(Node<K,V>[] table
)。这里和 JDK7 的操作不同,JDK7 HashMap 构造方法中就初始化了哈希表。
另外一个重要的构造函数,用一个 Map 初始化 HashMap
哈希桶
HashMap 采用哈希表存储数据,使用拉链法(链地址法)解决哈希值冲突,即每个哈希值对应存储一个链表。Node<K,V>
对象就是链表的实现。
HashMap get() 方法
首先计算出 key 的 hashCode,找到 table 中的数组索引,即 Node<K, V>
对象,如果存在,判断该结点是否等于要查询的节点,如果不是,按照链表或者红黑树的查询方式查找。
(n - 1) & hash
得到哈希值对应 table 的索引。如果桶是由树组成,Node
是子类TreeNode
实现的。
HashMap put() 方法
put 相比 get 更复杂,会涉及到 table 的 resize()
和 treeify()
。首先判断 table 是否存在,不存在就创建。
判断 hashCode 对应位置是否有值,没有值就直接存入。如果存在就判断这第一个键是否相等,最后才根据 onlyIfAbsent
(是否覆盖)来决定是否更新 value。如果第一个不相同,根据链表或者树的对应插入方法插入或更新(如果 key 存在,根据 onlyIfAbsent
判断)。
如果插入数据成功并且 Node 是链表,判断当前数量是否达到转换成树结构的阈值(TREEIFY_THRESHOLD
),进行 treeify()
操作。最后再判断哈希表是否需要扩容。
modCount
记录 HashMap 内部结构发生变化的次数,主要用于 fast-fail 机制。但是 key 值对应的 value 被更新不会计数 modCount
。afterNodeAccess()
和 afterNodeInsertion()
方法,提供了后置处理接口,子类可以实现该方法。
treeify 转换
链表转换为树结构,当该链表的长度超过8时执行。(扩展:为什么Map桶中个数超过 8 才转为红黑树?)
如果 table 大小小于 MIN_TREEIFY_CAPACITY
(树结构化的最小限制),优先进行 resize 的效果更好,因为当 table 某个位置上集中了多个键值对,是因为这些 key 的 hashCode 和数组长度取模之后结果相同(并不是因为这些 key 的 hashCode 相同,hashCode 相同的概率比较低),所以可能是 table 数组的数量比较小造成的,可以通过扩容的方式,使得这些 key 分散到多个数组位置上。
treeify 方法
遍历双向链表,转换为红黑树结构。
resize 扩容
每次 resize,新的数组是原数组大小的两倍。resize 过程:
初始化过程,包括 oldTab(原表),oldCap(原表长度),newCap(新表长度)和 newThr(新表扩容阈值)。
根据计算出来的新表大小生成新表 newTab
如果 oldTab 不为空,遍历 oldTab 的每个桶,放入 newTab。
返回 newTab。
在扩容开始的时候,table 就被覆盖为 newTab(空的扩容后大小),<u>如果自此有 put 方法调用,会写入新的空的 table,不会写入 oldTab</u>。
为什么扩容两倍?
更加容易计算他们所在的新桶位置。如原桶长度是 4,现在桶长度是 8,那么:桶0中的元素会被分到桶0和桶4中,桶1中的元素会被分到桶1和桶5中,以此类推。
oldCap 假如是16(二进制为10000),扩容变成32(二进制为100000)。当 hashCode & 10000 ==0
,那么 hashCode 的右起第五位肯定也是0,所以 hashCode & 100000
还是0,反之亦然。
与 JDK7 的区别
JDK7 种扩容的实现
函数 transfer()
是链表重新散列方法,也是导致 HashMap 死锁问题的关键:
对原数组中的元素遍历,并计算出元素 e 在新数组中的位置 i
e 放入新数组 i 对应链表 Entry 的头部,因为 Entry 可能有元素存在,所以先将 e.next 指向 Entry 第一个元素(如果 Entry 是空,则 e.next = null),这时候 Entry 第一个元素是 e,newTable[i] 指向的是原 Entry 的第一个元素,所以设置 newTable[i] = e
循环2,直到链表节点全部转移
循环1,直到所有索引数组全部转移
在多线程场景下会出现同时 put 操作,并进入了 transfer。假设现在有三个线程:T1、T2, oldTable[i]=A,newTable[i]=B(B.next=C),A 在 newTable 的位置还是 i,当前 e 引用 A。
JDK8 改进算法后不会再出现环状链表的情况,但并发时仍然不建议使用 HashMap。
版权声明: 本文为 InfoQ 作者【Alex🐒】的原创文章。
原文链接:【http://xie.infoq.cn/article/3e04311a649696993b3b8a051】。
本文遵守【CC-BY 4.0】协议,转载请保留原文出处及本版权声明。
评论