ConcurrentHashMap 是 J.U.C 包里面提供的一个线程安全并且高效的 HashMap,所以 ConcurrentHashMap 在并发编程的场景中使用的频率比较高,今天就从 ConcurrentHashMap 的使用上以及源码层面来分析 ConcurrentHashMap 到底是如何实现安全性的
api 使用
ConcurrentHashMap 是 Map 的派生类,所以 api 基本和 Hashmap 是类似,主要就是 put、 get 这些方法,接下来基于 ConcurrentHashMap 的 put 和 get 这两个方法作为切入点来分析 ConcurrentHashMap 的源码实现
Hash,一般翻译做散列、杂凑,或音译为哈希,是把任意长度的输入(又叫做预映射pre-image)通过散列算法变换成固定长度的输出,该输出就是散列值。这种转换是一种压缩映射。
还有例如 MD5 、 SHA 也是散列算法
发生hash冲突时的解决方案
JDK1.7 和 Jdk1.8 版本的变化
在 JDK1.7 中,ConrruentHashMap 由一个个 Segment 组成,简单来说, ConcurrentHashMap 是一个 Segment 数组,它通过继承 ReentrantLock 来进行加锁,通过每次锁住一个 segment 来保证每个 segment 内的操作的线程安全性,从而实现全局线程安全。
为什么要使用ConcurrentHashMap
在并发编程中,jdk1.7的情况下使用 HashMap 可能造成死循环,而jdk1.8 中有可能会造成数据丢失
ConcurrentHashMap:在hashMap的基础上,ConcurrentHashMap将数据分为多个segment(段),默认16个(concurrency level),然后每次操作对一个segment(段)加锁,避免多线程锁的几率,提高并发效率。
HashMap
底层数组+链表实现,可以存储null键和null值,线程不安全
初始size为1***6,扩容:newsize = oldsize2,size一定为2的n次幂
扩容针对整个Map,每次扩容时,原来数组中的元素依次重新计算存放位置,并重新插入
插入元素后才判断该不该扩容,有可能无效扩容(插入后如果扩容,如果没有再次插入,就会产生无效扩容)
当Map中元素总数超过Entry数组的75%,触发扩容操作,为了减少链表长度,元素分配更均匀
计算index方法:index = hash & (tab.length – 1)
HashMap的初始值还要考虑加载因子:
哈希冲突:若干Key的哈希值按数组大小取模后,如果落在同一个数组下标上,将组成一条Entry链,对Key的查找需要遍历Entry链上的每个元素执行equals()比较。
加载因子:为了降低哈希冲突的概率,默认当HashMap中的键值对达到数组大小的75%时,即会触发扩容。因此,如果预估容量是100,即需要设定100/0.75=134的数组大小。
空间换时间*:如果希望加快Key查找的时间,还可以进一步降低加载因子,加大初始大小,以降低哈希冲突的概率。*
ConcurrentHashMap
底层采用分段的数组+链表实现,线程安全
通过把整个Map分为N个Segment,可以提供相同的线程安全,但是效率提升N倍,默认提升16倍。(读操作不加锁,由于HashEntry的value变量是 volatile的,也能保证读取到最新的值。)
Hashtable的synchronized是针对整张Hash表的,即每次锁住整张表让线程独占,ConcurrentHashMap允许多个修改操作并发进行,其关键在于使用了锁分离技术
有些方法需要跨段,比如size()和containsValue(),它们可能需要锁定整个表而而不仅仅是某个段,这需要按顺序锁定所有段,操作完毕后,又按顺序释放所有段的锁
扩容:段内扩容(段内元素超过该段对应Entry数组长度的75%触发扩容,不会对整个Map进行扩容),插入前检测需不需要扩容,有效避免无效扩容
ConcurrentHashMap是使用了锁分段技术来保证线程安全的。
锁分段技术:首先将数据分成一段一段的存储,然后给每一段数据配一把锁,当一个线程占用锁访问其中一个段数据的时候,其他段的数据也能被其他线程访问。
ConcurrentHashMap提供了与Hashtable和SynchronizedMap不同的锁机制。Hashtable中采用的锁机制是一次锁住整个hash表,从而在同一时刻只能由一个线程对其进行操作;而ConcurrentHashMap中则是一次锁住一个桶。
ConcurrentHashMap默认将hash表分为16个桶,诸如get、put、remove等常用操作只锁住当前需要用到的桶。这样,原来只能一个线程进入,现在却能同时有16个写线程执行,并发性能的提升是显而易见的。
当hashmap中的元素个数超过数组大小loadFactor时,就会进行数组扩容,loadFactor的默认值为0.75,也就是说,默认情况下,数组大小为16,那么当hashmap中元素个数超过160.75=12的时候,就把数组的大小扩展为216=32,即扩大一倍,然后重新计算每个元素在数组中的位置,而这是一个非常消耗性能的操作,所以如果我们已经预知hashmap中元素的个数,那么预设元素的个数能够有效的提高hashmap的性能。比如说,我们有1000个元素new HashMap(1000), 但是理论上来讲new HashMap(1024)更合适,不过上面annegu已经说过,即使是1000,hashmap也自动会将其设置为1024。 但是new HashMap(1024)还不是更合适的,因为0.751000 < 1000, 也就是说为了让0.75 * size > 1000, 我们必须这样new HashMap(2048)才最合适,既考虑了&的问题,也避免了resize的问题。
总结:HashMap的实现原理:
final Entry<K,V> getEntry(Object key) {
if (size == 0) {
return null;
}
//通过key的hashcode值计算hash值
int hash = (key == null) ? 0 : hash(key);
//indexFor (hash&length-1) 获取最终数组索引,然后遍历链表,通过equals方法比对找出对应记录
for (Entry<K,V> e = table[indexFor(hash, table.length)];
e != null;
e = e.next) {
Object k;
if (e.hash == hash &&
((k = e.key) == key || (key != null && key.equals(k))))
return e;
}
return null;
}
结论
Hashtable和HashMap有几个主要的不同:线程安全以及速度。仅在你需要完全的线程安全的时候使用Hashtable,而如果你使用Java 5或以上的话,请使用ConcurrentHashMap吧。
jdk1.7 数据结构图如下
当每个操作分布在不同的 segment 上的时候,默认情况下,理论上可以同时支持 16 个线程的并发写入。Segment 的结构和 HashMap 类似,是一个数组和链表结构。
jdk1.8 数据结构图如下
相比于 1.7 版本,它做了两个改进
接下来我们从源码层面来了解一下它的原理. 我们基于 put 和 get 方法来分析它的实现即可
new的时候不会初始化数组,在put的时候才进行初始化
public V put(K key, V value) {
return putVal(key, value, false);
}
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)
// 如果数组为空,则进行数组的初始化
// 通过 hash 值对应的数组下标得到第一个节点; 以 volatile 读的方式来读取
// table 数组中的元素,保证每次拿到的数据都是最新的
tab = initTable();
else if ((f = tabAt(tab, i = (n - 1) & hash)) == null) {
//如果该下标返回的节点为空,则直接通过 cas 将新的值封装成 node 插入即可;
//如果 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 {
// 这里是扩容操作
}
}
// 更新hashMap中的元素个数
addCount(1L, binCount);
return null;
假如在上面这段代码中存在两个线程,在不加锁的情况下:线程 A 成功执行 casTabAt 操作后,随后的线程 B 可以通过 tabAt 方法立刻看到 table[i]的改变。原因如下:
线程 A 的 casTabAt 操作,具有 volatile 读写相同的内存语义,根据 volatile 的 happens-before 规则:线程 A 的 casTabAt 操作,一定对线程 B 的 tabAt 操作可见
initTable 初始化数组
private final Node<K,V>[] initTable() {
Node<K,V>[] tab; int sc;
while ((tab = table) == null || tab.length == 0) {
if ((sc = sizeCtl) < 0)
//被其他线程抢占了初始化的操作,则直接让出自己的 CPU 时间片
Thread.yield(); // lost initialization race; just spin
//通过 cas 操作,将 sizeCtl 替换为-1,标识当前线程抢占到了初始化资格
else if (U.compareAndSwapInt(this, SIZECTL, sc, -1)) {
try {
if ((tab = table) == null || tab.length == 0) {
// 默认初始容量为16
int n = (sc > 0) ? sc : DEFAULT_CAPACITY;
@SuppressWarnings("unchecked")
//初始化数组,长度为16,或者初始化在构造 ConcurrentHashMap 的时候传入的长度
Node<K,V>[] nt = (Node<K,V>[])new Node<?,?>[n];
table = tab = nt;//将这个数组赋值给table
//计算下次扩容的大小,实际就是当前容量的 0.75 倍,这里使用了右移来计算
sc = n - (n >>> 2);
}
} finally {
//设置sizeCtl为sc, 如果默认是16的话,那么这个时候sc=16*0.75=12
sizeCtl = sc;
}
break;
}
}
return tab;
}
数组初始化方法,这个方法比较简单,就是初始化一个合适大小的数组
sizeCtl 这个要单独说一下,如果没搞懂这个属性的意义,可能会被搞晕
这个标志是在 Node 数组初始化或者扩容的时候的一个控制位标识,负数代表正在进行初始化或者扩容操作
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);
}
该方法获取对象中 offset 偏移地址对应的对象 field 的值。实际上这段代码的含义等价于 tab[i], 但是为什么不直接使用 tab[i]来计算呢?
getObjectVolatile,一旦看到 volatile 关键字,就表示可见性。因为对 volatile 写操作 happen- before 于 volatile 读操作,因此其他线程对 table 的修改均对 get 读取可见;
虽然 table 数组本身是增加了 volatile 属性,但是“volatile 的数组只针对数组的引用具有 volatile 的语义,而不是它的元素”。 所以如果有其他线程对这个数组的元素进行写操作,那 么当前线程来读的时候不一定能读到最新的值。
出于性能考虑,Doug Lea 直接通过 Unsafe 类来对 table 进行操作。
在 putVal 方法执行完成以后,会通过 addCount 来增加 ConcurrentHashMap 中的元素个数, 并且还会可能触发扩容操作。这里会有两个非常经典的设计
计数addCount
在 putVal 最后调用 addCount 的时候,传递了两个参数,分别是 1 和 binCount(链表长度), 看看 addCount 方法里面做了什么操作
x 表示这次需要在表中增加的元素个数,check 参数表示是否需要进行扩容检查,大于等于 0 都需要进行检查
如何保证 addCount 的数据安全性以及性能
private transient volatile long baseCount; //在没有竞争的情况下,去通过cas操作更新元素个数
private transient volatile CounterCell[] counterCells;//在存在线程竞争的情况下,存储元素个数
addCount(1L, binCount);
return null;
private final void addCount(long x, int check) {
CounterCell[] as; long b, s;
// 判断 counterCells 是否为空
//1. 如果为空,就通过cas操作尝试修改baseCount变量,对这个变量进行原子累加操作
//(做这个操作的意义是:如果在没有竞争的情况下,仍然采用 baseCount 来记录元素个数
//2. 如果cas失败说明存在竞争,这个时候不能再采用 baseCount 来累加,而是通过
// CounterCell 来记录
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))) {
/*这里有几个判断
1. 计数表为空则直接调用 fullAddCount
2. 从计数表中随机取出一个数组的位置为空,直接调用 fullAddCount
3. 通过 CAS 修改 CounterCell 随机位置的值,如果修改失败说明出现并发情况(这里又
用到了一种巧妙的方法),调用fullAndCount。
Random 在线程并发的时候会有性能问题以及可能会产生相同的随机数,
ThreadLocalRandom.getProbe 可以解决这个问题,并且性能要比Random高 */
fullAddCount(x, uncontended);//执行fullAddCount方法
return;
}
if (check <= 1)//链表长度小于等于1,不需要考虑扩容
return;
s = sumCount();//统计ConcurrentHashMap元素个数
}
// 下面的逻辑是扩容操作
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();
}
}
}
CounterCells 解释:
ConcurrentHashMap 是采用 CounterCell 数组来记录元素个数的,像一般的集合记录集合大小,直接定义一个 size 的成员变量即可,当出现改变的时候只要更新这个变量就行。为什么 ConcurrentHashMap 要用这种形式来处理呢?
问题还是处在并发上,ConcurrentHashMap 是并发集合,如果用一个成员变量来统计元素个 数的话,为了保证并发情况下共享变量的安全性,必然会需要通过加锁或者自旋来实现, 如果竞争比较激烈的情况下,size 的设置上会出现比较大的冲突反而影响了性能,所以在 ConcurrentHashMap 采用了分片的方法来记录大小。看下面代码
private transient volatile int cellsBusy;// 标识当前 cell 数组是否在初始化或扩容中的CAS 标志位
private transient volatile CounterCell[] counterCells;// counterCells 数组,总数值的分值分别存在每个 cell 中
@sun.misc.Contended static final class CounterCell {
volatile long value;
CounterCell(long x) { value = x; }
}
//看到这段代码就能够明白了,CounterCell 数组的每个元素,都存储一个元素个数,而实际我们调用
//size 方法就是通过这个循环累加来得到的
//又是一个设计精华,大家可以借鉴; 有了这个前提,再会过去看 addCount 这个方法,就容易理解一些了
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;
}
扩容
判断链表的长度是否已经达到临界值 8. 如果达到了临界值,这个时候会根据当前数组的长度来决定是扩容还是将链表转化为红黑树。也就是说如果当前数组的长度小于 64,就会先扩容。否则,会把当前链表转化为红黑树
下一篇
多线程学习(八)之 “线程池的设计与原理解析”