目录:
- 学习准备
- 类核心属性、内部类、构造函数介绍
- 哈希冲突(哈希碰撞)
- put()方法源码分析
- resize()方法源码分析
学习准备
在阅读Java8 HashMap前你需要掌握数组、链表、二叉树、哈希表等知识。
我这里来简单的介绍一下它们:
- 数组:是通过一组连续的存储单元来存储数据的一种结构,通过下标随机访问的时间复杂度为O(1),修改操作涉及到元素的移动,复杂度为O(n)。
- 链表:链表的增删改操作仅处理节点的引用关系,时间复杂度为O(1);而查询操作则需要遍历整个链表复杂度为O(n)。
- 二叉树:对一棵相对平衡的有序二叉树来说,增改查等操作复杂度均为O(logn)。
- 哈希表:相比上面的几种结构哈希表的性能就比较高了,在不考虑哈希冲突的情况下,增删查操作复杂度能达到O(1)。其实现通过哈希函数(f(value))来定位数组下标。
属性、内部类、构造函数
1、核心属性介绍:
哈希桶数组:transient Node
table中每个元素可能是链表或红黑树。
2、核心内部类:
1 static class Nodeimplements Map.Entry { 2 final int hash; 3 final K key; 4 V value; 5 Node next; 6 7 Node(int hash, K key, V value, Node next) { 8 this.hash = hash; 9 this.key = key; 10 this.value = value; 11 this.next = next; 12 } 13 14 public final K getKey() { return key; } 15 public final V getValue() { return value; } 16 public final String toString() { return key + "=" + value; } 17 18 public final int hashCode() { 19 return Objects.hashCode(key) ^ Objects.hashCode(value); 20 } 21 22 public final V setValue(V newValue) { 23 V oldValue = value; 24 value = newValue; 25 return oldValue; 26 } 27 28 /** 29 * key和value都相等才判断为相同对象 30 */ 31 public final boolean equals(Object o) { 32 if (o == this) 33 return true; 34 if (o instanceof Map.Entry) { 35 Map.Entry,?> e = (Map.Entry,?>)o; 36 if (Objects.equals(key, e.getKey()) && 37 Objects.equals(value, e.getValue())) 38 return true; 39 } 40 return false; 41 } 42 }
3、构造函数:
1 /** 2 * 指定初始容量及负载因子 3 */ 4 public HashMap(int initialCapacity, float loadFactor) { 5 if (initialCapacity < 0) 6 throw new IllegalArgumentException("Illegal initial capacity: " + 7 initialCapacity); 8 if (initialCapacity > MAXIMUM_CAPACITY) 9 initialCapacity = MAXIMUM_CAPACITY; 10 if (loadFactor <= 0 || Float.isNaN(loadFactor)) 11 throw new IllegalArgumentException("Illegal load factor: " + 12 loadFactor); 13 this.loadFactor = loadFactor; 14 this.threshold = tableSizeFor(initialCapacity); 15 } 16 17 /** 18 * 指定初始容量,使用默认负载因子0.75 19 */ 20 public HashMap(int initialCapacity) { 21 this(initialCapacity, DEFAULT_LOAD_FACTOR); 22 } 23 24 /** 25 * 无参构造,默认容量大小为16,负载因子0.75 26 */ 27 public HashMap() { 28 this.loadFactor = DEFAULT_LOAD_FACTOR; // all other fields defaulted 29 } 30 31 /** 32 * 将指定元素添加到HashMap中,负载因子为0.75 33 */ 34 public HashMap(Map extends K, ? extends V> m) { 35 this.loadFactor = DEFAULT_LOAD_FACTOR; 36 putMapEntries(m, false); 37 }
哈希冲突(哈希碰撞)
哈希冲突,也叫哈希碰撞,是两个元素在经过hash算法计算后得到相同的下标,这样两个元素存储的位置就冲突了。
也就是说我们在设计hash函数的时候要尽量让每个元素都均匀的分散在各个下标里,但我们知道数组的下标毕竟是有限的,所以肯定是会发生hash冲突的。
那发生冲突后我们应该如何解决呢,目前有三种常见的解决方案:
- 开放寻址法:发生冲突后,继续找下一处未被占用的地址。
- 链表法:发生冲突后,存储到链表中。
- 再散列函数法:通过其它的hash函数再进行计算一次hash值。
值得注意的是Java8的HashMap采用的是链表法的方式解决hash冲突,当链表长度大于8是会转换为红黑树。
那为什么要这样做呢,肯定是事出有因的,因为遍历链表的复杂度为O(1),当冲突过多链表长度就会变得很长,导致查询数据时会严重影响效率。所以会在长度大于8时,转换成红黑树,将遍历的时间优化为O(logn)。
put()方法源码分析
1 public V put(K key, V value) { 2 return putVal(hash(key), key, value, false, true); 3 }
hash方法解析
首先我们来看下HashMap中添加元素的方法put(),可以看出其调用了pulVal方法,这个函数我们后续再分析,我们先来看下它的第一个入参,也就是hash(key)。
1 static final int hash(Object key) { 2 int h; 3 return (key == null) ? 0 : (h = key.hashCode()) ^ (h >>> 16); 4 }
在阅读hash代码时首先你需要了解位运算,我把上面用到的位运算贴在下面。
- ^:转换为二进制后进行比较,若相同则为0,不相同则为1。
- <<:左移,<< 2就是左移两位,转换成10进制来说就是* 2 ^ 2,也就是乘4。
- >>:右移,与左移相反>> 2就是除4。
- >>>:右移,左侧补位0;>>> 3就是右移3位,左侧位数补3个0。
- &:转换为二进制,若两个数都为1则为1,否则为0。
上述hash方法总共分为3步,加上解析出下标共计为4步:
- h = key.hashCode()
- h >>> 16
- (h = key.hashCode()) ^ (h >>> 16)
- (n - 1) & hash
现在我们根据上述位运算的描述分别来解析每个步骤后得到的值。
- 首先我们要知道hashCode是int类型,也就是4个字节,每个字节是8位的二进制;所以我们假设我们第一步h得到的hash值为1111 1111 1111 1111 1111 0000 1010 1100。
- 其次n为数组长度,为16。
经过计算后每步值就会如下:
h = key.hashCode(): 1111 1111 1111 1111 1111 0000 1010 1100
h >>> 16: 0000 0000 0000 0000 1111 1111 1111 1111
hash = h ^ (h >>> 16): 1111 1111 1111 1111 0000 1111 0101 0011
n - 1: 0000 0000 0000 0000 0000 0000 0000 1111
hash: 1111 1111 1111 1111 0000 1111 0101 0011
(n - 1) & hash: 0000 0000 0000 0000 0000 0000 0000 0011
转换为十进制后: 3
根据上面的解析你可能会有一个疑问,为啥HashMap中为计算下标是(n - 1) & hash呢?
首先在说明前,我需要提下n是HashMap的容量,而且它一定是2的n次幂。
所以n - 1最终的出的二进制值一定都会是1111,11111这种全是1的二进制;如16 - 1 = 1111,32 - 1 = 11111。
然后&是都为1则为1,否则为0,所以不管&的hash值是多少,只要是2的n次幂去&,一定只能得到后四位(以n=16为例)。
所以计算出来的值肯定不会超过15,数值一定在大小为16的HashMap中。
故HashMap的容量一定要是2的n次幂的原因就是,一,位运算速度高于取膜,二,2的n次幂与任何hash值进行&运算都不会大于它本身。
这也就是&的妙用之处啊。
putVal方法解析
1 final V putVal(int hash, K key, V value, boolean onlyIfAbsent, 2 boolean evict) { 3 // tab: HashMap数据源 4 // p: 根据hash计算出的数组下标的值 5 // n: tab的长度 6 // i: 根据hash计算出的数组下标 7 Node[] tab; Node p; int n, i; 8 if ((tab = table) == null || (n = tab.length) == 0) 9 // 若是空数组则调用resize(),有初始化、扩容等功效 10 // 且HashMap是延迟初始化的 11 n = (tab = resize()).length; 12 if ((p = tab[i = (n - 1) & hash]) == null) 13 // 若根据hash值得到的下标没有数据,则插入一条新的Node 14 tab[i] = newNode(hash, key, value, null); 15 else { 16 Node e; K k; 17 // 若index位置有值,且key一致则覆盖value 18 if (p.hash == hash && 19 ((k = p.key) == key || (key != null && key.equals(k)))) 20 e = p; 21 // 若index位置有值,则判断是否为红黑树 22 else if (p instanceof TreeNode) 23 // 将当前节点插入到红黑树上 24 e = ((TreeNode )p).putTreeVal(this, tab, hash, key, value); 25 else { 26 // 此种情况则为链表 27 for (int binCount = 0; ; ++binCount) { 28 // 链表最后一个结点才会为null,故e=最后一个链表节点 29 if ((e = p.next) == null) { 30 // 将原有节点的next指针指向新的节点 31 p.next = newNode(hash, key, value, null); 32 // 链表长度大于8转换为红黑树处理 33 if (binCount >= TREEIFY_THRESHOLD - 1) // -1 for 1st 34 treeifyBin(tab, hash); 35 break; 36 } 37 // 若key存在,则直接覆盖value 38 if (e.hash == hash && 39 ((k = e.key) == key || (key != null && key.equals(k)))) 40 break; 41 p = e; 42 } 43 } 44 // 若e非空,即为存在一个key相等的键值对 45 if (e != null) { // existing mapping for key 46 V oldValue = e.value; 47 // 控制新的value值是否覆盖旧的value值 48 if (!onlyIfAbsent || oldValue == null) 49 e.value = value; 50 // 模板方法,给LinkedHashMap用 51 afterNodeAccess(e); 52 return oldValue; 53 } 54 } 55 // 增加修改次数,此字段用于在迭代器修改值时快速失败 56 ++modCount; 57 // 大于阀值时扩容 58 if (++size > threshold) 59 resize(); 60 // 模板方法,给LinkedHashMap用 61 afterNodeInsertion(evict); 62 return null; 63 }
resize()方法源码分析
resize()扩容与初始化
1 final Node[] resize() { 2 Node [] oldTab = table; 3 int oldCap = (oldTab == null) ? 0 : oldTab.length; 4 int oldThr = threshold; 5 int newCap, newThr = 0; 6 // table不为空时 7 if (oldCap > 0) { 8 // 容量已经是最大值了,不能扩容了 9 if (oldCap >= MAXIMUM_CAPACITY) { 10 threshold = Integer.MAX_VALUE; 11 return oldTab; 12 } 13 // newCap = oldCap * 2 14 // 如果newCap增大两倍后任小于最大容量 && oldCap大于默认的16容量 15 else if ((newCap = oldCap << 1) < MAXIMUM_CAPACITY && 16 oldCap >= DEFAULT_INITIAL_CAPACITY) 17 // 那么扩容阀值threshold就会增加两倍 18 newThr = oldThr << 1; // double threshold 19 } 20 else if (oldThr > 0) // initial capacity was placed in threshold 21 newCap = oldThr; 22 else { // zero initial threshold signifies using defaults 23 // 其它情况,也就是使用无参构造时第一次调用put方法会初始化table 24 newCap = DEFAULT_INITIAL_CAPACITY; 25 newThr = (int)(DEFAULT_LOAD_FACTOR * DEFAULT_INITIAL_CAPACITY); 26 } 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 @SuppressWarnings({"rawtypes","unchecked"}) 34 Node [] newTab = (Node [])new Node[newCap]; 35 table = newTab; 36 // oldTab != null:table有数据才将扩容前的数组已到新数组中,否则不需要迁移 37 if (oldTab != null) { 38 // 遍历数组 39 for (int j = 0; j < oldCap; ++j) { 40 // 拿到每个节点的对象 41 Node e; 42 // 节点对象不为空时 43 if ((e = oldTab[j]) != null) { 44 // 将老数组的元素删除,也就是置为空,方便gc 45 oldTab[j] = null; 46 // e的后继节点为null,也即是此位置的数组桶还只有一个元素,没有发生hash冲突 47 if (e.next == null) 48 // 此时直接重新计算在新数组的下标就可以了 49 newTab[e.hash & (newCap - 1)] = e; 50 // 如果是红黑树,那么直接添加到红黑树中 51 else if (e instanceof TreeNode) 52 ((TreeNode )e).split(this, newTab, j, oldCap); 53 // 如果是链表的话,它对于1.7的处理做了优化 54 else { // preserve order 55 Node loHead = null, loTail = null; 56 Node hiHead = null, hiTail = null; 57 Node next; 58 do { 59 next = e.next; 60 if ((e.hash & oldCap) == 0) { 61 if (loTail == null) 62 loHead = e; 63 else 64 loTail.next = e; 65 loTail = e; 66 } 67 else { 68 if (hiTail == null) 69 hiHead = e; 70 else 71 hiTail.next = e; 72 hiTail = e; 73 } 74 } while ((e = next) != null); 75 if (loTail != null) { 76 loTail.next = null; 77 newTab[j] = loHead; 78 } 79 if (hiTail != null) { 80 hiTail.next = null; 81 newTab[j + oldCap] = hiHead; 82 } 83 } 84 } 85 } 86 } 87 return newTab; 88 }
Java8 HashMap链表扩容优化
emmmmm,上面链表的扩容代码辣么长,我就不细说了,因为要看懂它其实也不难,只要把我先说的弄懂了就可以很轻松的看懂。
首先上面说到了HashMap扩容是将原来容量左移1位,也就是扩容两倍,原来16的长度会变成32。
既然扩容了,那原先会发生hash冲突的key再此操作后就不一定会冲突了,所以在扩容元素迁移的时候肯定也不是要遍历整个链表后将其移到新的位置去了,是不。
HashMap的编写者就是根据这一特性优化了HashMap链表的扩容。
现在我来根据扩容前16长度,32长度来作说明,来看看HashMap是如何优化的:
首先16扩容到32的时候最后运算出的下标我们可以发现经过&运算后,唯一不同的就是低位的第五位一个是0一个是1。
而它们的十进制分别为5和21,也就是5和5 + n,其中n = HashMap原容量大小16。
也就是说我们在扩容的时候不需要向Java7那样遍历整个链表了,只需要看新增的那位bit是0还是1。
0的话索引还是没有变,保持原位,1的话索引右边,放在原位置 + 原容量大小的下标即可。
这个设计确实非常的巧妙,既省去了重新计算nash值的时间,而且同时,由于新增的1bit是0还是1可以认为是随机的,因此resize的过程,均匀的把之前的冲突的节点分散到新的bucket了。
这一块就是Java8新增的优化点。有一点注意区别,Java7中rehash的时候,旧链表迁移新链表的时候,如果在新表的数组索引位置相同,则链表元素会倒置,但是按照Java8的逻辑是不会倒置的。