本篇文章是网上多篇文章的精华的总结,结合自己看源代码的一些感悟,其中线程安全性和性能测试部分并未做实践测试,直接是“拿来”网上的博客的。
哈希表概述
哈希表本质上一个数组,数组中每一个元素称为一个箱子(Bin),箱子中存放的是键值对Entry<K,V>链表,因而也称之为链表散列。
我们可以用图来形象地说明这个结构:
哈希表是如何工作的?
存储
Step1:根据哈希函数来计算HashCode值h,其中键值对Entry<K,V>的K来计算时需要的参数。
Step2:根据HashCode,来计算存放在哈希表(长度为n)中的位置(箱子的位置),一种计算方法是取余:h%n。
Step3:如果该箱子中已经存在键值对数据,则使用开放寻址法或拉链法解决冲突。
获取
Step1:根据key值计算HashCode的值h。
Step2:假设箱子的个数为 n,那么这个键值对应该放在第 (h % n) 个箱子中。
Step3:如果这个箱子里有多个键值对,同时假设箱子里的多个值是采用链表的方式存储,则需要遍历这个链表,复杂度为O(n)。
扩容
哈希表还有 一个重要的属性:负载因子,它是衡量哈希表的空/满程度,一定程度上也能体现查询的效率。其计算公式为:
负载因子 = 总键值对数 / 箱子数量
负载因子越大,意味着哈希表越满,越容易导致冲突(更大的概念找到同一个箱子上),因而查询效率也就更低。因而,一般来说,当负载因子大于某个常数(可能是1,也可能是其他值,Java8的HashMap的负载因子为0.75)时,哈希表就会自动扩容。
哈希表在扩容的时候,一般都会选择扩大2的倍数,同时将原来的哈希表的数据迁移到新的哈希表中,这样即使key的哈希值不变,对箱子的取余结果(假设我们用这种方法来计算HashCode)也会不同,因此所有的箱子和元素的存放位置都有可能发生变化,这个过程也称为重哈希(rehash)。
哈表的扩容并不能有效解决负载因子过大的问题,因为在前面的取HashCode的方法中,假设所有key的HashCode值都一样,那么即使扩容以后他们在哈希表中的位置也不会变,实际存放在箱子中的链表长度也不变,因此也就不能提高哈希表的查询速度。
因而,哈希表存在以下两个问题:
1、在扩容的时候,重哈希的成本比较大
2、如果Hash函数设计地不合理(如上面举例说明的取余),会导致哈希表中极端情况下变成线性表,性能极低。
我们下面来看看Java8中是如何处理这两个问题的。
以上这部分内容多参考自:深入理解哈希表 ,图片来自于HashMap的图示
Java8中的HashMap
在说明这个问题之前,我们来看下HashMap在Java8中在类图关系,如下所示:
Java8中通过如下几种方式来解决上面的两个问题:
一、让元素分布地更合理
(下面这部分不知道是哪位大神写的,原文照抄吧)
学过概率论的读者也许知道,理想状态下哈希表的每个箱子中,元素的数量遵守泊松分布:
当负载因子为 0.75 时,上述公式中 λ 约等于 0.5,因此箱子中元素个数和概率的关系如下:
数量 | 概率 |
---|---|
0 | 0.60653066 |
1 | 0.30326533 |
2 | 0.07581633 |
3 | 0.01263606 |
4 | 0.00157952 |
5 | 0.00015795 |
6 | 0.00001316 |
7 | 0.00000094 |
8 | 0.00000006 |
这就是为什么我们将0.75设为负载因子,同时针对箱子中链表长度超过8以后要做另外的优化(一来是优化的概念较小,二来是优化过后的效率提升明显)。所以,一般情况下负载因子不建议修改;同时如果在数量为8的链表的概率较大,则几乎可以认为是哈希函数设计有问题导致的。
二、通过红黑树让查询更有效率(O(n)—>O(Log(n)))
第一点已经说明,当箱子中的链表元素超过8个时,会将这个链表转为红黑树,红黑树的查找效率为O(log(n))。红黑树的示图如下:
三、让扩容时重哈希(rehash)的成本变得更小
在Java7中,重哈希是要重新计算Hash值的,而在Java8中,通过高位运算的巧妙设计,避免了这种计算。下面我们举例说明:
我们要在初始大小为2的HashMap中存储3、5、7这3个值,Hash函数为取余法。
Step1:在开始的时候,3、5、7经过Hash过后 3%2=1、5%2=1、7%2=1,因而3、5、7存储在同一个箱子的链表中(地址为1)。
Step2:现在扩容了,扩容后的大小为2*2=4,现在经过Hash后3%4=3、5%4=1、7%4=3,因而3与7一起放在箱子的链表中(地址为3),5单独存放在一个箱子里(地址为1)。
整个过程如下图所示:
我们注意到,在扩容后3和7的位置变化了,由1—>3(=1+2)
再进行扩容,由4容为8,那么经过Hash后,3%8=3、5%8=5、7%8=7,分别存放于3、5(=1+4)、7(=3+4)这几个位置中。
我们发现,扩容后的元素要么在原位置,要么在原位置再移动2次幂的位置,整个过程只需要使用一个位运算符<<就可以了(在源码的resize方法中可以找到)。
我们用计算机的地址来展示这个过程:
n为table的长度,图(a)表示扩容前的key1和key2两种key确定索引位置的示例,图(b)表示扩容后key1和key2两种key确定索引位置的示例,其中hash1是key1对应的哈希与高位运算结果。元素在重新计算hash之后,因为n变为2倍,那么n-1的mask范围在高位多1bit(红色),因此新的index就会发生这样的变化:
因此,我们在扩充HashMap的时候,不需要像JDK1.7的实现那样重新计算hash,只需要看看原来的hash值新增的那个bit是1还是0就好了,是0的话索引没变,是1的话索引变成“原索引+oldCap”,可以看看下图为16扩充为32的resize示意图:
这个设计确实非常的巧妙,既省去了重新计算hash值的时间,而且同时,由于新增的1bit是0还是1可以认为是随机的,因此resize的过程,均匀的把之前的冲突的节点分散到新的bucket了。这一块就是JDK1.8新增的优化点。
以上这部分中的图示和位移讲解的内容参考自:深入分析hashmap
另外:
四:我们可以通过适当地初始化大小来控制扩容的次数:既然扩容是不可避免的,我们就尽可能少地让它发生,要实际编程的时候,应该根据业务合理地设置初始大小的值。
此外,Java8中HashMap还提供了另外一些参数来控制HashMap的性能,如下所示:
/**
* 默认的初始化大小(必须为2的幂)
*/
static final int DEFAULT_INITIAL_CAPACITY = 1 << 4; // aka 16
/**
* 最大的存储数量(默认的数量,可以在构造函数中指定)
* 必须为2的幂同时小于2的30次方
*/
static final int MAXIMUM_CAPACITY = 1 << 30;
/**
* 默认的负载因子,可以在构建函数中指定
*/
static final float DEFAULT_LOAD_FACTOR = 0.75f;
/**
* HashMap由链表转为红黑树存储的阀值
* 1.8提供的新特性
*/
static final int TREEIFY_THRESHOLD = 8;
/**
* HashMap由红黑树转为链表存储的阀值
*/
static final int UNTREEIFY_THRESHOLD = 6;
/**
* HashMap的箱子中的链表转为红黑树之前还有一个判断:
* 只在所有箱子(键值对)的数量大于64才会发生转换
* 这样是为了避免在哈希表建立初期,多个键值对恰好被放入了同一个链表而导致不必要的转化
*/
static final int MIN_TREEIFY_CAPACITY = 64;
源码中的关键方法
方法一、hash方法
1 static final int hash(Object key) {
2 int h;
3 return (key == null) ? 0 : (h = key.hashCode()) ^ (h >>> 16);//这里其实就要求大家来重写HashCode方法
4 }
方法二、putVal方法
下面是putVal方法的执行过程图示:
1 final V putVal(int hash, K key, V value, boolean onlyIfAbsent,
2 boolean evict) {
3 Node<K,V>[] tab; Node<K,V> p; int n, i;
4 // 步骤①:tab为空则创建
5 if ((tab = table) == null || (n = tab.length) == 0)
6 n = (tab = resize()).length;
7 // 步骤②:计算index,并对null做处理
8 if ((p = tab[i = (n - 1) & hash]) == null)
9 tab[i] = newNode(hash, key, value, null);
10 else {
11 Node<K,V> e; K k;
12 // 步骤③:节点key存在,直接覆盖value
13 if (p.hash == hash &&
14 ((k = p.key) == key || (key != null && key.equals(k))))
15 e = p;
16 // 步骤④:判断该链为红黑树
17 else if (p instanceof TreeNode)
18 e = ((TreeNode<K,V>)p).putTreeVal(this, tab, hash, key, value);
19 // 步骤⑤:该链为链表
20 else {
21 for (int binCount = 0; ; ++binCount) {
22 if ((e = p.next) == null) {
23 p.next = newNode(hash, key,value,null);
24 //链表长度大于8转换为红黑树进行处理
25 if (binCount >= TREEIFY_THRESHOLD - 1) // -1 for 1st
26 treeifyBin(tab, hash);
27 break;
28 }
29 // key已经存在直接覆盖value
30 if (e.hash == hash &&
31 ((k = e.key) == key || (key != null && key.equals(k))))
32 break;
33 p = e;
34 }
35 }
36
37 if (e != null) { // existing mapping for key
38 V oldValue = e.value;
39 if (!onlyIfAbsent || oldValue == null)
40 e.value = value;
41 afterNodeAccess(e);
42 return oldValue;
43 }
44 }
45 ++modCount;
46 // 步骤⑥:超过最大容量 就扩容
47 if (++size > threshold)
48 resize();
49 afterNodeInsertion(evict);
50 return null;
51 }
这个 getNode() 方法就是根据哈希表元素个数与哈希值求模(使用的公式是 (n - 1) &hash
)得到 key 所在的桶的头结点,如果头节点恰好是红黑树节点,就调用红黑树节点的 getTreeNode() 方法,否则就遍历链表节点。
方法三、节点查找方法getNode
1 final Node<K,V> getNode(int hash, Object key) {
2 Node<K,V>[] tab; Node<K,V> first, e; int n; K k;
3 if ((tab = table) != null && (n = tab.length) > 0 &&
4 (first = tab[(n - 1) & hash]) != null) {
5 if (first.hash == hash && // always check first node
6 ((k = first.key) == key || (key != null && key.equals(k))))
7 return first;
8 if ((e = first.next) != null) {
9 if (first instanceof TreeNode)
10 return ((TreeNode<K,V>)first).getTreeNode(hash, key);
11 do {
12 if (e.hash == hash &&
13 ((k = e.key) == key || (key != null && key.equals(k))))
14 return e;
15 } while ((e = e.next) != null);
16 }
17 }
18 return null;
19 }
方法四、红黑树生成方法
1 //将桶内所有的 链表节点 替换成 红黑树节点
2
3 final void treeifyBin(Node<K,V>[] tab, int hash) {
4
5 int n, index; Node<K,V> e;
6
7 //如果当前哈希表为空,或者哈希表中元素的个数小于 进行树形化的阈值(默认为 64),就去新建/扩容
8
9 if (tab == null || (n = tab.length) < MIN_TREEIFY_CAPACITY)
10
11 resize();
12
13 else if ((e = tab[index = (n - 1) & hash]) != null) {
14
15 //如果哈希表中的元素个数超过了 树形化阈值,进行树形化
16
17 // e 是哈希表中指定位置桶里的链表节点,从第一个开始
18
19 TreeNode<K,V> hd = null, tl = null; //红黑树的头、尾节点
20
21 do {
22
23 //新建一个树形节点,内容和当前链表节点 e 一致
24
25 TreeNode<K,V> p = replacementTreeNode(e, null);
26
27 if (tl == null) //确定树头节点
28
29 hd = p;
30
31 else {
32
33 p.prev = tl;
34
35 tl.next = p;
36
37 }
38
39 tl = p;
40
41 } while ((e = e.next) != null);
42
43 //让桶的第一个元素指向新建的红黑树头结点,以后这个桶里的元素就是红黑树而不是链表了
44
45 if ((tab[index] = hd) != null)
46
47 hd.treeify(tab);
48
49 }
50
51 }
52
53 TreeNode<K,V> replacementTreeNode(Node<K,V> p, Node<K,V> next) {
54
55 return new TreeNode<>(p.hash, p.key, p.value, next);
56
57 }
方法五、红黑树节点查找方法
1 final TreeNode<K,V> find(int h, Object k, Class<?> kc) {
2 TreeNode<K,V> p = this;
3 do {
4 int ph, dir; K pk;
5 TreeNode<K,V> pl = p.left, pr = p.right, q;
6 if ((ph = p.hash) > h)
7 p = pl;
8 else if (ph < h)
9 p = pr;
10 else if ((pk = p.key) == k || (k != null && k.equals(pk)))
11 return p;
12 else if (pl == null)
13 p = pr;
14 else if (pr == null)
15 p = pl;
16 else if ((kc != null ||
17 (kc = comparableClassFor(k)) != null) &&
18 (dir = compareComparables(kc, k, pk)) != 0)
19 p = (dir < 0) ? pl : pr;
20 else if ((q = pr.find(h, k, kc)) != null)
21 return q;
22 else
23 p = pl;
24 } while (p != null);
25 return null;
26 }
方法六、扩容方法
1 final Node<K,V>[] resize() {
2 Node<K,V>[] oldTab = table;
3 int oldCap = (oldTab == null) ? 0 : oldTab.length;
4 int oldThr = threshold;
5 int newCap, newThr = 0;
6 if (oldCap > 0) {
7 // 超过最大值就不再扩充了,就只好随你碰撞去吧
8 if (oldCap >= MAXIMUM_CAPACITY) {
9 threshold = Integer.MAX_VALUE;
10 return oldTab;
11 }
12 // 没超过最大值,就扩充为原来的2倍
13 else if ((newCap = oldCap << 1) < MAXIMUM_CAPACITY &&
14 oldCap >= DEFAULT_INITIAL_CAPACITY)
15 newThr = oldThr << 1; // double threshold
16 }
17 else if (oldThr > 0) // initial capacity was placed in threshold
18 newCap = oldThr;
19 else { // zero initial threshold signifies using defaults
20 newCap = DEFAULT_INITIAL_CAPACITY;
21 newThr = (int)(DEFAULT_LOAD_FACTOR * DEFAULT_INITIAL_CAPACITY);
22 }
23 // 计算新的resize上限
24 if (newThr == 0) {
25
26 float ft = (float)newCap * loadFactor;
27 newThr = (newCap < MAXIMUM_CAPACITY && ft < (float)MAXIMUM_CAPACITY ?
28 (int)ft : Integer.MAX_VALUE);
29 }
30 threshold = newThr;
31 @SuppressWarnings({"rawtypes","unchecked"})
32 Node<K,V>[] newTab = (Node<K,V>[])new Node[newCap];
33 table = newTab;
34 if (oldTab != null) {
35 // 把每个bucket都移动到新的buckets中
36 for (int j = 0; j < oldCap; ++j) {
37 Node<K,V> e;
38 if ((e = oldTab[j]) != null) {
39 oldTab[j] = null;
40 if (e.next == null)
41 newTab[e.hash & (newCap - 1)] = e;
42 else if (e instanceof TreeNode)
43 ((TreeNode<K,V>)e).split(this, newTab, j, oldCap);
44 else { // preserve order
45 Node<K,V> loHead = null, loTail = null;
46 Node<K,V> hiHead = null, hiTail = null;
47 Node<K,V> next;
48 do {
49 next = e.next;
50 // 原索引
51 if ((e.hash & oldCap) == 0) {
52 if (loTail == null)
53 loHead = e;
54 else
55 loTail.next = e;
56 loTail = e;
57 }
58 // 原索引+oldCap
59 else {
60 if (hiTail == null)
61 hiHead = e;
62 else
63 hiTail.next = e;
64 hiTail = e;
65 }
66 } while ((e = next) != null);
67 // 原索引放到bucket里
68 if (loTail != null) {
69 loTail.next = null;
70 newTab[j] = loHead;
71 }
72 // 原索引+oldCap放到bucket里
73 if (hiTail != null) {
74 hiTail.next = null;
75 newTab[j + oldCap] = hiHead;
76 }
77 }
78 }
79 }
80 }
81 return newTab;
82 }
方法七、扩容后的元素转移方法
1 void transfer(Entry[] newTable) {
2 Entry[] src = table; //src引用了旧的Entry数组
3 int newCapacity = newTable.length;
4 for (int j = 0; j < src.length; j++) { //遍历旧的Entry数组
5 Entry<K,V> e = src[j]; //取得旧Entry数组的每个元素
6 if (e != null) {
7 src[j] = null;//释放旧Entry数组的对象引用(for循环后,旧的Entry数组不再引用任何对象)
8 do {
9 Entry<K,V> next = e.next;
10 int i = indexFor(e.hash, newCapacity); //!!重新计算每个元素在数组中的位置
11 e.next = newTable[i]; //标记[1]
12 newTable[i] = e; //将元素放在数组上
13 e = next; //访问下一个Entry链上的元素
14 } while (e != null);
15 }
16 }
17 17 }
线程安全性
在多线程使用场景中,应该尽量避免使用线程不安全的HashMap,而使用线程安全的ConcurrentHashMap。那么为什么说HashMap是线程不安全的,下面举例子说明在并发的多线程使用场景中使用HashMap可能造成死循环。代码例子如下(便于理解,仍然使用JDK1.7的环境):
1 public class HashMapInfiniteLoop {
2
3 private static HashMap<Integer,String> map = new HashMap<Integer,String>(2,0.75f);
4 public static void main(String[] args) {
5 map.put(5, "C");
6
7 new Thread("Thread1") {
8 public void run() {
9 map.put(7, "B");
10 System.out.println(map);
11 };
12 }.start();
13 new Thread("Thread2") {
14 public void run() {
15 map.put(3, "A);
16 System.out.println(map);
17 };
18 }.start();
19 }
20 }
其中,map初始化为一个长度为2的数组,loadFactor=0.75,threshold=2*0.75=1,也就是说当put第二个key的时候,map就需要进行resize。
通过设置断点让线程1和线程2同时debug到transfer方法(3.3小节代码块)的首行。注意此时两个线程已经成功添加数据。放开thread1的断点至transfer方法的“Entry next = e.next;” 这一行;然后放开线程2的的断点,让线程2进行resize。结果如下图:
注意,Thread1的 e 指向了key(3),而next指向了key(7),其在线程二rehash后,指向了线程二重组后的链表。
线程一被调度回来执行,先是执行 newTalbe[i] = e, 然后是e = next,导致了e指向了key(7),而下一次循环的next = e.next导致了next指向了key(3)。
e.next = newTable[i] 导致 key(3).next 指向了 key(7)。注意:此时的key(7).next 已经指向了key(3), 环形链表就这样出现了。
于是,当我们用线程一调用map.get(11)时,悲剧就出现了——Infinite Loop。
性能表现:JDK1.8 vs JDK1.7
HashMap中,如果key经过hash算法得出的数组索引位置全部不相同,即Hash算法非常好,那样的话,getKey方法的时间复杂度就是O(1),如果Hash算法技术的结果碰撞非常多,假如Hash算极其差,所有的Hash算法结果得出的索引位置一样,那样所有的键值对都集中到一个桶中,或者在一个链表中,或者在一个红黑树中,时间复杂度分别为O(n)和O(lgn)。 鉴于JDK1.8做了多方面的优化,总体性能优于JDK1.7,下面我们从两个方面用例子证明这一点。
Hash较均匀的情况
为了便于测试,我们先写一个类Key,如下:
1 class Key implements Comparable<Key> {
2
3 private final int value;
4
5 Key(int value) {
6 this.value = value;
7 }
8
9 @Override
10 public int compareTo(Key o) {
11 return Integer.compare(this.value, o.value);
12 }
13
14 @Override
15 public boolean equals(Object o) {
16 if (this == o) return true;
17 if (o == null || getClass() != o.getClass())
18 return false;
19 Key key = (Key) o;
20 return value == key.value;
21 }
22
23 @Override
24 public int hashCode() {
25 return value;
26 }
27 }
这个类复写了equals方法,并且提供了相当好的hashCode函数,任何一个值的hashCode都不会相同,因为直接使用value当做hashcode。为了避免频繁的GC,我将不变的Key实例缓存了起来,而不是一遍一遍的创建它们。代码如下:
1 public class Keys {
2
3 public static final int MAX_KEY = 10_000_000;
4 private static final Key[] KEYS_CACHE = new Key[MAX_KEY];
5
6 static {
7 for (int i = 0; i < MAX_KEY; ++i) {
8 KEYS_CACHE[i] = new Key(i);
9 }
10 }
11
12 public static Key of(int value) {
13 return KEYS_CACHE[value];
14 }
15 }
现在开始我们的试验,测试需要做的仅仅是,创建不同size的HashMap(1、10、100、……10000000),屏蔽了扩容的情况,代码如下:
1 static void test(int mapSize) {
2
3 HashMap<Key, Integer> map = new HashMap<Key,Integer>(mapSize);
4 for (int i = 0; i < mapSize; ++i) {
5 map.put(Keys.of(i), i);
6 }
7
8 long beginTime = System.nanoTime(); //获取纳秒
9 for (int i = 0; i < mapSize; i++) {
10 map.get(Keys.of(i));
11 }
12 long endTime = System.nanoTime();
13 System.out.println(endTime - beginTime);
14 }
15
16 public static void main(String[] args) {
17 for(int i=10;i<= 1000 0000;i*= 10){
18 test(i);
19 }
20 }
在测试中会查找不同的值,然后度量花费的时间,为了计算getKey的平均时间,我们遍历所有的get方法,计算总的时间,除以key的数量,计算一个平均值,主要用来比较,绝对值可能会受很多环境因素的影响。结果如下:
通过观测测试结果可知,JDK1.8的性能要高于JDK1.7 15%以上,在某些size的区域上,甚至高于100%。由于Hash算法较均匀,JDK1.8引入的红黑树效果不明显,下面我们看看Hash不均匀的的情况。
Hash极不均匀的情况
假设我们又一个非常差的Key,它们所有的实例都返回相同的hashCode值。这是使用HashMap最坏的情况。代码修改如下:
1 class Key implements Comparable<Key> {
2
3 //...
4
5 @Override
6 public int hashCode() {
7 return 1;
8 }
9 }
仍然执行main方法,得出的结果如下表所示:
从表中结果中可知,随着size的变大,JDK1.7的花费时间是增长的趋势,而JDK1.8是明显的降低趋势,并且呈现对数增长稳定。当一个链表太长的时候,HashMap会动态的将它替换成一个红黑树,这话的话会将时间复杂度从O(n)降为O(logn)。hash算法均匀和不均匀所花费的时间明显也不相同,这两种情况的相对比较,可以说明一个好的hash算法的重要性。
小结
1.有两个字典,分别存有 100 条数据和 10000 条数据,如果用一个不存在的 key 去查找数据,在哪个字典中速度更快?
完整的答案是:在 Redis 中,得益于自动扩容和默认哈希函数,两者查找速度一样快。在 Java 和 Objective-C 中,如果哈希函数不合理,返回值过于集中,会导致大字典更慢。Java 由于存在链表和红黑树互换机制,搜索时间呈对数级增长,而非线性增长。在理想的哈希函数下,无论字典多大,搜索速度都是一样快。
2. 扩容是一个特别耗性能的操作,所以当程序员在使用HashMap的时候,估算map的大小,初始化的时候给一个大致的数值,避免map进行频繁的扩容。
3. 负载因子是可以修改的,也可以大于1,但是建议不要轻易修改,除非情况非常特殊。
4. HashMap是线程不安全的,不要在并发的环境中同时操作HashMap,建议使用ConcurrentHashMap。
5. JDK1.8引入红黑树大程度优化了HashMap的性能。
参考文档
https://tech.meituan.com/java-hashmap.html
https://www.cnblogs.com/gotodsp/p/6534699.html
https://www.cnblogs.com/shengkejava/p/6771469.html
http://www.importnew.com/14417.html 这篇讲性能的文章值得一看
http://alex09.iteye.com/blog/539545 这是讲Java7中的HashMap的
https://www.cnblogs.com/chinajava/p/5808416.html 这里面讲了Java的HashMap和Redis的HashMap对比
http://blog.csdn.net/Richard_Jason/article/details/53887222
http://www.importnew.com/7099.html