简介
通过之前学习HashMap,知道在多线程环境下,同时进行HashMap的put操作时,可能造成死循环,所以需要一个更安全的Map在多线程环境下使用。
了解HashMap传送门:HashMap的深入了解
HashTable作为一个历史遗留类,所有线程去竞争同一把锁。当一个线程访问HashTable时,其他线程访问HashTable同步方法,就会进入等待或轮训状态,效率极其低下。
在 JDK1.5加入ConcurrentHashMap,了解ConcurrentHashMap,还有一些其他的知识要了解,如ReentrantLock,AbstractQueuedSynchronizer,本人目前对这些知识没有掌握,这里暂时不写,后续补上。
JDK1.7中ConcurrentHashMap使用了Segment(桶)锁分段技术。它将数据分段存储,并对每一段数据分别加锁,即一个线程访问一段数据,其他段数据仍然可以被其他线程访问,提高效率。
JDK1.8中,ConcurrentHashMap的实现参考了JDK1.8中HashMap的实现,舍弃了Segment,采用了数组+链表+红黑树,所以本文以JDK1.8源码为主。ConcurrentHashMap内部大量采用了CAS操作+volatile,CAS操作又是乐观锁的一种实现方式,所以这里先了解乐观锁和悲观锁,还有volatile。
乐观锁和悲观锁
乐观锁
在每次取数据的时候,乐观的认为别人不会在此期间对数据进行修改,所以不上锁。但是在更新的时候会判断一下别人是否对数据进行改动过,若数据改动过,则重新获取数据,重新判定改动。若判断未改动过,则更新数据。
悲观锁
在每次取数据的时候,悲观的认为别人会在此期间对数据进行修改,所以取数据的时候都会上锁。别人想拿到这个锁时会阻塞。
传统数据库用到的大多是悲观锁,如行锁、表锁、读锁,都是在操作前先上锁。java中为保证线程安全synchronized和ReetrantLock都是悲观锁的实现。
乐观锁的两种实现方式
-
版本号机制
通常在数据表中多加一列版本号字段(version),用于表示数据的修改次数。数据被修改一次,version+1。当需要更新数据时,将版本号一起读取出来,在更新时,判断版本号和数据库中版本号是否一致。如果一致,则更新,不一致,则重新获取数据进行更新操作。
-
CAS算法
CAS算法的意思是compare and swap,比较并且交换。此算法实现了在不使用锁的情况下,实现线程之间的变量同步。CAS是CPU指令级的操作,只有一步原子操作,所以非常快。
CAS涉及三个操作数:
(1)内存值 V
(2)旧的预期值 A
(3)要修改的新值 B。
当且仅当旧的预期值 A和内存值 V相同时,将内存值修改为B,否则什么都不做。
举个例子:有两个线程t1、t2,他们都要去更新一个变量的值,所以他们会把主内存的值完全拷贝一份到自己的工作内存空间,当t1在线程竞争中更新了变量的值,然后将变量的值写回到主存中。t2再去获取变量,会将内存值和原有拷贝的值进行对比,若不相等,证明数据已经被修改,则放弃这次操作,重新进行更新操作。
volatile
学习volatile需要先了解内存屏障。
内存屏障 Memory Barrier
内存屏障,又称内存栅栏,是一个CPU指令
1,保证特定操作的执行顺序
2,影响某些数据(或者是某条指令的执行结果)的内存可见性
编译器和CPU可以重排指令,保证最终的执行结果,尝试优化性能。插入一条Memory Barrier会告诉编译器和CPU,不管什么指令,都不能和这条Memory Barrier指令重排序。
Memery Barrier 所做的另外一件事是强刷出各种CPU cache,如一个Write-barrier(写入屏障)将刷出所有在Barrier之前写入cashe的数据,因此,任何CPU上的线程都能读取到这些数据的最新版本。
volatile
volatile就是基于Memory Barrier实现的。如果一个变量是volatile修饰的,JMM会在写入这个变量之前,插入一个write-Barrier指令,在变量之后插入一个Read-Barrier指令。
也就是说,
-
写入一个volatile变量,可以保证任何线程访问该变量都是最新值。
-
在写入变量前的写入操作,其更新数据对于其他线程也是可见的。因为Memory Barrier会刷出cache中的所有先前的写入。
刷出cache,直白的讲就是让原来CPU中的缓存消失,这样就需要重新从主存中取数据。
JDK 1.8 实现
JDK1.8的实现已经完全抛弃segment分段锁机制,利用CAS和Synchronized保证并发更新的安全,底层采用数组+链表+红黑树的存储结构。盗一张图:
内部结构-字段
Table:默认为null,初始化在第一次插入时,默认数据大小为16,用来存储Node节点数据,扩容时总是2的幂次方(同HashMap中数组table)
transient volatile Node<K,V>[] table;
Node:保存key,value,hash,以及nextNode的数组结构。其中key和value用volatile修饰,保证并发的可见性。数据结构为链表。
class Node<K,V> implements Map.Entry<K,V> {
final int hash;
final K key;
volatile V val;
volatile Node<K,V> next;
... 省略部分代码
}
TreeBean:同上,hash<0,数据结构为红黑树
nextTable:默认为null,扩容时新生成的数组,大小为原来的2倍
sizeCtl:默认为0,用来控制table的初始化和扩容。
- -1:代表table正在初始化(这里后续需要补充)
- -N:表示有N-1个线程正在进行扩容操作
- 其余情况:
1.如果table未初始化,表示table需要初始化的大小
2.如果table初始化完成,表示table的容量,默认是table长度的0.75倍(此时同HashMap中的threshold)
ForwardingNode:一个特殊的Node节点,hash值为-1,其中存储nextTable的引用。只有table扩容时才起作用,作为一个占位符放在table中,表示该节点为null或者已经被移动
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;
}
}
初始化
实例初始化
实例化ConcurrentHashMap时带参数时,会根据参数调整table的大小,假设参数为100,最终会调整成256,确保table的大小总是2的幂次方,算法如下:
ConcurrentHashMap<String, String> hashMap = new ConcurrentHashMap<>(100);
private static final int tableSizeFor(int c) {
int n = c - 1;
n |= n >>> 1;
n |= n >>> 2;
n |= n >>> 4;
n |= n >>> 8;
n |= n >>> 16;
return (n < 0) ? 1 : (n >= MAXIMUM_CAPACITY) ? MAXIMUM_CAPACITY : n + 1;
}
ConcurrentHashMap在构造函数中只会初始化sizeCtl的值,而不是初始化table。
table初始化
table的初始化实在第一次put操作时完成。但是put操作是并发时,table的初始化是如何实现的。
private final Node<K,V>[] initTable() {
Node<K,V>[] tab; int sc;
while ((tab = table) == null || tab.length == 0) {
//如果一个线程发现sizeCtl<0,意味着另外的线程执行CAS操作成功,当前线程只需要让出cpu时间片
if ((sc = sizeCtl) < 0)
Thread.yield(); // lost initialization race; just spin
//将当前对象做偏移量与预期值比较,如果相等则赋值-1(可以理解为sizeCtl=-1)
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;
}
sizeCtl默认为0,如果ConcurrentHashMap初始化时有参数,sizeCtl会是一个2的幂次方的值。当第一次执行put操作时,会执行U.compareAndSwapInt方法,使sizeCtl的值为-1,其他线程调用Thread.yield()方法等待table初始化完成。
内部结构-方法
假设table已经初始化完成,put操作采用CAS+synchronized实现并发插入或更新操作。
final V putVal(K key, V value, boolean onlyIfAbsent) {
if (key == null || value == null) throw new NullPointerException();
int hash = spread(key.hashCode()); //hash算法
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(); //初始化
//f==null,证明这个位置第一次插入节点,使用CAS算法直接插入
//i = (n - 1) & hash 定位索引位置
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) //final MOVED=-1,证明正在扩容
tab = helpTransfer(tab, f); //帮助扩容
else {
else {
V oldVal = null;
synchronized (f) {
//同步代码块,将节点插入列表或红黑树,下面贴出
}
if (binCount != 0) {
if (binCount >= TREEIFY_THRESHOLD)
treeifyBin(tab, i); //超过阈值,转为红黑树
if (oldVal != null)
return oldVal;
break;
}
}
}
}
addCount(1L, binCount); //数量+1,判断是否需要扩容
return null;
}
static final int spread(int h) {return (h ^ (h >>> 16)) & HASH_BITS;}
从代码中可知,获取table中元素tabAt方法使用了Unsafe.getObjectVolatile来获取,而不是直接使用table[index],原因是虽然table被volatile修饰,但是只能保证table引用对象的可见性,并不能保证table内部数据的可见性,所以直接使用索引获取,无法保证拿到的是最新元素。
-
问:既然不能保证内部数据的可见性,为什么还要在table上加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);
}
Unsafe.getObjectVolatile可以保证,每次获取的数据都是最新元素。
同步内置锁代码块
synchronized (f) { //在节点f上做同步
if (tabAt(tab, i) == f) { //同步后再次判断索引位置是否为f,防止其他线程修改
if (fh >= 0) { //如果f.hash>0,证明为头结点
binCount = 1;
for (Node<K,V> e = f;; ++binCount) {
K ek;
//如果找到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; //插入节点到尾部
if ((e = e.next) == null) {
pred.next = new Node<K,V>(hash, key,
value, null);
break;
}
}
}
else if (f instanceof TreeBin) { //如果是红黑树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;
}
}
}
}
table扩容
ConcurrentHashMap的扩容步骤与HashMap的扩容机制流程一致:
- 先构建一个大小为table两倍的nextTable,
- 然后将原有数据复制到nextTable中。
但是ConcurrentHashMap是支持并发操作的,在扩容时也有可能出现并发的情况,这种情况下,第二步支持节点的并发复制。
private final void addCount(long x, int check) {
CounterCell[] as; long b, s;
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();
}
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();
}
}
}
看了好几遍,看不懂。以后回过头再看。
get操作
读取数据并不涉及到并发,这里与HashMap相似。
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或者该节点是否为空,否则直接返回null
- 判断是否在索引处,如果是直接返回value;判断是否为红黑树,是的或则进行查找并返回;循环判断链表,返回key值相同的value
-
问:为什么ConcurrentHashMap的读操作不需要加锁?
提示:volatile关键字
总结
ConcurrentHashMap是并发散列映射表的实现,他允许完全并发的读取,并且支持给定数量的并发更新。
-
ConcurrentHashMap和Collections.synchronizedMap(hashMap)、HashTable的比较
-
后两者使用全局锁来同步不同线程之间的并发访问,导致对容器的访问变成串行化了。
-
JDK1.6采用ReentrantLock 分段锁的方式,使多个线程在segment上进行写操作不会发生阻塞。
-
JDK1.8中使用的是内部锁,对节点进行同步,效率提升,但是实现方式上复杂度也较之前有了非常大提升(虽然不用我们写,但是读源码也挺累的)。
-