【Java面试】ConcurrentHashMap再JDK7和8中的区别以及ConcurrentHashMap底层实现

60 篇文章 1 订阅


如果还不了解ConcurrentHashMap的可以看: ConcurrentHashMap概述

ConcurrentHashMap在jdk1.7中的设计

再JDK7中,ConcurrentHashMap使用的是segments+table+链表的结构。
其中对每一个segment进行加锁,那么只要访问的是不同的segment,就可以实现并发访问hashmap的能力了。
每一个segment都是一个HashEntry<K,V>[] table, table中的每一个元素本质上都是一个HashEntry的
单向队列。比如table[3]为首节点,table[3]->next为节点1,之后为节点2,依次类推。
在这里插入图片描述
如果不懂volatile 关键字的作用,可以看:volatile关键字作用

public class ConcurrentHashMap<K, V> extends AbstractMap<K, V>
implements ConcurrentMap<K, V>, Serializable {
	// 将整个hashmap分成几个小的map,每个segment都是一个锁;与hashtable相比,这么设计的目
	的是对于put, remove等操作,可以减少并发冲突,对
	// 不属于同一个片段的节点可以并发操作,大大提高了性能
	final Segment<K,V>[] segments;
	// 本质上Segment类就是一个小的hashmap,里面table数组存储了各个节点的数据,继承了
	ReentrantLock, 可以作为互拆锁使用
	static final class Segment<K,V> extends ReentrantLock implements Serializable
	{
	transient volatile HashEntry<K,V>[] table;
	transient int count;
	}
	// 基本节点,存储Key, Value值
	static final class HashEntry<K,V> {
	final int hash;
	final K key;
	volatile V value;
	volatile HashEntry<K,V> next;
	}
}

在jdk1.8中做的改进

改进一:取消segments字段,直接采用transient volatile HashEntry<K,V>[] table保存数据,采用
table数组元素作为锁,从而实现了对每一行数据进行加锁,进一步减少并发冲突的概率。

改进二:将原先table数组+单向链表的数据结构,变更为table数组+单向链表+红黑树的结构。对于
hash表来说,最核心的能力在于将key hash之后能均匀的分布在数组中。如果hash之后散列的很均
匀,那么table数组中的每个队列长度主要为0或者1。但实际情况并非总是如此理想,虽然
ConcurrentHashMap类默认的加载因子为0.75,但是在数据量过大或者运气不佳的情况下,还是会存
在一些队列长度过长的情况,如果还是采用单向列表方式,那么查询某个节点的时间复杂度为O(n);因此,对于个数超过8(默认值)的列表,jdk1.8中采用了红黑树的结构,那么查询的时间复杂度可以降低到O(logN),可以改进性能。
为了说明以上2个改动,看一下put操作是如何实现的。

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;
// 如果table为空,初始化;否则,根据hash值计算得到数组索引i,如果tab[i]为空,直接新
建节点Node即可。注:tab[i]实质为链表或者红黑树的首节点。
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
}
// 如果tab[i]不为空并且hash值为MOVED,说明该链表正在进行transfer操作,返回扩容完成
后的table。
else if ((fh = f.hash) == MOVED)
tab = helpTransfer(tab, f);
else {
V oldVal = null;
// 针对首个节点进行加锁操作,而不是segment,进一步减少线程冲突
synchronized (f) {
if (tabAt(tab, i) == f) {
if (fh >= 0) {
binCount = 1;
for (Node<K,V> e = f;; ++binCount) {
K ek;
// 如果在链表中找到值为key的节点e,直接设置e.val = value即
可。
if (e.hash == hash &&
((ek = e.key) == key ||
(ek != null && key.equals(ek)))) {
oldVal = e.val;
if (!onlyIfAbsent)
e.val = value;
break;
}
// 如果没有找到值为key的节点,直接新建Node并加入链表即可。
Node<K,V> pred = e;
if ((e = e.next) == null) {
pred.next = new Node<K,V>(hash, key,
value, null);
break;
}
}
}
// 如果首节点为TreeBin类型,说明为红黑树结构,执行putTreeVal操作。
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) {
// 如果节点数>=8,那么转换链表结构为红黑树结构。
if (binCount >= TREEIFY_THRESHOLD)
treeifyBin(tab, i);
if (oldVal != null)
return oldVal;
break;
}
}
}
// 计数增加1,有可能触发transfer操作(扩容)。
addCount(1L, binCount);
return null;
}

另外,在其他方面也有一些小的改进,比如新增字段 transient volatile CounterCell[] counterCells; 可
方便的计算hashmap中所有元素的个数,性能大大优于jdk1.7中的size()方法。

ConcurrentHashMap jdk1.7、jdk1.8性能比较

public class CompareConcurrentHashMap {
private static ConcurrentHashMap<String, Integer> map = new
ConcurrentHashMap<String, Integer>(40000);
public static void putPerformance(int index, int num) {
for (int i = index; i < (num + index) ; i++)
map.put(String.valueOf(i), i);
}
public static void getPerformance2() {
long start = System.currentTimeMillis();
for (int i = 0; i < 400000; i++)
map.get(String.valueOf(i));
long end = System.currentTimeMillis();
System.out.println("get: it costs " + (end - start) + " ms");
}
public static void main(String[] args) throws InterruptedException {
long start = System.currentTimeMillis();
final CountDownLatch cdLatch = new CountDownLatch(4);
for (int i = 0; i < 4; i++) {
final int finalI = i;
new Thread(new Runnable() {
public void run() {
CompareConcurrentHashMap.putPerformance(100000 * finalI,
100000);
cdLatch.countDown();
}
}).start();
}
cdLatch.await();
long end = System.currentTimeMillis();
System.out.println("put: it costs " + (end - start) + " ms");
CompareConcurrentHashMap.getPerformance2();
}
}

程序运行多次后取平均值,结果如下:
在这里插入图片描述

谈谈ConcurrentHashMap1.7和1.8的不同实现

在多线程环境下,使用 HashMap 进行 put 操作时存在丢失数据的情况,为了避免这种bug的隐患,强烈建议使用 ConcurrentHashMap 代替 HashMap ,为了对更深入的了解,本文将对JDK1.7和1.8的不同实现进行分析。

JDK1.7

数据结构
jdk1.7中采用 Segment + HashEntry 的方式进行实现,结构如下:
在这里插入图片描述
ConcurrentHashMap 初始化时,计算出 Segment 数组的大小 ssize 和每个 Segment 中 HashEntry 数
组的大小 cap ,并初始化 Segment 数组的第一个元素;其中 ssize 大小为2的幂次方,默认为16, cap大小也是2的幂次方,最小值为2,最终结果根据根据初始化容量 initialCapacity 进行计算,计算过
程如下:

if (c * ssize < initialCapacity)
	++c;
int cap = MIN_SEGMENT_TABLE_CAPACITY;
while (cap < c)
	cap <<= 1;

其中 Segment 在实现上继承了 ReentrantLock ,这样就自带了锁的功能。

put实现
当执行 put 方法插入数据时,根据key的hash值,在 Segment 数组中找到相应的位置,如果相应位置的Segment 还未初始化,则通过CAS(Compare and Swap,是一个原子操作)进行赋值,接着执行 Segment 对象的 put 方法通过加锁机制插入数据,实现如下:
场景:线程A和线程B同时执行相同 Segment 对象的 put 方法
1、线程A执行 tryLock() 方法成功获取锁,则把 HashEntry 对象插入到相应的位置;
2、线程B获取锁失败,则执行 scanAndLockForPut() 方法,在 scanAndLockForPut 方法中,会通过
重复执行 tryLock() 方法尝试获取锁,在多处理器环境下,重复次数为64,单处理器重复次数为1,当
执行 tryLock() 方法的次数超过上限时,则执行 lock() 方法挂起线程B;
3、当线程A执行完插入操作时,会通过 unlock() 方法释放锁,接着唤醒线程B继续执行;

size实现
因为 ConcurrentHashMap 是可以并发插入数据的,所以在准确计算元素时存在一定的难度,一般的思路是统计每个 Segment 对象中的元素个数,然后进行累加,但是这种方式计算出来的结果并不一样的准确的,因为在计算后面几个 Segment 的元素个数时,已经计算过的 Segment 同时可能有数据的插入或者删除,在1.7的实现中,采用了如下方式:

try {
	for (;;) {
	if (retries++ == RETRIES_BEFORE_LOCK) {
		for (int j = 0; j < segments.length; ++j)
			ensureSegment(j).lock(); // force creation
		}
		sum = 0L;
		size = 0;
		overflow = false;
		for (int j = 0; j < segments.length; ++j) {
		Segment<K,V> seg = segmentAt(segments, j);
		if (seg != null) {
			sum += seg.modCount;
			int c = seg.count;
			if (c < 0 || (size += c) < 0)
				overflow = true;
			}
		}
	if (sum == last)
		break;
		last = sum;
	}
} finally {
	if (retries > RETRIES_BEFORE_LOCK) {
		for (int j = 0; j < segments.length; ++j)
		segmentAt(segments, j).unlock();
		}
	}

先采用不加锁的方式,连续计算元素的个数,最多计算3次:
1、如果前后两次计算结果相同,则说明计算出来的元素个数是准确的;
2、如果前后两次计算结果都不同,则给每个 Segment 进行加锁,再计算一次元素的个数;

JDK1.8

数据结构
1.8中放弃了 Segment 臃肿的设计,取而代之的是采用 Node + CAS + Synchronized (JDK1.6之后优化了锁机制)来保证并发安全进行实现,结构如下:
在这里插入图片描述

只有在执行第一次 put 方法时才会调用 initTable() 初始化 Node 数组,实现如下:

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;
}

put实现
当执行 put 方法插入数据时,根据key的hash值,在 Node 数组中找到相应的位置,实现如下:
1、如果相应位置的 Node 还未初始化,则通过CAS插入相应的数据;

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
}

2、如果相应位置的 Node 不为空,且当前该节点不处于移动状态,则对该节点加 synchronized 锁,如果该节点的 hash 不小于0,则遍历链表更新节点或插入新节点;

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;
		}
	}
}

3、如果该节点是 TreeBin 类型的节点,说明是红黑树结构,则通过 putTreeVal 方法往红黑树中插入
节点;

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;
	}
}

4、如果 binCount 不为0,说明 put 操作对数据产生了影响,如果当前链表的个数达到8个,则通过
treeifyBin 方法转化为红黑树,如果 oldVal 不为空,说明是一次更新操作,没有对元素个数产生影
响,则直接返回旧值;

if (binCount != 0) {
	if (binCount >= TREEIFY_THRESHOLD)
		treeifyBin(tab, i);
if (oldVal != null)
	return oldVal;
break;
}

5、如果插入的是一个新节点,则执行 addCount() 方法尝试更新元素个数 baseCount ;

size实现
1.8中使用一个 volatile 类型的变量 baseCount 记录元素的个数,当插入新数据或则删除数据时,会通
过 addCount() 方法更新 baseCount ,实现如下:

if ((as = counterCells) != null ||
	!U.compareAndSwapLong(this, BASECOUNT, b = baseCount, s = b + x)) {
	CounterCell a; long v; int m;
	boolean uncontended = true;
	if (as == null || (m = as.length - 1) < 0 ||
		(a = as[ThreadLocalRandom.getProbe() & m]) == null ||
		!(uncontended =
		U.compareAndSwapLong(a, CELLVALUE, v = a.value, v + x))) {
		fullAddCount(x, uncontended);
		return;
	}
	if (check <= 1)
		return;
	s = sumCount();
}

1、初始化时 counterCells 为空,在并发量很高时,如果存在两个线程同时执行 CAS 修改 baseCount
值,则失败的线程会继续执行方法体中的逻辑,使用 CounterCell 记录元素个数的变化;
2、如果 CounterCell 数组 counterCells 为空,调用 fullAddCount() 方法进行初始化,并插入对应
的记录数,通过 CAS 设置cellsBusy字段,只有设置成功的线程才能初始化 CounterCell 数组,实现如
下:

else if (cellsBusy == 0 && counterCells == as &&
		U.compareAndSwapInt(this, CELLSBUSY, 0, 1)) {
	boolean init = false;
	try { // Initialize table
		if (counterCells == as) {
			CounterCell[] rs = new CounterCell[2];
			rs[h & 1] = new CounterCell(x);
			counterCells = rs;
			init = true;
		}
	} finally {
		cellsBusy = 0;
	}
	if (init)
		break;
	}

3、如果通过 CAS 设置cellsBusy字段失败的话,则继续尝试通过 CAS 修改 baseCount 字段,如果修改baseCount 字段成功的话,就退出循环,否则继续循环插入 CounterCell 对象;

else if (U.compareAndSwapLong(this, BASECOUNT, v = baseCount, v + x))
	break;

所以在1.8中的 size 实现比1.7简单多,因为元素个数保存 baseCount 中,部分元素的变化个数保存在
CounterCell 数组中,实现如下:

public int size() {
	long n = sumCount();
	return ((n < 0L) ? 0 :
	(n > (long)Integer.MAX_VALUE) ? Integer.MAX_VALUE :
	(int)n);
}
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;
}

通过累加 baseCount 和 CounterCell 数组中的数量,即可得到元素的总个数;

深入分析ConcurrentHashMap1.8的扩容实现

参考此篇
总所周知,ConcurrentHashMap是支持并发的,那么,再并发情况下它是如何安全的实现数组的扩容的呢?

什么情况会触发扩容

当往hashMap中成功插入一个key/value节点时,有可能触发扩容动作:
1、如果新增节点之后,所在链表的元素个数达到了阈值 8,则会调用 treeifyBin 方法把链表转换成红
黑树,不过在结构转换之前,会对数组长度进行判断,实现如下:
在这里插入图片描述
如果数组长度n小于阈值 MIN_TREEIFY_CAPACITY ,默认是64,则会调用 tryPresize 方法把数组长度扩大到原来的两倍,并触发 transfer 方法,重新调整节点的位置。
在这里插入图片描述
2、新增节点之后,会调用 addCount 方法记录元素个数,并检查是否需要进行扩容,当数组元素个数达到阈值时,会触发 transfer 方法,重新调整节点的位置。
在这里插入图片描述
总结,扩容会发生再如下情况时:

  • 新增元素后,某个链表中的元素个数超过8个,需要将链表转换为红黑树,此时如果数组长度小于阈值MIN_TREEIFY_CAPACITY,那么就会进行扩容。
  • 新增元素后,会调用addCount方法,检查是否需要阔用,如果数组元素达到阈值,那么就进行扩容。

transfer实现

transfer 方法实现了在并发的情况下,高效的从原始组数往新数组中移动元素,假设扩容之前节点的
分布如下,这里区分蓝色节点和红色节点,是为了后续更好的分析:
在这里插入图片描述
在上图中,第14个槽位插入新节点之后,链表元素个数已经达到了8,且数组长度为16,优先通过扩容来缓解链表过长的问题,实现如下:
1、根据当前数组长度n,新建一个两倍长度的数组 nextTable ;
在这里插入图片描述
2、初始化 ForwardingNode 节点,其中保存了新数组 nextTable 的引用,在处理完每个槽位的节点之
后当做占位节点,表示该槽位已经处理过了;
在这里插入图片描述
3、通过 for 自循环处理每个槽位中的链表元素,默认 advance 为真,通过CAS设置 transferIndex 属
性值,并初始化 i 和 bound 值, i 指的是当前处理的槽位序号, bound 指需要处理的槽位边界,先处理槽位15的节点;
在这里插入图片描述
4、在当前假设条件下,槽位15中没有节点,则通过CAS插入在第二步中初始化的 ForwardingNode 节
点,用于告诉其它线程该槽位已经处理过了;
在这里插入图片描述
5、如果槽位15已经被线程A处理了,那么线程B处理到这个节点时,取到该节点的hash值应该为
MOVED ,值为 -1 ,则直接跳过,继续处理下一个槽位14的节点;
在这里插入图片描述
6、处理槽位14的节点,是一个链表结构,先定义两个变量节点 ln 和 hn ,按我的理解应该是 lowNode和 highNode ,分别保存hash值的第X位为0和1的节点,具体实现如下:
在这里插入图片描述
使用 fn&n 可以快速把链表中的元素区分成两类,A类是hash值的第X位为0,B类是hash值的第X位为
1,并通过 lastRun 记录最后需要处理的节点,A类和B类节点可以分散到新数组的槽位14和30中(和HashMap一样的新索引位置),在原数组的槽位14中,蓝色节点第X为0,红色节点第X为1,把链表拉平显示如下:
在这里插入图片描述
1、通过遍历链表,记录 runBit 和 lastRun ,分别为1和节点6,所以设置 hn 为节点6, ln 为null;
2、重新遍历链表,以 lastRun 节点为终止条件,根据第X位的值分别构造ln链表和hn链表:
ln链:和原来链表相比,顺序已经不一样了
在这里插入图片描述
hn链:
在这里插入图片描述
通过CAS把ln链表设置到新数组的i位置,hn链表设置到i+n的位置;
7、如果该槽位是红黑树结构,则构造树节点 lo 和 hi ,遍历红黑树中的节点,同样根据 hash&n 算法,把节点分为两类,分别插入到 lo 和 hi 为头的链表中,根据 lo 和 hi 链表中的元素个数分别生成 ln 和hn 节点,其中 ln 节点的生成逻辑如下:
(1)如果 lo 链表的元素个数小于等于 UNTREEIFY_THRESHOLD ,默认为6,则通过 untreeify 方法把树节点链表转化成普通节点链表;
(2)否则判断 hi 链表中的元素个数是否等于0:如果等于0,表示 lo 链表中包含了所有原始节点,则
设置原始红黑树给 ln ,否则根据 lo 链表重新构造红黑树。
在这里插入图片描述
最后,同样的通过CAS把 ln 设置到新数组的 i 位置, hn 设置到 i+n 位置

底层实现原理

ConcurrentHashMap的整体架构

下图为ConcurrentHashMap在JDK1.8中的结构图,其由数组,链表,红黑树构成。
当我们去初始化一个ConcurrentHashMap实例的时候,默认会创建一个长度为16的数组。
由于ConcurrentHashMap的核心依旧是Hash表,因此依旧会存在Hash冲突这一问题。
因此ConcurrentHashMap采用链式寻址的方式来解决Hash冲突,当Hash冲突过多时,会造成链表长度过长的问题,从而导致ConcurrentHashMap中的数组元素的查询复杂度增加。
因此在JDK1.8之后引入了红黑树,当数组长度小于64并且链表长度大于等于8的时候,单向链表就会转换为红黑树。而如果链表的长度小于6,那么红黑树又会退化为单向链表。
在这里插入图片描述

ConcurrentHashMap的基本功能

ConcurrentHashMap本质是一个HashMap,因此功能和HashMap是一样的。他是在HashMap的基础上提供了一个并发安全的实现。并发安全的主要实现主要是通过对于Node节点去加锁,来保证数据更新的安全性。
在这里插入图片描述

ConcurrentHashMap在性能方面的优化

如何做好并发性能和数据安全性之间做好平衡,在很多地方都有实现。
例如CPU的三级缓存,mysql的buffer_pool,synchronized的锁升级等等。
ConcurrentHashMap也做了类似的优化,主要体现在如下几点:

  • JDK1.8中,ConcurrentHashMap锁的粒度是数组中的某一个节点,而在JDK1.7中,他锁定的是Segment。锁的范围更大,因此性能上会更低。
  • 引入了红黑树这个数据结构,降低了数据查询的时间复杂度。
  • 在数组长度不够的时候,ConcurrentHashMap需要进行扩容,而在扩容的时候,ConcurrentHashMap实现了多线程并发扩容的一个实现。
    简单来说,就是开启多个线程,然后对数组进行分片,每一个线程负责一个分片的数据迁移,从而整体提高了扩容时的效率。
    在这里插入图片描述
  • ConcurrentHashMap有一个size方法来获取总的元素个数,而在多线程并发场景中,在保证原子性的前提下,去实现对数组中链表中元素个数的累加是性能很低的。所以ConcurrentHashMap再此做了两个优化:
    • 第一个点是在线程竞争不激烈的时候,直接采用CAS的方式采用元素个数的累加。

    • 如果线程竞争比较激烈,那么使用一个数组来维护元素个数,如果要增加元素个数的时候,直接从数组中随机一个,再通过CAS算法来实现原子递增。
      它的核心思想是通过数组来实现并发更新的一个负载。
      在这里插入图片描述

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包

打赏作者

ZhangBlossom

你的鼓励将是我创作的最大动力

¥1 ¥2 ¥4 ¥6 ¥10 ¥20
扫码支付:¥1
获取中
扫码支付

您的余额不足,请更换扫码支付或充值

打赏作者

实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值