深入浅出解剖 ConcurrentHashMap进化史

621 阅读18分钟

一、学习目标

1、为什么 HashTable 容器效率低

2、JDK 1.7 的 ConcurrentHashMap 如何实现线程安全操作

3、JDK 1.8 的 ConcurrentHashMap 与 JDK 1.7 的 ConcurrentHashMap 的异同,以及如何实现线程安全操作

二、效率低下的 HashTable 容器

HashTable 容器使用 synchronized 来保证线程安全,但在线程竞争激烈的情况下 HashTable 的效率非常低下。

因为HashTable(同一把锁)使用的是全局锁,当一个线程访问 HashTable 的同步方法时,其他线程访问 HashTable 的同步方法时,可能会进入阻塞或轮询状态。如线程 1 使用 put 进行添加元素,线程 2 不但不能使用 put 方法添加元素,并且也不能使用 get 方法来获取元素,所以竞争越激烈效率越低。

image

三、JDK 1.7 的 ConcurrentHashMap

1、ConcurrentHashMap 的锁分段技术

HashTable 容器在竞争激烈的并发环境下表现出效率低下的原因是所有访问 HashTable 的线程都必须竞争同一把锁。

那假如容器里有多把锁,每一把锁用于锁容器其中一部分数据,那么当多线程访问容器里不同数据段的数据时,线程间就不会存在锁竞争,从而可以有效的提高并发访问效率,这就是 ConcurrentHashMap 所使用的锁分段技术。

锁分段核心在于:首先将数据分成一段一段的存储,然后给每一段数据配一把锁,当一个线程占用锁访问其中一个段数据的时候,其他段的数据也能被其他线程访问。

2、ConcurrentHashMap 的结构

image

ConcurrentHashMap重要属性:

  • segmentMask:段掩码,和segmentShift配合使用定位Segment
  • segmentShift:段偏移量,和segmentMask配合使用定位Segment
  • segments:Segment[] 数组,由一个个 Segment (数组 + 链表结构,也就是哈希表)组成
  • RETRIES_BEFORE_LOCK:在采用加锁方法之前,最多尝试的次数 2次,在ConcurrentHashMap#size()和ConcurrentHashMap#containsValue()中使用

ConcurrentHashMap 是由 Segment[] 数组,而 Segment 是由 HashEntry[] 数组组成

  • Segment:是一种可重入锁 ReentrantLock,在 ConcurrentHashMap 里扮演锁的角色
  • HashEntry:则用于存储键值对数据

Segment 的结构和 HashMap 类似,是一种数组 + 链表结构,也就是哈希表。对应源码就是 数组(table),链表(HashEntry)。当对 HashEntry[] 数组的数据进行修改时,必须首先获得它对应的 Segment 锁。

static final class Segment<K,V> extends ReentrantLock implements Serializable {

	// 存放数据的数组
	transient volatile HashEntry<K,V>[] table;

	// table数组容量
	transient int count;

	// 段被修改的次数(如执行put或者remove)
	transient int modCount;

	// 进行扩容的阀值
	transient int threshold;

	// 负载因子
	final float loadFactor;

	Segment(float lf, int threshold, HashEntry<K,V>[] tab) {
		this.loadFactor = lf;
		this.threshold = threshold;
		this.table = tab;
	}
}

HashEntry 是一个链表,这里value和next用volatile修饰保证了可见性

static final class HashEntry<K,V> {
	final int hash;
	final K key;
	volatile V value;
	volatile HashEntry<K,V> next;

	HashEntry(int hash, K key, V value, HashEntry<K,V> next) {
		this.hash = hash;
		this.key = key;
		this.value = value;
		this.next = next;
	}
}

3、ConcurrentHashMap 的初始化

构造方法预览:

image

image

从源码上可以看出,ConcurrentHashMap 初始化方法默认使用以下三个参数:

  • initialCapacity(初始化容量)为 16
  • loadFactor(负载因子)为 0.75
  • concurrentLevel(并发等级)为 16

计算 segments 数组的长度 ssize:

image

从源码上可以看出:

  • segments 数组的长度 ssize 通过 concurrencyLevel 计算得出,默认情况下长度为16。为了能通过按位与的哈希算法来定位 segments 数组的索引 ,必须保证 segments 数组的长度是 2 的 N 次方,所以 ssize 一定大于等于 concurrentLevel 的最小的 2 的次幂,锁永远都是有多的。
  • 默认情况下:
    • segmentMask:段掩码,默认情况下为 15
    • segmentShift:段偏移量,默认情况下为 28
    • ssize:segments 数组的长度,默认情况下为 16

4、ConcurrentHashMap 的 put 操作

先看看例子:

AtomicLong atomicLong = new AtomicLong();

Map<String, String> map = new ConcurrentHashMap<>();

// 创建大小固定为 5 的线程池。定长线程池的大小最好根据系统资源进行设置。如Runtime.getRuntime().availableProcessors()
ExecutorService fixedThreadPool = Executors.newFixedThreadPool(10);

for (int i = 0; i < 8; i++) {
    fixedThreadPool.execute(new Runnable() {
        @Override
        public void run() {
            String s = RandomStringUtils.randomAlphanumeric(8);
            String v1 = map.put("k1", s);
            System.out.println("第" + atomicLong.incrementAndGet() + "次, s=" + s + ", 线程="+ Thread.currentThread().getName() + ", 打印v1=" + v1);
        }
    });
}

运行结果:

image

可以看到第一次,线程3 put 值为 cpkSeFpP,返回 null,第二次,线程5 put 值为 cpkSeFpP,返回的是第一次线程3 put 进去的值。

现在再看看源码

image

在上图第2步,对元素的 hashCode 进行一次再哈希。之所以进行再哈希,其目的是为了减少哈希冲突,使元素能够均匀的分布在不同的 Segment 上,从而提高容器的存取效率。 假如哈希的质量差到极点,那么所有的元素都在一个 Segment 中,不仅存取元素缓慢,分段锁也会失去意义。

在上图第4步,调用 ConcurrentHashMap#ensureSegment(),定位 Segment 并确保定位的 Segment 已初始化

// 根据索引去segments数组中获取Segment,如果已经存在了则返回,否则创建并自旋插入
private Segment<K,V> ensureSegment(int k) {
	final Segment<K,V>[] ss = this.segments;
	long u = (k << SSHIFT) + SBASE; // raw offset
	Segment<K,V> seg;
	// 该索引处还没有Segment
	if ((seg = (Segment<K,V>)UNSAFE.getObjectVolatile(ss, u)) == null) {
		// 这里能直接赋值的原因是ss[0]在构造函数中已经初始化了
		Segment<K,V> proto = ss[0]; // use segment 0 as prototype
		int cap = proto.table.length;
		float lf = proto.loadFactor;
		int threshold = (int)(cap * lf);
		HashEntry<K,V>[] tab = (HashEntry<K,V>[])new HashEntry[cap];
		// 再次检查
		if ((seg = (Segment<K,V>)UNSAFE.getObjectVolatile(ss, u))
			== null) { // recheck
			Segment<K,V> s = new Segment<K,V>(lf, threshold, tab);
			// 自旋插入,成功则退出
			while ((seg = (Segment<K,V>)UNSAFE.getObjectVolatile(ss, u))
				   == null) {
				if (UNSAFE.compareAndSwapObject(ss, u, null, seg = s))
					break;
			}
		}
	}
	return seg;
}

真正插入元素是在 ConcurrentHashMap.Segment#put() 方法里,因为需要对共享变量进行写入操作,所以为了线程安全,在操作共享变量时必须得加锁。源码如下:

image

在上图第1步,假设有两个线程A、B同时操作ConcurrentHashMap.Segment#put(),线程A尝试获取锁,获取锁成功,返回 node = null,然后线程A对这个node进行赋值操作,线程B获取锁失败,那么回调用 ConcurrentHashMap.Segment#scanAndLockForPut(),不断自旋获取锁,没有获取到,当前线程就阻塞,当线程A操作完释放锁之后,线程B获取到线程A 刚刚put的最新的节点数据(结合上面例子进行理解)。

ConcurrentHashMap.Segment#scanAndLockForPut(),其中while(!tryLock())类似于自旋锁的功能,循环式的判断对象锁是否能够被成功获取,直到获取到锁才会退出循环,防止执行 put 操作的线程频繁阻塞,这些优化都提升了 put 操作的性能。

image

顺便一提,扩容机制:

  • 是否需要扩容:put 操作在插入元素前会先判断 Segment 里的 HashEntry 数组是否超过容量(threshold),如果超过阀值,数组进行扩容。Segment 的扩容判断比 HashMap 更恰当,因为 HashMap 是在插入元素后判断元素是否已经到达容量的,如果到达了就进行扩容,但是很有可能扩容之后没有新元素插入,这时 HashMap 就进行了一次无效的扩容。
  • 如何扩容:扩容的时候首先会创建一个两倍于原容量的数组,然后将原数组里的元素进行再 hash 后插入到新的数组里。为了高效 ConcurrentHashMap 不会对整个容器进行扩容,而只对某个 segment 进行扩容。

5、ConcurrentHashMap 的 get 操作

image

我们知道 HashTable 容器的 get 方法是需要加锁的,那么 ConcurrentHashMap 的 get 操作是如何做到不加锁的呢?

  • 原因:ConcurrentHashMap#get() 将要使用的共享变量都定义成 volatile,定义成 volatile 的变量,能够保证在线程之间保持可见性,能够被多线程同时读,并且不会读到过期的值,但是只能被单线程写(有一种情况可以被多线程写,就是写入的值不依赖于原值)。
  • volatile原理:根据 java 内存模型的 happen before 原则,对 volatile 字段的写入操作先于读操作,即使两个线程同时修改和获取 volatile 变量,get 操作也能拿到最新的值,这是用 volatile 替换锁的经典应用场景。

6、ConcurrentHashMap 的 size 操作

如果我们要统计整个 ConcurrentHashMap 里元素的大小,就必须统计所有 Segment 里元素的大小后求和。

Segment 里的全局变量 count 是一个 volatile 变量,那么在多线程场景下,我们是不是直接把所有 Segment 的 count 相加就可以得到整个 ConcurrentHashMap 大小了呢?不是的,虽然相加时可以获取每个 Segment 的 count 的最新值,但是拿到之后可能累加前使用的 count 发生了变化,那么统计结果就不准了。

所以最安全的做法,是在统计 size 的时候把所有 Segment 的 put,remove 和 clean 方法全部锁住,但是这种做法显然非常低效。 因为在累加 count 操作过程中,之前累加过的 count 发生变化的几率非常小,所以 ConcurrentHashMap 的做法是先尝试 2 次通过不锁住 Segment 的方式来统计各个 Segment 大小,如果统计的过程中,容器的 count 发生了变化,则再采用加锁的方式来统计所有 Segment 的大小。

那么 ConcurrentHashMap 是如何判断在统计的时候容器是否发生了变化呢?使用 modCount 变量,在 put , remove 和 clean 方法里操作元素前都会将变量 modCount 进行加 1,那么在统计 size 前后比较 modCount 是否发生变化,从而得知容器的大小是否发生变化。

7、ConcurrentHashMap 的 remove 操作

remove 操作和 put 方法差不多,都需要获取对象锁才能操作,通过 key 找到元素所在的 Segment 对象然后移除,源码如下:

image

image

四、JDK 1.8 的 ConcurrentHashMap

JDK 1.7 的 ConcurrentHashMap 的缺点:解决了 HashMap 并发的安全性,但是当冲突的链表过长时,在查询遍历的时候依然很慢。

JDK 1.8 的 ConcurrentHashMap 的优化:采用跟 JDK 1.8 的 HashMap 一样的数据结构,数组 + 链表/红黑树,抛弃了原有的 Segment 分段锁实现,采用了 CAS + synchronized 来保证并发的安全性。

JDK1.8 中的 ConcurrentHashMap 对节点Node类中的共享变量,和 JDK1.7 一样,使用volatile关键字,保证多线程操作时,变量的可见性。

1、ConcurrentHashMap 的 put 操作

image

当进行 put 操作时,流程大概可以分如下几个步骤:

  • 首先会判断 key、value 是否为空,如果为空就抛异常,所以ConcurrentHashMap的kv任何一个都不能为null;
  • 判断容器数组是否为空,如果为空就初始化数组;
  • 判断要插入的元素f,在当前数组下标是否第一次插入,如果是就通过 CAS 方式插入;
  • 判断f.hash == -1是否成立,如果成立,说明当前f是ForwardingNode节点,表示有其它线程正在扩容,则一起进行扩容操作;
  • 其他的情况,就是把新的Node节点按链表或红黑树的方式插入到合适的位置;
  • 节点插入完成之后,接着判断链表长度是否超过8,如果超过8个,就将链表转化为红黑树结构;
  • 最后,插入完成之后,进行扩容判断;

put 操作大致的流程,就是这样的,可以看的出,复杂程度比 JDK1.7 上了一个台阶。

2、ConcurrentHashMap 的 initTable 初始化数组操作

image

sizeCtl 是一个对象属性,使用了 volatile 关键字修饰保证并发的可见性,默认为 0。

当第一次执行 put 操作时,通过Unsafe.compareAndSwapInt()方法,俗称CAS,将 sizeCtl修改为 -1,有且只有一个线程能够修改成功,接着执行 table 初始化任务。

如果别的线程发现sizeCtl<0,意味着有另外的线程执行 CAS 操作成功,当前线程通过执行Thread.yield()让出 CPU 时间片等待 table 初始化完成。

3、ConcurrentHashMap 的 helpTransfer 帮组扩容

我们继续来看看 put 方法中第 5 步helpTransfer()方法,如果f.hash == -1成立,说明当前f是ForwardingNode节点,意味有其它线程正在扩容,则一起进行扩容操作,源码如下:

image

private transient volatile int sizeCtl; 是一个对象属性,可以理解为扩容指标。默认为0,当 sizeCtl 为 -1 表示 ConcurrentHashMap 正在初始化或者扩容。计算规则:- (1 + 扩容的线程数)

这个过程,操作步骤如下:

  • 第 1 步,对 table、node 节点、node 节点的 nextTable,进行数据校验;
  • 第 2 步,根据数组的 length 得到一个标识符号;
  • 第 3 步,进一步校验 nextTab、tab、sizeCtl 值,如果 nextTab 没有被并发修改并且 tab 也没有被并发修改,同时 sizeCtl < 0,说明还在扩容;
  • 第 4 步,对 sizeCtl 参数值进行分析判断,如果不满足任何一个判断,将sizeCtl + 1, 调用Unsafe#compareAndSwapInt()增加了一个线程帮助其扩容;

4、ConcurrentHashMap 的 addCount 扩容判断

image

这个过程,操作步骤如下:

  • 第 1 步,利用 CAS 将方法更新 baseCount 的值
  • 第 2 步,检查是否需要扩容,默认 check = 1,需要检查;
  • 第 3 步,如果满足扩容条件,判断当前是否正在扩容,如果是正在扩容就一起扩容;
  • 第 4 步,如果不在扩容,将 sizeCtl 更新为负数,并进行扩容处理;

5、ConcurrentHashMap 的 get 操作

image

JDK 1.8 ConcurrentHashMap#get() 跟 JDK 1.7 ConcurrentHashMap#get() 的 get 操作不加锁的于原理是一样的

  • 原因:ConcurrentHashMap#get() 将要使用的共享变量都定义成 volatile,定义成 volatile 的变量,能够保证在线程之间保持可见性,能够被多线程同时读,并且不会读到过期的值,但是只能被单线程写(有一种情况可以被多线程写,就是写入的值不依赖于原值)。
  • volatile原理:根据 java 内存模型的 happen before 原则,对 volatile 字段的写入操作先于读操作,即使两个线程同时修改和获取 volatile 变量,get 操作也能拿到最新的值,这是用 volatile 替换锁的经典应用场景。

从源码中可以看出,步骤如下:

  • 第 1 步,判断数组是否为空,通过 key 定位到数组下标是否为空;
  • 第 2 步,判断 node 节点第一个元素是不是要找到,如果是直接返回;
  • 第 3 步,如果是红黑树结构,就从红黑树里面查询;
  • 第 4 步,如果是链表结构,循环遍历判断;

五、总结

1、为什么 HashTable 容器效率低

  • 原因:使用 synchronized 来保证线程安全,但是 HashTable(同一把锁)使用的是全局锁。如线程 1 使用 put 进行添加元素,线程 2 不但不能使用 put 方法添加元素,并且也不能使用 get 方法来获取元素,所以竞争越激烈效率越低。

2、JDK 1.7 的 ConcurrentHashMap

ConcurrentHashMap 的锁分段技术:

  • 首先将数据分成一段一段的存储,然后给每一段数据配一把锁,当一个线程占用锁访问其中一个段数据的时候,其他段的数据也能被其他线程访问。

ConcurrentHashMap 的结构:

  • 由 Segment[] 数组 组成,而 Segment 是由 HashEntry[] 数组组成

Segment 的结构:

  • 由 HashEntry[] 数组组成,是一种可重入锁 ReentrantLock,扮演锁的角色。结构和 HashMap 类似,是一种数组 + 链表结构,也就是哈希表。对应源码就是 数组(table),链表(HashEntry)。当对 HashEntry[] 数组的数据进行修改时,必须首先获得它对应的 Segment 锁。

HashEntry 的结构:

  • 是链表结构,用于存储键值对数据

ConcurrentHashMap 的初始化:

  • 为了能通过按位与的哈希算法来定位 segments 数组的索引 ,必须保证 segments 数组的长度是 2 的 N 次方,所以 ssize 一定大于等于 concurrentLevel 的最小的 2 的次幂,锁永远都是有多的。
  • 默认情况下:
    • segmentMask:段掩码,默认情况下为 15
    • segmentShift:段偏移量,默认情况下为 28
    • ssize:segments 数组的长度,默认情况下为 16。通过 concurrencyLevel(并发等级)计算得出。

ConcurrentHashMap 的 put 操作:

  • 首先,对 value 进行判空,ConcurrentHashMap 不允许 value 为空
  • 然后,重新对 key 重新计算hash值,之所以进行再哈希,其目的是为了减少哈希冲突,使元素能够均匀的分布在不同的 Segment 上,从而提高容器的存取效率。 假如哈希的质量差到极点,那么所有的元素都在一个 Segment 中,不仅存取元素缓慢,分段锁也会失去意义。
  • 然后,定位 Segment 并确保定位的 Segment 已初始化
  • 最后,调用ConcurrentHashMap.Segment#put(),因为需要对共享变量进行写入操作,所以为了线程安全,在操作共享变量时必须得加锁。
    • 假设有两个线程A、B同时操作ConcurrentHashMap.Segment#put(),线程A尝试获取锁,获取锁成功,返回 node = null,然后线程A对这个node进行赋值操作,线程B获取锁失败,那么回调用 ConcurrentHashMap.Segment#scanAndLockForPut(),不断自旋获取锁,没有获取到,当前线程就阻塞,直到获取之后退出循环,当线程A操作完释放锁之后,线程B获取到线程A 刚刚put的最新的节点数据(结合上面例子进行理解)。

ConcurrentHashMap 的扩容机制:

  • 是否需要扩容:put 操作在插入元素前会先判断 Segment 里的 HashEntry 数组是否超过容量(threshold),如果超过阀值,数组进行扩容。Segment 的扩容判断比 HashMap 更恰当,因为 HashMap 是在插入元素后,再判断元素是否已经到达容量的,如果到达了就进行扩容,但是很有可能扩容之后没有新元素插入,这时 HashMap 就进行了一次无效的扩容。
  • 如何扩容:扩容的时候首先会创建一个两倍于原容量的数组,然后将原数组里的元素进行再 hash 后插入到新的数组里。为了高效 ConcurrentHashMap 不会对整个容器进行扩容,而只对某个 segment 进行扩容。

ConcurrentHashMap 的 get 操作:

我们知道 HashTable 容器的 get 方法是需要加锁的,那么 ConcurrentHashMap 的 get 操作是如何做到不加锁的呢?

  • 原因:ConcurrentHashMap#get() 将要使用的共享变量都定义成 volatile,定义成 volatile 的变量,能够保证在线程之间保持可见性,能够被多线程同时读,并且不会读到过期的值,但是只能被单线程写(有一种情况可以被多线程写,就是写入的值不依赖于原值)。
  • volatile原理:根据 java 内存模型的 happen before 原则,对 volatile 字段的写入操作先于读操作,即使两个线程同时修改和获取 volatile 变量,get 操作也能拿到最新的值,这是用 volatile 替换锁的经典应用场景。

ConcurrentHashMap 的 size 操作:

如果我们要统计整个 ConcurrentHashMap 里元素的大小,就必须统计所有 Segment 里元素的大小后求和。

Segment 里的全局变量 count 是一个 volatile 变量,那么在多线程场景下,我们是不是直接把所有 Segment 的 count 相加就可以得到整个 ConcurrentHashMap 大小了呢?不是的,虽然相加时可以获取每个 Segment 的 count 的最新值,但是拿到之后可能累加前使用的 count 发生了变化,那么统计结果就不准了。

所以最安全的做法,是在统计 size 的时候把所有 Segment 的 put,remove 和 clean 方法全部锁住,但是这种做法显然非常低效。 因为在累加 count 操作过程中,之前累加过的 count 发生变化的几率非常小,所以 ConcurrentHashMap 的做法是先尝试 2 次通过不锁住 Segment 的方式来统计各个 Segment 大小,如果统计的过程中,容器的 count 发生了变化,则再采用加锁的方式来统计所有 Segment 的大小。

那么 ConcurrentHashMap 是如何判断在统计的时候容器是否发生了变化呢?使用 modCount 变量,在 put , remove 和 clean 方法里操作元素前都会将变量 modCount 进行加 1,那么在统计 size 前后比较 modCount 是否发生变化,从而得知容器的大小是否发生变化。

3、JDK 1.8 的 ConcurrentHashMap

JDK 1.7 的 ConcurrentHashMap 的缺点:

  • 解决了 HashMap 并发的安全性,但是当冲突的链表过长时,在查询遍历的时候依然很慢。

JDK 1.8 的 ConcurrentHashMap 的优化:

  • 数据结构优化:采用跟 JDK 1.8 的 HashMap 一样的数据结构,数组 + 链表/红黑树
  • 锁的重新实现:抛弃了原有的 Segment 分段锁实现,采用了 CAS + synchronized 来保证并发的安全性

JDK1.8 中的 ConcurrentHashMap 对节点Node类中的共享变量,和 JDK1.7 一样,使用volatile关键字,保证多线程操作时,变量的可见性。

ConcurrentHashMap 的 put 操作:

  • 首先,对 key、value 进行判空,ConcurrentHashMap的kv任何一个都不能为null
  • 然后,重新对 key 重新计算hash值,之所以进行再哈希,其目的是为了减少哈希冲突,使元素能够均匀的分布在不同的 Segment 上,从而提高容器的存取效率。 假如哈希的质量差到极点,那么所有的元素都在一个 Segment 中,不仅存取元素缓慢,分段锁也会失去意义。
  • 然后,判断容器数组是否为空,如果为空就初始化数组;
  • 然后,在当前数组下标是否第一次插入,如果是就通过 CAS 方式插入(JDK 1.7 是上锁插入);
  • 判断f.hash == -1是否成立,如果成立,说明当前f是ForwardingNode节点,表示有其它线程正在扩容,则一起进行扩容操作;
  • 其他的情况,就是把新的Node节点按链表或红黑树的方式插入到合适的位置;
  • 节点插入完成之后,接着判断链表长度是否超过8,如果超过8个,就将链表转化为红黑树结构;
  • 最后,插入完成之后,进行扩容判断;

ConcurrentHashMap 的 initTable 初始化数组操作:

  • sizeCtl 是一个对象属性,使用了 volatile 关键字修饰保证并发的可见性,默认为 0。
  • 当第一次执行 put 操作时,通过Unsafe.compareAndSwapInt()方法,俗称CAS,将 sizeCtl修改为 -1,有且只有一个线程能够修改成功,接着执行 table 初始化任务。
  • 如果别的线程发现sizeCtl<0,意味着有另外的线程执行 CAS 操作成功,当前线程通过执行Thread.yield()让出 CPU 时间片等待 table 初始化完成。

ConcurrentHashMap 的 helpTransfer 帮组扩容:

  • 关键在于:sizeCtl 是一个对象属性,可以理解为扩容指标。默认为0,当 sizeCtl 为 -1 表示 ConcurrentHashMap 正在初始化或者扩容。计算规则:- (1 + 扩容的线程数)。

这个过程,操作步骤如下:

  • 第 1 步,对 table、node 节点、node 节点的 nextTable,进行数据校验;
  • 第 2 步,根据数组的 length 得到一个标识符号;
  • 第 3 步,进一步校验 nextTab、tab、sizeCtl 值,如果 nextTab 没有被并发修改并且 tab 也没有被并发修改,同时 sizeCtl < 0,说明还在扩容;
  • 第 4 步,对 sizeCtl 参数值进行分析判断,如果不满足任何一个判断,将sizeCtl + 1, 调用Unsafe#compareAndSwapInt()增加了一个线程帮助其扩容;

ConcurrentHashMap 的 addCount 扩容判断:

  • 第 1 步,利用 CAS 将方法更新 baseCount 的值
  • 第 2 步,检查是否需要扩容,默认 check = 1,需要检查;
  • 第 3 步,如果满足扩容条件,判断当前是否正在扩容,如果是正在扩容就一起扩容;
  • 第 4 步,如果不在扩容,将 sizeCtl 更新为负数,并进行扩容处理;

ConcurrentHashMap 的 get 操作:

JDK 1.8 ConcurrentHashMap#get() 跟 JDK 1.7 ConcurrentHashMap#get() 的 get 操作不加锁的于原理是一样的

  • 原因:ConcurrentHashMap#get() 将要使用的共享变量都定义成 volatile,定义成 volatile 的变量,能够保证在线程之间保持可见性,能够被多线程同时读,并且不会读到过期的值,但是只能被单线程写(有一种情况可以被多线程写,就是写入的值不依赖于原值)。
  • volatile原理:根据 java 内存模型的 happen before 原则,对 volatile 字段的写入操作先于读操作,即使两个线程同时修改和获取 volatile 变量,get 操作也能拿到最新的值,这是用 volatile 替换锁的经典应用场景。

六、参考

mp.weixin.qq.com/s?__biz=MzI…

www.infoq.cn/article/Con…

blog.csdn.net/zzti_erlie/…