HashMap 怎么 hash?又如何 map?

作者:樊腾飞

链接:

https://my.oschina.net/editorial-story/blog/2396106

HashMap 是 Java 中 Map 的一个实现类,它是一个双列结构(数据+链表),这样的结构使得它的查询和插入效率都很高。HashMap 允许 null 键和值,它的键唯一,元素的存储无序,并且它是线程不安全的。

由于 HashMap 的这些特性,它在 Java 中被广泛地使用,下面我们就基于 Java 8 分析一下 HashMap 的源码。

双列结构:数组+链表

首先 HashMap 是一个双列结构,它是一个散列表,存储方式是键值对。 它继承了 AbstractMap,实现了 Map<K,V> Cloneable Serializable 接口。

HashMap 的双列结构是数组 Node[]+链表,我们知道数组的查询很快,但是修改很慢,因为数组定长,所以添加或者减少元素都会导致数组扩容。而链表结构恰恰相反,它的查询慢,因为没有索引,需要遍历链表查询。但是它的修改很快,不需要扩容,只需要在首或者尾部添加即可。HashMap 正是应用了这两种数据结构,以此来保证它的查询和修改都有很高的效率。

HashMap 在调用 put() 方法存储元素的时候,会根据 key 的 hash 值来计算它的索引,这个索引有什么用呢?HashMap 使用这个索引来将这个键值对储存到对应的数组位置,比如如果计算出来的索引是 n,则它将存储在 Node[n] 这个位置。

HashMap 在计算索引的时候尽量保证它的离散,但还是会有不同的 key 计算出来的索引是一样的,那么第二次 put 的时候,key 就会产生冲突。HashMap 用链表的结构解决这个问题,当 HashMap 发现当前的索引下已经有不为 null 的 Node 存在时,会在这个 Node 后面添加新元素,同一索引下的元素就组成了链表结构,Node 和 Node 之间如何联系可以看下面 Node 类的源码分析。

先了解一下 HashMap 里数组的几个参数:

DEFAULT_INITIAL_CAPACITY,默认初始长度,16:

MAXIMUM_CAPACITY,最大长度,2^30:

DEFAULT_LOAD_FACTOR,默认加载因子,0.75:

threshold,阈值,扩容的临界值(capacity * load factor)

再看看 HashMap 构造函数

下边是非常重要的一个内部类 Node ,它实现了 Map.Entry,Node 是 HashMap 中的基本元素,每个键值对都储存在一个 Node 对象里, Node 类有四个成员变量:hash key 的哈希值、键值对 key 与 value,以及 next 指针。next 也是 Node 类型,这个 Node 指向的是链表下一个键值对,这也就是前文提到的 hash 冲突时 HashMap 的处理办法。

Node 类内部实现了 Map.Entry 接口中的 getKey()、getValue() 等方法,所以在遍历 Map 的时候我们可以用 Map.entrySet() 。

HashMap put() 流程

put() 方法

put() 主要是将 key 和 value 保存到 Node 数组中,HashMap 根据 key 的 hash 值来确定它的索引,源码里 put 方法将调用内部的 putVal() 方法。

HashMap 在 put 键值对的时候会调用 hash() 方法计算 key 的 hash 值,hash() 方法会调用 Object 的 native 方法 hashCode() 并且将计算之后的 hash 值高低位做异或运算,以增加 hash 的复杂度。(Java 里一个 int 类型占 4 个字节,一个字节是 8 bit,所以下面源码中的 h 与 h 右移 16 位就相当于高低位异或)

putVal() 方法

这部分是主要 put 的逻辑

  1. 计算容量:根据 map 的 size 计算数组容量大小,如果元素数量也就是 size 大于数组容量 ×0.75,则对数组进行扩容,扩容到原来的 2 倍。

  2. 查找数据索引:根据 key 的 hash 值和数组长度找到 Node 数组索引。

  3. 储存:这里有以下几种情况(假设计算出的 hash 为 i,数组为 tab,变量以代码为例)

    1. 当前索引为 null,直接 new 一个 Node 并存到数组里,tab[i]=newNode(hash, key, value, null)

    2. 数组不为空,这时两个元素的 hash 是一样的,再调用 equals 方法判断 key 是否一致,相同,则覆盖当前的 value,否则继续向下判断

    3. 上面两个条件都不满足,说明 hash 发生冲突,Java 8 里实现了红黑树,红黑树在进行插入和删除操作时通过特定算法保持二叉查找树的平衡,从而可以获得较高的查找性能。本篇也是基于 Java 8 的源码进行分析,在这里 HashMap 会判断当前数组上的元素 tab[i] 是否是红黑树,如果是,调用红黑树的 putTreeVal 的 put 方法,它会将新元素以红黑树的数据结构储存到数组中。

如果以上条件都不成立,表明 tab[i] 上有其它 key 元素存在,并且没有转成红黑树结构,这时只需调用 tab[i].next 来遍历此链表,找到链表的尾然后将元素存到当前链表的尾部。

HashMap 的 get()

get() 方法会调用 getNode() 方法,这是 get() 的核心,getNode() 方法的两个参数分别是 hash 值和 key。

这里重点来看 getNode() 方法,前面讲到过,HashMap 是通过 key 生成的 hash 值来存储到数组的对应索引上,HashMap 在 get 的时候也是用这种方式来查找元素的。

  1. 根据 hash 值和数组长度找到 key 对应的数组索引。

  2. 拿到当前的数组元素,也就是这个链表的第一个元素 first,先用 hash 和 equals() 判断是不是第一个元素,是的话直接返回,不是的话继续下面的逻辑。

  3. 不是链表的第一个元素,判断这个元素 first 是不是红黑树,如果是调用红黑树的 getTreeNode 方法来查询。

  4. 如果不是红黑树结构,从 first 元素开始遍历当前链表,直到找到要查询的元素,如果没有则返回 null。

数组扩容时再哈希(re-hash)的理解

前面提到,当 HashMap 在 put 元素的时候,HashMap 会调用 resize() 方法来重新计算数组容量,数组扩容之后,数组长度发生变化。我们知道 HashMap 是根据 key 的 hash 和数组长度计算元素位置的,那当数组长度发生变化时,如果不重新计算元素的位置,当我们 get 元素的时候就找不到正确的元素了,所以 HashMap 在扩容的同时也重新对数组元素进行了计算。

这时还有一个问题,re-hash 的时候同一个桶(bucket)上的链表会重新排列还是链表仍然在同一桶上。先考虑一下当它扩容的时候同一个桶上的元素再与新数组长度做与运算 & 时,可能计算出来的数组索引不同。假如数组长度是 16,扩容后的数组长度将是 32。

下边用二进制说明这个问题:

最终的结果是 0000 1111,和用 oldLen 计算的结果一样,其实看上式可以发现真正能改变索引值的是 hash 第 5 位(从右向左)上的值,也就是 length 的最高非零位,所以,同一个链表上的元素在扩容后,它们的索引只有两种可能,一种就是保持原位(最高非零位是 0),另一种就是 length+ 原索引 i (第五位是 1,结果就相等于 25+原索引 i,也就是 length+i)。

下边所示的 HashMap 源码中就是用这个思路来 re-hash 一个桶上的链表,e.hash & oldCap == 0 判断 hash 对应 length 的最高非 0 位是否是 1,是 1 则把元素存在原索引,否则将元素存在 length+原索引的位置。HashMap 定义了四个 Node 对象,lo 开头的是低位的链表(原索引),hi 开头的是高位的链表(length+原索引,所以相当于是新 length 的高位)。

HashMap 与 HashTable

另外对比一下 HashMap 与 HashTable:

  • HashMap 是线程不安全的,HashTable 线程安全,因为它在 get、put 方法上加了 synchronized 关键字。

  • HashMap 和 HashTable 的 hash 值是不一样的,所在的桶的计算方式也不一样。HashMap 的桶是通过 & 运算符来实现 (tab.length - 1) & hash,而 HashTable 是通过取余计算,速度更慢(hash & 0x7FFFFFFF) % tab.length (当 tab.length = 2^n 时,因为 HashMap 的数组长度正好都是 2^n,所以两者是等价的)

  • HashTable 的 synchronized 是方法级别的,也就是它是在 put() 方法上加的,这也就是说任何一个 put 操作都会使用同一个锁,而实际上不同索引上的元素之间彼此操作不会受到影响;ConcurrentHashMap 相当于是 HashTable 的升级,它也是线程安全的,而且只有在同一个桶上加锁,也就是说只有在多个线程操作同一个数组索引的时候才加锁,极大提高了效率。

总结

  • HashMap 底层是数组+链表结构,数组长度默认是 16,当元素的个数大于数组长度×0.75 时,数组会扩容。

  • HashMap 是散列表,它根据 key 的 hash 值来找到对应的数组索引来储存, 发生 hash 碰撞的时候(计算出来的 hash 值相等) HashMap 将采用拉链式来储存元素,也就是我们所说的单向链表结构。

  • 在 Java7 中,如果 hash 碰撞,导致拉链过长,查询的性能会下降, 所以在 Java8 中添加红黑树结构,当一个桶的长度超过 8 时,将其转为红黑树链表,如果小于 6,又重新转换为普通链表。

  • re-hash 再哈希问题:HashMap 扩容的时候会重新计算每一个元素的索引,重新计算之后的索引只有两种可能,要么等于原索引要么等于原索引加上原数组长度。

  • 由上一条可知,每次扩容,整个 hash table 都需要重新计算索引,非常耗时,所以在日常使用中一定要注意这个问题。

参考文档

  • Why is the bucket size 16 by default in HashMap? 

  • 集合源码解析之 HashMap(基于 Java8) 

  • Java HashMap 工作原理 

  • HashMap 源码详细分析(JDK1.8)

作者介绍

樊腾飞,有开源精神,乐于分享,希望通过写博客认识更多志同道合的人。个人博客:rollsbean.com

猜你喜欢

转载自blog.csdn.net/Dome_/article/details/83684579