ConcurrentHashMap(JDK 1.7)
HashTable容器在竞争激烈的并发环境下表现出效率低下的原因,是因为所有访问HashTable的线程都必须竞争同一把锁,那假如容器里有多把锁,每一把锁用于锁容器其中一部分数据,那么当多线程访问容器里不同数据段的数据时,线程间就不会存在锁竞争,从而可以有效的提高并发访问效率,这就是ConcurrentHashMap所使用的锁分段技术,首先将数据分成一段一段的存储,然后给每一段数据配一把锁,当一个线程占用锁访问其中一个段数据的时候,其他段的数据也能被其他线程访问。有些方法需要跨段,比如size()和containsValue(),它们可能需要锁定整个表而而不仅仅是某个段,这需要按顺序锁定所有段,操作完毕后,又按顺序释放所有段的锁。这里“按顺序”是很重要的,否则极有可能出现死锁,在ConcurrentHashMap内部,段数组是final的,并且其成员变量实际上也是final的,但是,仅仅是将数组声明为final的并不保证数组成员也是final的,这需要实现上的保证。这可以确保不会出现死锁,因为获得锁的顺序是固定的。
ConcurrentHashMap是由Segment数组结构和HashEntry数组结构组成。Segment是一种可重入锁ReentrantLock,在ConcurrentHashMap里扮演锁的角色,HashEntry则用于存储键值对数据。一个ConcurrentHashMap里包含一个Segment数组,Segment的结构和HashMap类似,是一种数组和链表结构, 一个Segment里包含一个HashEntry数组,每个HashEntry是一个链表结构的元素, 每个Segment守护者一个HashEntry数组里的元素,当对HashEntry数组的数据进行修改时,必须首先获得它对应的Segment锁。
应用场景
当有一个大数组时需要在多个线程共享时就可以考虑是否把它给分成多个节点了,避免大锁。并可以考虑通过hash算法进行一些模块定位。
其实不止用于线程,当设计数据表的事务时(事务某种意义上也是同步机制的体现),可以把一个表看成一个需要同步的数组,如果操作的表数据太多时就可以考虑事务分离了(这也是为什么要避免大表的出现),比如把数据进行字段拆分,水平分表等.
-
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.5 size()操作
因为ConcurrentHashMap是可以并发插入数据的,所以在准确计算元素数量时存在一定的难度,一般的思路是统计每个Segment对象中的元素个数,然后进行累加,但是这种方式计算出来的结果并不一样的准确的,因为在计算后面几个Segment的元素个数时,已经计算过的Segment同时可能有数据的插入或则删除。
如果我们要统计整个ConcurrentHashMap里元素的大小,就必须统计所有Segment里元素的大小后求和。Segment里的全局变量count是一个volatile变量,那么在多线程场景下,我们是不是直接把所有Segment的count相加就可以得到整个ConcurrentHashMap大小了呢?
不是的,虽然相加时可以获取每个Segment的count的最新值,但是拿到之后可能累加前使用的count发生了变化,那么统计结果就不准了。所以最安全的做法,是在统计size的时候把所有Segment的put,remove和clean方法全部锁住,但是这种做法显然非常低效。
因为在累加count操作过程中,之前累加过的count发生变化的几率非常小,所以ConcurrentHashMap的做法是先尝试2次通过不锁住Segment的方式来统计各个Segment大小,如果统计的过程中,容器的count发生了变化,则再采用加锁的方式来统计所有Segment的大小。
那么ConcurrentHashMap是如何判断在统计的时候容器是否发生了变化呢?使用modCount变量,在put , remove和clean方法里操作元素前都会将变量modCount进行加1,那么在统计size前后比较modCount是否发生变化,从而得知容器的大小是否发生变化。
总结:
先采用不加锁的方式,连续计算元素的个数,最多计算3次:
1、如果前后两次计算结果相同,则说明计算出来的元素个数是准确的;
2、如果前后两次计算结果都不同,则给每个Segment进行加锁(限制无法插入或者删除数据),再计算一次元素的个数;
缺点
这一种结构的带来的副作用是Hash的过程要比普通的HashMap要长
优点
写操作的时候可以只对元素所在的Segment进行加锁即可,不会影响到其他的Segment,这样,在最理想的情况下,ConcurrentHashMap可以最高同时支持Segment数量大小的写操作(刚好这些写操作都非常平均地分布在所有的Segment上)。通过这一种结构,ConcurrentHashMap的并发能力可以大大的提高。
ConcurrentHashMap(JDK 1.8)
CocurrentHashMap 抛弃了原有的 Segment 分段锁,采用了 CAS + synchronized 来保证并发安全性。
其中的 value和 next 都用了 volatile 修饰,保证了可见性。最大特点是引入了 CAS
CAS是compare and swap的缩写,即我们所说的比较交换。cas是一种基于锁的操作,而且是乐观锁。在java中锁分为乐观锁和悲观锁。悲观锁是将资源锁住,等一个之前获得锁的线程释放锁之后,下一个线程才可以访问。而乐观锁采取了一种宽泛的态度,通过某种方式不加锁来处理资源,比如通过给记录加version来获取数据,性能较悲观锁有很大的提高。
CAS有3个操作数,内存值 V、旧的预期值 A、要修改的新值 B。当且仅当预期值 A 和内存值 V 相同时,将内存值V修改为 B,否则什么都不做。借助 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 transient volatile 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实现
JDK1.8的ConcurrentHashMap中保存元素的个数的记录方法也有不同,首先在添加和删除元素时,会通过CAS操作更新ConcurrentHashMap的baseCount属性值来统计元素个数。但是CAS操作可能会失败,因此,ConcurrentHashMap又定义了一个CounterCell数组来记录CAS操作失败时的元素个数。因此,ConcurrentHashMap中元素的个数则通过如下方式获得:
元素总数 = baseCount + sum(CounterCell)
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;
}
而JDK1.8中提供了两种方法获取ConcurrentHashMap中的元素个数。
public int size() {
long n = sumCount();
return ((n < 0L) ? 0 :
(n > (long)Integer.MAX_VALUE) ? Integer.MAX_VALUE :
(int)n);
}
public long mappingCount() {
long n = sumCount();
return (n < 0L) ? 0L : n; // ignore transient negative values
}
如代码所示,size只能获取int范围内的ConcurrentHashMap元素个数;而如果hash表中的数据过多,超过了int类型的最大值,则推荐使用mappingCount()方法获取其元素个数。
JDK 8 推荐使用mappingCount 方法,因为这个方法的返回值是 long 类型,不会因为 size 方法是 int 类型限制最大值(size 方法是接口定义的,不能修改)。
put完成后,会调用addCount函数会通过CAS更新baseCount,更新失败,会创建一个CounterCell对象,保存失败的记录数到counterCells数组,如果失败,会调用fullAddCount死循环创建直到成功。每次addCount都会去调用sumCount函数遍历counterCells数组,与baseCount累加,更新baseCount。
在没有并发的情况下,使用一个 baseCount volatile 变量就足够了,当并发的时候,CAS 修改 baseCount 失败后,就会使用 CounterCell 类了,会创建一个这个对象,通常对象的 volatile value 属性是 1。在计算 size 的时候,会将 baseCount 和 CounterCell 数组中的元素的 value 累加,得到总的大小,但这个数字仍旧可能是不准确的。
所以在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 对象;
通过累加 baseCount 和 CounterCell 数组中的数量,即可得到元素的总个数;
ConcurrentHashMap1.7和ConcurrentHashMap1.8的比较:
1.数据结构:1.8取消了Segment分段锁的数据结构,取而代之的是数组+链表+红黑树的结构。
2.保证线程安全机制:JDK1.7采用segment的分段锁机制实现线程安全,其中segment继承自ReentrantLock。JDK1.8采用CAS+Synchronized保证线程安全。
3.锁的粒度:原来是对需要进行数据操作的Segment加锁,现调整为对每个数组元素加锁(Node)。
4.链表转化为红黑树:定位结点的hash算法简化会带来弊端,Hash冲突加剧,因此1.8在链表节点数量大于8时,会将链表转化为红黑树进行存储。
5.查询时间复杂度:从原来的遍历链表O(n),变成遍历红黑树O(logN)。