关于HashMap和ConcurrentHashMap的内容,可以参看Java基础-HashMap集合。Java并发编程-ConcurrentHashMap。这两篇文章,本章将深入探讨两者JDK1.7和JDK1.8两个版本的扩容机制。
HashMap的初始化容量是16,默认加载因子是0.75,扩容时扩容到原来的两倍,这些特性对于其它版本的hashMap和concurrentHashMap同样满足。当hashmap中的元素个数超过数组大小加载因子loadFactor时,就会进行数组扩容,loadFactor的默认值为0.75,也就是说,默认情况下,数组大小为16,那么当hashmap中元素个数超过160.75=12的时候,就把数组的大小扩展为2*16=32,即扩大一倍,然后重新计算每个元素在数组中的位置,而这是一个非常消耗性能的操作,所以如果我们已经预知hashmap中元素的个数,那么预设元素的个数能够有效的提高hashmap的性能。
JDK1.7版本中,HashMap中链表的插入是采用头插法。
扩容代码如下:
void transfer(Entry[] newTable) {
Entry[] src = table; //src引用了旧的Entry数组
int newCapacity = newTable.length;
for (int j = 0; j < src.length; j++) {
//遍历旧的Entry数组
Entry<K, V> e = src[j]; //取得旧Entry数组的每个元素
if (e != null) {
src[j] = null;//释放旧Entry数组的对象引用(for循环后,旧的Entry数组不再引用任何对象)
do {
Entry<K, V> next = e.next;
int i = indexFor(e.hash, newCapacity); //!!重新计算每个元素在数组中的位置
e.next = newTable[i]; //标记[1]
newTable[i] = e; //将元素放在数组上
e = next; //访问下一个Entry链上的元素
} while (e != null);
}
}
}
上面的这段代码不并不难理解,对于扩容操作,底层实现都需要新生成一个数组,然后拷贝旧数组里面的每一个Node链表到新数组里面,这个方法在单线程下执行是没有任何问题的,但是在多线程下面却有很大问题,头插法,也就是说,新table中链表的顺序和旧列表中是相反的,在HashMap线程不安全的情况下,这种头插法可能会导致环状节点。主要的问题在于基于头插法的数据迁移,会有几率造成链表倒置,从而引发链表闭链,导致程序死循环,并吃满CPU。
在JDK8里面,HashMap的底层数据结构已经变为数组+链表+红黑树的结构了,因为在hash冲突严重的情况下,链表的查询效率是O(n),所以JDK8做了优化对于单个链表的个数大于8的链表,会直接转为红黑树结构算是以空间换时间,这样以来查询的效率就变为O(logN),图示如下:
链表转换为红黑树需要满足两个条件,第一个是链表长度达到8,第二个是散列表数组长度已经达到64,否则的话,就算slot内部链表长度达到了8,它也不会链转树,它仅仅会发生一次resize,散列表扩容。
源代码如下:
final Node<K,V>[] resize() {
Node<K,V>[] oldTab = table;
int oldCap = (oldTab == null) ? 0 : oldTab.length;
int oldThr = threshold;
int newCap, newThr = 0;
if (oldCap > 0) {
if (oldCap >= MAXIMUM_CAPACITY) {
threshold = Integer.MAX_VALUE;
return oldTab;
}
else if ((newCap = oldCap << 1) < MAXIMUM_CAPACITY &&
oldCap >= DEFAULT_INITIAL_CAPACITY)
newThr = oldThr << 1; // double threshold
}
else if (oldThr > 0) // initial capacity was placed in threshold
newCap = oldThr;
else {
// zero initial threshold signifies using defaults
newCap = DEFAULT_INITIAL_CAPACITY;
newThr = (int)(DEFAULT_LOAD_FACTOR * DEFAULT_INITIAL_CAPACITY);
}
if (newThr == 0) {
float ft = (float)newCap * loadFactor;
newThr = (newCap < MAXIMUM_CAPACITY && ft < (float)MAXIMUM_CAPACITY ?
(int)ft : Integer.MAX_VALUE);
}
threshold = newThr;
@SuppressWarnings({
"rawtypes","unchecked"})
Node<K,V>[] newTab = (Node<K,V>[])new Node[newCap];
table = newTab;
if (oldTab != null) {
for (int j = 0; j < oldCap; ++j) {
Node<K,V> e;
if ((e = oldTab[j]) != null) {
oldTab[j] = null;
if (e.next == null)
newTab[e.hash & (newCap - 1)] = e;
else if (e instanceof TreeNode)
((TreeNode<K,V>)e).split(this, newTab, j, oldCap);
else {
//重点关注区域
// preserve order
Node<K,V> loHead = null, loTail = null;
Node<K,V> hiHead = null, hiTail = null;
Node<K,V> next;
do {
next = e.next;
if ((e.hash & oldCap) == 0) {
if (loTail == null)
loHead = e;
else
loTail.next = e;
loTail = e;
}
else {
if (hiTail == null)
hiHead = e;
else
hiTail.next = e;
hiTail = e;
}
} while ((e = next) != null);
if (loTail != null) {
loTail.next = null;
newTab[j] = loHead;
}
if (hiTail != null) {
hiTail.next = null;
newTab[j + oldCap] = hiHead;
}
}
}
}
}
return newTab;
}
扩容算法:
table数组的长度是2的次方,因此每次都是按照上一次tableSize位移运算得到的,做一次左移1位运算(这里因为性能原因,CPU不支持直接乘以运算)。
创建新的数组之后,老数组的数据迁移过程如下:
迁移是一个桶位一个桶位的迁移处理。迁移的核心思想是尾插法。
第一种:slot存储的是null
第二种:存储的是个node,但node没有链化
此时node的next是null,说明这个slot它没有发生过hash冲突,直接迁移即可。根据新表的tableSize计算出它在新表中的位置,直接迁移过去即可。
第三种:node存储的是一个链化的node
此时node的next不是null,说明这个slot它发生过冲突,需要把当前slot中保存的这个链表拆成两个链表,分别是高位链和低位链。(所有的node#hash字段转化成二进制后,低位都是相同的,低位指的是tablesize-1转化出来的二进制有效位,比如table数组长度是16,16-1=15,15转换成二进制数是1111,此时高位是第5位,此时有的第5位可能是0,也可能是1.这块对应的node迁移到新表中,它们所存放的slot位置也是不一样的)。低位链迁移到新表中,和高位链是一样的,高位链是1,存储扩容,也就是到了新表之后,是老表的位置+老表size。
第四种:存储了一个红黑树的TreeNode对象
TreeNode结构依然保留了next字段,它内部其实还维护一个链表,链表方便split拆分红黑树的时候用的,它也是根据高位和低位拆分成高位链和低位链,其他过程基本和上述相同,只不过拆分出来的链表要看它的长度,如果小于6,直接把这个TreeNode转化为普通的链表,否则的话,升级为红黑树
HashMap是线程不安全的,我们来看下线程安全的ConcurrentHashMap,在JDK7的时候,这种安全策略采用的是分段锁的机制,ConcurrentHashMap维护了一个Segment数组,Segment这个类继承了重入锁ReentrantLock,并且该类里面维护了一个 HashEntry
// 方法参数上的 node 是这次扩容后,需要添加到新的数组中的数据。
private void rehash(HashEntry<K,V> node) {
HashEntry<K,V>[] oldTable = table;
int oldCapacity = oldTable.length;
// 2 倍
int newCapacity = oldCapacity << 1;
threshold = (int)(newCapacity * loadFactor);
// 创建新数组
HashEntry<K,V>[] newTable =
(HashEntry<K,V>[]) new HashEntry[newCapacity];
// 新的掩码,如从 16 扩容到 32,那么 sizeMask 为 31,对应二进制 ‘000...00011111’
int sizeMask = newCapacity - 1;
// 遍历原数组,老套路,将原数组位置 i 处的链表拆分到 新数组位置 i 和 i+oldCap 两个位置
for (int i = 0; i < oldCapacity ; i++) {
// e 是链表的第一个元素
HashEntry<K,V> e = oldTable[i];
if (e != null) {
HashEntry<K,V> next = e.next;
// 计算应该放置在新数组中的位置,
// 假设原数组长度为 16,e 在 oldTable[3] 处,那么 idx 只可能是 3 或者是 3 + 16 = 19
int idx = e.hash & sizeMask;
if (next == null) // 该位置处只有一个元素,那比较好办
newTable[idx] = e;
else {
// Reuse consecutive sequence at same slot
// e 是链表表头
HashEntry<K,V> lastRun = e;
// idx 是当前链表的头结点 e 的新位置
int lastIdx = idx;
// 下面这个 for 循环会找到一个 lastRun 节点,这个节点之后的所有元素是将要放到一起的
for (HashEntry<K,V> last = next;
last != null;
last = last.next) {
int k = last.hash & sizeMask;
if (k != lastIdx) {
lastIdx = k;
lastRun = last;
}
}
// 将 lastRun 及其之后的所有节点组成的这个链表放到 lastIdx 这个位置
newTable[lastIdx] = lastRun;
// 下面的操作是处理 lastRun 之前的节点,
// 这些节点可能分配在另一个链表中,也可能分配到上面的那个链表中
for (HashEntry<K,V> p = e; p != lastRun; p = p.next) {
V v = p.value;
int h = p.hash;
int k = h & sizeMask;
HashEntry<K,V> n = newTable[k];
newTable[k] = new HashEntry<K,V>(h, p.key, v, n);
}
}
}
}
// 将新来的 node 放到新数组中刚刚的 两个链表之一 的 头部
int nodeIndex = node.hash & sizeMask; // add the new node
node.setNext(newTable[nodeIndex]);
newTable[nodeIndex] = node;
table = newTable;
}
注意这里面的代码,外部已经加锁,所以这里面是安全的,我们看下具体的实现方式:先对数组的长度增加一倍,然后遍历原来的旧的table数组,把每一个数组元素也就是Node链表迁移到新的数组里面,最后迁移完毕之后,把新数组的引用直接替换旧的。此外这里这有一个小的细节优化,在迁移链表时用了两个for循环,第一个for的目的是为了,判断是否有迁移位置一样的元素并且位置还是相邻,根据HashMap的设计策略,首先table的大小必须是2的n次方,我们知道扩容后的每个链表的元素的位置,要么不变,要么是原table索引位置+原table的容量大小,举个例子假如现在有三个元素(3,5,7)要放入map里面,table的的容量是2,简单的假设元素位置=元素的值 % 2,得到如下结构:
[0]=null
[1]=3->5->7
现在将table的大小扩容成4,分布如下:
[0]=null
[1]=5->7
[2]=null
[3]=3
因为扩容必须是2的n次方,所以HashMap在put和get元素的时候直接取key的hashCode然后经过再次均衡后直接采用&位运算就能达到取模效果,这个不再细说,上面这个例子的目的是为了说明扩容后的数据分布策略,要么保留在原位置,要么会被均衡在旧的table位置,这里是1加上旧的table容量这是是2,所以是3。基于这个特点,第一个for循环,作的优化如下,假设我们现在用0表示原位置,1表示迁移到index+oldCap的位置,来代表元素:
[0]=null
[1]=0->1->1->0->0->0->0
第一个for循环的会记录lastRun,比如要迁移[1]的数据,经过这个循环之后,lastRun的位置会记录第三个0的位置,因为后面的数据都是0,代表他们要迁移到新的数组中同一个位置中,所以就可以把这个中间节点,直接插入到新的数组位置而后面附带的一串元素其实都不需要动。
接着第二个循环里面在此从第一个0的位置开始遍历到lastRun也就是第三个元素的位置就可以了,只循环处理前面的数据即可,这个循环里面根据位置0和1做不同的链表追加,后面的数据已经被优化的迁移走了,但最坏情况下可能后面一个也没优化,比如下面的结构:
[0]=null
[1]=1->1->0->0->0->0->1->0
这种情况,第一个for循环没多大作用,需要通过第二个for循环从头开始遍历到尾部,按0和1分发迁移,这里面使用的是还是头插法的方式迁移,新迁移的数据是追加在链表的头部,但这里是线程安全的所以不会出现循环链表,导致死循环问题。迁移完成之后直接将最新的元素加入,最后将新的table替换旧的table即可。
并发map的存储的数据结构如下:
private transient volatile int sizeCtl;
// 整个 ConcurrentHashMap 就是一个 Node[],链表结构
static class Node<K,V> implements Map.Entry<K,V> {
}
// hash 表
transient volatile Node<K,V>[] table;
// 扩容时的 新 hash 表
private transient volatile Node<K,V>[] nextTable;
// 扩容时如果某个 bin 迁移完毕, 用 ForwardingNode 作为旧 table bin 的头结点
//即当某个下标已经处理完了,就加个fnode,让其它线程知道这个下标处理过了,就不会再这上面操作了
//如果其他线程来get,它就知道要到新的表中get
static final class ForwardingNode<K,V> extends Node<K,V> {
}
// 用在 compute 以及 computeIfAbsent 时, 用来占位, 计算完成后替换为普通 Node
static final class ReservationNode<K,V> extends Node<K,V> {
}
// 作为 treebin 的头节点, 存储 root 和 first
//它有一个长度阈值,如果长度超过8,链表就会变成红黑树,转换之前,会先尝试扩容。如果红黑树元素个数小于6,又会转换为链表。
//TreeBin作为红黑树头结点,TreeNode作为红黑树结点
static final class TreeBin<K,V> extends Node<K,V> {
}
// 作为 treebin 的节点, 存储 parent, left, right
static final class TreeNode<K,V> extends Node<K,V> {
}
并发map的负载因子不可以修改的,负载因子是用final修饰的,值是固定的0.75
Node的hash字段在一般情况下是值必须是>=0,它的负值有其它意义:散列表在扩容的时候,会触发一个数据迁移的过程,把原表的数据迁移,迁移到扩容后的散列表的逻辑。老散列表迁移完了一个桶,需要放一个标记结点forwardingNode,这个Node的hash值它固定是-1.另外,这里红黑树由一个特殊的结点来处理是TreeBin结构,它本身继承Node,它的哈希值比较特殊,它是固定值-2
最重要:sizeCtl:
sizeCtl==-1:表示当前的散列表正在做初始化,由于并发Map的散列表结构它是懒惰初始化的,即使用时创建,但它要保证在并发的条件下,散列表结构只能被创建一次,当多个线程都执行到initTable逻辑的时候,就会使用CAS的方式取修改这个sizeCtl的值。CAS采用的期望初始值是0,更新之后是-1。CAS修改成功的线程,去真正执行创建散列表的逻辑。CAS失败的线程,会进行一个自旋检查,检查这个table是否被创建出来,每次进行自旋检查之后,会让线程短暂的释放它所占用的CPU,让当前线程重新竞争CPU资源,把CPU资源让给其它更饥饿的线程去使用。
sizeCtl>0:sizeCtl表示下次触发扩容的阈值,比如sizeCtl=12的时候,当插入新数据的时候,检查容量,当发现>=12,就会触发扩容操作。
sizeCtl<0且sizeCtl≠-1:它表示当前散列表正处于扩容状态,高16位表示扩容表示戳,低16位表示参与扩容工作的线程数量+1。扩容表示戳非常的特殊,它必须保证每个线程计算出来的值是一致的。扩容标识戳的计算方式:它要保证每个线程在扩容,散列表从小到大,每次翻倍计算出来的值是一致的。扩容标识戳和老表table长度是强相关的,即不同的长度计算出来的戳是不一样的。(注意:Java中表示负数最高位要设置为1)
并发map是如何保证写数据安全?
并发map采用的方式是synchronized锁桶的头结点,来保证桶内的写操作是线程安全的。
如果slot内是空的(没有头结点,没有数据),这个时候,它是依赖CAS来实现线程安全的。线程会使用CAS的方式向slot里面写头结点数据。成功的话,它就返回,失败的话,说明有其它线程竞争到这个slot位置了。当前线程只能重新执行写逻辑。但是CAS在并发量小的时候性能还不错,但是并发量大的情况下,比较低。CAS首先是比较期望值,如果期望值和内存值是一致的,再执行替换操作。CAS会反映到内核层面。
并发map的扩容流程
1.触发扩容的线程需要做一些额外的事情:触发扩容条件的线程需要修改sizeCtl的值。根据扩容前的散列表长度,计算出扩容唯一标识戳。sizeCtl低16位存储的值是参与扩容工作的线程数+1.当将低16位设置成2,表示有一个线程正在工作了。另外,这个线程需要创建一个新的table,大小是扩容前的两倍。并且需要告诉新表的引用地址到map.nextTable字段。因为后续的协助扩容的线程需要知道将数据迁移到哪里。
2.迁移工作时从高位桶开始,一直迁移到下标是0的桶。
3.我们会创建一个forwardingNode对象,它用来表示指定slot已经被迁移完毕的,forwardingNode里面有一个指向新表的字段,它提供了一个查询方法。当我们查询到这个结点如果碰到了forwardingNode字段,我们就会去新表中执行查询。
4.假设散列表正在扩容中,如果又来了一个线程要向里面写数据:如果这个写操作访问的桶还没有被迁移,那么就要先拿到桶的锁,然后执行正常的插入操作即可,而迁移桶位的时候也会加锁,所以不存在并发的问题,加锁是加的链表的头结点。如果写操作访问的桶是头结点,头结点正好是forwardingNode结点,碰到fwd结点,说明当前正在扩容中,那么这个线程就会协助扩容线程做扩容工作,这样能够提高扩容的速率。当扩容工作完成后,当前线程就可以返回到写数据的逻辑里面了,最终数据会被写到新扩容后的table中。
5.最后一个退出扩容任务的线程它会再重新检查老表,看看有没有遗漏的slot,判断条件是slot是不是forwardingnode结点,如果是,就跳过,如果不是,当前线程就迁移到这个slot的数据。