JDK1.8源码解析-HashMap
Posted JavaByTalk
tags:
篇首语:本文由小常识网(cha138.com)小编为大家整理,主要介绍了JDK1.8源码解析-HashMap 相关的知识,希望对你有一定的参考价值。
JDK1.8源码解析-HashMap (一)
本文主要介绍了JDK1.8中HashMap的实现原理,对部分常用的API进行源码解读,网上该主题的资源非常多,作者参考了很多相关文章不在文中一一列举了,在此基础上加入了自己对部分源码的理解。
1. HashMap概述
根据JDK1.8中HashMap的JavaDo的描述,HashMap可以允许key为null,value为null的键值对,值得注意的是,只存在一组key=null的键值对,当key!=null时,可以存在多组value=null的键值对。对比HashTable类,HashMap的主要的区别在于线程不安全以及允许null的出现,特别要注意的是由于HashTable不允许null出现,所有继承HashTable的类也遵循该法则,例如Java.Util.Properties
。在Java8中,HashMap通过了红黑树对hash collision的情况进行了优化,后文将详细介绍。因此简单地说:
【1】新代码避免使用HashTable
,以及继承HashTable
实现扩展。
【2】遇到并发需求时,使用java.util.concurrent.ConcurrentHashMap<K, V>
, 或者Collections.synchronizedMap(new HashMap(...))
实现同步。
【3】非并发需求时,使用HashMap性能最高。整个HashMap的数据结构通过数组+链表实现,当产生严重的hash碰撞时,会造成某个hash桶中的链表过长,此时将链表转换成红黑树存储数据,提高增删改查性能。
2. 源码解析
2.1 Hash算法
hash算法的目的是通过计算key的哈希值来确定value应该存放在哈希桶(数组)的那个位置。可以想到是通过hash % table.length(桶长度)可以均匀地将键值对分布在桶中,但是模运算消耗比较大,为了提高性能,HashMap规定了数组长度必须是power of two,并且以非常巧妙的方法做到取模操作。核心思想是当数组长度len是2的n次方时,len-1的二进制由n个1组成,当hash值与len-1取与时,相当于只取了hash值在n次位以下的位数,因为当hash值>=2^n 时,hash值必定可以分解为 k*(2^n) + remainder
,因此如果优化后的操作可以保证只取到n位以下的余数,那么该操作将等价于取模。需要注意的是,Java8并没有沿用Java7中单独设置的hash & (len-1)
方法,而是在所有需要用到获取桶下标的地方显式地用了该表达式,因此我们可以在源码中看到的hash算法就如下图所示,该方法Java7与Java8相同:
此处仅以hash>=0举例,对于int型的hashcode来说,当然可以小于0,只不过在n以上的位数上仍然可能有值。
1 static final int hash(Object key) {
2 int h;
3 return (key == null) ? 0 : (h = key.hashCode()) ^ (h >>> 16);
4 }
这个方法中用了key的hash值的高位(16位)与低位进行异或,目的是为了混合原始哈希码的高位和低位,以此来加大低位的随机性。而且混合后的低位掺杂了高位的部分特征,这样高位的信息也被变相保留下来。原因是,之前提到了当数组的长度为2^n ,而取模(hash & (len-1))
只取低位,会造成大量碰撞,因此为了更好地反应哈希码的全局属性,Java8运用了代码中的
扰动函数,可能出于性能考虑,相较Java7,Java8只做了一次高低位混合。关于扰动函数的细节超出了本文的范围,有兴趣的读者可以关注[2]中的介绍。
2.2 Node<K,V>
类
Node<K,V>
是HashMap的内部类实现Map.Entry<K,V>
接口,HashMap的哈希桶数组中存放的键值对对象就是Node<K,V>
。类中维护了一个next指针指向链表中的下一个元素。值得注意的是,当链表中的元素数量超过TREEIFY_THRESHOLD
后会HashMap会将链表转换为红黑树,此时该下标的元素将成为TreeNode<K,V>
,继承于LinkedHashMap.Entry<K,V>
,而LinkedHashMap.Entry<K,V>
是Node<K,V>
的子类,因此HashMap的底层数组数据类型即为Node<K,V>
。
源码中的数组申明:
transient Node<K,V>[] table;
源码中的默认常量:
static final int DEFAULT_INITIAL_CAPACITY = 1 << 4; // 默认数组长度16
static final int MAXIMUM_CAPACITY = 1 << 30; // 最大数组容量2^30
static final float DEFAULT_LOAD_FACTOR = 0.75f; // 默认负载比
static final int TREEIFY_THRESHOLD = 8; // 链表转红黑树的阈值
static final int UNTREEIFY_THRESHOLD = 6; // 扩容时红黑树转链表的阈值
这里顺带看一下HashMap是如何保证数组长度必为2^n 的:
1 static final int tableSizeFor(int cap) {
2 int n = cap - 1;
3 n |= n >>> 1;
4 n |= n >>> 2;
5 n |= n >>> 4;
6 n |= n >>> 8;
7 n |= n >>> 16;
8 return (n < 0) ? 1 : (n >= MAXIMUM_CAPACITY) ? MAXIMUM_CAPACITY : n + 1;
9 }
此方法的目的是为了保证有参构造传入的初始化数组长度是>=cap的最小2的k次幂。对n不断地无符号右移并且位或可以将n从最高位为1开始的所有右侧位数变成1,最后n+1即为大于n的最小2的k次幂,每一次移m位会将2m位全变成1。
第一行代码int n = cap - 1
是为了保证如果cap本身就是2^k 那么结果也将是其本身。
2.3 put方法
1final 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 // 当底层数组==null,初始化数组获取数组长度
5 if ((tab = table) == null || (n = tab.length) == 0)
6 n = (tab = resize()).length;
7 // 根据hash值获取桶下标中当前元素,如果为null,说明之前没有存放过与key相对应的value,直接插入
8 if ((p = tab[i = (n - 1) & hash]) == null)
9 tab[i] = newNode(hash, key, value, null);
10 else {
11 // 处理hash碰撞情况
12 Node<K,V> e; K k;
13 // hash碰撞,并且当前桶中的第一个元素即为相同key,e!=null, 见注1
14 if (p.hash == hash &&
15 ((k = p.key) == key || (key != null && key.equals(k))))
16 e = p;
17 // 如果当前元素类型为TreeNode,表示为红黑树,putTreeVal返回待存放的node, e可能为null
18 else if (p instanceof TreeNode)
19 e = ((TreeNode<K,V>)p).putTreeVal(this, tab, hash, key, value);
20 else {
21 // 链表结构,遍历链表找到需要插入的位置
22 for (int binCount = 0; ; ++binCount) {
23 // 遍历至链表尾部,无相同key的元素,插入链表尾部, e=null
24 if ((e = p.next) == null) {
25 p.next = newNode(hash, key, value, null);
26 // 插入后,如果链表长度大于等于树化阈值,将链表转换成红黑树
27 if (binCount >= TREEIFY_THRESHOLD - 1) // -1 for 1st
28 treeifyBin(tab, hash);
29 break;
30 }
31 // 链表中找到相同key的元素,退出遍历,(e=p.next)!=null
32 if (e.hash == hash &&
33 ((k = e.key) == key || (key != null && key.equals(k))))
34 break;
35 // p指向p.next,继续检查链表中下一个元素
36 p = e;
37 }
38 }
39 // e!=null时,说明遍历链表或树过程中找到了key相同的元素
40 // 根据onlyIfAbsent或者旧value是否为null来判断是否要覆盖value
41 if (e != null) { // existing mapping for key
42 V oldValue = e.value;
43 if (!onlyIfAbsent || oldValue == null)
44 e.value = value;
45 // 用于LinkedHashMap的回调方法,HashMap为空实现
46 afterNodeAccess(e);
47 // 返回替换之前的旧元素, 见注3
48 return oldValue;
49 }
50 }
51 // 新键值对的添加属于"Structural modifications", modCount要自增,见注2
52 ++modCount;
53 // 当前键值对数超过threshold时,对桶数组进行扩容,详见2.4
54 if (++size > threshold)
55 resize();
56 // 用于LinkedHashMap的回调方法,HashMap为空实现
57 afterNodeInsertion(evict);
58 // 新添加键值对,返回null, 见注3
59 return null;
60 }
注
【1】 p.hash == hash &&((k = p.key) == key || (key != null && key.equals(k)))
这个判断在put方法里出现了2次,都是为了校验新加的元素和当前元素是不是拥有相同的key,如果是的话将会覆盖旧的value。这里需要注意的是,当两个对象相等,hashCode()方法必须返回相同的hash,但反过来不一定。这句判断主要有2个维度,第一,对于相同的对象key,他们的hash必须相等,对于null,2.1中的代码我们也看到了,null对应的hash等于0,所以两个key=null,他们也拥有相同的hash,也可以推断,如果一对键值对,key=null,那么它永远存放在HashMap底层数组的第一个桶中。第二,如果key!=null,我们应该用equals方法来判断对象是否相等,所以(k = p.key) == key
这句话是为了短路当key为同一个对象(反复put覆盖旧value)或者null的时候的情况。
【2】在许多非线程安全的集合类中都会看到modCount成员变量,简单地讲,这个变量的用途是当迭代器在做集合遍历的时候能够快速识别其他线程对当前对象的结构性修改,从而抛出java.util.ConcurrentModificationException
实现fail-fast机制。一般我们在做集合迭代时会用Iterator iterator = map.entrySet().iterator();
获取迭代器,调用iterator.next()
方法后获取迭代器中的下一个元素。在HashMap中,每一次iterator()
将返回一个新的java.util.HashMap.EntryIterator<K, V>
对象,EntryIterator
是java.util.HashMap.HashIterator<K, V>
的子类,无参构造时会初始化expectedModCount=modCount
成员变量,该变量就是用来校验当前的迭代器与其所属的map对象是否保持同步。每一次java.util.HashMap.EntryIterator.next()
被调用,都会调用父类的java.util.HashMap.HashIterator.nextNode()
方法,方法中,当检测到expectedModCount!=modCount
就会抛出ConcurrentModificationException
,说明所属的map对象发生了所谓的
"Structural modifications"从而实现fail-fast机制。
【3】根据HashMap中Javadoc的描述,put方法会返回覆盖的旧键值对的value,当返回为null时表示,map中不存在对应的key,键值对为新添加的项,或者map中对应key的value本身为null。
2.4 resize方法
resize()虽然不是公有方法,但是它是HashMap实现扩容机制的核心方法,在put方法中出现了两处。第一在当底层数组为null的时候,resize()实现了桶数组的初始化;第二在新键值对插入后(结构性修改),如果超过了阈值则需要进行扩容。
1final 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 // 如果旧的容量已经等于最大的容量,将threshold设为最大的Integer, 保证今后不再扩容
8 if (oldCap >= MAXIMUM_CAPACITY) {
9 threshold = Integer.MAX_VALUE;
10 return oldTab;
11 }
12 // 如果新的容量扩容一倍后小于最大容量,新的threshold也扩容一倍
13 else if ((newCap = oldCap << 1) < MAXIMUM_CAPACITY &&
14 oldCap >= DEFAULT_INITIAL_CAPACITY)
15 newThr = oldThr << 1; // double threshold
16 }
17 // 旧的容量为0,但threshold大于零,代表有参构造有cap传入,threshold已经被初始化成最小2的n次幂
18 // 直接将该值赋给新的容量
19 else if (oldThr > 0) // initial capacity was placed in threshold
20 newCap = oldThr;
21 // 无参构造创建的map,给出默认容量和threshold 16, 16*0.75
22 else { // zero initial threshold signifies using defaults
23 newCap = DEFAULT_INITIAL_CAPACITY;
24 newThr = (int)(DEFAULT_LOAD_FACTOR * DEFAULT_INITIAL_CAPACITY);
25 }
26 // 新的threshold = 新的cap * 0.75
27 if (newThr == 0) {
28 float ft = (float)newCap * loadFactor;
29 newThr = (newCap < MAXIMUM_CAPACITY && ft < (float)MAXIMUM_CAPACITY ?
30 (int)ft : Integer.MAX_VALUE);
31 }
32 threshold = newThr;
33 // 计算出新的数组长度后赋给当前成员变量table
34 @SuppressWarnings({"rawtypes","unchecked"})
35 Node<K,V>[] newTab = (Node<K,V>[])new Node[newCap];
36 table = newTab;
37 // 如果原先的数组没有初始化,那么resize的初始化工作到此结束,否则进入扩容元素重排逻辑
38 if (oldTab != null) {
39 // 遍历新数组的所有桶下标
40 for (int j = 0; j < oldCap; ++j) {
41 Node<K,V> e;
42 if ((e = oldTab[j]) != null) {
43 // 旧数组的桶下标赋给临时变量e,并且解除旧数组中的引用,否则就数组无法被GC回收
44 oldTab[j] = null;
45 // 如果e.next==null,代表桶中就一个元素,不存在链表或者红黑树
46 if (e.next == null)
47 // 用同样的hash映射算法把该元素加入新的数组
48 newTab[e.hash & (newCap - 1)] = e;
49 // 如果e是TreeNode并且e.next!=null,那么处理树中元素的重排
50 else if (e instanceof TreeNode)
51 ((TreeNode<K,V>)e).split(this, newTab, j, oldCap);
52 // e是链表的头并且e.next!=null,那么处理链表中元素重排
53 else { // preserve order
54 // loHead,loTail 代表扩容后不用变换下标,见注1
55 Node<K,V> loHead = null, loTail = null;
56 // hiHead,hiTail 代表扩容后变换下标,见注1
57 Node<K,V> hiHead = null, hiTail = null;
58 Node<K,V> next;
59 // 遍历链表
60 do {
61 next = e.next;
62 if ((e.hash & oldCap) == 0) {
63 if (loTail == null)
64 // 初始化head指向链表当前元素e,e不一定是链表的第一个元素,初始化后loHead
65 // 代表下标保持不变的链表的头元素
66 loHead = e;
67 else
68 // loTail.next指向当前e
69 loTail.next = e;
70 // loTail指向当前的元素e, 初始化后,loTail和loHead指向相同的内存,
71 // 所以当loTail.next指向下一个元素时,
72 // 底层数组中的元素的next引用也相应发生变化,
73 // 造成lowHead.next.next.....跟随loTail同步,
74 // 使得lowHead可以链接到所有属于该链表的元素。
75 loTail = e;
76 }
77 else {
78 if (hiTail == null)
79 // 初始化head指向链表当前元素e, 初始化后hiHead代表下标更改的链表头元素
80 hiHead = e;
81 else
82 hiTail.next = e;
83 hiTail = e;
84 }
85 } while ((e = next) != null);
86 // 遍历结束, 将tail指向null,并把链表头放入新数组的相应下标,形成新的映射。
87 if (loTail != null) {
88 loTail.next = null;
89 newTab[j] = loHead;
90 }
91 if (hiTail != null) {
92 hiTail.next = null;
93 newTab[j + oldCap] = hiHead;
94 }
95 }
96 }
97 }
98 }
99 return newTab;
100}
注
【1】重点关注下Java8对rehash的优化,实际上Java8没有将原来数组中的元素rehash再作映射,而是巧妙地利用了扩容后newCap = 2*oldCap
的特性。之前提到过HashMap利用(hash & len-1)
来确定元素的下标,由于len是2的n次幂,所以len-1的0~n-1位都为1,hash & len-1
就是利用了这n位作掩码产生下标。现在当新的容量为旧容量的两倍,相当于len左移1位,所以新的掩码将较之前多1位,这1位就决定了原来的hash将被映射到新的下标还是保持原先的下标不变。通过(e.hash & oldCap) == 0
可以判断原先的高位是否为1。因为如果原先在n位上是1,那么与操作的结果就是1,反之则为0。而最后只需要将高位为1的hash移到新的下标j+oldCap
,因为新的掩码多一位必然使得与操作后保留hash中的这一高位,相当于下标增加了原来的cap。一张图胜过一万句话,下图借用引用[1]中示例,更清晰地解释了这波优越的操作。
3. 小节
本篇介绍HashMap的基本数据结构及其实现,通过put方法引出了Java8对resize的优化,整体上都是围绕Java8针对数组容量的优化设计。由于篇幅限制,关于红黑树的优化以及其他一些常用公有方法,将留到下一篇再介绍。
以上
引用
[1] Java 8系列之重新认识HashMap https://tech.meituan.com/java-hashmap.html
[2] An introduction to optimising a hashing strategy, https://www.javacodegeeks.com/2015/09/an-introduction-to-optimising-a-hashing-strategy.html
[3] Java 1.8中HashMap的resize()方法扩容部分的理解 https://blog.csdn.net/u013494765/article/details/77837338
以上是关于JDK1.8源码解析-HashMap 的主要内容,如果未能解决你的问题,请参考以下文章