/*
* ConcurrentHashMap
*
* 在日常开发中使用的HashMap是线程不安全的,而线程安全类Hashtable只是简单的在方法上加锁实
* 现线程安全,效率低下,所以在线程安全的环境下通常会使用ConcurrentHashMap
*
* 问题:
- ConcurrentHashMap是怎么做到线程安全的?
- - get方法如何线程安全地获取key、value?
- put方法如何线程安全地设置key、value?
- size方法如果线程安全地获取容器容量?
- 底层数据结构扩容时如果保证线程安全?
- 初始化数据结构时如果保证线程安全?
- ConcurrentHashMap并发效率是如何提高的?
- 和加锁相比较,为什么它比Hashtable效率高?
可以通过减少锁竞争来优化并发性能,而ConcurrentHashMap则在JDK8-使用了锁分段(减小锁范围)、
JDK8开始大量使用CAS(乐观锁,减小上下文切换开销,无阻塞)和少量的同步代码块等技术
### 节点类型
Node节点
static class Node<K,V> implements Map.Entry<K,V> {
final int hash;
final K key;
volatile V val;
volatile Node<K,V> next;
- 默认桶上的结点就是Node结点。Node只有一个next指针,是一个单链表,提供find方法实现链表查询
- 当出现hash冲突时,Node结点会首先以链表的形式链接到table上,当结点数量超过一定数目时,链表
会转化为红黑树。
TreeNode节点
static final class TreeNode<K,V> extends Node<K,V> {
TreeNode<K,V> parent; // red-black tree links
TreeNode<K,V> left;
TreeNode<K,V> right;
TreeNode<K,V> prev; // needed to unlink next upon deletion
boolean red;
- TreeNode就是红黑树的结点,TreeNode不会直接链接到table[i]——桶上面,而是由TreeBin链接,
TreeBin会指向红黑树的根结点。
TreeBin节点
static final class TreeBin<K,V> extends Node<K,V> {
TreeNode<K,V> root;
volatile TreeNode<K,V> first;
volatile Thread waiter;
volatile int lockState;
// values for lockState
static final int WRITER = 1; // set while holding write lock
static final int WAITER = 2; // set when waiting for write lock
static final int READER = 4; // increment value for setting read lock
- TreeBin会直接链接到table[i]——桶上面,该结点提供了一系列红黑树相关的操作,以及加锁、解锁操作。
另外TreeBin提供了一系列的操作
- TreeBin(TreeNode<K,V> b),将以b为头结点的链表转换为红黑树
- lockRoot(),对红黑树的根结点加写锁
- unlockRoot(),释放写锁
- find(int h, Object k),从根结点开始遍历查找,找到相等的结点就返回它,没找到就返回null,
当存在写锁时,以链表方式进行查找,不阻塞读锁
ForwardingNode
static final class ForwardingNode<K,V> extends Node<K,V> {
final Node<K,V>[] nextTable;
- ForwardingNode在table扩容时使用,内部记录了扩容后的table,即nextTable
- 当table需要扩容时,依次遍历table中的每个槽,如果不为null,把所有元素根据hash值放入扩容后的
nextTable中,在原table的槽内放置一个ForwardingNode
- ForwardingNode是一种临时结点,在扩容进行中才会出现,hash值固定为-1,且不存储实际数据
- 如果旧table数组的一个hash桶中全部的结点都迁移到了新table中,则在这个桶中放置一个ForwardingNode
- 读操作碰到ForwardingNode时,将操作转发到扩容后的新table数组上去执行;写操作碰见它时,则尝试帮助
扩容,扩容是支持多线程一起扩容。
ReservationNode保留结点
static final class ReservationNode<K,V> extends Node<K,V> {
ReservationNode() {
super(RESERVED, null, null); //-3
}
Node<K,V> find(int h, Object k) {
return null;
}
}
- 在并发场景下、在从Key不存在到插入的时间间隔内,为了防止哈希槽被其他线程抢占
,当前线程会使用一个reservationNode节点放到槽中并加锁,从而保证线程安全
- hash值固定为-3,不保存实际数据。只在computeIfAbsent和compute这两个函数式
API中充当占位符加锁使用
构造器
public ConcurrentHashMap() {
}
public ConcurrentHashMap(int initialCapacity) { 初始化容积--数组长度
this(initialCapacity, LOAD_FACTOR, 1);
}
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;
}
初始化ConcurrentHashMap的时候这个`Node[]`数组是还未初始化的,会等到第一次put方法调用时才初始化
//不允许空的key和value,否则异常
final V putVal(K key, V value, boolean onlyIfAbsent) {
if (key == null || value == null) throw new NullPointerException();
... ...
//判断Node数组为空
if (tab == null || (n = tab.length) == 0)
//初始化Node数组
tab = initTable();
此时是会有并发问题的,如果多个线程同时调用initTable初始化Node数组怎么办
private final Node<K,V>[] initTable() {
Node<K,V>[] tab; int sc;
//每次循环都获取最新的Node数组引用
while ((tab = table) == null || tab.length == 0) {
//sizeCtl是一个标记位,若为-1也就是小于0,代表有线程在进行初始化工作了
if ((sc = sizeCtl) < 0)
//让出CPU时间片
Thread.yield(); // lost initialization race; just spin
//CAS操作,将本实例的sizeCtl变量设置为-1
else if (U.compareAndSwapInt(this, SIZECTL, sc, -1)) {
//如果CAS操作成功了,代表本线程将负责初始化工作
try {
//再检查一遍数组是否为空
if ((tab = table) == null || tab.length == 0) {
//在初始化Map时,sizeCtl代表数组大小,默认16
//所以此时n默认为16
int n = (sc > 0) ? sc : DEFAULT_CAPACITY;
@SuppressWarnings("unchecked")
//Node数组
Node<K,V>[] nt = (Node<K,V>[])new Node<?,?>[n];
//将其赋值给table变量
table = tab = nt;
//通过位运算,n减去n二进制右移2位,相当于乘以0.75
//例如16经过运算为12,与乘0.75一样,只不过位运算更快
sc = n - (n >>> 2);
}
} finally {
//将计算后的sc(12)直接赋值给sizeCtl,表示达到12长度就扩容
//由于这里只会有一个线程在执行,直接赋值即可,没有线程安全问题
//只需要保证可见性
sizeCtl = sc;
}
break;
}
}
return tab
}
table变量使用了volatile来保证每次获取到的都是最新写入的值:
transient volatile Node<K,V>[] table;
总结:
就算有多个线程同时进行put操作,在初始化数组时使用了乐观锁CAS操作来决定
到底是哪个线程有资格进行初始化,其他线程均只能等待。
用到的并发技巧:
- volatile变量(sizeCtl):它是一个标记位,用来告诉其他线程这个坑位有没有
人在,其线程间的可见性由volatile保证。
- CAS操作:CAS操作保证了设置sizeCtl标记位的原子性,保证了只有一个线程能设
置成功
### put操作的线程安全
final V putVal(K key, V value, boolean onlyIfAbsent) {
if (key == null || value == null)
throw new NullPointerException();
//对key的hashCode进行散列
int hash = spread(key.hashCode());
int binCount = 0;
//一个无限循环,直到put操作完成后退出循环
for (Node<K,V>[] tab = table;;) {
Node<K,V> f; int n, i, fh;
//当Node数组为空时进行初始化
if (tab == null || (n = tab.length) == 0)
tab = initTable();
//Unsafe类volatile的方式取出hashCode散列后通过与运算得出的Node数组下标值对应的Node对象
//此时的Node对象若为空,则代表还未有线程对此Node进行插入操作
else if ((f = tabAt(tab, i = (n - 1) & hash)) == null) {
//直接CAS方式插入数据
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;
//对Node对象进行加锁
synchronized (f) {
//二次确认此Node对象还是原来的那一个
if (tabAt(tab, i) == f) {
if (fh >= 0) {
binCount = 1;
//无限循环,直到完成put
for (Node<K,V> e = f;; ++binCount) {
K ek;
//和HashMap一样,先比较hash,再比较equals
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) {
//和链表头Node节点不冲突,就将其初始化为新Node作为上一个Node节点的next
//形成链表结构
pred.next = new Node<K,V>(hash, key,value, null);
break;
}
}
}
...
}
值得关注的是tabAt(tab, i)方法,其使用Unsafe类volatile的操作volatile式地查看值,
保证每次获取到的值都是最新的:
static final <K,V> Node<K,V> tabAt(Node<K,V>[] tab, int i) {
return (Node<K,V>)U.getObjectVolatile(tab, ((long)i << ASHIFT) + ABASE);
}
虽然上面的table变量加了volatile,但也只能保证其引用的可见性,并不能确保其数组中的
对象是否是最新的,所以需要Unsafe类volatile式地拿到最新的Node。
总结:
由于其减小了锁的粒度,若Hash完美不冲突的情况下,可同时支持n个线程同时put操作,n为
Node数组大小,在默认大小16下,可以支持最大同时16个线程无竞争同时操作且线程安全。当
hash冲突严重时,Node链表越来越长,将导致严重的锁竞争,此时会进行扩容,将Node进行再
散列,
总结一下用到的并发技巧:
- 减小锁粒度:将Node链表的头节点作为锁,若在默认大小16情况下,将有16把锁,大大减小
了锁竞争(上下文切换),就像开头所说,将串行的部分最大化缩小,在理想情况下线程的put
操作都为并行操作。同时直接锁住头节点,保证了线程安全
- Unsafe的getObjectVolatile方法:此方法确保获取到的值为最新。
### 扩容操作的线程安全
在扩容时,ConcurrentHashMap支持多线程并发扩容,在扩容过程中同时支持get查数据,若有
线程put数据,还会帮助一起扩容,这种无阻塞算法将并行最大化
private final void transfer(Node<K,V>[] tab, Node<K,V>[] nextTab) {
......
//锁住这个Node
synchronized (f) {
//确认Node是原先的Node
if (tabAt(tab, i) == f) {
......
}
在put值的时候,首先会计算hash值,再散列到指定的Node数组下标中
//根据key的hashCode再散列
int hash = spread(key.hashCode());
//使用(n - 1) & hash 运算,定位Node数组中下标值,实际上就是执行 hash % n
(f = tabAt(tab, i = (n - 1) & hash);
其中n为Node数组长度,这里假设为16。
假设有一个key进来,它的散列之后的hash=9,那么它的下标值是多少呢?
- (16 - 1)和 9 进行与运算 -> 0000 1111 和 0000 1001 结果还是 0000 1001 = 9
假设Node数组需要扩容,我们知道,扩容是将数组长度增加两倍,也就是32,那么下标值会是多少呢?
- (32 - 1)和 9 进行与运算 -> 0001 1111 和 0000 1001 结果还是9
此时,我们把散列之后的hash换成20,那么会有怎样的变化呢?
- (16 - 1)和 20 进行与运算 -> 0000 1111 和 0001 0100 结果是 0000 0100 = 4
- (32 - 1)和 20 进行与运算 -> 0001 1111 和 0001 0100 结果是 0001 0100 = 20
此时应该可以发现,如果hash在高X位为1,(X为数组长度的二进制-1的最高位),则扩容时是需要变
换在Node数组中的索引值的,不然就hash不到,丢失数据,所以这里在迁移的时候将高X位为1的Node
分类为hn,将高X位为0的Node分类为ln。
然后将其CAS操作放入新的Node数组中
setTabAt(nextTab, i, ln);
setTabAt(nextTab, i + n, hn);
其中,低位链表放入原下标处,而高位链表则需要加上原Node数组长度,上面已经举例说明了,这样就
可以保证高位Node在迁移到新Node数组中依然可以使用hash算法散列到对应下标的数组中去了。
最后将原Node数组中对应下标Node对象设置为fwd标记Node,表示该节点迁移完成,到这里,一个节点
的迁移就完成了,将进行下一个节点的迁移,也就是i-1=14下标的Node节点。
### 扩容时的get操作
假设Node下标为16的Node节点正在迁移,突然有一个线程进来调用get方法,正好key又散列到下标为
16的节点,此时怎么办?
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;
}
//假如Node节点的hash值小于0,则有可能是fwd节点
else if (eh < 0)
//调用节点对象的find方法查找值
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;
}
重点看有注释的那两行,在get操作的源码中,会判断Node中的hash是否小于0,是否还记得我们的占位Node,其hash为MOVED,为常量值-1,所以此时判断线程正在迁移,委托给fwd占位Node去查找值:
//内部类 ForwardingNode中
Node<K,V> find(int h, Object k) {
// loop to avoid arbitrarily deep recursion on forwarding nodes
// 这里的查找,是去新Node数组中查找的
// 下面的查找过程与HashMap查找无异,不多赘述
outer: for (Node<K,V>[] tab = nextTable;;) {
Node<K,V> e; int n;
if (k == null || tab == null || (n = tab.length) == 0 ||
(e = tabAt(tab, (n - 1) & h)) == null)
return null;
for (;;) {
int eh; K ek;
if ((eh = e.hash) == h &&
((ek = e.key) == k || (ek != null && k.equals(ek))))
return e;
if (eh < 0) {
if (e instanceof ForwardingNode) {
tab = ((ForwardingNode<K,V>)e).nextTable;
continue outer;
}
else
return e.find(h, k);
}
if ((e = e.next) == null)
return null;
}
}
}
```
到这里应该可以恍然大悟了,之所以占位Node需要保存新Node数组的引用也是因为这个,它可以支持在迁移
的过程中照样不阻塞地查找值,可谓是精妙绝伦的设计。
### 多线程协助扩容
在put操作时,假设正在迁移,正好有一个线程进来,想要put值到迁移的Node上,怎么办?
final V putVal(K key, V value, boolean onlyIfAbsent) {
......
//若此时发现了占位Node,证明此时HashMap正在迁移
else if ((fh = f.hash) == MOVED)
//进行协助迁移
tab = helpTransfer(tab, f);
...
}
final Node<K,V>[] helpTransfer(Node<K,V>[] tab, Node<K,V> f) {
... ...
//sizeCtl加一,标示多一个线程进来协助扩容
if (U.compareAndSwapInt(this, SIZECTL, sc, sc + 1)) {
//扩容
transfer(tab, nextTab);
break;
}
}
... ...
return nextTab;
}
return table;
}
只要数组长度足够长,就可以同时容纳足够多的线程来一起扩容,最大化并行任务,提高性能。
*
* ### 在什么情况下会进行扩容操作
* - 在put值时,发现Node为占位Node(fwd)时,会协助扩容。
* - 在新增节点后,检测到链表长度大于8时。
final V putVal(K key, V value, boolean onlyIfAbsent) {
...
if (binCount != 0) {
//TREEIFY_THRESHOLD=8,当链表长度大于8时
if (binCount >= TREEIFY_THRESHOLD)
//调用treeifyBin方法
treeifyBin(tab, i);
if (oldVal != null)
return oldVal;
break;
}
...
}
treeifyBin方法会将链表转换为红黑树,增加查找效率,但在这之前会检查数组长度,若小于
64,则会优先做扩容操作
private final void treeifyBin(Node<K,V>[] tab, int index) {
Node<K,V> b; int n, sc;
if (tab != null) {
//MIN_TREEIFY_CAPACITY=64,若数组长度小于64,则先扩容
if ((n = tab.length) < MIN_TREEIFY_CAPACITY)
//扩容为原始数组的1倍
tryPresize(n << 1);
else if ((b = tabAt(tab, index)) != null && b.hash >= 0) {
synchronized (b) {
//...转换为红黑树的操作
}
}
}
}
在每次新增节点之后,都会调用addCount方法,检测Node数组大小是否达到阈值:
final V putVal(K key, V value, boolean onlyIfAbsent) {
...
//此方法统计容器元素数量
addCount(1L, binCount);
return null;
}
private final void addCount(long x, int check) {
}
总结
ConcurrentHashMap运用各类CAS操作{Unsafe},将扩容操作的并发性能实现最大化,在扩容过程中,就算有
线程调用get查询方法,也可以安全的查询数据,若有线程进行put操作,还会协助扩容,利用
sizeCtl标记位和各种volatile变量进行CAS操作达到多线程之间的通信、协助,在迁移过程中只锁
一个Node节点,即保证了线程安全,又提高了并发性能。
### get操作的线程安全
对于get操作,其实没有线程安全的问题,只有可见性的问题,只需要确保get的数据是线程之间可见的即可
在get操作中除了增加了迁移的判断以外,基本与HashMap的get操作无异
使用了tabAt方法Unsafe类volatile的方式去获取Node数组中的Node,保证获得到的Node是最新的。
*/