ConcurrentHashMap(JDK 1.7)
-
ConcurrentHashMap 是由 Segment 数组和 HashEntry 数组和链表组成
-
Segment 是基于重入锁(ReentrantLock):一个数据段竞争锁。每个 HashEntry 一个链表结构的元素,利用 Hash 算法得到索引确定归属的数据段,也就是对应到在修改时需要竞争获取的锁。ConcurrentHashMap 支持 CurrencyLevel(Segment 数组数量)的线程并发。每当一个线程占用锁访问一个 Segment 时,不会影响到其他的 Segment
-
核心数据如 value,以及链表都是 volatile 修饰的,保证了获取时的可见性
-
首先是通过 key 定位到 Segment,之后在对应的 Segment 中进行具体的 put 操作如下:
-
将当前 Segment 中的 table 通过 key 的 hashcode 定位到 HashEntry。
-
遍历该 HashEntry,如果不为空则判断传入的 key 和当前遍历的 key 是否相等,相等则覆盖旧的 value
-
不为空则需要新建一个 HashEntry 并加入到 Segment 中,同时会先判断是否需要扩容
-
最后会解除在 1 中所获取当前 Segment 的锁
-
-
虽然 HashEntry 中的 value 是用 volatile 关键词修饰的,但是并不能保证并发的原子性,所以 put 操作时仍然需要加锁处理
首先第一步的时候会尝试获取锁,如果获取失败肯定就有其他线程存在竞争,则利用 scanAndLockForPut() 自旋获取锁
-
尝试自旋获取锁
-
如果重试的次数达到了 MAX_SCAN_RETRIES 则改为阻塞锁获取,保证能获取成功。最后解除当前 Segment 的锁
ConcurrentHashMap设计
ConcurrentHashMap是线程安全,通过分段锁的方式提高了并发度。分段Segment是一开始就确定的了,后期不能再进行扩容的。
其中的段Segment继承了重入锁ReentrantLock,所以调用lock方法将当前segment进行加锁,有了锁的功能,同时含有类似HashMap中的数组加链表结构(这里没有使用红黑树)
虽然Segment的个数是不能扩容的,但是单个Segment里面的数组是可以扩容的。
jdk1.7中采用Segment + HashEntry的方式进行实现,
结构如下:
2.1 整体概览
ConcurrentHashMap有3个参数:
· initialCapacity:初始总容量,默认16
· loadFactor:加载因子,默认0.75
· concurrencyLevel:并发级别,默认16
segment的个数即ssize(段数)
取大于等于并发级别的最小的2的幂次。如concurrencyLevel=16,那么sszie=16,如concurrencyLevel=10,那么ssize=16
单个segment的初始容量cap(容量/段数,如果有余数则向上+1)
c=initialCapacity/ssize,并且可能需要+1。如15/7=2,那么c要取3,如16/8=2,那么c取2,c可能是一个任意值,那么同上述一样,cap取的值就是大于等于c的最下2的幂次。最小值要求是2
单个segment的阈值threshold(cap*loadFactor)
如2*0.75=1.5。
· 所以默认情况下,
· initialCapacity:初始总容量,默认16
· loadFactor:加载因子,默认0.75
· concurrencyLevel:并发级别,默认16
segment的个数sszie=16,每个segment的初始容量cap=2(最少为2),单个segment的阈值threshold=1
2.2 put过程
· 首先根据key计算出一个hash值,找到对应的Segment
· 调用Segment的lock方法,为后面的put操作加锁
· 根据key计算出hash值,找到Segment中数组中对应index的链表,并将该数据放置到该链表中
· 判断当前Segment包含元素的数量大于阈值,则Segment进行扩容
2.4 get过程
· 根据key计算出对应的segment
· 再根据key计算出对应segment中数组的index
· 最终遍历上述index位置的链表,查找出对应的key的value
2.5size实现
因为ConcurrentHashMap是可以并发插入数据的,所以在准确计算元素时存在一定的难度,一般的思路是统计每个Segment对象中的元素个数,然后进行累加,但是这种方式计算出来的结果并不一样的准确的,因为在计算后面几个Segment的元素个数时,已经计算过的Segment同时可能有数据的插入或则删除,在1.7的实现中,采用了如下方式:
先采用不加锁的方式,连续计算元素的个数,最多计算3次:
1、如果前后两次计算结果相同,则说明计算出来的元素个数是准确的;
2、如果前后两次计算结果都不同,则给每个Segment进行加锁(限制无法插入或者删除数据),再计算一次元素的个数;
ConcurrentHashMap(JDK 1.8)
CocurrentHashMap 抛弃了原有的 Segment 分段锁,采用了 CAS + synchronized 来保证并发安全性。
其中的 val next 都用了 volatile 修饰,保证了可见性。
最大特点是引入了 CAS
借助 Unsafe 来实现 native code。CAS有3个操作数,内存值 V、旧的预期值 A、要修改的新值 B。当且仅当预期值 A 和内存值 V 相同时,将内存值V修改为 B,否则什么都不做。Unsafe 借助 CPU 指令 cmpxchg 来实现。
CAS 使用实例
对 sizeCtl 的控制都是用 CAS 来实现的:
-
-1 代表 table 正在初始化
-
N 表示有 -N-1 个线程正在进行扩容操作
-
如果 table 未初始化,表示table需要初始化的大小
-
如果 table 初始化完成,表示table的容量,默认是table大小的0.75倍,用这个公式算 0.75(n – (n >>> 2))
CAS 会出现的问题:ABA
解决:对变量增加一个版本号,每次修改,版本号加 1,比较的时候比较版本号。
put 过程
-
根据 key 计算出 hashcode
-
判断是否需要进行初始化
-
通过 key 定位出的 Node,如果为空表示当前位置可以写入数据,利用 CAS 尝试写入,失败则自旋保证成功
-
如果当前位置的 hashcode == MOVED == -1,则需要进行扩容
-
如果都不满足,则利用 synchronized 锁写入数据
-
如果数量大于 TREEIFY_THRESHOLD 则要转换为红黑树
get 过程
-
根据计算出来的 hashcode 寻址,如果就在桶上那么直接返回值
-
如果是红黑树那就按照树的方式获取值
-
就不满足那就按照链表的方式遍历获取值
1.8的ConcurrentHashMap设计
1.8的ConcurrentHashMap摒弃了1.7的segment(锁段)设计,而是启用了一种全新的方式实现,利用CAS算法,在1.8HashMap的基础上实现了线程安全的版本,即也是采用数组+链表+红黑树的形式。
数组可以扩容,链表可以转化为红黑树
看完ConcurrentHashMap整个类的源码,给自己的感觉就是和HashMap的实现基本一模一样,当有修改操作时借助了synchronized来对table[i]进行锁定保证了线程安全以及使用了CAS来保证原子性操作,其它的基本一致.
例如:ConcurrentHashMap的get(int key)方法的实现思路为:根据key的hash值找到其在table所对应的位置i,然后在table[i]位置所存储的链表(或者是树)进行查找是否有键为key的节点,如果有,则返回节点对应的value,否则返回null。思路是不是很熟悉,是不是和HashMap中该方法的思路一样。所以,如果你也在看ConcurrentHashMap的源码,不要害怕,思路还是原来的思路,只是多了些许东西罢了。
3.1 整体概览(JDK1.8)
sizeCtl最重要的属性之一,看源码之前,这个属性表示什么意思,一定要记住。
private transientvolatile int sizeCtl;//控制标识符//( transient是Java语言的关键字,用来表示一个域不是该对象串行化的一部分。当一个对象被串行化的时候,transient型变量的值不包括在串行化的表示中,然而非transient型的变量是被包括进去的。 )
sizeCtl是控制标识符,不同的值表示不同的意义。
· 负数代表正在进行初始化或扩容操作 ,其中-1代表正在初始化 ,-N 表示有N-1个线程正在进行扩容操作
· 正数或0代表hash表还没有被初始化,这个数值表示初始化或下一次进行扩容的大小,类似于扩容阈值。它的值始终是当前ConcurrentHashMap容量的0.75倍,表示阈值,实际容量>=sizeCtl,则扩容。
改进一:取消segments字段,直接采用transient volatile HashEntry<K,V>[] table保存数据,采用table数组元素作为锁,从而实现了对每一行数据进行加锁,进一步减少并发冲突的概率。
改进二:将原先table数组+单向链表的数据结构,变更为table数组+单向链表+红黑树的结构。对于hash表来说,最核心的能力在于将key hash之后能均匀的分布在数组中。如果hash之后散列的很均匀,那么table数组中的每个队列长度主要为0或者1。但实际情况并非总是如此理想,虽然ConcurrentHashMap类默认的加载因子为0.75,但是在数据量过大或者运气不佳的情况下,还是会存在一些队列长度过长的情况,如果还是采用单向列表方式,那么查询某个节点的时间复杂度为O(n);因此,对于个数超过8(默认值)的列表,jdk1.8中采用了红黑树的结构,那么查询的时间复杂度可以降低到O(logN),可以改进性能。
为了说明以上2个改动,看一下put操作是如何实现的。
其实在我看来,就是两处改进:
1.在未初始化的时候,采用CAS初始化,这样可以避免多个key相同同时初始化导致某些先来的键值对丢失。
2.在检测到容器在扩容的时候,不做任何的操作,等待新表再进一步操作。这样既可以解决多个线程同时导致扩容所带来的链表死循环问题;也可以解决put的同时扩容,旧表在赋值给新表后,另外线程在get到null值。
3.最后在每个真正put的操作都加锁,可以避免由于同时put导致某个位置重叠之类的问题。
3.2 Put 过程
finalV putVal(K key, V value, boolean onlyIfAbsent) {
if(key == null || value == null) thrownew NullPointerException();
inthash = spread(key.hashCode());
intbinCount = 0;
for(Node<K,V>[] tab = table;;) {
Node<K,V> f;int n, i, fh;
// 如果table为空或者长度为0,初始化;否则,根据hash值计算得到数组索引i,如果tab[i]为空,直接新建节点Node即可(此处用cas写入新的数值,大概就是是null则新建Node)。注:tab[i]实质为链表或者红黑树的首节点。
if (tab == null || (n= tab.length) == 0)
tab = initTable();
elseif ((f = tabAt(tab, i = (n - 1) & hash)) ==null) {
if (casTabAt(tab, i, null,
newNode<K,V>(hash, key, value, null)))
break; // no lock when adding to empty bin
}
// 如果tab[i]不为空并且hash值为MOVED,说明该链表正在进行transfer操作,返回扩容完成后的table。(sizeCtl是控制标识符,此处MOVED=-1)//在hashmap有可能会因为取得旧表而get null,在这儿解决了 。因为这儿是发现扩容就等待新表再做下一步的操作。
elseif ((fh = f.hash) == MOVED)
tab = helpTransfer(tab, f);
else {
V oldVal = null;
// 针对首个节点进行加锁操作,而不是segment,进一步减少线程冲突。1.7是整个segment加锁,一个segment又有很多个数组节点。
synchronized(f) {
if(tabAt(tab, i) == f) {
if (fh>= 0) {
binCount = 1;
for(Node<K,V> e = f;; ++binCount) {
K ek;
// 如果在链表中找到值为key的节点e,直接设置e.val =value即可。
if(e.hash == hash &&
((ek = e.key)== key ||
(ek != null&& key.equals(ek)))) {
oldVal = e.val;
if(!onlyIfAbsent)
e.val = value;
break;
}
// 如果没有找到值为key的节点,直接新建Node并加入链表即可。
Node<K,V>pred = e;
if((e = e.next) == null) {
pred.next = newNode<K,V>(hash, key,
value, null);
break;
}
}
}
// 如果首节点为TreeBin类型,说明为红黑树结构,执行putTreeVal操作。
elseif(f instanceof 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;
}
}
}
}
if (binCount != 0) {
// 如果节点数>=8,那么转换链表结构为红黑树结构。
if(binCount >= TREEIFY_THRESHOLD)
treeifyBin(tab, i);
if(oldVal != null)
returnoldVal;
break;
}
}
}
// 计数增加1,有可能触发transfer操作(扩容)。
addCount(1L, binCount);
returnnull;
}
当执行 put 方法插入数据时,根据key的hash值,在Node 数组中找到相应的位置,实现如下:
1. 如果相应位置的 Node 还未初始化,则通过CAS插入相应的数据;
2. 如果相应位置的 Node 不为空,且当前该节点不处于移动状态,则对该节点加 synchronized 锁,如果该节点的 hash 不小于0,则遍历链表更新节点或插入新节点;
3. 如果该节点是 TreeBin 类型的节点,说明是红黑树结构,则通过 putTreeVal 方法往红黑树中插入节点;
4. 如果 binCount 不为0,说明put 操作对数据产生了影响,如果当前链表的个数达到8个,则通过treeifyBin 方法转化为红黑树,如果 oldVal 不为空,说明是一次更新操作,没有对元素个数产生影响,则直接返回旧值;
5. 如果插入的是一个新节点,则执行 addCount() 方法尝试更新元素个数 baseCount ;
3.3 get过程
1. 根据k计算出hash值,首先定位到table[]中的i。
2. 若table[i]存在,则继续查找。
3. 首先比较链表头部,如果是则返回。
4. 然后如果为红黑树,查找树。
5. 最后再循环链表查找。
·
3.4扩容过程
一旦链表中的元素个数超过了8个,那么可以执行数组扩容或者链表转为红黑树,这里依据的策略跟HashMap依据的策略是一致的。
当数组长度还未达到64个时,优先数组的扩容,否则选择链表转为红黑树。
3.5 size实现
1.8中使用一个 volatile 类型的变量 baseCount记录元素的个数,当插入新数据或则删除数据时,会通过 addCount() 方法更新 baseCount,实现如下:
在size()方法和mappingCount方法中都出现了sumCount()方法,因此,我们也顺便看一下。
/* ----------------Counter support -------------- */
/**
* A padded cell for distributingcounts. Adapted from LongAdder
* and Striped64. See their internal docs for explanation.
*/
@sun.misc.Contended staticfinal class CounterCell {
volatilelong value;
CounterCell(long x) { value = x; }
}
// Table of countercells. When non-null, size is a power of 2
privatetransientvolatile CounterCell[] counterCells;
//ConcurrentHashMap中元素个数,基于CAS无锁更新,但返回的不一定是当前Map的真实元素个数。
privatetransientvolatilelong baseCount;
finallong 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;
}
所以在1.8中的 size 实现比1.7简单多,因为元素个数保存baseCount 中,部分元素的变化个数保存在 CounterCell 数组中,实现如下:
1. 初始化时 counterCells 为空,在并发量很高时,如果存在两个线程同时执行 CAS 修改baseCount值,则失败的线程会继续执行方法体中的逻辑,使用CounterCell 记录元素个数的变化;
2. 如果 CounterCell 数组 counterCells 为空,调用 fullAddCount() 方法进行初始化,并插入对应的记录数,通过 CAS 设置cellsBusy字段,只有设置成功的线程才能初始化 CounterCell 数组,实现如下:
3. 如果通过 CAS 设置cellsBusy字段失败的话,则继续尝试通过 CAS 修改 baseCount 字段,如果修改 baseCount 字段成功的话,就退出循环,否则继续循环插入 CounterCell 对象;