目录
ConcurrentHashMap它是一个线程安全的Map容器类,那么它是如何来保证线程安全的?本文将通过源码,尝试分析它的底层原理,本文基于JDK8。
ConcurrentHashMap的底层原理和HashMap是比较相似的,比较大的区别就是在保证线程安全方面,建议在看本文之前,如果不了解HashMap可以先移步到我的另一篇博客java HashMap源码分析-优快云博客。
ConcurrentHashMap的UML图如下。
一、一些重要属性
常量
MAXIMUM_CAPACITY = 1 << 30 ,这里1左移30位,表示 数组最大容量MAXIMUM_CAPACITY 为 2^30 即2的30次方,这个容量与 HashMap 的最大容量是一样的。
DEFAULT_CAPACITY = 16 表示的就是默认的数组初始容量为16,默认初始容量与HashMap的默认初始容量一样,都为16。
LOAD_FACTOR 加载因子为0.75,即当数组中加入的数据超过了当前容量的0.75倍时,要进行数组的扩容,这一点与 HashMap 是一样的,加载因子都是0.75。
table 就是我们 ConcurrentHashMap 底层真正存贮数据的那个数组,名为table。HashMap 底层的数组名字也叫 table。
MIN_TREEIFY_CAPACITY 变量代表了数组的阈值长度64。
TREEIFY_THRESHOLD这个变量代表了链表的长度阈值8,与上面的64紧密配合。当链表的长度大于8且数组的长度大于64,就会把链表树化,提高查找效率。这个转换成树的时机与HashMap 一样,都是数组长度大于等于64并且链表长度大于等于8时,链表转换成红黑树结构。
sizeCtl属性
想要读懂 ConcurrentHashMap 的源码,sizeCtl这个变量非常关键。这里大致总结了 sizeCtl 的几种情况:
- sizeCtl 为0,代表数组未初始化,且数组的初始容量为16。
- sizeCtl 为正数,如果数组未初始化,那么其记录的是数组的初始容量,如果数组已经初始化,那么记录的是数则的扩容阈值。
- sizeCtl 为 -1,表示数组正在进行初始化。
- sizeCtl 小于0,并且不是 -1,表示数组正在扩容,-(1+n),表示此时有n个线程正在共同完成数组的扩容操作。
Node类
我们先来看看最基础的内部存储结构 Node,这就是一个一个的节点,如这段代码所示:
可以看出,每个 Node 里面是 key-value 的形式,并且把 value 用 volatile 修饰,以便保证可见性,同时内部还有一个指向下一个节点的 next 指针,方便产生链表结构。
TreeNode类
树节点类,另外一个核心的数据结构。
当链表长度过长的时候,会转换为TreeNode。但是与HashMap不相同的是,它并不是直接转换为红黑树,而是把这些结点包装成TreeNode放在TreeBin对象中,由TreeBin完成对红黑树的包装。而且TreeNode在ConcurrentHashMap集成自Node类,而并非HashMap中的集成自LinkedHashMap.Entry<K,V>类,也就是说TreeNode带有next指针,这样做的目的是方便基于TreeBin的访问。
TreeBin类
这个类并不负责包装用户的key、value信息,而是包装的很多TreeNode节点。它代替了TreeNode的根节点,也就是说在实际的ConcurrentHashMap“数组”中,存放的是TreeBin对象,而不是TreeNode对象,这是与HashMap的区别。另外这个类还带有了读写锁。
这里仅贴出它的构造方法。可以看到在构造TreeBin节点时,仅仅指定了它的hash值为TREEBIN常量,这也就是个标识位。同时也看到我们熟悉的红黑树构造方法。
ForwardingNode类
ForwardingNode节点是数组扩容时用来连接新数组和旧数组的一个临时节点,在扩容进行中才会出现,只是在扩容阶段使用的节点。
可以看到ForwardingNode节点继承自Node,并添加了一个nextTable字段指向新表,并且hash值为MOEVD,即-1。当遍历到这个节点时,表明此节点处的链表正在复制转移,它的hash值固定为-1,且不存储实际数据。
如果旧table数组的一个hash桶中全部的结点都迁移到了新table中,也就是当前桶扩容完毕,则在这个桶中放置一个ForwardingNode。此时查询会跳转到查询扩容后的table。
读操作碰到ForwardingNode时,将操作转发到扩容后的新table数组上去执行;写操作碰见它时,则尝试帮助扩容,扩容是支持多线程一起扩容的。
这里面定义的find的方法是从nextTable里进行查询节点,而不是以自身为头节点进行查找。
其中的该外层循环用于避免在处理转发节点时出现深层递归。如果节点是转发节点ForwardingNode,则需要更新表指针并继续查找。
总结一下,通过使用外层和内层循环的结构,避免了在转发节点时的深度递归,增强了代码的健壮性。多重条件判断可以确保在查找过程中有效地处理各种情况,包括节点为空、转发节点、链表等。由于ConcurrentHashMap的并发设计,find
方法能够安全地在多线程环境下查找节点。
此方法通过循环和条件判断实现了高效的键查找,支持多线程安全性,同时也能灵活处理扩容带来的节点迁移。这样的设计确保了 ConcurrentHashMap
在高并发情况下的性能和可用性。
二、Unsafe类方法
在ConcurrentHashMap中,随处可以看到U, 大量使用了U.compareAndSwapXXX的方法。这个方法是利用一个CAS算法实现无锁化的修改值的操作,可以大大降低锁代理的性能消耗。
这个算法的基本思想就是不断地去比较当前内存中的变量值与你指定的一个变量值是否相等,如果相等,则接受你指定的修改的值,否则拒绝你的操作。因为当前线程中的值已经不是最新的值,你的修改很可能会覆盖掉其他线程修改的结果。这一点与乐观锁的思想是比较类似的。
unsafe静态块:
unsafe 代码块控制了一些属性的修改工作,比如最常用的SIZECTL 。 在这一版本的concurrentHashMap中,大量应用来的CAS方法进行变量、属性的修改工作。 利用CAS进行无锁操作,可以大大提高性能。
ConcurrentHashMap定义了三个原子操作,用于对指定位置的节点进行操作。正是这些原子操作保证了ConcurrentHashMap的线程安全。
@SuppressWarnings("unchecked") //transient volatile Node<K,V>[] table; tab变量确实是volatile
static final <K,V> Node<K,V> tabAt(Node<K,V>[] tab, int i) {//获取table中索引 i 处的元素。
return (Node<K,V>)U.getObjectVolatile(tab, ((long)i << ASHIFT) + ABASE);//如果tab是volatile变量,则该方法保证其可见性。
}
static final <K,V> boolean casTabAt(Node<K,V>[] tab, int i,//通过CAS设置table索引为 i 处的元素。
Node<K,V> c, Node<K,V> v) {
return U.compareAndSwapObject(tab, ((long)i << ASHIFT) + ABASE, c, v);
}
//transient volatile Node<K,V>[] table; tab变量确实是volatile
static final <K,V> void setTabAt(Node<K,V>[] tab, int i, Node<K,V> v) {//修改table 索引 i 处的元素。
U.putObjectVolatile(tab, ((long)i << ASHIFT) + ABASE, v);//如果tab是volatile变量,则该方法保证其可见性。
}
我们不难看出,以上三个方法都是调用的 Unsafe(U)类中的方法,Unsafe类中定义了大量对内存的操作方法,是native的,不建议开发者直接使用。
tabAt和setTabAt最终调用的两个方法分别是 U.getObjectVolatile() 和 U.putObjectVolatile。顾名思义,其实是通过volatile保证的tab的可见性(Volatile只保证可见性不保证原子性)。前提是tab变量是Volatile修饰的变量。
我们通过调用栈,最终可以看到其实 tab 就是 ConcurrentHashMap 中的table。而这个变量是这么定义的,可见其确实是Volatile修饰的变量。
再看casTabAt方法,这个就是CAS方法了。CAS是Compare and Swap三个单词的缩写,比较交换。CAS在Java中又称之为乐观锁,即我们总认为是没有锁的。一般是通过上述用法达到自旋的目的。CAS一般通过自旋达到自旋锁的目的,即认为没有锁,失败重试,这种思路。
三、构造方法
ConcurrentHashMap一共有五个构造方法。
无参构造方法
1)无参构造方法
通过无参构造方法创建map,则不会做任何操作,直到向map中put元素的时候,才会去初始化ConcurrentHashMap内部的数组。
从无参构造源码也可以看出,在 ConcurrentHashMap 无参构造方法中,它没有做任何的动作。也就是说,采用无参构造创建 ConcurrentHashMap 时,底层并没有创建数组对象。(这里补充一点,创建数组对象的动作是在后续进行 put 操作添加元素时创建的)。
初始化源码上方有一句话,翻译过来就是"创建一个新数组,数组默认长度为16",也对应了上面我说到的,默认初始容量为16。
带参构造方法
2)可以指定初始容量的构造函数,通过这个构造函数可以指定初始容量。
在方法中,进行一些合法性的校验,如果初始容量小于0,则抛出异常,如果初始化容量大于等于最大的容量的二分之一,则让初始容量等于最大容量,否则,通过tableSizeFor()方法生成一个大于等于当前初始化容量,并且最接近于2的倍数的大小的数字,作为内部数组的数组长度。
进入tableSizeFor()方法看一下,
这个方法可能大家看不太懂,通俗来说这个方法的目的是返回一个 2 的整数次幂的数。如2^4 = 16,2^5 = 32,2^6 = 64。
结合上述扩容方法和 tableSizeFor 方法,我们可以知道,当我们传入一个初始值的时候,实际计算出的结果是 (传入的值+传入值的一半+1)的结果向上取整并且必须是2的整数次幂。
比如,如果我们传入的是32,那么计算出的初始容量就是 32 + 16 + 1 = 49,49不是2的整数次幂,向上取整最小为 64,所以初始容量为64而不是传入的32。如果我们传入的是16,那么计算出的结果就是 16 + 8 +