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的过程。
整理自: