ConcurrentHashMap
继承自AbstractMap,实现ConcurrentMap接口
如何保证线程安全?
table
、nextTable
、sizeCtl
、baseCount
等变量均用volatile
修饰- 初始化时用
compareAndSwapInt
修改sizeCtl
,使用yield
方法 - put操作对链表头节点加锁
- 获取元素用Unsafe类的
getObjectVolatile
方法 - 设置数组元素用
compareAndSwapObject
方法 - 用for循环
for (Node<K,V>[] tab = table;;)
- 如果其他线程正在修改tab,那么尝试就会失败
JDK8中的实现
- 摒弃了Segment(锁段)的概念
- 利用CAS算法 + Synchronized
- 底层采用数组+链表+红黑树的存储结构
并发度(Concurrency Level)
- 程序运行时能够同时更新ConccurentHashMap且不产生锁竞争的最大线程数
- 如果并发度设置的过小,会带来严重的锁竞争问题;
- 如果并发度设置的过大,CPU cache命中率会下降
重要对象
- table:默认为null,初始化发生在第一次插入操作,默认大小为16的数组,用来存储Node节点数据,扩容时大小总是2的幂次方。
nextTable:默认为null,扩容时新生成的数组,其大小为原数组的两倍。
sizeCtl:默认为0,用来控制table的初始化和扩容操作
负数代表正在进行初始化或扩容操作
-1代表正在初始化
-N 表示有N-1个线程正在进行扩容操作
正数或0代表hash表还没有被初始化
这个数值表示初始化或下一次进行扩容的大小CELLSBUSY:自旋锁
- DEFAULT_CONCURRENCY_LEVEL:默认的并发度为16
- LOAD_FACTOR:0.75f
- TREEIFY_THRESHOLD:8
- UNTREEIFY_THRESHOLD:6
- MIN_TREEIFY_CAPACITY:64
重要的类
- Node
- TreeNode
- TreeBin
- ForwardingNode:一个特殊的Node节点,hash值为-1,其中存储nextTable的引用
- CAS
CAS算法实现无锁化的修改值的操作,他可以大大降低锁代理的性能消耗 - Unsafe
unsafe代码块控制了一些属性的修改工作,比如最常用的SIZECTL 。
核心方法
实例初始化
实例化ConcurrentHashMap时带参数时,会根据参数调整table的大小,假设参数为100,最终会调整成256,确保table的大小总是2的幂次方
ConcurrentHashMap在构造函数中只会初始化sizeCtl值
并不会直接初始化table,而是延缓到第一次put操作
table初始化
put是可以并发执行的,如何实现table只初始化一次?
sizeCtl默认为0,执行第一次put操作的线程会执行Unsafe.compareAndSwapInt方法修改sizeCtl为-1
如果初始失败 调用yeild 自旋
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()方法
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);
...
...
}
addCount(1L, binCount);
return null;
}
hash算法
static final int spread(int h) {return (h ^ (h >>> 16)) & HASH_BITS;}
定位索引位置并获取索引处元素
if ((f = tabAt(tab, i = (n - 1) & hash)) == null)
取出table
用for循环for (Node<K,V>[] tab = table;;)
如果其他线程正在修改tab,那么尝试就会失败
尝试成功后
- 如果tab为空或长度为0,初始化table
- 否则说明table有元素
取出索引位置元素
如果f为null说明table中这个位置第一次插入元素
利用Unsafe.compareAndSwapObject方法插入Node节点。
退出循环,addCount(1L, binCount)方法会检查当前容量是否需要进行扩容。
casTabAt(tab, i, null,new Node<K,V>(hash, key, value, null))
- 如果CAS成功,说明Node节点已经插入
- 如果CAS失败,自旋重新尝试在这个位置插入节点。
如果f不为null
如果f的hash值为-1,说明当前f是ForwardingNode节点,意味有其它线程正在扩容,则一起进行扩容操作。
其余情况把新的Node节点按链表或红黑树的方式插入到合适的位置,这个过程采用同步内置锁实现并发
put操作类似HashMap
区别是
- 操作放在for循环里,如果其他线程正在修改tab,那么尝试就会失败
- 针对首个节点进行加锁操作,而不是segment,进一步减少线程冲突
get()方法
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;
}
table扩容
当table容量不足的时候,即table的元素数量达到容量阈值sizeCtl,需要对table进行扩容。
构建一个nextTable,大小为table的两倍。
把table的数据复制到nextTable中。
private final void addCount(long x, int check) {
... 省略部分代码
if (check >= 0) {
Node<K,V>[] tab, nt; int n, sc;
while (s >= (long)(sc = sizeCtl) && (tab = table) != null &&
(n = tab.length) < MAXIMUM_CAPACITY) {
int rs = resizeStamp(n);
if (sc < 0) {
if ((sc >>> RESIZE_STAMP_SHIFT) != rs || sc == rs + 1 ||
sc == rs + MAX_RESIZERS || (nt = nextTable) == null ||
transferIndex <= 0)
break;
if (U.compareAndSwapInt(this, SIZECTL, sc, sc + 1))
transfer(tab, nt);
}
else if (U.compareAndSwapInt(this, SIZECTL, sc,
(rs << RESIZE_STAMP_SHIFT) + 2))
transfer(tab, null);
s = sumCount();
}
}
}
通过Unsafe.compareAndSwapInt修改sizeCtl值,保证只有一个线程能够初始化nextTable,
扩容后的数组长度为原来的两倍,但是容量是原来的1.5。
tabAt
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);
}
casTabAt
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);
}
在ConcurrentHashMap(1.8)中,内部使用一个volatile的数组table保存数据,细心的同学可以发现,Doug Lea每次在获取数组的元素时,采用Unsafe类的getObjectVolatile方法,在设置数组元素时,采用compareAndSwapObject方法,而不是直接通过下标去操作,这是为什么?
volatile修饰的变量如果是对象或数组之类的,其含义是对象或数组的地址具有可见性,但是数组或对象内部成员改变不具备可见性
这个是因为Java数组在元素层面的元数据设计上的缺失,无法表达元素是final、volatile等语义,所以使用getObjectVolatile用来补上无法表达元素是volatile的坑,@Stable用来补上final的坑,
数组元素就跟没有标volatile的成员字段一样,无法保证线程之间可见性。
只有触发happens before关系的操作,才能保证线程之间的可见性,比如使用table[0] = new Object()直接赋值,这个赋值不会触发任何happens before关系的操作,相当于对一个无volatile变量进行赋值一样。