并发编程学习笔记(二十五、ConcurrentHashMap,Java8 HashMap简述)
目录:
- 学习准备
- 类核心属性、内部类、构造函数介绍
- 哈希冲突(哈希碰撞)
- put()方法源码分析
- resize()方法源码分析
学习准备
在阅读Java8 HashMap前你需要掌握数组、链表、二叉树、哈希表等知识。
我这里来简单的介绍一下它们:
- 数组:是通过一组连续的存储单元来存储数据的一种结构,通过下标随机访问的时间复杂度为O(1),修改操作涉及到元素的移动,复杂度为O(n)。
- 链表:链表的增删改操作仅处理节点的引用关系,时间复杂度为O(1);而查询操作则需要遍历整个链表复杂度为O(n)。
- 二叉树:对一棵相对平衡的有序二叉树来说,增改查等操作复杂度均为O(logn)。
- 哈希表:相比上面的几种结构哈希表的性能就比较高了,在不考虑哈希冲突的情况下,增删查操作复杂度能达到O(1)。其实现通过哈希函数(f(value))来定位数组下标。
属性、内部类、构造函数
1、核心属性介绍:
哈希桶数组:transient Node<K,V>[] table;
table中每个元素可能是链表或红黑树。
2、核心内部类:
1 static class Node<K,V> implements Map.Entry<K,V> { 2 final int hash; 3 final K key; 4 V value; 5 Node<K,V> next; 6 7 Node(int hash, K key, V value, Node<K,V> 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(n),当冲突过多链表长度就会变得很长,导致查询数据时会严重影响效率。所以会在长度大于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<K,V>[] tab; Node<K,V> 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<K,V> 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<K,V>)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<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 // 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<K,V>[] newTab = (Node<K,V>[])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<K,V> 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<K,V>)e).split(this, newTab, j, oldCap); 53 // 如果是链表的话,它对于1.7的处理做了优化 54 else { // preserve order 55 Node<K,V> loHead = null, loTail = null; 56 Node<K,V> hiHead = null, hiTail = null; 57 Node<K,V> 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在此操作后便不一定会冲突(比如原来根据算法都会分在下标5上,但是因为容量增大了所以不会分配在5,而是21),是不。
HashMap的编写者就是根据这一特性优化了HashMap链表的扩容。
现在我来根据扩容前16长度,32长度来作说明,来看看HashMap是如何优化的:
首先从16扩容到32,在经过&运算后得出的下标,可发现唯一不同的就是唯一不同的就是低位的第五位一个是0一个是1。
而它们的十进制分别为5和21,也就是5和5 + n,其中n = HashMap原容量大小16。
也就是说我们在扩容的时候不需要像Java7那样重新计算hash值了,只需要看新增的那位bit是0还是1。
0的话索引还是没有变,保持原位,1的话索引右边,放在原位置 + 原容量大小的下标即可。
这个设计确实非常的巧妙,既省去了重新计算nash值的时间,而且同时,由于新增的1bit是0还是1可以认为是随机的,因此resize的过程,均匀的把之前的冲突的节点分散到新的bucket了。
这一块就是Java8新增的优化点。有一点注意区别,Java7中rehash的时候,旧链表迁移新链表的时候,如果在新表的数组索引位置相同,则链表元素会倒置,但是按照Java8的逻辑是不会倒置的。