深入理解HashMap+ConcurrrentHashMap扩容的原理

关于HashMap和ConcurrentHashMap的内容,可以参看Java基础-HashMap集合Java并发编程-ConcurrentHashMap。这两篇文章,本章将深入探讨两者JDK1.7和JDK1.8两个版本的扩容机制。

1.JDK1.7版本的HashMap扩容机制(重要)

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

2.JDK1.8版本的HashMap扩容机制(重要)

在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转化为普通的链表,否则的话,升级为红黑树

2.JDK1.7版本的ConcurrentHashMap扩容机制

HashMap是线程不安全的,我们来看下线程安全的ConcurrentHashMap,在JDK7的时候,这种安全策略采用的是分段锁的机制,ConcurrentHashMap维护了一个Segment数组,Segment这个类继承了重入锁ReentrantLock,并且该类里面维护了一个 HashEntry<K,V>[] table数组,在写操作put,remove,扩容的时候,会对Segment加锁,所以仅仅影响这个Segment,不同的Segment还是可以并发的,所以解决了线程的安全问题,同时又采用了分段锁也提升了并发的效率。

// 方法参数上的 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即可。

4.JDK1.8版本的ConcurrentHashMap扩容机制(重要)

并发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的数据。

猜你喜欢

转载自blog.csdn.net/qq_39736597/article/details/113726067