HashMap
HashMap基本是面试必问的点,因为这个数据结构用的太频繁了,jdk1.8中的优化也是比较巧妙。有必要去深入探讨一下。但是涉及的内容比较多,这里只先探讨jdk8中HashMap的实现,至于jdk7中HashMap的死循环问题、红黑树的原理等都不会在本篇文章扩展到。其他的文章将会再去探讨整理。
本篇文章较长,高能预警。
一、前言
之前的List
,讲了ArrayList
、LinkedList
,最后讲到了CopyOnWriteArrayList
,就前两者而言,反映的是两种思想:
(1)ArrayList
以数组形式实现,顺序插入、查找快,插入、删除较慢
(2)LinkedList
以链表形式实现,顺序插入、查找较慢,插入、删除方便
那么是否有一种数据结构能够结合上面两种的优点呢?有,答案就是HashMap
。
HashMap
是一种非常常见、方便和有用的集合,是一种键值对(K-V)形式的存储结构,在有了HashCode
的基础后,下面将还是用图示的方式解读HashMap
的实现原理。
Java为数据结构中的映射定义了一个接口java.util.Map
,此接口主要有四个常用的实现类,分别是HashMap
、Hashtable
、LinkedHashMap
和TreeMap
,类继承关系如下图所示:
(1) HashMap
:它根据键的hashCode
值存储数据,大多数情况下可以直接定位到它的值,因而具有很快的访问速度,但遍历顺序却是不确定的。 HashMap
最多只允许一条记录的键为null
,允许多条记录的值为null
。HashMap
非线程安全,即任一时刻可以有多个线程同时写HashMap
,可能会导致数据的不一致。如果需要满足线程安全,可以用 Collections
的synchronizedMap
方法使HashMap
具有线程安全的能力,或者使用ConcurrentHashMap
。
(2) Hashtable
:Hashtable
是遗留类,很多映射的常用功能与HashMap
类似,不同的是它承自Dictionary
类,并且是线程安全的,任一时间只有一个线程能写Hashtable
,并发性不如ConcurrentHashMap
,因为ConcurrentHashMap
引入了分段锁。Hashtable
不建议在新代码中使用,不需要线程安全的场合可以用HashMap
替换,需要线程安全的场合可以用ConcurrentHashMap
替换。
(3) LinkedHashMap
:LinkedHashMap
是HashMap
的一个子类,保存了记录的插入顺序,在用Iterator
遍历LinkedHashMap
时,先得到的记录肯定是先插入的,也可以在构造时带参数,按照访问次序排序。
(4) TreeMap
:TreeMap
实现SortedMap
接口,能够把它保存的记录根据键排序,默认是按键值的升序排序,也可以指定排序的比较器,当用Iterator
遍历TreeMap
时,得到的记录是排过序的。如果使用排序的映射,建议使用TreeMap
。在使用TreeMap
时,key必须实现Comparable
接口或者在构造TreeMap
传入自定义的Comparator
,否则会在运行时抛出java.lang.ClassCastException
类型的异常。
对于上述四种Map
类型的类,要求映射中的key
是不可变对象。不可变对象是该对象在创建后它的哈希值不会被改变。如果对象的哈希值发生变化,Map
对象很可能就定位不到映射的位置了。
二、HashMap的结构
其中哈希表是一个数组,我们经常把数组中的每一个节点称为一个桶,哈希表中的每个节点都用来存储一个键值对。
在插入元素时,如果发生冲突(即多个键值对映射到同一个桶上)的话,就会通过链表的形式来解决冲突。
因为一个桶上可能存在多个键值对,所以在查找的时候,会先通过key
的哈希值先定位到桶,再遍历桶上的所有键值对,找出key
相等的键值对,从而来获取value
。
如图所示,HashMap
底层是基于数组和链表实现的。其中有两个重要的参数:
- 容量
- 负载因子
容量的默认大小是 16,负载因子是 0.75,当 HashMap
的 size > 16*0.75 时就会发生扩容(容量和负载因子都可以自由调整)。
三、继承关系
1 | public class HashMap<K,V> extends AbstractMap<K,V> |
说明:
HashMap
继承自AbstractMap
,AbstractMap
是Map
接口的骨干实现,AbstractMap
中实现了Map
中最重要最常用和方法,这样HashMap
继承AbstractMap
就不需要实现Map
的所有方法,让HashMap
减少了大量的工作。
四、属性
1 | //默认的初始容量为16 |
4.1 几个属性的详细说明
1 | int threshold; // 所能容纳的key-value对极限 |
首先,Node[] table
的初始化长度length
(默认值是16),Load factor
为负载因子(默认值是0.75),threshold
是HashMap
所能容纳的最大数据量的Node
(键值对)个数。threshold = length * Load factor
。也就是说,在数组定义好长度之后,负载因子越大,所能容纳的键值对个数越多。
结合负载因子的定义公式可知,threshold
就是在此Load factor
和length
(数组长度)对应下允许的最大元素数目,超过这个数目就重新resize
(扩容),扩容后的HashMap
容量是之前容量的两倍(为什么是两倍下文会说明)。
size
这个字段其实很好理解,就是HashMap
中实际存在的键值对数量。注意size
和table
的长度length
、容纳最大键值对数量threshold
的区别。
而modCount
字段主要用来记录HashMap
内部结构发生变化的次数,主要用于迭代的快速失败。强调一点,内部结构发生变化指的是结构发生变化,例如put新键值对,但是某个key
对应的value
值被覆盖不属于结构变化。
在HashMap
中,哈希桶数组table
的长度length
大小必须为2的n次方(一定是合数),这是一种非常规的设计,因为常规的设计是把桶的大小设计为素数。相对来说素数导致冲突的概率要小于合数,具体证明可以参考 http://blog.csdn.net/liuqiyao_01/article/details/14475159 ,Hashtable
初始化桶大小为11,就是桶大小设计为素数的应用(Hashtable扩容后不能保证还是素数)。HashMap
采用这种非常规设计,主要是为了在取模和扩容时做优化,同时为了减少冲突,HashMap
定位哈希桶索引位置时,也加入了高位参与运算的过程。下文会说明。
这里存在一个问题,即使负载因子和Hash
算法设计的再合理,也免不了会出现拉链过长的情况,一旦出现拉链过长,则会严重影响HashMap
的性能。于是,在JDK1.8版本中,对数据结构做了进一步的优化,引入了红黑树。而当链表长度太长(默认超过8)时,并且链表的长度超过64时,链表就转换为红黑树,利用红黑树快速增删改查的特点提高HashMap
的性能,其中会用到红黑树的插入、删除、查找等算法。
这里着重提一下MIN_TREEIFY_CAPACITY
字段,容易与TREEIFY_THRESHOLD
打架,TREEIFY_THRESHOLD
是指桶中元素达到8个,就将其本来的链表结构改为红黑树,提高查询的效率。MIN_TREEIFY_CAPACITY
是指最小树化的哈希表元素个数,也就是说,小于这个值,就算你(数组)桶里的元素数量大于8了,还是要用链表存储,只有同时满足:表中数据容量已经扩容到MIN_TREEIFY_CAPACITY
这个长度,并且桶里的数据个数达到8个的时候,才会将该桶里的结构进行树化。注意扩容是数组的复制。
4.2 Node结构
1 | static class Node<K,V> implements Map.Entry<K,V> { |
Node
是HashMap
的一个内部类,实现了Map.Entry
接口,本质是就是一个映射(键值对)。上图中的每个黑色圆点就是一个Node
对象。
例如程序执行下面代码:
1 | map.put("美团","小美"); |
系统将调用"美团"这个key
的hashCode()
方法得到其hashCode
值(该方法适用于每个Java
对象)。
然后再通过Hash
算法来定位该键值对的存储位置,有时两个key会定位到相同的位置,表示发生了Hash
碰撞。
当然Hash
算法计算结果越分散均匀,Hash
碰撞的概率就越小,map
的存取效率就会越高。
如果哈希桶数组很大,即使较差的Hash
算法也会比较分散,如果哈希桶数组数组很小,即使好的Hash
算法也会出现较多碰撞,所以就需要在空间成本和时间成本之间权衡,其实就是在根据实际情况确定哈希桶数组的大小,并在此基础上设计好的hash算法减少Hash
碰撞。
那么通过什么方式来控制map
使得Hash
碰撞的概率又小,哈希桶数组(Node[] table
)占用空间又少呢?答案就是好的Hash
算法(5.4节)和扩容机制(5.5节)。下文会讲到。
五、方法
5.1 get方法
1 | //get方法主要调用的是getNode方法,所以重点要看getNode方法的实现 |
实现步骤大致如下:
- 通过
hash
值获取该key映射到的桶。 - 桶上的
key
就是要查找的key
,则直接命中。 - 桶上的
key
不是要查找的key
,则查看后续节点: - 如果后续节点是树节点,通过调用树的方法查找该
key
。 - 如果后续节点是链式节点,则通过循环遍历链查找该
key
。
5.2 put方法
1 | //put方法的具体实现也是在putVal方法中,所以我们重点看下面的putVal方法 |
1 | final void treeifyBin(Node<K,V>[] tab, int hash) { |
put
方法比较复杂,实现步骤大致如下:
- 先通过
hash
值计算出key
映射到哪个桶。 - 如果桶上没有碰撞冲突,则直接插入。
- 如果出现碰撞冲突了,则需要处理冲突:
- 如果该桶使用红黑树处理冲突,则调用红黑树的方法插入。
- 否则采用传统的链式方法插入。如果链的长度到达临界值,则把链转变为红黑树。
- 如果桶中存在重复的键,则为该键替换新值。
- 如果
size
大于阈值,则进行扩容。
5.3 remove方法
1 | //remove方法的具体实现在removeNode方法中,所以我们重点看下面的removeNode方法 |
5.4 hash方法(确定哈希桶数组索引位置)
不管增加、删除、查找键值对,定位到哈希桶数组的位置都是很关键的第一步。前面说过HashMap
的数据结构是数组和链表的结合,所以我们当然希望这个HashMap
里面的元素位置尽量分布均匀些,尽量使得每个位置上的元素数量只有一个,那么当我们用hash
算法求得这个位置的时候,马上就可以知道对应位置的元素就是我们要的,不用遍历链表,大大优化了查询的效率。HashMap
定位数组索引位置,直接决定了hash
方法的离散性能。
注意get
方法和put
方法源码中都需要先计算key
映射到哪个桶上,然后才进行之后的操作,计算的主要代码如下:
1 | (n - 1) & hash |
上面代码中的n指的是哈希表的大小,hash
指的是key
的哈希值,hash
是通过下面这个方法计算出来的,采用了二次哈希的方式,其中key的hashCode
方法是一个native
方法:
1 | static final int hash(Object key) { //jdk1.8 & jdk1.7 |
对于任意给定的对象,只要它的hashCode()
返回值相同,那么程序调用方法一所计算得到的Hash
码值总是相同的。我们首先想到的就是把hash值对数组长度取模运算,这样一来,元素的分布相对来说是比较均匀的。但是,模运算的消耗还是比较大的,在HashMap
中是这样做的:调用方法二来计算该对象应该保存在table
数组的哪个索引处。
这个方法非常巧妙,它通过h & (table.length -1)
来得到该对象的保存位,而HashMap
底层数组的长度总是2的n次方,这是HashMap
在速度上的优化。当length
总是2的n次方时,h& (length-1)
运算等价于对length
取模,也就是h%length
,但是&比%具有更高的效率。
在JDK1.8的实现中,优化了高位运算的算法,通过hashCode()
的高16位异或低16位实现的:(h = k.hashCode()) ^ (h >>> 16)
,主要是从速度、功效、质量来考虑的,这么做可以在数组table
的length
比较小的时候,也能保证考虑到高低Bit
都参与到Hash
的计算中,同时不会有太大的开销。
总结就是:由于在计算中位运算比取模运算效率高的多,所以 HashMap 规定数组的长度为 2^n 。这样用 2^n - 1 做位运算与取模效果一致,并且效率还要高出许多。这样回答了上文中:好的Hash算法到底是什么。
5.5 resize方法
1 | final Node<K,V>[] resize() { |
HashMap
在进行扩容时,使用的rehash
方式非常巧妙,因为每次扩容都是翻倍,与原来计算(n-1)&hash
的结果相比,只是多了一个bit位,所以节点要么就在原来的位置,要么就被分配到“原位置+旧容量”这个位置。
例如,原来的容量为32,那么应该拿hash
跟31(0x11111)做与操作;在扩容扩到了64的容量之后,应该拿hash
跟63(0x111111)做与操作。新容量跟原来相比只是多了一个bit位,假设原来的位置在23,那么当新增的那个bit位的计算结果为0时,那么该节点还是在23;相反,计算结果为1时,则该节点会被分配到23+31的桶上。
这样做的好处:正是因为这样巧妙的rehash
方式,保证了rehash
之后每个桶上的节点数必定小于等于原来桶上的节点数,即保证了rehash
之后不会出现更严重的冲突。回答了上文中好的扩容机制。
六、总结
HashMap
的结构底层是一个数组,每个数组元素是一个桶,后面可能会连着一串因为碰撞而聚在一起的(key,value)节点,以链表的形式或者树的形式挂着- 按照原来的拉链法来解决冲突,如果一个桶上的冲突很严重的话,是会导致哈希表的效率降低至O(n),而通过红黑树的方式,可以把效率改进至
O(logn)
。相比链式结构的节点,树型结构的节点会占用比较多的空间,所以这是一种以空间换时间的改进方式。 threshold
是数组长度扩容的临界值modCount
字段主要用来记录HashMap内部结构发生变化的次数,这里结构变化必须是新的值塞进来或者某个值删除这种类型,而不是仅仅是覆盖- 只有同时满足:表中数据容量已经扩容到
MIN_TREEIFY_CAPACITY
这个长度,并且桶里的数据个数达到8个的时候,才会将该桶里的结构进行树化。 - 好的hash算法:由于在计算中位运算比取模运算效率高的多,所以
HashMap
规定数组的长度为 2^n 。这样用2^n - 1
与hash
做位运算与取模效果一致,并且效率还要高出许多。 - 好的扩容机制:因为每次扩容都是翻倍,与原来计算
(n-1)&hash
的结果相比,只是多了一个bit位,所以节点要么就在原来的位置,要么就被分配到“原位置+旧容量”这个位置。这样做的好处:正是因为这样巧妙的rehash方式,保证了rehash之后每个桶上的节点数必定小于等于原来桶上的节点数,即保证了rehash之后不会出现更严重的冲突。 - 还有就是要记住
put
的过程。
整理自: