Java集合系列-ConcurrentHashMap-扩容机制的全面解析
lipiwang 2024-11-06 19:40 6 浏览 0 评论
本人是工作7年的老程序员,在头条分享我对Java运用和源码、各种框架运用和源码的认识和理解,如果对您有所帮助,请持续关注。
声明:所有的文章都是自己工作之余一个字一个字码上去的,希望对学习Java的同学有所帮助,如果有理解不到位的地方,欢迎交流。
上一篇文章我对ConcurrentHashMap的非常重要的put方法做了一个全面的解析,但是遗留了两个也非常重要的知识点:扩容和线程安全,接下来两篇文章就围绕这两个内容展开。此篇内容是我经过几天的空闲时间写出来的,由于篇幅过长,请耐心看完,相信对你会有所有帮助,如果有理解不到位的地方,欢迎交流。首先贴出扩容的流程图
本篇文章的主要内容如下:
1:回忆一下HashMap是怎样扩容的 2:ConcurrentHashMap什么时候会进行扩容 3:ConcurrentHashMap的扩容详解
一、回忆一下HashMap是怎样扩容的
在HashMap中数组的初始化和扩容用的是同一个方法,相对ConcurrentHashMap还是比较容易理解的,下面我就对HashMap的扩容总结一下以及什么时候才进行扩容,非常详细的HashMap扩容机制请看HashMap扩容机制
1:HashMap的扩容总结
1:首先判断数组是否被初始化,如果没有被初始化,则进行初始化工作,判断是否被初始化的条件:table==null || table.length==0 2:如果是初始化,根据不同的构造函数进行初始化: 2.1:如果是无参构造函数,则默认数组的长度为16,扩容阀门为16*0.75=12 2.2:如果是有参数构造函数,则数组的长度就是threshold. 3:如果已经初始化了,判断条件:int oldCap = (oldTab == null) ? 0 : oldTab.length>0 扩容规则:数组的长度扩大为原来的2倍,扩容阀门threshold扩大为原来的2倍。 4:迁移老数据: 4.1:如果老的下标只有一个元素,则直接通过(newCap-1)&hash计算出新的下标 4.2:如果老的下标是一个红黑树,则利用红黑树的迁移规则 4.3:如果老的下标是一个链表,则利用链表的迁移规则
HashMap迁移红黑树和链表时做了一个优化,因为HashMap数组的长度都是2的n次方幂,所以数组长度扩大2倍后,用二进制表示:前面多出了一个高位1,所以元素放到原来的位置,还是原来位置+oldCap,就要看这个元素和多出那一个高位1是怎样的了,如果元素的那一位也是1,则是原来的位置+oldCap,如果是0,则是原来的位置,具体的请看HashMap扩容机制
2:HashMap什么是否才会进行扩容
因为初始化和扩容是同一个方法,所以主要有两个部分用到 1:当第一次调用put时进行初始化工作 2:当加入新的元素时,如果集合元素大于了阀门,就会调用扩容方法
HashMap的调用内容如图所示:
二、ConcurrentHashMap什么时候会进行扩容
通过读ConcurrentHashMap的源码知道,扩容的主要工作就在transfer方法中,我们稍后会对这个方法进行详细的讲解,接下来我们看看ConcurrentHashMap中哪些地方调用了transfer进行扩容的。如下图所示
接下来我们一个一个的去分析。
1:addCount方法中:
while (s >= (long)(sc = sizeCtl) && (tab = table) != null && (n = tab.length) < MAXIMUM_CAPACITY) { //进入while循环:说明当前的元素数量大于了sizeCtrl,就需要扩容了。 int rs = resizeStamp(n); if (sc < 0) { //走到这一步:说明sizeCtrl<0,说明正在扩容,所以需要当前线程协助扩容,需要不需要协助需要进行判断 if ((sc >>> RESIZE_STAMP_SHIFT) != rs || sc == rs + 1 || sc == rs + MAX_RESIZERS || (nt = nextTable) == null || transferIndex <= 0) //走到这一步:说明不需要协助了。 break; if (U.compareAndSwapInt(this, SIZECTL, sc, sc + 1)) //走到这一步:说明需要进行协助扩容,上面的CAS操作就是将扩容线程+1. transfer(tab, nt); } else if (U.compareAndSwapInt(this, SIZECTL, sc, (rs << RESIZE_STAMP_SHIFT) + 2)) //走到这一步:说明当前线程是第一个扩容的线程 transfer(tab, null); }
addCount方法会在putVal()方法最后调用,有主要有两个作用:
1:高并发的对元素的总数进行操作,也就是对CounterCell和baseCount的操作。 1.1:如果没有并发,每增加一个元素就利用CAS机制增加baseCount. 1.2:如果有并发,则通过操作CounterCell数组。 2:判断是否需要扩容。 2.1:如果sizeCtrl<0:说明其他线程正在扩容,当前线程就需要判断是否能够辅助扩容了。 2.2:如果sizeCtrl>0:说明还没有其他线程进行扩容,当前线程是第一个扩容的线程。
两个调用的地方还是有区别的,如果是辅助其他线程进行扩容则transfer的最后一个参数不为空,如果当前线程是第一个扩容的线程,则最后一个参数为空。addCount的流程图如下:
2:helpTransfer方法中
我在分析ConcurrentHashMap的put方法中讲到过这个helpTransfer方法,主要是协助其他线程进行扩容,详细的分析请看哪一篇文章,helpTransfer的流程图如下:
3:tryPresize方法中
从字面上理解这个方法的含义就是尝试扩容,主要有两个地方调用了这个方法,如图
1:putAll()方法:把给定的Map集合加入到ConcurrentHashMap中 2:treeifyBin()方法:这个方式是在链表转换成红黑树的时候,所以当链表长度大于8时,链表不一定就转换成红黑树,如果此时底层数组的长度小于64,则进行扩容,否则将链表转换成红黑树
tryPresize的源代码如下:
private final void tryPresize(int size) { int c = (size >= (MAXIMUM_CAPACITY >>> 1)) ? MAXIMUM_CAPACITY : tableSizeFor(size + (size >>> 1) + 1); int sc; while ((sc = sizeCtl) >= 0) { Node<K,V>[] tab = table; int n; if (tab == null || (n = tab.length) == 0) { //走到这一步$1:说明数组还未初始化,则进行初始化工作,和initTab的方法流程一样。 n = (sc > c) ? sc : c; if (U.compareAndSwapInt(this, SIZECTL, sc, -1)) { try { if (table == tab) { @SuppressWarnings("unchecked") Node<K,V>[] nt = (Node<K,V>[])new Node<?,?>[n]; table = nt; sc = n - (n >>> 2); } } finally { sizeCtl = sc; } } } else if (c <= sc || n >= MAXIMUM_CAPACITY) //走到这一步$2:说明不需要进行扩容或者容量已经达到最大 break; else if (tab == table) { //走到这一步$3:尝试进行扩容或者辅助其他线程进行扩容了 int rs = resizeStamp(n); if (sc < 0) { Node<K,V>[] nt; if ((sc >>> RESIZE_STAMP_SHIFT) != rs || sc == rs + 1 || sc == rs + MAX_RESIZERS || (nt = nextTable) == null || transferIndex <= 0) 1:(sc >>> RESIZE_STAMP_SHIFT) != rs:表示已经扩容结束 2:sc == rs + 1 || sc == rs + MAX_RESIZERS:这两个判断是扩容线程已经达到最大,但是在ConcurrentHashMap中这两个永远都是false 3: (nt = nextTable) == null || transferIndex <= 0:表示扩容结束 //走到这一步:说明不需要进行协助扩容 break; if (U.compareAndSwapInt(this, SIZECTL, sc, sc + 1)) //通过CAS将扩容线程+1,成功后则协助进行扩容 transfer(tab, nt); } else if (U.compareAndSwapInt(this, SIZECTL, sc, (rs << RESIZE_STAMP_SHIFT) + 2)) //当前线程是第一个扩容的线程。 transfer(tab, null); } } }
tryPresize()方法主要有3个分支流程:
$1:判断底层数组是否被初始化,如果没有初始化则进行初始化,这个在putAll时会出现,而treeifyBin不会进入这个条件中 $2:我们讲过sizeCtrl,如果元素的数量大于等于sizeCtrl时将会进行扩容:c<=sc,如果不符合这个条件,则无需扩容,如果sizeCtrl达到了最大值,则也不能扩容了。 $3:这一步就是尝试进行扩容了,首先要判断当前线程是否能够进行扩容或者协助扩容 第一个条件:(sc >>> RESIZE_STAMP_SHIFT) != rs:表明已经扩容结束了。 第二个条件和第三个条件:sc == rs + 1 || sc == rs + MAX_RESIZERS:这两个条件表示扩容线程已经达到了最大值,当前线程不能进行扩容了,但是在ConcurrentHashMap中这两个条件永远都是false 第四个条件和第五个条件:表示扩容结束了 如果这5个条件任意一个为true,则当前线程不需要扩容或者协助扩容了,直接跳出循环。 如果这5个条件都是false,则需要判断是否正在扩容,如果正在扩容,则协助其他线程进行扩容,如果没有进行扩容,则当前线程是第一个扩容线程。
tryPresize的流程图如下:
三、ConcurrentHashMap的扩容详解
上面我们了解了什么时候会调用transfer方法,也了解了HashMap的扩容非常的简单,它不用考虑多线程的情况,但是ConcurrentHashMap就不一样了,它需要考虑多线程的情况,并且为了性能的原因,Doug Lea大神设计了其他线程协助扩容的机制,所以它的扩容变得比较复杂了,接下来我们详细分析它的扩容机制是怎样设计的。
分析扩容细节之前,我总结了扩容的两个要点:
第一个要点:底层数组的扩容:一般会扩大到原来的两倍,并且只允许一个线程进行,不允许多线程并发扩大数组的长度。 第二个要点:数据的迁移,就是把已存在的元素迁移到新的数组中。这个阶段可以多线程并发辅助扩容。
那在扩容时,transfer方法怎样体现出这两个要点呢?如图:
上面我们分析了什么时候调用transfer方法,只有当前线程为第一个扩容线程时,transfer的第二个参数才为null,所以只能是第一个扩容线程才能扩大数组的容量。下面的for循环就是对老数据的迁移工作了。
我们结合上面两个要点开始对transfer方法进行详细的分析,还是老原则,贴出transfer的源代码。
第一个要点:扩大数组的长度
private final void transfer(Node<K,V>[] tab, Node<K,V>[] nextTab) { int n = tab.length, stride; //stride:在数据迁移时,每一个线程要负责迁移老数组table的多少个桶(数组下标) if ((stride = (NCPU > 1) ? (n >>> 3) / NCPU : n) < MIN_TRANSFER_STRIDE) stride = MIN_TRANSFER_STRIDE; // subdivide range if (nextTab == null) { // initiating //走到这一步:只有第一个扩容线程才能走到这一步,创建一个新的Node数组,长度是原来数组的2倍。 try { @SuppressWarnings("unchecked") Node<K,V>[] nt = (Node<K,V>[])new Node<?,?>[n << 1]; nextTab = nt; } catch (Throwable ex) { // try to cope with OOME sizeCtl = Integer.MAX_VALUE; return; } //将创建的新的数组赋值给nextTable.将老数组的长度赋值给transferIndex nextTable = nextTab; transferIndex = n; } //.....迁移老的数据,等会分析 }
上面的代码要理解如下内容:
1:n:老数组的长度 2:stride:在数据迁移时,每一个线程负责迁移老数组的多少个桶(也就是数组的下标),最少为16个,所以从这可以看出,如果利用默认的构造函数创建Map,第一次扩容时只能有一个线程进行扩容,因为n=16. 3:nextTable:全局变量,private transient volatile Node<K,V>[] nextTable:只有在扩容时才不为空。 4:新数组的长度变成老数组的2倍。 5:transferIndex:扩容线程要迁移下标的标记,从老数组的末尾到首部(从右到左)进行数据迁移。例如: 第一个扩容线程要迁移的桶:[transferIndex-stride,transferIndex-1] 第二个扩容线程要迁移的桶:[transferIndex-2*stride,transferIndex-stride-1] 最后一个扩容线程要迁移的桶:[0,stride-1]
上面的把数组扩大到原来的2倍没有什么难度了,一些变量我也做了说明,接下来我们看看怎样把老数组中的数据迁移到新的数组中去。
第一部分代码:
int nextn = nextTab.length; //扩容时的节点封装,当一个下标的数据迁移完成后,会被标成ForwardingNode ForwardingNode<K,V> fwd = new ForwardingNode<K,V>(nextTab); //表示老数组的一个下标的数据是否迁移完成 boolean advance = true; //最后一个协助扩容线程会把此值设置成true,表明数据迁移完成。 boolean finishing = false; //i:表示迁移老数组中的下标 //bound:表示一个扩容线程迁移的最后一个桶(最后一个下标),因为迁移数据是从右到左的。 //i和bound就是代表的[bound=transferIndex-stride,i=transferIndex-1]。 //for循环:迁移[bound,i]之间的数据。 for (int i = 0, bound = 0;;) { Node<K,V> f; int fh; //while循环:计算出当前线程要迁移数据的下标范围,例如[bound=0,i=15] while (advance) { int nextIndex, nextBound; if (--i >= bound || finishing) //走到这一步$1:说明当前线程已经计算出了要负责迁移的数据范围 advance = false; else if ((nextIndex = transferIndex) <= 0) { //走到这一步$2:说明数据已经迁移完成了,无需在迁移了。 i = -1; advance = false; } else if (U.compareAndSwapInt (this, TRANSFERINDEX, nextIndex, nextBound = (nextIndex > stride ? nextIndex - stride : 0))) { //走到这一步$3:说明计算出当前线程要计算出负责迁移数据的范围 bound = nextBound; i = nextIndex - 1; advance = false; } }
上面的代码总结如下:
1:while循环的意思:计算出当前线程要迁移数据的数组下标范围[bound,i] 2:for循环的意思:就是从右到左开始迁移数据,就是从i开始迁移数据,直到bound结束,当前线程迁移数据结束。
1
为了更好的理解这个while循环,举例如下:
一个线程进入了while循环,此时i=0,bound=0.
1:首先判断if语句,不符合,继续判断 2:进入else if语句,nextIndex=transferIndex=16>0,不符合,继续判断 3:进入else if语句。通过CAS判断,成功后 nextIndex=16. nextBound=0:通过计算很容易获取。所以bound=0. i=nextIndex-1=15. 所以计算出当前线程迁移数组的下标[bound,i]=[0,15],但是不是从bound到i迁移的,而是从i到bound倒着迁移的。
上面通过while循环已经计算出了当前线程要迁移数据数组下标的范围,那么接下来就要进行真正的迁移数据了,接下来我们看看怎样迁移的。我们首先看一些简化的代码
for (int i = 0, bound = 0;;) { while (advance){ //while上面已经分析,这里代码省略 } if (i < 0 || i >= n || i + n >= nextn) { //$1:已经迁移完成,进行一些扫尾工作 } else if ((f = tabAt(tab, i)) == null){ //$2:此数组下标没有数据 } else if ((fh = f.hash) == MOVED) //$3:此数组下标已经迁移过了 else { //$4:开始迁移,要么是链表、要么是红黑树 } }
通过上面简化的代码可以看出,通过while循环获取到迁移数组的下标范围后,通过for循环迭代计算的下标范围,然后通过条件语句进入不同的逻辑,下面我们详细分析这些条件语句。
1:$1:已经迁移完成,需要进行扫尾工作
if (i < 0 || i >= n || i + n >= nextn) { int sc; if (finishing) { //进入这一步:将新的数组赋值给全局变量table,sizeCtrl变成0.75table.length.迁移完成,退出无限循环。 nextTable = null; table = nextTab; sizeCtl = (n << 1) - (n >>> 1); return; } if (U.compareAndSwapInt(this, SIZECTL, sc = sizeCtl, sc - 1)) { //进入这一步:通过CAS将迁移线程-1成功后进入。 if ((sc - 2) != resizeStamp(n) << RESIZE_STAMP_SHIFT) //进入这一步:说明所有的迁移数据的线程已经没有了,退出循环 return; finishing = advance = true; i = n; // recheck before commit } }
2:$2:此数组下标没有数据,将此下标标记为ForwardingNode节点
else if ((f = tabAt(tab, i)) == null) advance = casTabAt(tab, i, null, fwd);
3:$3:此下标已经是ForwardingNode节点,说明此下标已经迁移过
else if ((fh = f.hash) == MOVED) advance = true; // already processed
4:$4:开始迁移数据,要么迁移的是链表,要么迁移的是红黑树
else { //迁移一个下标数据时,要加锁。 synchronized (f) { //此数组和HashMap的迁移类似,这里就不在把代码贴出来了。 } }
上面迁移链表和红黑树时,和HashMap的机制相同,如果不熟悉,请看HashMap的文章。这里我就不在重复这些内容了。上面我们就完整分析了ConcurrentHashMap的扩容机制,为了让流程更加的清晰,我接下来用一个流程图来分析。
相关推荐
- linux实例之设置时区的方式有哪些
-
linux系统下的时间管理是一个复杂但精细的功能,而时区又是时间管理非常重要的一个辅助功能。时区解决了本地时间和UTC时间的差异,从而确保了linux系统下时间戳和时间的准确性和一致性。比如文件的时间...
- Linux set命令用法(linux cp命令的用法)
-
Linux中的set命令用于设置或显示系统环境变量。1.设置环境变量:-setVAR=value:设置环境变量VAR的值为value。-exportVAR:将已设置的环境变量VAR导出,使其...
- python环境怎么搭建?小白看完就会!简简单单
-
很多小伙伴安装了python不会搭建环境,看完这个你就会了Python可应用于多平台包括Linux和MacOSX。你可以通过终端窗口输入"python"命令来查看本地是否...
- Linux环境下如何设置多个交叉编译工具链?
-
常见的Linux操作系统都可以通过包管理器安装交叉编译工具链,比如Ubuntu环境下使用如下命令安装gcc交叉编译器:sudoapt-getinstallgcc-arm-linux-gnueab...
- JMeter环境变量配置技巧与注意事项
-
通过给JMeter配置环境变量,可以快捷的打开JMeter:打开终端。执行jmeter。配置环境变量的方法如下。Mac和Linux系统在~/.bashrc中加如下内容:export...
- C/C++|头文件、源文件分开写的源起及作用
-
1C/C++编译模式通常,在一个C++程序中,只包含两类文件——.cpp文件和.h文件。其中,.cpp文件被称作C++源文件,里面放的都是C++的源代码;而.h文件则被称...
- linux中内部变量,环境变量,用户变量的区别
-
unixshell的变量分类在Shell中有三种变量:内部变量,环境变量,用户变量。内部变量:系统提供,不用定义,不能修改环境变量:系统提供,不用定义,可以修改,可以利用export将用户变量转为环...
- 在Linux中输入一行命令后究竟发生了什么?
-
Linux,这个开源的操作系统巨人,以其强大的命令行界面而闻名。无论你是初学者还是经验丰富的系统管理员,理解在Linux终端输入一条命令并按下回车后发生的事情,都是掌握Linux核心的关键。从表面上看...
- Nodejs安装、配置与快速入门(node. js安装)
-
Nodejs是现代JavaScript语言产生革命性变化的一个主要框架,它使得JavaScript从一门浏览器语言成为可以在服务器端运行、开发各种各样应用的通用语言。在不同的平台下,Nodejs的安装...
- Ollama使用指南【超全版】(olaplex使用方法图解)
-
一、Ollama快速入门Ollama是一个用于在本地运行大型语言模型的工具,下面将介绍如何在不同操作系统上安装和使用Ollama。官网:https://ollama.comGithub:http...
- linux移植(linux移植lvgl)
-
1uboot移植l移植linux之前需要先移植一个bootlader代码,主要用于启动linux内核,lLinux系统包括u-boot、内核、根文件系统(rootfs)l引导程序的主要作用将...
- Linux日常小技巧参数优化(linux参数调优)
-
Linux系统参数优化可以让系统更加稳定、高效、安全,提高系统的性能和使用体验。下面列出一些常见的Linux系统参数优化示例,包括修改默认配置、网络等多方面。1.修改默认配置1.1修改默认编辑器默...
- Linux系统编程—条件变量(linux 条件变量开销)
-
条件变量是用来等待线程而不是上锁的,条件变量通常和互斥锁一起使用。条件变量之所以要和互斥锁一起使用,主要是因为互斥锁的一个明显的特点就是它只有两种状态:锁定和非锁定,而条件变量可以通过允许线程阻塞和等...
- 面试题-Linux系统优化进阶学习(linux系统的优化)
-
一.基础必备优化:1.关闭SElinux2.FirewalldCenetOS7Iptables(C6)安全组(阿里云)3.网络管理服务||NetworkManager|network...
- 嵌入式Linux开发教程:Linux Shell
-
本章重点介绍Linux的常用操作和命令。在介绍命令之前,先对Linux的Shell进行了简单介绍,然后按照大多数用户的使用习惯,对各种操作和相关命令进行了分类介绍。对相关命令的介绍都力求通俗易懂,都给...
你 发表评论:
欢迎- 一周热门
- 最近发表
- 标签列表
-
- maven镜像 (69)
- undefined reference to (60)
- zip格式 (63)
- oracle over (62)
- date_format函数用法 (67)
- 在线代理服务器 (60)
- shell 字符串比较 (74)
- x509证书 (61)
- localhost (65)
- java.awt.headless (66)
- syn_sent (64)
- settings.xml (59)
- 弹出窗口 (56)
- applicationcontextaware (72)
- my.cnf (73)
- httpsession (62)
- pkcs7 (62)
- session cookie (63)
- java 生成uuid (58)
- could not initialize class (58)
- beanpropertyrowmapper (58)
- word空格下划线不显示 (73)
- jar文件 (60)
- jsp内置对象 (58)
- makefile编写规则 (58)