可访问个人网站进行阅读最新版本,精力有限无法多网站同步更新,更新只会在个人网站进行
参考自
- https://ddnd.cn/2019/03/10/jdk1-8-concurrenthashmap/
- https://juejin.im/post/5c8276216fb9a049d51a4cd6
面试题
并发安全机制
分段锁机制
synchronized + CAS
内部数据结构
一、了解Hashtable
在线程竞争激烈的情况下HashTable的效率非常低下。因为当一个线程访问HashTable的同步方法时,其他线程访问HashTable的同步方法时,可能会进入阻塞或轮询状态。如线程1使用put进行添加元素,线程2不但不能使用put方法添加元素,并且也不能使用get方法来获取元素,所以竞争越激烈效率越低。
在讲ConcurrentHashMap之前,先讲下Hashtable,它使用 synchronized 关键字实现线程安全,比如 get 方法和 put 方法:
public synchronized V get(Object key) {}
public synchronized V put(K key, V value) {}
注意到,synchronized 关键字加在非静态方法上,说明同步锁对象即是 Hashtable 对象本身,只有一个锁。
1.1 Hashtable与ConcurrentHashMap区别:
线程安全的实现:Hashtable
采用对象锁(synchronized修饰对象方法)来保证线程安全,也就是一个Hashtable
对象只有一把锁,如果线程1拿了对象A的锁进行有synchronized
修饰的put
方法,其他线程是无法操作对象A中有synchronized
修饰的方法的(如get
方法、remove
方法等),竞争激烈所以效率低下。而ConcurrentHashMap
采用CAS
+ synchronized
来保证并发安全性,且synchronized
关键字不是用在方法上而是用在了具体的对象上,实现了更小粒度的锁。
数据结构的实现:Hashtable
采用的是数组 + 链表,当链表过长会影响查询效率,而ConcurrentHashMap
采用数组 + 链表 + 红黑树,当链表长度超过某一个值,则将链表转成红黑树,提高查询效率。
二、底层数据结构
jdk1.7中 ConcurrentHashMap 使用了锁分段技术
假如容器里有多把锁,每一把锁用于锁容器其中一部分数据,那么当多线程访问容器里不同数据段的数据时,线程间就不会存在锁竞争,从而可以有效的提高并发访问效率,这就是ConcurrentHashMap所使用的锁分段技术
//每个Segment都是一个ReentrantLock锁,同时它内部保存着一个HashEntry数组
final Segment<K,V>[] segments;
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;
}
/**
* Sets next field with volatile write semantics. (See above
* about use of putOrderedObject.)
* 设置next,注意unsafe的使用,ConcurrentHashMap中很多这种操作
*/
final void setNext(HashEntry<K,V> n) {
UNSAFE.putOrderedObject(this, nextOffset, n);
}
// Unsafe mechanics
static final sun.misc.Unsafe UNSAFE;
static final long nextOffset;
static {
try {
UNSAFE = sun.misc.Unsafe.getUnsafe();
Class k = HashEntry.class;
//计算 nextOffset ,以使用设置next
nextOffset = UNSAFE.objectFieldOffset
(k.getDeclaredField("next"));
} catch (Exception e) {
throw new Error(e);
}
}
}
jdk1.7
在JDK1.7版本中,ConcurrentHashMap的数据结构是由一个Segment数组和多个HashEntry组成,主要实现原理是实现了锁分离的思路解决了多线程的安全问题,Segment
在实现上继承了ReentrantLock
,这样就自带了锁的功能。如下图所示:
一个ConcurrentHashMap里包含一个Segment数组,Segment的结构和HashMap类似,是一种数组和链表结构, 每个Segment元素 里包含一个HashEntry数组,每个HashEntry元素是一个链表结构的元素, 每个Segment守护着一个HashEntry数组里的元素,当对HashEntry数组的数据进行修改时,必须首先获得它对应的Segment锁。
jdk1.8
private static final int MAXIMUM_CAPACITY = 1 << 30;
private static final int DEFAULT_CAPACITY = 16;
static final int TREEIFY_THRESHOLD = 8;
static final int UNTREEIFY_THRESHOLD = 6;
static final int MIN_TREEIFY_CAPACITY = 64;
static final int MOVED = -1; // 表示正在转移
static final int TREEBIN = -2; // 表示已经转换成树
static final int RESERVED = -3; // hash for transient reservations
static final int HASH_BITS = 0x7fffffff; // usable bits of normal node hash
transient volatile Node<K,V>[] table;//默认没初始化的数组,用来保存元素
private transient volatile Node<K,V>[] nextTable;//转移的时候用的数组
/**
* 用来控制表初始化和扩容的,默认值为0,当在初始化的时候指定了大小,这会将这个大小保存在sizeCtl中,大小为数组的0.75
* 当为负的时候,说明表正在初始化或扩张,
* -1表示初始化
* -(1+n) n:表示活动的扩张线程
*/
private transient volatile int sizeCtl;
/*
Node是最核心的内部类,包装了key-value键值对,所有插入ConcurrentHashMap的数据都包装在这里面。
它与HashMap中的定义很相似,但是有一些差别它对value和next属性设置了volatile同步锁,它不允许调用setValue方法直接改变Node的value域,它增加了find方法辅助map.get()方法
*/
static class Node<K,V> implements Map.Entry<K,V> {
final int hash; //key的hash值
final K key; //key
volatile V val; //value
volatile Node<K,V> next; //表示链表中的下一个节点
Node(int hash, K key, V val, Node<K,V> next) {
this.hash = hash;
this.key = key;
this.val = val;
this.next = next;
}
public final K getKey() { return key; }
public final V getValue() { return val; }
public final int hashCode() { return key.hashCode() ^ val.hashCode(); }
}
/*
树节点类,另外一个核心的数据结构。 当链表长度过长的时候,会转换为TreeNode。
但是与HashMap不相同的是,它并不是直接转换为红黑树,而是把这些结点包装成TreeNode放在TreeBin对象中,由TreeBin完成对红黑树的包装。
而且TreeNode在ConcurrentHashMap继承自Node类,而并非HashMap中的集成自LinkedHashMap.Entry
*/
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(int hash, K key, V val, Node<K,V> next,
TreeNode<K,V> parent) {
super(hash, key, val, next);
this.parent = parent;
}
}
// TreeBin 用作树的头结点,只存储root和first节点,不存储节点的key、value值。
/*
这个类并不负责包装用户的key、value信息,而是包装的很多TreeNode节点。它代替了TreeNode的根节点,也就是说在实际的ConcurrentHashMap“数组”中,存放的是TreeBin对象,而不是TreeNode对象,这是与HashMap的区别
*/
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
}
// ForwardingNode在转移的时候放在头部的节点,是一个空节点
/*
一个用于连接两个table的节点类。它包含一个nextTable指针,用于指向下一张表。而且这个节点的key value next指针全部为null,它的hash值为-1.
这里面定义的find的方法是从nextTable里进行查询节点,而不是以自身为头节点进行查找
*/
static final class ForwardingNode<K,V> extends Node<K,V> {
final Node<K,V>[] nextTable;
ForwardingNode(Node<K,V>[] tab) {
super(MOVED, null, null, null);
this.nextTable = tab;
}
}
JDK1.8的实现已经摒弃了Segment的概念,而是直接用Node数组+链表+红黑树的数据结构来实现,并发控制使用Synchronized和CAS来操作,整个看起来就像是优化过且线程安全的HashMap,虽然在JDK1.8中还能看到Segment的数据结构,但是已经简化了属性,只是为了兼容旧版本。
2.1 构造方法
//无参构造函数,什么也不做,table的初始化放在了第一次插入数据时,默认容量大小是16和HashMap的一样,默认sizeCtl为0
public ConcurrentHashMap() {
}
//传入容量大小的构造函数。
public ConcurrentHashMap(int initialCapacity) {
//如果传入的容量大小小于0 则抛出异常。
if (initialCapacity < 0)
throw new IllegalArgumentException();
//如果传入的容量大小大于允许的最大容量值 则cap取允许的容量最大值 否则cap =
//((传入的容量大小 + 传入的容量大小无符号右移1位 + 1)的结果向上取最近的2幂次方),
//即如果传入的容量大小是12 则 cap = 32(12 + (12 >>> 1) + 1=19
//向上取2的幂次方即32),这里为啥一定要是2的幂次方,原因和HashMap的threshold一样,都是为
//了让位运算和取模运算的结果一样。
//MAXIMUM_CAPACITY即允许的最大容量值 为2^30。
int cap = ((initialCapacity >= (MAXIMUM_CAPACITY >>> 1)) ?
MAXIMUM_CAPACITY :
//tableSizeFor这个函数即实现了将一个整数取2的幂次方。
tableSizeFor(initialCapacity + (initialCapacity >>> 1) + 1));
//将上面计算出的cap 赋值给sizeCtl,注意此时sizeCtl为正数,代表进行扩容的容量大小。
this.sizeCtl = cap;
}
//包含指定Map的构造函数。
//置sizeCtl为默认容量大小 即16。
public ConcurrentHashMap(Map<? extends K, ? extends V> m) {
this.sizeCtl = DEFAULT_CAPACITY;
putAll(m);
}
//传入容量大小和负载因子的构造函数。
//默认并发数大小是1。
public ConcurrentHashMap(int initialCapacity, float loadFactor) {
this(initialCapacity, loadFactor, 1);
}
//传入容量大小、负载因子和并发数大小的构造函数
public ConcurrentHashMap(int initialCapacity,
float loadFactor, int concurrencyLevel) {
if (!(loadFactor > 0.0f) || initialCapacity < 0 || concurrencyLevel <= 0)
throw new IllegalArgumentException();
//如果传入的容量大小 小于 传入的并发数大小,
//则容量大小取并发数大小,这样做的原因是确保每一个Node只会分配给一个线程,而一个线程则
//可以分配到多个Node,比如当容量大小为64,并发数大
//小为16时,则每个线程分配到4个Node。
if (initialCapacity < concurrencyLevel) // Use at least as many bins
initialCapacity = concurrencyLevel; // as estimated threads
//size = 1.0 + (long)initialCapacity / loadFactor 这里计算方法和上面的构造函数不一样。
long size = (long)(1.0 + (long)initialCapacity / loadFactor);
//如果size大于允许的最大容量值则 sizeCtl = 允许的最大容量值 否则 sizeCtl =
//size取2的幂次方。
int cap = (size >= (long)MAXIMUM_CAPACITY) ?
MAXIMUM_CAPACITY : tableSizeFor((int)size);
this.sizeCtl = cap;
}
ConcurrentHashMap
的构造函数有5个,从数量上看就和HashMap
、Hashtable
(4个)的不同,多出的那个构造函数是public ConcurrentHashMap(int initialCapacity,float loadFactor, int concurrencyLevel)
,即除了传入容量大小、负载因子之外还多传入了一个整型的concurrencyLevel
,这个整型是我们预先估计的并发量,比如我们估计并发是30
,那么就可以传入30
。
其他的4个构造函数的参数和HashMap
的一样,而具体的初始化过程却又不相同,HashMap
和Hashtable
传入的容量大小和负载因子都是为了计算出初始阈值(threshold),而ConcurrentHashMap
传入的容量大小和负载因子是为了计算出sizeCtl用于初始化table
,这个sizeCtl即table数组的大小,不同的构造函数计算sizeCtl方法都不一样。
2.2 unSafe方法
在ConcurrentHashMap中,大量使用了U.compareAndSwapXXX的方法,这个方法是利用一个CAS算法实现无锁化的修改值的操作,他可以大大降低锁代理的性能消耗。这个算法的基本思想就是不断地去比较当前内存中的变量值与你指定的一个变量值是否相等,如果相等,则接受你指定的修改的值,否则拒绝你的操作。因为当前线程中的值已经不是最新的值,你的修改很可能会覆盖掉其他线程修改的结果。这一点与乐观锁,SVN的思想是比较类似的。
unsafe代码块控制了一些属性的修改工作,比如最常用的SIZECTL 。 在这一版本的concurrentHashMap中,大量应用来的CAS方法进行变量、属性的修改工作。 利用CAS进行无锁操作,可以大大提高性能。
/*
* 用来返回节点数组的指定位置的节点的原子操作
*/
@SuppressWarnings("unchecked")
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);
}
/*
* cas原子操作,在指定位置设定值
*/
static final <K,V> boolean casTabAt(Node<K,V>[] tab, int i,
Node<K,V> c, Node<K,V> v) {
return U.compareAndSwapObject(tab, ((long)i << ASHIFT) + ABASE, c, v);
}
/*
* 原子操作,在指定位置设定值
*/
static final <K,V> void setTabAt(Node<K,V>[] tab, int i, Node<K,V> v) {
U.putObjectVolatile(tab, ((long)i << ASHIFT) + ABASE, v);
}
三、存取机制
3.1 put方法
put()能将对应的key与value保存到map中。在ConcurrentHashMap中,key与value都不能为空,否则会抛出NullPointerException异常。如果put()时,key已经存在,则会返回put()前该key对应的value。
public V put(K key, V value) {
return putVal(key, value, false);
}
final V putVal(K key, V value, boolean onlyIfAbsent) {
//不允许键值为null,这点与线程安全的Hashtable保持一致,和HashMap不同。
if (key == null || value == null) throw new NullPointerException();
//取键key的hashCode()和HashMap、Hashtable都一样,然后再执行spread()方法计算得到哈希地
//址,这个spread()方法和HashMap的hash()方法一样,都是将hashCode()做无符号右移16位,只不
//过spread()加多了 &0x7fffffff,让结果为正数。
int hash = spread(key.hashCode());
int binCount = 0;
for (Node<K,V>[] tab = table;;) {
Node<K,V> f; int n, i, fh;
//如果table数组为空或者长度为0(未初始化),则调用initTable()初始化table,初始化函数
//下面介绍。
if (tab == null || (n = tab.length) == 0)
tab = initTable();
//调用实现了CAS原子性操作的tabAt方法
//tabAt方法的第一个参数是Node数组的引用,第二个参数在Node数组的下标,实现的是在Nod
//e数组中查找指定下标的Node,如果找到则返回该Node节点(链表头节点),否则返回null,
//这里的i = (n - 1)&hash即是计算待插入的节点在table的下标,即table容量-1的结果和哈
//希地址做与运算,和HashMap的算法一样。
else if ((f = tabAt(tab, i = (n - 1) & hash)) == null) {
//如果该下标上并没有节点(即链表为空),则直接调用实现了CAS原子性操作的
//casTable()方法,
//casTable()方法的第一个参数是Node数组的引用,第二个参数是待操作的下标,第三
//个参数是期望值,第四个参数是待操作的Node节点,实现的是将Node数组下标为参数二
//的节点替换成参数四的节点,如果期望值和实际值不符返回false,否则参数四的节点成
//功替换上去,返回ture,即插入成功。注意这里:如果插入成功了则跳出for循环,插入
//失败的话(其他线程抢先插入了),那么会执行到下面的代码。
if (casTabAt(tab, i, null,
new Node<K,V>(hash, key, value, null)))
break; // no lock when adding to empty bin
}
//如果该下标上的节点的哈希地址为-1(即链表的头节点为ForwardingNode节点),则表示
//table需要扩容,值得注意的是ConcurrentHashMap初始化和扩容不是用同一个方法,而
//HashMap和Hashtable都是用同一个方法,当前线程会去协助扩容,扩容过程后面介绍。
else if ((fh = f.hash) == MOVED)
tab = helpTransfer(tab, f);
//如果该下标上的节点既不是空也不是需要扩容,则表示这个链表可以插入值,将进入到链表
//中,将新节点插入或者覆盖旧值。
else {
V oldVal = null;
//通过关键字synchroized对该下标上的节点加锁(相当于锁住锁住
//该下标上的链表),其他下标上的节点并没有加锁,所以其他线程
//可以安全的获得其他下标上的链表进行操作,也正是因为这个所
//以提高了ConcurrentHashMap的效率,提高了并发度。
synchronized (f) {
if (tabAt(tab, i) == f) {
//如果该下标上的节点的哈希地址大于等于0,则表示这是
//个链表。
if (fh >= 0) {
binCount = 1;
//遍历链表。
for (Node<K,V> e = f;; ++binCount) {
K ek;
//如果哈希地址、键key相同 或者 键key不为空
//且键key相同,则表示存在键key和待插入的键
//key相同,则执行更新值value的操作。
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;
//如果找到了链表的最后一个节点都没有找到相
//同键Key的,则是插入操作,将插入的键值新建
//个节点并且添加到链表尾部,这个和HashMap一
//样都是插入到尾部。
if ((e = e.next) == null) {
pred.next = new Node<K,V>(hash, key,
value, null);
break;
}
}
}
//如果该下标上的节点的哈希地址小于0 且为树节点
//则将带插入键值新增到红黑树
else if (f instanceof TreeBin) {
Node<K,V> p;
binCount = 2;
//如果插入的结果不为null,则表示为替换
if ((p = ((TreeBin<K,V>)f).putTreeVal(hash,
key,value)) != null){
oldVal = p.val;
if (!onlyIfAbsent)
p.val = value;
}
}
}
}
//判断链表的长度是否大于等于链表的阈值(8),大于则将链表转成
//红黑树,提高效率。这点和HashMap一样。
if (binCount != 0) {
if (binCount >= TREEIFY_THRESHOLD)
treeifyBin(tab, i);
if (oldVal != null)
return oldVal;
break;
}
}
}
addCount(1L, binCount);
return null;
}
总结流程:
- 判断键值是否为
null
,为null
抛出异常。 - 调用
spread()
方法计算key的hashCode()获得哈希地址,这个HashMap相似。 - 如果当前table为空,则初始化table,需要注意的是这里并没有加
synchronized
,也就是允许多个线程去尝试初始化table,但是在初始化函数里面使用了CAS
保证只有一个线程去执行初始化过程。 - 使用 容量大小-1 & 哈希地址 计算出待插入键值的下标,如果该下标上的bucket为
null
,则直接调用实现CAS
原子性操作的casTabAt()
方法将节点插入到table中,如果插入成功则完成put操作,结束返回。插入失败(被别的线程抢先插入了)则继续往下执行。 - 如果该下标上的节点(头节点)MOVED(-1)的哈希地址为-1,代表需要扩容,该线程执行
helpTransfer()
方法协助扩容。 - 如果该下标上的bucket不为空,且又不需要扩容,则进入到bucket中,同时synchronized锁住这个bucket,注意只是锁住该下标上的bucket而已,其他的bucket并未加锁,其他线程仍然可以操作其他未上锁的bucket,这个就是ConcurrentHashMap为什么高效的原因之一。
- 进入到bucket里面,首先判断这个bucket存储的是红黑树(哈希地址小于0,原因后面分析)还是链表。
- 如果是链表,则遍历链表看看是否有哈希地址和键key相同的节点,有的话则根据传入的参数进行覆盖或者不覆盖,没有找到相同的节点的话则将新增的节点插入到链表尾部。如果是红黑树,则将节点插入。到这里结束加锁。
- 最后判断该bucket上的链表长度是否大于链表转红黑树的阈值(8),大于则调用
treeifyBin()
方法将链表转成红黑树,以免链表过长影响效率。 - 调用
addCount()
方法,作用是将ConcurrentHashMap的键值对数量+1,还有另一个作用是检查ConcurrentHashMap是否需要扩容。
补充说明:
为什么ConcurrentHashMap中,key与value都不能为空
ConcurrentHashMap的使用场景为多线程,如果有A、B两个线程,线程A调用concurrentHashMap.get(key)方法,返回为null,我们还是不知道这个null是没有映射的null还是存的值就是null。虽然可以用concurrentHashMap.containsKey(key)来判断,但是多线程下,如果A调用concurrentHashMap.get(key)方法之后,containsKey方法之前,有一个线程B执行了concurrentHashMap.put(key,null)的操作。那么我们调用containsKey方法返回的就是true了。这就与我们的假设的真实情况不符合了。也就是上面说的二义性。
spread()具体干了什么?有什么意义?
// 计算hash值
// 让高16位 亦或 低16位,再把高的16位置为0
static final int spread(int h) { // & HASH_BITS用于把hash值转化为正数
return (h ^ (h >>> 16)) & HASH_BITS;
}
这里我也实在是迷惑了,和hashmap的hash()方法实现一样的,只是多了个HASH_BITS=0x7fffffff
static final int hash(Object key) {
int h;
return (key == null) ? 0 : (h = key.hashCode()) ^ (h >>> 16);
}
3.2 get方法
public V get(Object key) {
Node<K,V>[] tab; Node<K,V> e, p; int n, eh; K ek;
//运用键key的hashCode()计算出哈希地址
int h = spread(key.hashCode());
//如果table不为空 且 table长度大于0 且 计算出的下标上bucket不为空,
//则代表这个bucket存在,进入到bucket中查找,
//其中(n - 1) & h为计算出键key相对应的数组下标的算法。
if ((tab = table) != null && (n = tab.length) > 0 &&
(e = tabAt(tab, (n - 1) & h)) != null) {
//如果哈希地址、键key相同则表示查找到,返回value,这里查找到的是头节点。
if ((eh = e.hash) == h) {
if ((ek = e.key) == key || (ek != null && key.equals(ek)))
return e.val;
}
//如果bucket头节点的哈希地址小于0,则代表bucket为红黑树,在红黑树中查找。
else if (eh < 0)
return (p = e.find(h, key)) != null ? p.val : null;
//如果bucket头节点的哈希地址不小于0,则代表bucket为链表,遍历链表,在链表中查找。
while ((e = e.next) != null) {
if (e.hash == h &&
((ek = e.key) == key || (ek != null && key.equals(ek))))
return e.val;
}
}
return null;
}
总结流程:
- 调用
spread()
方法计算key的hashCode()获得哈希地址。 - 计算出键key所在的下标,算法是(n - 1) & h,如果table不为空,且下标上的bucket不为空,则到bucket中查找。
- 如果bucket的头节点的哈希地址小于0,则代表这个bucket存储的是红黑树,否则是链表。
- 到红黑树或者链表中查找,找到则返回该键key的值,找不到则返回null。
3.3 initTable初始化方法
调用ConcurrentHashMap的构造方法仅仅是设置了一些参数而已,而整个table的初始化是在向ConcurrentHashMap中插入元素的时候发生的。如调用put、computeIfAbsent、compute、merge等方法的时候,调用时机是检查table==null。
初始化方法主要应用了关键属性sizeCtl 如果这个值 < 0,表示其他线程正在进行初始化,就放弃这个操作。
在这也可以看出ConcurrentHashMap的初始化只能由一个线程完成。如果获得了初始化权限,就用CAS方法将sizeCtl置为-1,防止其他线程进入。初始化数组后,将sizeCtl的值改为0.75*n
sizeCtl含义
- 负数代表正在进行初始化或扩容操作
- -1代表正在初始化
- -N 表示有N-1个线程正在进行扩容操作
- 正数或0代表hash表还没有被初始化,这个数值表示初始化或下一次进行扩容的大小,这一点类似于扩容阈值的概念。还后面可以看到,它的值始终是当前ConcurrentHashMap容量的0.75倍,这与loadfactor是对应的。
private final Node<K,V>[] initTable() {
Node<K,V>[] tab; int sc;
//如果table为null或者长度为0, //则一直循环试图初始化table(如果某一时刻别的线程将table初始化好了,那table不为null,该//线程就结束while循环)。
while ((tab = table) == null || tab.length == 0) {
//如果sizeCtl小于0,
//即有其他线程正在初始化或者扩容,执行Thread.yield()将当前线程挂起,让出CPU时间,
//该线程从运行态转成就绪态。
//如果该线程从就绪态转成运行态了,此时table可能已被别的线程初始化完成,table不为
//null,该线程结束while循环。
if ((sc = sizeCtl) < 0)
Thread.yield(); // lost initialization race; just spin
//如果此时sizeCtl不小于0,即没有别的线程在做table初始化和扩容操作,
//那么该线程就会调用Unsafe的CAS操作compareAndSwapInt尝试将sizeCtl的值修改成
//-1(sizeCtl=-1表示table正在初始化,别的线程如果也进入了initTable方法则会执行
//Thread.yield()将它的线程挂起 让出CPU时间),
//如果compareAndSwapInt将sizeCtl=-1设置成功 则进入if里面,否则继续while循环。
else if (U.compareAndSwapInt(this, SIZECTL, sc, -1)) {
try {
//再次确认当前table为null即还未初始化,这个判断不能少。
if ((tab = table) == null || tab.length == 0) {
//如果sc(sizeCtl)大于0,则n=sc,否则n=默认的容量大
小16,
//这里的sc=sizeCtl=0,即如果在构造函数没有指定容量
大小,
//否则使用了有参数的构造函数,sc=sizeCtl=指定的容量大小。
int n = (sc > 0) ? sc : DEFAULT_CAPACITY;
@SuppressWarnings("unchecked")
//创建指定容量的Node数组(table)。
Node<K,V>[] nt = (Node<K,V>[])new Node<?,?>[n];
table = tab = nt;
//计算阈值,n - (n >>> 2) = 0.75n当ConcurrentHashMap储存的键值对数量
//大于这个阈值,就会发生扩容。
//这里的0.75相当于HashMap的默认负载因子,可以发现HashMap、Hashtable如果
//使用传入了负载因子的构造函数初始化的话,那么每次扩容,新阈值都是=新容
//量 * 负载因子,而ConcurrentHashMap不管使用的哪一种构造函数初始化,
//新阈值都是=新容量 * 0.75。
sc = n - (n >>> 2);
}
} finally {
sizeCtl = sc;
}
break;
}
}
return tab;
}
- 判断table是否为
null
,即需不需要首次初始化,如果某个线程进到这个方法后,其他线程已经将table初始化好了,那么该线程结束该方法返回。 - 如果table为
null
,进入到while循环,如果sizeCtl
小于0(其他线程正在对table初始化),那么该线程调用Thread.yield()
挂起该线程,让出CPU时间,该线程也从运行态转成就绪态,等该线程从就绪态转成运行态的时候,别的线程已经table初始化好了,那么该线程结束while循环,结束初始化方法返回。如果从就绪态转成运行态后,table仍然为null
,则继续while循环。 - 如果table为
null
且sizeCtl
不小于0,则调用实现CAS
原子性操作的compareAndSwap()
方法将sizeCtl设置成-1,告诉别的线程我正在初始化table,这样别的线程无法对table进行初始化。如果设置成功,则再次判断table是否为空,不为空则初始化table,容量大小为默认的容量大小(16),或者为sizeCtl。其中sizeCtl的初始化是在构造函数中进行的,sizeCtl = ((传入的容量大小 + 传入的容量大小无符号右移1位 + 1)的结果向上取最近的2幂次方)
四、扩容机制
4.1 transfer扩容方法
transfer()
方法为ConcurrentHashMap
扩容操作的核心方法。由于ConcurrentHashMap
支持多线程扩容,而且也没有进行加锁,所以实现会变得有点儿复杂。整个扩容操作分为两步:
- 构建一个nextTable,其大小为原来大小的两倍,这个步骤是在单线程环境下完成的
- 将原来table里面的内容复制到nextTable中,这个步骤是允许多线程操作的,所以性能得到提升,减少了扩容的时间消耗
/**
* Moves and/or copies the nodes in each bin to new table. See
* above for explanation.
* 把数组中的节点复制到新的数组的相同位置,或者移动到扩张部分的相同位置
* 在这里首先会计算一个步长,表示一个线程处理的数组长度,用来控制对CPU的使用,
* 每个CPU最少处理16个长度的数组元素,也就是说,如果一个数组的长度只有16,那只有一个线程会对其进行扩容的复制移动操作
* 扩容的时候会一直遍历,知道复制完所有节点,没处理一个节点的时候会在链表的头部设置一个fwd节点,这样其他线程就会跳过他,
* 复制后在新数组中的链表不是绝对的反序的
*/
private final void transfer(Node<K,V>[] tab, Node<K,V>[] nextTab) {
int n = tab.length, stride;
if ((stride = (NCPU > 1) ? (n >>> 3) / NCPU : n) < MIN_TRANSFER_STRIDE) //MIN_TRANSFER_STRIDE 用来控制不要占用太多CPU
stride = MIN_TRANSFER_STRIDE; // subdivide range //MIN_TRANSFER_STRIDE=16
/*
* 如果复制的目标nextTab为null的话,则初始化一个table两倍长的nextTab
* 此时nextTable被设置值了(在初始情况下是为null的)
* 因为如果有一个线程开始了表的扩张的时候,其他线程也会进来帮忙扩张,
* 而只是第一个开始扩张的线程需要初始化下目标数组
*/
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;
/*
* 创建一个fwd节点,这个是用来控制并发的,当一个节点为空或已经被转移之后,就设置为fwd节点
* 这是一个空的标志节点
*/
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 (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;
}
}
if (i < 0 || i >= n || i + n >= nextn) {
int sc;
if (finishing) { //已经完成转移
nextTable = null;
table = nextTab;
sizeCtl = (n << 1) - (n >>> 1); //设置sizeCtl为扩容后的0.75
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
}
}
else if ((f = tabAt(tab, i)) == null) //数组中把null的元素设置为ForwardingNode节点(hash值为MOVED[-1])
advance = casTabAt(tab, i, null, fwd);
else if ((fh = f.hash) == MOVED)
advance = true; // already processed
else {
synchronized (f) { //加锁操作
if (tabAt(tab, i) == f) {
Node<K,V> ln, hn;
if (fh >= 0) { //该节点的hash值大于等于0,说明是一个Node节点
/*
* 因为n的值为数组的长度,且是power(2,x)的,所以,在&操作的结果只可能是0或者n
* 根据这个规则
* 0--> 放在新表的相同位置
* n--> 放在新表的(n+原来位置)
*/
int runBit = fh & n;
Node<K,V> lastRun = f;
/*
* lastRun 表示的是需要复制的最后一个节点
* 每当新节点的hash&n -> b 发生变化的时候,就把runBit设置为这个结果b
* 这样for循环之后,runBit的值就是最后不变的hash&n的值
* 而lastRun的值就是最后一次导致hash&n 发生变化的节点(假设为p节点)
* 为什么要这么做呢?因为p节点后面的节点的hash&n 值跟p节点是一样的,
* 所以在复制到新的table的时候,它肯定还是跟p节点在同一个位置
* 在复制完p节点之后,p节点的next节点还是指向它原来的节点,就不需要进行复制了,自己就被带过去了
* 这也就导致了一个问题就是复制后的链表的顺序并不一定是原来的倒序
*/
for (Node<K,V> p = f.next; p != null; p = p.next) {
int b = p.hash & n; //n的值为扩张前的数组的长度
if (b != runBit) {
runBit = b;
lastRun = p;
}
}
if (runBit == 0) {
ln = lastRun;
hn = null;
}
else {
hn = lastRun;
ln = null;
}
/*
* 构造两个链表,顺序大部分和原来是反的
* 分别放到原来的位置和新增加的长度的相同位置(i/n+i)
*/
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)
/*
* 假设runBit的值为0,
* 则第一次进入这个设置的时候相当于把旧的序列的最后一次发生hash变化的节点(该节点后面可能还有hash计算后同为0的节点)设置到旧的table的第一个hash计算后为0的节点下一个节点
* 并且把自己返回,然后在下次进来的时候把它自己设置为后面节点的下一个节点
*/
ln = new Node<K,V>(ph, pk, pv, ln);
else
/*
* 假设runBit的值不为0,
* 则第一次进入这个设置的时候相当于把旧的序列的最后一次发生hash变化的节点(该节点后面可能还有hash计算后同不为0的节点)设置到旧的table的第一个hash计算后不为0的节点下一个节点
* 并且把自己返回,然后在下次进来的时候把它自己设置为后面节点的下一个节点
*/
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;
}
}
/*
* 在复制完树节点之后,判断该节点处构成的树还有几个节点,
* 如果≤6个的话,就转回为一个链表
*/
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;
}
}
}
}
}
}
具体流程
首先根据运算得到需要遍历的次数i,然后利用tabAt方法获得i位置的元素:
- 如果这个位置为空,就在原table中的i位置放入forwardNode节点,这个也是触发并发扩容的关键点;
- 如果这个位置是Node节点(fh>=0),如果它是一个链表的头节点,就构造一个反序链表,把他们分别放在nextTable的i和i+n的位置上
- 如果这个位置是TreeBin节点(fh<0),也做一个反序处理,并且判断是否需要untreefi,把处理的结果分别放在nextTable的i和i+n的位置上
- 遍历过所有的节点以后就完成了复制工作,这时让nextTable作为新的table,并且更新sizeCtl为新容量的0.75倍,完成扩容。
多线程遍历节点,处理了一个节点,就把对应点的值set为forward,另一个线程看到forward,就向后继续遍历,再加上给节点上锁的机制,就完成了多线程的控制。这样交叉就完成了复制工作。而且还很好的解决了线程安全的问题。
4.2 红黑树转换
在putVal函数中,treeifyBin是在链表长度达到一定阈值(8)后转换成红黑树的函数。 但是并不是直接转换,而是进行一次容量判断,如果容量没有达到转换的要求,直接进行扩容操作并返回;如果满足条件才将链表的结构转换为TreeBin ,这与HashMap不同的是,它并没有把TreeNode直接放入红黑树,而是利用了TreeBin这个小容器来封装所有的TreeNode。
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));
}
}
}
}
}
五、面试题
5.1 HashMap、Hashtable、ConcurrentHashMap三者对比
HashMap | Hashtable | ConcurrentHashMap | |
---|---|---|---|
是否线程安全 | 否 | 是 | 是 |
线程安全采用的方式 | 采用synchronized 类锁,效率低 | 采用CAS + synchronized ,锁住的只有当前操作的bucket,不影响其他线程对其他bucket的操作,效率高 | |
数据结构 | 数组+链表+红黑树(链表长度超过8则转红黑树) | 数组+链表 | 数组+链表+红黑树(链表长度超过8则转红黑树) |
是否允许null 键值 | 是 | 否 | 否 |
哈希地址算法 | (key的hashCode)^(key的hashCode无符号右移16位) | key的hashCode | ( (key的hashCode)^(key的hashCode无符号右移16位) )&0x7fffffff |
定位算法 | 哈希地址&(容量大小-1) | (哈希地址&0x7fffffff)%容量大小 | 哈希地址&(容量大小-1) |
扩容算法 | 当键值对数量大于阈值,则容量扩容到原来的2倍 | 当键值对数量大于等于阈值,则容量扩容到原来的2倍+1 | 当键值对数量大于等于sizeCtl,单线程创建新哈希表,多线程复制bucket到新哈希表,容量扩容到原来的2倍 |
链表插入 | 将新节点插入到链表尾部 | 将新节点插入到链表头部 | 将新节点插入到链表尾部 |
继承的类 | 继承abstractMap 抽象类 | 继承Dictionary 抽象类 | 继承abstractMap 抽象类 |
实现的接口 | 实现Map 接口 | 实现Map 接口 | 实现ConcurrentMap 接口 |
默认容量大小 | 16 | 11 | 16 |
默认负载因子 | 0.75 | 0.75 | 0.75 |
统计size方式 | 直接返回成员变量size | 直接返回成员变量count | 遍历CounterCell 数组的值进行累加,最后加上baseCount 的值即为size |