为什么使用ConcurrentHashMap?
因为原本的HashMap是线程不安全的,HashTable虽然是安全的但是效率太低(它使用的是一把给整个Entry数组加了一把大锁,所有get/put及其相关调用全在这一把锁之下进行)。那么有些操作我们需要线程同步的情况下又想高效率应该怎么办呢?
这时候就有了ConcurrentHashMap。那么为什么说HashMap是线程不安全的呢?
在jdk1.7中使用ConcurrentHashMap会在多线程rehash也就是其扩容操作时造成循环引用问题。具体原因是因为在jdk1.7中采用的是头插法(好处是可以省去遍历链表的过程),而在jdk1.8中使用的是尾插法(需要遍历链表)。
JDK1.7的循环引用问题
那么会有同学问,头插法为什么会在多线程扩容时造成循环引用问题呢?
首先大家记住一点,头插法在扩容时会有栈结构的特性,即先插的节点最后出来进行转移。
下面我们一组图来了解一下这个过程。
假设有两个线程:T1 T2,为了方便大家简单理解,我们就假设HashMap的当前数组容量是2,此时,HashMap中的存储结构如下:
![image-20221102161604297](https://xjxdeimg.oss-cn-beijing.aliyuncs.com/img/typora/202211021616335.png)
可见,在索引1处的链表引用关系是 a -> b -> c -> d -> null。
现在,有线程T1和T2同时对该HashMap进行扩容,并且它们扩容后,都把结点元素全部移动到新数组的索引3处。
假设线程T1运行到Entry<K,V> next = e.next;
这行代码,时间片就用完了,即当前T1已计算得出e=a,e.next=b。
好了,现在线程T2开始执行并且完成了整个扩容操作,并把链表移到了索引3处,此时HashMap存储结构如下:
可见,由于头插法的缘故,在索引3处的链表引用关系是 d -> c -> b -> a -> null。
好了,线程线程T1拿到时间片了,继续执行Entry<K,V> next = e.next;
后面的代码,注意此时T1中e=a,e.next=b,所以需要将结点a头插到索引3的位置(注意这里的链表已经被改过了),如下。
由于T2中扩容后得到的链表关系是 d -> c -> b -> a -> null,因此T1线程中此时链表结点引用关系实际上应是这样的:
然后,执行e = next(b);和Entry<K,V> next = e.next;
代码(此时b的next已经被T2改为a而不是原来的c了),对e变量以及e.next变量重新赋值,得到:e=b,e.next=a。
所以,继续将b头插到索引3的位置,如下:
然后,执行e = next;和Entry<K,V> next = e.next;
代码,对e变量以及e.next变量重新赋值,得到:e=a,e.next=null。
所以,继续将a头插到索引3的位置,如下:
由于e.next为null,因此T1线程中的循环就结束了,那么执行到这,已经可以看出,链表结点a和b互相引用了,即形成了一个环。当我们使用get方法,取到索引为3中的某元素时候,将会出现死循环,另外,由于d结点和c结点并没有其他结点指向它们,所以,d和c结点的数据也将会丢失。这就是JDK1.7中的多线程扩容造成的循环引用问题。
那么又会有同学问了,JDK8中不是已经改为尾插法了嘛?为什么还说线程不安全呢?
这里是因为我们只关注循环引用问题了,忘了其get/put操作其实多线程本来也会出现问题,比如两个线程并发修改时就很容易出现数据不一致的问题。要避免 HashMap 的线程安全问题,有多个解决方法,比如改用 HashTable 或者 Collections.synchronizedMap() 方法。但是这两者都有一个问题,就是性能,无论读还是写,他们两个都会给整个集合加锁,导致同一时间的其他操作阻塞。ConcurrentHashMap 的优势在于兼顾性能和线程安全,一个线程进行写操作时,它会锁住一小部分,其他部分的读写不受影响,其他线程访问没上锁的地方不会被阻塞,下面我们就来介绍一下大名鼎鼎的ConcurrentHashMap。
ConcurrentHashMap详解
什么是ConcurrentHashMap
java.util.concurrent.ConcurrentHashMap
属于 JUC 包下的一个集合类,可以实现线程安全。它是一个支持高并发更新与查询的哈希表(基于HashMap)。在保证安全的前提下,进行检索不需要锁(valetile进行实现)。
在JDK1.7和JDK1.8中ConcurrentHashMap有比较大的改动,因此这里我们分为两部分进行学习。
JDK1.7的ConcurrentHashMap的实现
JDK1.7的ConcurrentHashMap与HashMap和Hashtable 最大的不同在于:put和 get 需要两次Hash才能到达指定的HashEntry,第一次hash到达Segment,第二次到达Segment里面的HashEntry数组,然后在遍历里面的Entry链表。
HashEntry和 Segment是ConcurrentHashMap类中的两个静态内部类。
Segment类我们在下面会介绍,这里介绍一下HashEntry类,它是用来封装具体的键值对的,是个典型的四元组。与HashMap中的Entry类似,HashEntry也包括同样的四个域,分别是key、hash、value和next。不同的是,在HashEntry类中,key,hash和next域都被声明为final的,value域被volatile所修饰,因此HashEntry对象几乎是不可变的,这是ConcurrentHashmap读操作并不需要加锁的一个重要原因。next域被声明为final本身就意味着我们不能从hash链的中间或尾部添加或删除节点,因为这需要修改next引用值,因此所有的节点的修改只能从头部开始。对于put操作,可以一律添加到Hash链的头部。但是对于remove操作,可能需要从中间删除一个节点,这就需要将要删除节点的前面所有节点整个复制(重新new)一遍,最后一个节点指向要删除结点的下一个结点。特别地,由于value域被volatile修饰,所以其可以确保被读线程读到最新的值,这是ConcurrentHashmap读操作并不需要加锁的另一个重要原因。
分段锁机制
Hashtable之所以效率低下主要是因为其实现使用了synchronized关键字对put等操作进行加锁,而synchronized关键字加锁是对整个对象进行加锁,也就是说在进行put等修改Hash表的操作时,锁住了整个Hash表,从而使得其表现的效率低下;因此,在JDK1.5~1.7版本,Java使用了分段锁机制实现ConcurrentHashMap.
简而言之,ConcurrentHashMap在对象中保存了一个Segment数组,即可以理解为整个Hash表划分为多个分段(小Hash表);而每个Segment元素,即每个分段则类似于一个Hashtable;这样,在执行put操作时首先根据hash算法定位到元素属于哪个Segment,然后对该Segment加锁即可。因此,ConcurrentHashMap在多线程并发编程中可以实现多线程安全的put操作。接下来将详细分析JDK1.7版本中ConcurrentHashMap的实现原理。
JDK1.7的ConcurrentHashMap的数据结构
![image-20221102170939667](https://xjxdeimg.oss-cn-beijing.aliyuncs.com/img/typora/202211041848989.png)
ConcurrentHashMap类结构如上图所示。由图可知,在ConcurrentHashMap中,定义了一个Segment<K, V>[]数组来将Hash表实现分段存储,从而实现分段加锁; 而一个Segment元素则与HashMap结构类似,其包含了一个HashEntry数组,用来存储Key/Value对。Segment继承了ReetrantLock,表示Segment是一个可重入锁,因此ConcurrentHashMap通过可重入锁对每个分段进行加锁。但是如果每个Segment越来越大时,锁的粒度
就变得有些大了。其优势在于保证在操作不同段 map 的时候可以并发执行,操作同段 map 的时候,进行锁的竞争和等待。这相对于直接对整个map同步synchronized是有优势的。
缺点在于分成很多段时会比较浪费内存空间(不连续,碎片化); 操作map时竞争同一个分段锁的概率非常小时,分段锁反而会造成更新等操作的长时间等待; 当某个段很大时,分段锁的性能会下降。
这里jdk 1.7中ConcurrentHashmap采用的底层数据结构为数组+链表的形式。
ConcurrentHashMap的初始化
JDK1.7的ConcurrentHashMap的初始化主要分为两个部分:一是初始化ConcurrentHashMap,即初始化segments数组、segmentShift段偏移量和segmentMask段掩码等;二是初始化每个segment分段。接下来,我们将分别介绍这两部分初始化。
ConcurrentHashMap包含多个构造函数,而所有的构造函数最终都调用了如下的构造函数:
public ConcurrentHashMap(int initialCapacity,
float loadFactor, int concurrencyLevel) {
if (!(loadFactor > 0) || initialCapacity < 0 || concurrencyLevel <= 0)
throw new IllegalArgumentException();
if (concurrencyLevel > MAX_SEGMENTS)
concurrencyLevel = MAX_SEGMENTS;
// Find power-of-two sizes best matching arguments
int sshift = 0;
int ssize = 1;
while (ssize < concurrencyLevel) {
++sshift;
ssize <<= 1;
}
this.segmentShift = 32 - sshift;
this.segmentMask = ssize - 1;
if (initialCapacity > MAXIMUM_CAPACITY)
initialCapacity = MAXIMUM_CAPACITY;
int c = initialCapacity / ssize;
if (c * ssize < initialCapacity)
++c;
int cap = MIN_SEGMENT_TABLE_CAPACITY;
while (cap < c)
cap <<= 1;
// create segments and segments[0]
Segment<K,V> s0 =
new Segment<K,V>(loadFactor, (int)(cap * loadFactor),
(HashEntry<K,V>[])new HashEntry[cap]);
Segment<K,V>[] ss = (Segment<K,V>[])new Segment[ssize];
UNSAFE.putOrderedObject(ss, SBASE, s0); // ordered write of segments[0]
this.segments = ss;
}
由代码可知,该构造函数需要传入三个参数:
initialCapacity
:初始总容量,默认 16loadFactor
:加载因子,默认 0.75concurrencyLevel
:并发级别,默认 16
其中,concurrencyLevel主要用来初始化segments、segmentShift和segmentMask等,即初始化整个ConcurrentHashMap的;而initialCapacity和loadFactor则主要用来初始化每个Segment分段。
默认情况下会有16个Segment分段数组即Segment[16]。即在理想状态下,ConcurrentHashMap 可以支持 16 个线程执行并发写操作(如果并发级别设为16),及任意数量线程的读操作(因为读操作无锁)。
初始化ConcurrentHashMap
根据ConcurrentHashMap的构造方法可知,在初始化时创建了两个中间变量ssize和sshift,它们都是通过concurrencyLevel计算得到的。其中ssize表示了segments数组的长度,为了能通过按位与的散列算法来定位segments数组的索引,必须保证segments数组的长度是2的N次方。其实之后的HashEntry数组大小的计算也需要是2的N次方。
索引计算公式为i = (n - 1) & hash,如果n为2次幂,那么n-1的低位就全是1,哈希值进行与操作时可以保证低位的值不变,从而保证分布均匀,效果等同于hash%n,但是位运算比取余运算要高效的多。
所以需要在初始化时通过循环计算出一个大于或等于concurrencyLevel的最小的2的N次方值来作为数组的长度;而sshift表示了计算ssize时进行移位操作的次数。
segmentShift用于定位参与散列运算的位数,其等于32减去sshift,使用32是因为ConcurrentHashMap的hash()方法返回的最大数是32位的;segmentMask是散列运算的掩码,等于ssize减去1,所以掩码的二进制各位都为1.
因为ssize的最大长度为65536,所以segmentShift最大值为16,segmentMask最大值为65535. 由于segmentShift和segmentMask与散列运算相关,因此之后还会对此进行分析。
其中并发级别控制了Segment的个数
,在一个ConcurrentHashMap创建后Segment的个数是不能变的,扩容
过程过改变的是每个Segment的大小
。
初始化Segment分段
ConcurrentHashMap通过initialCapacity和loadFactor来初始化每个Segment. 在初始化Segment时,也定义了一个中间变量cap,其等于initialCapacity除以ssize的倍数c,如果c大于1,则取大于等于c的2的N次方,cap表示Segment中HashEntry数组的长度;loadFactor表示了Segment的加载因子,通过cap*loadFactor获得每个Segment的阈值threshold.
默认情况下,initialCapacity等于16,loadFactor等于0.75,concurrencyLevel等于16.
定位Segment
由于采用了Segment分段锁机制实现一个高效的同步,那么首先则需要通过hash散列算法计算key的hash值,从而定位其所在的Segment. 因此,首先需要了解ConcurrentHashMap中hash()函数的实现。
private int hash(Object k) {
int h = hashSeed;
if ((0 != h) && (k instanceof String)) {
return sun.misc.Hashing.stringHash32((String) k);
}
h ^= k.hashCode();
// Spread bits to regularize both segment and index locations,
// using variant of single-word Wang/Jenkins hash.
h += (h << 15) ^ 0xffffcd7d;
h ^= (h >>> 10);
h += (h << 3);
h ^= (h >>> 6);
h += (h << 2) + (h << 14);
return h ^ (h >>> 16);
}
通过hash()函数可知,首先通过计算一个随机的hashSeed减少String类型的key值的hash冲突;然后利用Wang/Jenkins hash算法对key的hash值进行再hash计算。通过这两种方式都是为了减少散列冲突,从而提高效率。因为如果散列的质量太差,元素分布不均,那么使用Segment分段加锁也就没有意义了。
private Segment<K,V> segmentForHash(int h) {
long u = (((h >>> segmentShift) & segmentMask) << SSHIFT) + SBASE;
return (Segment<K,V>) UNSAFE.getObjectVolatile(segments, u);
}
接下来,ConcurrentHashMap通过上述定位函数则可以定位到key所在的Segment分段。
ConcurrentHashMap的操作
在介绍ConcurrentHashMap的操作之前,首先需要介绍一下Unsafe类,因为在JDK1.7新版本中是通过Unsafe类的方法实现锁操作的。Unsafe类是一个保护类,一般应用程序很少用到,但其在一些框架中经常用到,如JDK、Netty、Spring等框架。Unsafe类提供了一些硬件级别的原子操作,其在JDK1.7和JDK1.8中的ConcurrentHashMap都有用到,但其用法却不同,在此只介绍在JDK1.7中用到的几个方法:
arrayBaseOffset(Class class):获取数组第一个元素的偏移地址。
arrayIndexScale(Class class):获取数组中元素的增量地址。
getObjectVolatile(Object obj, long offset):获取obj对象中offset偏移地址对应的Object型field属性值,支持Volatile读内存语义。
get
JDK1.7的ConcurrentHashMap的get操作是不加锁的,因为在每个Segment中定义的HashEntry数组和在每个HashEntry中定义的value和next HashEntry节点都是volatile类型的,volatile类型的变量可以保证其在多线程之间的可见性,因此可以被多个线程同时读,从而不用加锁。而其get操作步骤也比较简单,定位Segment –> 定位HashEntry –> 通过getObjectVolatile()方法获取指定偏移量上的HashEntry –> 通过循环遍历链表获取对应值。
定位Segment:(((h >>> segmentShift) & segmentMask) << SSHIFT) + SBASE
定位HashEntry:(((tab.length - 1) & h)) << TSHIFT) + TBASE
put
ConcurrentHashMap的put方法就要比get方法复杂的多,其实现源码如下:
public V put(K key, V value) {
Segment<K,V> s;
if (value == null)
throw new NullPointerException();
int hash = hash(key);
int j = (hash >>> segmentShift) & segmentMask;
if ((s = (Segment<K,V>)UNSAFE.getObject // nonvolatile; recheck
(segments, (j << SSHIFT) + SBASE)) == null) // in ensureSegment
s = ensureSegment(j);
return s.put(key, hash, value, false);
}
同样的,put方法首先也会通过hash算法定位到对应的Segment,此时,如果获取到的Segment为空,则调用ensureSegment()方法;否则,直接调用查询到的Segment的put方法插入值,注意此处并没有用getObjectVolatile()方法读,而是在ensureSegment()中再用volatile读操作,这样可以在查询segments不为空的时候避免使用volatile读,提高效率。在ensureSegment()方法中,首先使用getObjectVolatile()读取对应Segment,如果还是为空,则以segments[0]为原型创建一个Segment对象,并将这个对象设置为对应的Segment值并返回。
在Segment的put方法中,首先需要调用tryLock()方法获取锁,然后通过hash算法对key值重定位到对应的HashEntry,然后遍历整个链表,如果查到key值,则在将元素插入到 segment 前,会检查本次插入会不会导致 segment 中元素的数量超过阈值,如果会,那么就先对 segment 进行扩容和重哈希操作,然后再进行插入。;而如果没有查询到对应的key,则需要调用rehash()方法对Segment中保存的table进行扩容,扩容为原来的2倍,并在扩容之后插入对应的元素。插入一个key/value对后,需要将统计Segment中元素个数的count属性加1。最后,插入成功之后,需要使用unLock()释放锁。
size
ConcurrentHashMap的size操作的实现方法也非常巧妙,一开始并不对Segment加锁,而是直接尝试将所有的Segment元素中的count相加,这样执行两次,然后将两次的结果对比,如果两次结果相等则直接返回;而如果两次结果不同,则再将所有Segment加锁,然后再执行统计得到对应的size值。
JDK1.8的ConcurrentHashMap的实现
在JDK1.7之前,ConcurrentHashMap是通过分段锁机制来实现的,所以其最大并发度受Segment的个数限制。因此,在JDK1.8中,ConcurrentHashMap的实现原理摒弃了这种设计,而是选择了与HashMap类似的数组+链表+红黑树的方式实现,而加锁则采用CAS和synchronized实现。
CAS原理
一般地,锁分为悲观锁和乐观锁:悲观锁认为对于同一个数据的并发操作,一定是为发生修改的;而乐观锁则任务对于同一个数据的并发操作是不会发生修改的,在更新数据时会采用尝试更新不断重试的方式更新数据。
CAS(Compare And Swap,比较交换):CAS有三个操作数,内存值V、预期值A、要修改的新值B,当且仅当A和V相等时才会将V修改为B,否则什么都不做。Java中CAS操作通过JNI本地方法实现,在JVM中程序会根据当前处理器的类型来决定是否为cmpxchg指令添加lock前缀。如果程序是在多处理器上运行,就为cmpxchg指令加上lock前缀(Lock Cmpxchg);反之,如果程序是在单处理器上运行,就省略lock前缀。
Intel的手册对lock前缀的说明如下:
确保对内存的读-改-写操作原子执行。之前采用锁定总线的方式,但开销很大;后来改用缓存锁定来保证指令执行的原子性。
禁止该指令与之前和之后的读和写指令重排序。
把写缓冲区中的所有数据刷新到内存中。
CAS同时具有volatile读和volatile写的内存语义。
不过CAS操作也存在一些缺点:1. 存在ABA问题,其解决思路是使用版本号;2. 循环时间长,开销大;3. 只能保证一个共享变量的原子操作。
为了能更好的利用CAS原理解决并发问题,JDK1.5之后在java.util.concurrent.atomic包下采用CAS实现了一系列的原子操作类(aomic包下的).
ConcurrentHashMap的数据结构
JDK1.8的ConcurrentHashMap数据结构比JDK1.7之前的要简单的多,其使用的是HashMap一样的数据结构:数组+链表+红黑树。ConcurrentHashMap中包含一个table数组,其类型是一个Node数组;而Node是一个继承自Map.Entry<K, V>的链表,而当这个链表结构中的数据大于8,则将数据结构升级为TreeBin类型的红黑树结构。
另外,JDK1.8中的ConcurrentHashMap中还包含一个重要属性sizeCtl,其是一个控制标识符,不同的值代表不同的意思:其为0时,表示hash表还未初始化,而为正数时这个数值表示初始化或下一次扩容的大小,相当于一个阈值;即如果hash表的实际大小>=sizeCtl,则进行扩容,默认情况下其是当前ConcurrentHashMap容量的0.75倍;而如果sizeCtl为-1,表示正在进行初始化操作;而为-N时,则表示有N-1个线程正在进行扩容。
关键概念点
-
sizeCtl变量(volatile修饰):通过CAS操作+volatile, 控制数组初始化和扩容操作
-
-1 代表正在初始化
-
-N 前16位记录数组容量,后16位记录扩容线程大小+1,是个负数
-
正数0,表示未初始化
-
正数,0.75*当前数组大小
-
-
ForwardingNode:<key,value>键值对,封装为Node对象
-
table变量(volatile):也就是所说的数组,默认为null,默认大小为16的数组,每次扩容时大小总是2的幂次方
-
nextTable(volatile):扩容时新生成的数组,大小为table的两倍
ConcurrentHashMap的初始化
JDK1.8的ConcurrentHashMap的初始化过程也比较简单,所有的构造方法最终都会调用如下这个构造方法。
public ConcurrentHashMap(int initialCapacity,
float loadFactor, int concurrencyLevel) {
if (!(loadFactor > 0.0f) || initialCapacity < 0 || concurrencyLevel <= 0)
throw new IllegalArgumentException();
if (initialCapacity < concurrencyLevel) // Use at least as many bins
initialCapacity = concurrencyLevel; // as estimated threads
long size = (long)(1.0 + (long)initialCapacity / loadFactor);
int cap = (size >= (long)MAXIMUM_CAPACITY) ?
MAXIMUM_CAPACITY : tableSizeFor((int)size);
this.sizeCtl = cap;
}
该初始化过程通过指定的初始容量initialCapacity,加载因子loadFactor和预估并发度concurrencyLevel三个参数计算table数组的初始大小sizeCtl的值。
可以看到,在构造ConcurrentHashMap时,并不会对hash表(Node<K, V>[] table)进行初始化,hash表的初始化是在插入第一个元素时进行的。在put操作时,如果检测到table为空或其长度为0时,则会调用initTable()方法对table进行初始化操作。即ConcurrentHashMap初始化操作并不是在构造函数实现的,而是在put操作中实现,这与大部分集合实现类相似
private final Node<K,V>[] initTable() {
Node<K,V>[] tab; int sc;
while ((tab = table) == null || tab.length == 0) {
if ((sc = sizeCtl) < 0)
Thread.yield(); // lost initialization race; just spin
else if (U.compareAndSwapInt(this, SIZECTL, sc, -1)) {
try {
if ((tab = table) == null || tab.length == 0) {
int n = (sc > 0) ? sc : DEFAULT_CAPACITY;
@SuppressWarnings("unchecked")
Node<K,V>[] nt = (Node<K,V>[])new Node<?,?>[n];
table = tab = nt;
sc = n - (n >>> 2);
}
} finally {
sizeCtl = sc;
}
break;
}
}
return tab;
}
可以看到,该方法使用一个循环实现table的初始化;在循环中,首先会判断sizeCtl的值,如果其小于0,则说明其正在进行初始化或扩容操作,则不执行任何操作,调用yield()方法使当前线程返回等待状态;而如果sizeCtl大于等于0,则使用CAS操作比较sizeCtl的值是否是-1,如果是-1则进行初始化。初始化时,如果sizeCtl的值为0,则创建默认容量的table;否则创建大小为sizeCtl的table;然后重置sizeCtl的值为0.75n,即当前table容量的0.75倍,并返回创建的table,此时初始化hash表完成。
Node链表和红黑树结构转换
上文中说到,一个table元素会根据其包含的Node节点数在链表和红黑树两种结构之间切换,因此我们本节先介绍Node节点的结构转换的实现。
首先,在table中添加一个元素时,如果添加的元素所在的链表节点个数超过8(默认值)并且数组大小已经超过64,则会触发链表向红黑树结构转换。具体的实现方法如下:
private final void treeifyBin(Node<K,V>[] tab, int index) {
Node<K,V> b; int n, sc;
if (tab != null) {
if ((n = tab.length) < MIN_TREEIFY_CAPACITY)
tryPresize(n << 1);
else if ((b = tabAt(tab, index)) != null && b.hash >= 0) {
synchronized (b) {
if (tabAt(tab, index) == b) {
TreeNode<K,V> hd = null, tl = null;
for (Node<K,V> e = b; e != null; e = e.next) {
TreeNode<K,V> p =
new TreeNode<K,V>(e.hash, e.key, e.val,
null, null);
if ((p.prev = tl) == null)
hd = p;
else
tl.next = p;
tl = p;
}
setTabAt(tab, index, new TreeBin<K,V>(hd));
}
}
}
}
}
该方法首先会检查hash表的大小是否大于等于MIN_TREEIFY_CAPACITY,默认值为64,如果小于该值,则表示不需要转化为红黑树结构,直接将hash表扩容即可。
如果当前table的长度大于64,则使用CAS获取指定的Node节点,然后对该节点通过synchronized加锁,由于只对一个Node节点加锁,因此该操作并不影响其他Node节点的操作,因此极大的提高了ConcurrentHashMap的并发效率。加锁之后,便是将这个Node节点所在的链表转换为TreeBin结构的红黑树。
然后,在table中删除元素时,如果元素所在的红黑树节点个数小于6,则会触发红黑树向链表结构转换。具体实现如下:
static <K,V> Node<K,V> untreeify(Node<K,V> b) {
Node<K,V> hd = null, tl = null;
for (Node<K,V> q = b; q != null; q = q.next) {
Node<K,V> p = new Node<K,V>(q.hash, q.key, q.val, null);
if (tl == null)
hd = p;
else
tl.next = p;
tl = p;
}
return hd;
}
该方法实现简单,在此不再进行细致分析。
ConcurrentHashMap的操作
get
通过get获取hash表中的值时,首先需要获取key值的hash值。而在JDK1.8的ConcurrentHashMap中通过speed()方法获取。
static final int spread(int h) {
return (h ^ (h >>> 16)) & HASH_BITS;
}
speed()方法将key的hash值进行再hash,让hash值的高位也参与hash运算,从而减少哈希冲突。然后再查询对应的value值。
public V get(Object key) {
Node<K,V>[] tab; Node<K,V> e, p; int n, eh; K ek;
int h = spread(key.hashCode());
if ((tab = table) != null && (n = tab.length) > 0 &&
(e = tabAt(tab, (n - 1) & h)) != null) {
if ((eh = e.hash) == h) {
if ((ek = e.key) == key || (ek != null && key.equals(ek)))
return e.val;
}
else if (eh < 0)
return (p = e.find(h, key)) != null ? p.val : null;
while ((e = e.next) != null) {
if (e.hash == h &&
((ek = e.key) == key || (ek != null && key.equals(ek))))
return e.val;
}
}
return null;
}
查询时,首先通过tabAt()方法找到key对应的Node链表或红黑树,然后遍历该结构便可以获取key对应的value值。其中,tabAt()方法主要通过Unsafe类的getObjectVolatile()方法获取value值,通过volatile读获取value值,可以保证value值的可见性,从而保证其是当前最新的值。
put
JDK1.8的ConcurrentHashMap的put操作实现方式主要定义在putVal(K key, V value, boolean onlyIfAbsent)中。
final V putVal(K key, V value, boolean onlyIfAbsent) {
if (key == null || value == null) throw new NullPointerException();
int hash = spread(key.hashCode());
int binCount = 0;
for (Node<K,V>[] tab = table;;) {
Node<K,V> f; int n, i, fh;
if (tab == null || (n = tab.length) == 0)
tab = initTable();
else if ((f = tabAt(tab, i = (n - 1) & hash)) == null) {
if (casTabAt(tab, i, null,
new Node<K,V>(hash, key, value, null)))
break; // no lock when adding to empty bin
}
else if ((fh = f.hash) == MOVED)
tab = helpTransfer(tab, f);
else {
V oldVal = null;
synchronized (f) {
if (tabAt(tab, i) == f) {
if (fh >= 0) {
binCount = 1;
for (Node<K,V> e = f;; ++binCount) {
K ek;
if (e.hash == hash &&
((ek = e.key) == key ||
(ek != null && key.equals(ek)))) {
oldVal = e.val;
if (!onlyIfAbsent)
e.val = value;
break;
}
Node<K,V> pred = e;
if ((e = e.next) == null) {
pred.next = new Node<K,V>(hash, key,
value, null);
break;
}
}
}
else if (f instanceof TreeBin) {
Node<K,V> p;
binCount = 2;
if ((p = ((TreeBin<K,V>)f).putTreeVal(hash, key,
value)) != null) {
oldVal = p.val;
if (!onlyIfAbsent)
p.val = value;
}
}
}
}
if (binCount != 0) {
if (binCount >= TREEIFY_THRESHOLD)
treeifyBin(tab, i);
if (oldVal != null)
return oldVal;
break;
}
}
}
addCount(1L, binCount);
return null;
}
put操作大致可分为以下几个步骤:
- 计算key的hash值,即调用speed()方法计算hash值;
- 获取hash值对应的Node节点位置,此时通过一个循环实现。有以下几种情况:
- 如果table表为空,则首先进行初始化操作,初始化之后再次进入循环获取Node节点的位置;
- 如果table不为空,但没有找到key对应的Node节点,则直接调用casTabAt()方法插入一个新节点,此时不用加锁;
- 如果table不为空,且key对应的Node节点也不为空,但Node头结点的hash值为MOVED(-1),则表示需要扩容,此时调用helpTransfer()方法进行扩容;
- 其他情况下,则直接向Node中插入一个新Node节点,此时需要对这个Node链表或红黑树通过synchronized加锁。
- 插入元素后,判断对应的Node结构是否需要改变结构,如果需要则调用treeifyBin()方法将Node链表升级为红黑树结构;
- 最后,调用addCount()方法记录table中元素的数量。
整个具体过程这里用图来帮助大家理解:
这里就可以看出JDK1.8中保证线程安全用的方法为乐观锁(CAS)+Sysnchronized。
多线程并发向同一个散列桶(Node数组中的每一个node对象)添加元素时若散列桶为空:则触发乐观锁机制,线程获取散列桶中的版本号,在添加元素之前判断线程中的版本号与桶中的版本号是否一致,一直则插入新结点,不一致则再次CAS尝试插入。若散列桶不为空: 则使用synchroinized进行加锁(锁的对象就是当前链表头结点),然后再进行插入操作。
并且在ConcurrentHashMap中,Unsafe.compareAndSwapXXX 方法使用的及其频繁,这类方法是利用一个CAS算法实现无锁化的修改值操作,可以大大减少使用加锁造成的性能消耗。这个算法的基本思想就是不断比较当前内存中的变量值和你预期变量值是否相等,如果相等,则接受修改的值,否则拒绝你的而操作。因为当前线程中的值已经不是最新的值,你的修改很可能会覆盖掉其他线程修改的结果。
为什么不用ReentrantLock而用synchronized ?
减少内存开销: 如果使用ReentrantLock则需要节点继承AQS来获得同步支持,增加内存开销,而1.8中只有头节点需要进行同步。
内部优化: synchronized则是JVM直接支持的,JVM能够在运行时作出相应的优化措施:锁粗化、锁消除、锁自旋等等。
size
JDK1.8的ConcurrentHashMap中保存元素的个数的记录方法也有不同,首先在添加和删除元素时,会通过CAS操作更新ConcurrentHashMap的baseCount属性值来统计元素个数。但是CAS操作可能会失败,因此,ConcurrentHashMap又定义了一个CounterCell数组来记录CAS操作失败时的元素个数。因此,ConcurrentHashMap中元素的个数则通过如下方式获得:
元素总数 = baseCount + sum(CounterCell)
final long sumCount() {
CounterCell[] as = counterCells; CounterCell a;
long sum = baseCount;
if (as != null) {
for (int i = 0; i < as.length; ++i) {
if ((a = as[i]) != null)
sum += a.value;
}
}
return sum;
}
而JDK1.8中提供了两种方法获取ConcurrentHashMap中的元素个数。
public int size() {
long n = sumCount();
return ((n < 0L) ? 0 :
(n > (long)Integer.MAX_VALUE) ? Integer.MAX_VALUE :
(int)n);
}
public long mappingCount() {
long n = sumCount();
return (n < 0L) ? 0L : n; // ignore transient negative values
}
如代码所示,size只能获取int范围内的ConcurrentHashMap元素个数;而如果hash表中的数据过多,超过了int类型的最大值,则推荐使用mappingCount()方法获取其元素个数。
ConcurrentHashMap的扩容原理
扩容方法 transfer():
ConcurrentHashMap 为了减少扩容带来的时间影响,在扩容过程中没有进行加锁,并且支持多线程进行扩容操作。在扩容过程中主要使用 sizeCtl 和 transferIndex 这两个属性来协调多线程之间的并发操作,并且在扩容过程中大部分数据可以做到访问不阻塞,整个扩容操作分为以下几个步骤:
-
根据 CPU 核数和数组长度,计算每个线程应该处理的桶数量,如果CPU为单核,则使用一个线程处理所有桶
-
根据当前数组长度n,新建一个两倍长度的数组 nextTable(该这个步骤是单线程执行的)
-
将原来 table 中的元素复制到 nextTable 中,这里允许多线程进行操作,具体操作步骤如下:
-
初始化 ForwardingNode 对象,充当占位节点,hash 值为 -1,该占位对象存在时表示集合正在扩容状态。
ForwardingNode 的 key、value、next 属性均为 null ,nextTable 属性指向扩容后的数组,它的作用主要有以下两个:
- 占位作用,用于标识数组该位置的桶已经迁移完毕
- 作为一个转发的作用,扩容期间如果遇到查询操作,遇到转发节点,会把该查询操作转发到新的数组上去,不会阻塞查询操作。
-
通过 for 循环从右往左依次迁移当前线程所负责数组:
-
① 如果当前桶没有元素,则直接通过 CAS 放置一个 ForwardingNode 占位对象,以便查询操作的转发和标识当前位置已经被处理过。
② 如果线程遍历到节点的 hash 值为 MOVE,也就是 -1(即 ForwardingNode 节点),则直接跳过,继续处理下一个桶中的节点
③ 如果不满足上面两种情况,则直接给当前桶节点加上 synchronized 锁,然后重新计算该桶的元素在新数组中的应该存放的位置,并进行数据迁移。重计算节点的存放位置时,通过 CAS 把低位节点 lowNode 设置到新数组的 i 位置,高位节点 highNode 设置到 i+n 的位置(i 表示在原数组的位置,n表示原数组的长度)
如果数组中的节点是链表结构,则顺序遍历链表并使用头插法进行构造新链表
如果数组中的节点是红黑树结构,则for循环以链表方式遍历整棵红黑树,使用尾插法拼接
④ 当前桶位置的数据迁移完成后,将 ForwardingNode 占位符对象设置到当前桶位置上,表示该位置已经被处理了
- 每当一条线程扩容结束就会更新一次 sizeCtl 的值,进行减 1 操作,当最后一条线程扩容结束后,需要重新检查一遍数组,防止有遗漏未成功迁移的桶。扩容结束后,将 nextTable 设置为 null,表示扩容已结束,将 table 指向新数组,sizeCtl 设置为扩容阈值。
sizeCtl:是一个控制标识符,在不同的地方有不同用途,它不同的取值不同也代表不同的含义。在扩容时,它代表的是当前并发扩容的线程数量
- 负数代表正在进行初始化或扩容操作:-1代表正在初始化,-N 表示有N-1个线程正在进行扩容操作
- 正数或0代表hash表还没有被初始化或下一次进行扩容的大小,这一点类似于扩容阈值的概念。
下面为transfer方法源代码:
private final void transfer(Node<K,V>[] tab, Node<K,V>[] nextTab) {
//计算stride,与运行环境CPU个数有关,至少是16
int n = tab.length, stride;
if ((stride = (NCPU > 1) ? (n >>> 3) / NCPU : n) < MIN_TRANSFER_STRIDE)
stride = MIN_TRANSFER_STRIDE; // subdivide range
//初始化新哈希数组,为旧数组长度2倍
if (nextTab == null) { // initiating
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 = nextTab;
transferIndex = n;
}
int nextn = nextTab.length;
//旧数组索引位置在rehash过程中,头节点都会被设置为该ForwardingNode
//表示该索引位置有线程在rehash,保证在多线程场景下,其他线程遍历到
//该索引位置直接跳过
ForwardingNode<K,V> fwd = new ForwardingNode<K,V>(nextTab);
boolean advance = true;
boolean finishing = false; // to ensure sweep before committing nextTab
for (int i = 0, bound = 0;;) {
Node<K,V> f; int fh;
//while循环用于获取旧哈希数组中的每个索引,依次从tab.length-1...到索引0.
while (advance) {
int nextIndex, nextBound;
if (--i >= bound || finishing)
advance = false;
else if ((nextIndex = transferIndex) <= 0) {
i = -1;
advance = false;
}
else if (U.compareAndSwapInt
(this, TRANSFERINDEX, nextIndex,
nextBound = (nextIndex > stride ?
nextIndex - stride : 0))) {
bound = nextBound;
i = nextIndex - 1;
advance = false;
}
}
//遍历完,rehash结束
if (i < 0 || i >= n || i + n >= nextn) {
int sc;
if (finishing) {
nextTable = null;
table = nextTab;
sizeCtl = (n << 1) - (n >>> 1);
return;
}
if (U.compareAndSwapInt(this, SIZECTL, sc = sizeCtl, sc - 1)) {
if ((sc - 2) != resizeStamp(n) << RESIZE_STAMP_SHIFT)
return;
finishing = advance = true;
i = n; // recheck before commit
}
}
//索引位置头节点为null,直接将旧哈希表该索引头节点设置为fwd
else if ((f = tabAt(tab, i)) == null)
advance = casTabAt(tab, i, null, fwd);
else if ((fh = f.hash) == MOVED)
advance = true; // already processed
else {
//每个索引位置只能有一个线程进行rehash,保证线程安全,需要对头节点加锁
synchronized (f) {
if (tabAt(tab, i) == f) {
Node<K,V> ln, hn;
if (fh >= 0) {
int runBit = fh & n;
Node<K,V> lastRun = f;
for (Node<K,V> p = f.next; p != null; p = p.next) {
int b = p.hash & n;
if (b != runBit) {
runBit = b;
lastRun = p;
}
}
if (runBit == 0) {
ln = lastRun;
hn = null;
}
else {
hn = lastRun;
ln = null;
}
for (Node<K,V> p = f; p != lastRun; p = p.next) {
int ph = p.hash; K pk = p.key; V pv = p.val;
if ((ph & n) == 0)
ln = new Node<K,V>(ph, pk, pv, ln);
else
hn = new Node<K,V>(ph, pk, pv, hn);
}
setTabAt(nextTab, i, ln);
setTabAt(nextTab, i + n, hn);
setTabAt(tab, i, fwd);
advance = true;
}
else if (f instanceof TreeBin) {
TreeBin<K,V> t = (TreeBin<K,V>)f;
TreeNode<K,V> lo = null, loTail = null;
TreeNode<K,V> hi = null, hiTail = null;
int lc = 0, hc = 0;
for (Node<K,V> e = t.first; e != null; e = e.next) {
int h = e.hash;
TreeNode<K,V> p = new TreeNode<K,V>
(h, e.key, e.val, null, null);
if ((h & n) == 0) {
if ((p.prev = loTail) == null)
lo = p;
else
loTail.next = p;
loTail = p;
++lc;
}
else {
if ((p.prev = hiTail) == null)
hi = p;
else
hiTail.next = p;
hiTail = p;
++hc;
}
}
ln = (lc <= UNTREEIFY_THRESHOLD) ? untreeify(lo) :
(hc != 0) ? new TreeBin<K,V>(lo) : t;
hn = (hc <= UNTREEIFY_THRESHOLD) ? untreeify(hi) :
(lc != 0) ? new TreeBin<K,V>(hi) : t;
setTabAt(nextTab, i, ln);
setTabAt(nextTab, i + n, hn);
setTabAt(tab, i, fwd);
advance = true;
}
}
}
}
}
}
put() 方法的 helpTransfer() 协助扩容:
put() 在多线程情况下可能有以下两种情况:
如果检测到 ConcurrentHashMap 正在进行扩容操作,也就是当前桶位置上被插入了 ForwardingNode 节点,那么当前线程也要协助进行扩容,协助扩容时会调用 helpTransfer() 方法,当方法被调用的时候,当前 ConcurrentHashMap 一定已经有了 nextTable 对象,首先拿到这个 nextTable 对象,调用transfer方法。
如果检测到要插入的节点是非空且不是 ForwardingNode 节点,就对这个节点加锁,这样就保证了线程安全。尽管这个有一些影响效率,但是还是会比hashTable 的 synchronized 要好得多。
扩容具体过程也用一张图来帮助理解:
![image-20221102201543906](https://xjxdeimg.oss-cn-beijing.aliyuncs.com/img/typora/202211022015985.png)
![image-20221102201613935](https://xjxdeimg.oss-cn-beijing.aliyuncs.com/img/typora/202211022016999.png)
![image-20221102201634613](https://xjxdeimg.oss-cn-beijing.aliyuncs.com/img/typora/202211022016668.png)
最后我们比较一下两个版本的区别。
ConcurrentHashMap 在 JDK7 和 JDK8的区别
- 数据结构: JDK7 的数据结构是 Segment数组 + HashEntry数组 + 链表,JDK8 的数据结构是 HashEntry数组 + 链表 + 红黑树,当链表的长度超过8时,链表就会转换成红黑树,从而降低时间复杂度(由O(n) 变成了 O(logN)),提高了效率
- 锁的实现: JDK7的锁是segment,是基于ReentronLock实现的,包含多个HashEntry;而JDK8 降低了锁的粒度,采用 table 数组元素作为锁,从而实现对每行数据进行加锁,进一步减少并发冲突的概率,并使用 synchronized 来代替 ReentrantLock,因为在低粒度的加锁方式中,synchronized 并不比 ReentrantLock 差,在粗粒度加锁中ReentrantLock 可以通过 Condition 来控制各个低粒度的边界,更加的灵活,而在低粒度中,Condition的优势就没有了。
- 统计集合中元素个数 size 的方式: JDK7 是先尝试 2次通过不锁住 segment 的方式来统计各个 segment 大小,如果统计的过程中,容器的 count 发生了变化,则再采用加锁的方式来统计所有Segment的大小;在 JDK8 中,对于size的计算,在扩容和 addCount() 方法中就已经有处理了,等到调用 size() 时直接返回元素的个数
ConcurrentHashMap扩容机制
jdk1.7之前:每个segment对象进行扩容
每个segment对象都相当于一个小型的HashMap
每个segment内容会进行扩容,逻辑和hashmap扩容机制类似
先生成新的数组,然后讲旧数组元素转移到新的数组种
扩容判断也是每个segment内部单独判断,判断是否超过阈值
jdk1.8之后:不再基于segment对象实现
当某个线程put操作时,如果发现CocurrentHashMap正在扩容那么该线程一起扩容。
当某个线程put操作,发现没有进行扩容,则将key-value添加到ConcurrentHashMap种,超过阈值再进行扩容。
支持多线程扩容
扩容之前也生成新的数组
在转移元素之前,将数组分成多组,依靠多线程完成数组转移操作。
感谢耐心看到这里的同学,觉得文章对您有帮助的话希望同学们不要吝啬您手中的赞,动动您智慧的小手,您的认可就是我创作的动力!
之后还会勤更自己的学习笔记,感兴趣的朋友点点关注哦。