HashMap扩展问题:HashMap如何实现线程安全?
HashMap如何实现线程安全?
方法一:java.util.Collections.synchronizedMap(Map<K,V> m)
底层实际上是将hashMap又封装了一层,变成SynchronizedMap<K,V>,并在每一个对HashMap的操作方法上添加了synchronized修饰。代码如下:(扩展:synchronized的原理是什么?synchronized和ReentrantLock有什么区别?)
private static class SynchronizedMap<K,V>
implements Map<K,V>, Serializable {
private static final long serialVersionUID = 1978198479659022715L;
private final Map<K,V> m; // Backing Map
final Object mutex; // Object on which to synchronize
SynchronizedMap(Map<K,V> m) {
this.m = Objects.requireNonNull(m);
mutex = this;
}
public Set<K> keySet() {
synchronized (mutex) {
if (keySet==null)
keySet = new SynchronizedSet<>(m.keySet(), mutex);
return keySet;
}
}
public Set<Map.Entry<K,V>> entrySet() {
synchronized (mutex) {
if (entrySet==null)
entrySet = new SynchronizedSet<>(m.entrySet(), mutex);
return entrySet;
}
}
//部分代码省略
}
方法二:ConcurrentHashMap
1. jdk1.8中数据结构由Node数组+链表实现,而1.7中数据结构由Segement数组+Entry数组+链表组成。
1.7:
1.7版本的ConcurrentHashMap采?了分段锁(Segment)技术,其中Segment继承了ReentrantLock。(扩展:AQS) 在插?ConcurrentHashMap元素时,先尝试获得Segment锁,先是?旋获锁,如果?旋次数超过阈值,则转为ReentrantLock上锁。
ConcurrentHashMap 的扩容是仅仅和每个Segment元素中HashEntry数组的?度有关,但需要扩容时,只扩容当前Segment中HashEntry数组即可。也就是说ConcurrentHashMap中Segment[]数组的?度是在初始化的时候就确定了,后?扩容不会改变这个?度。
1.8:
1.8版本放弃了Segment,跟HashMap?样,?Node描述插?集合中的元素。但是Node中的val和next使?了volatile来修饰,保存了内存可?性。与HashMap相同的是,ConcurrentHashMap 1.8版本使?了数组+链表+红?树的结构。
同时,ConcurrentHashMap使?了CAS+Synchronized保证了并发的安全性。
对每一个node节点的head头节点加锁。Synchronized主要用于扩容,CAS用来做查找,替换,赋值等操作。
读操作无锁:Node节点的val和next通过volatile修饰。数组通过volatile修饰。保证了数据的可见性。
为什么弃用Segement而用Synchroniized?
· 减少内存开销,如果使用ReentrantLock则需要节点继承AQS来获取同步支持,增加内存开销,而1.8只有头部节点需要进行同步
· 内部优化:synchronized是jvm直接支持的,jvm能够运行时做出相应的优化措施,锁粗化,锁消除,锁自旋,这使得synchronized能够随着JDK版本升级而不用改动代码前提下获得性能提升。
2.采用了 (h ^ (h >>> 16)) & HASH_BITS 计算节点的hash。
h ^ (h >>> 16) 参考这里。我们可以看到,ConcurrentHashMap中,相比于HashMap多了一个 & HASH_BITS ,这个计算保证了计算出来的Hash一定是一个正数。而要保证是正数的原因是因为负数在ConcurrentHashMap中存在特殊含义,通过保证正数来防止产生冲突。代码如下:
/*
* Encodings for Node hash fields. See above for explanation.
*/
static final int MOVED = -1; // 标识该节点正在进行复制/移动(也就是数组在扩容)
static final int TREEBIN = -2; // 用于该节点是红黑树中的节点
static final int RESERVED = -3; // 标识这个节点正在被重新计算 只用于ConcurrentHashMap.compute方法和ConcurrentHashMap.computeIfAbsent方法
static final int HASH_BITS = 0x7fffffff; // 用于spread()方法计算哈希值。保证计算出来的hash是个正数
3.使用了helpTransfer方法帮助扩容。(扩展:ConcurrentHashMap如何实现帮助扩容的?)
4.使用了((long)i << ASHIFT) + ABASE来获取某个元素在数组中的值,涉及到偏移量的计算,原理是二分查找可以参考这里的 ASHIFT偏移量计算
通过计算
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);
}
如果只是为了背书,那么你看到这里就可以了。因为下面的东西很繁琐又臭又长。写出来只是为了加深我自己的印象和理解。
首先我是从put方法往下捋的,在通过int hash = spread(key.hashCode());计算出哈希之后,进入循环将当前入参封装成Node节点塞值。
循环中分成了三种情况处理:
循环中情况1:
- 当table==null,也就是还没有初始化的时候,那么先初始化。
这里涉及到的参数是transient volatile int sizeCtl;sizeCtl为-1的时候表示正在执行初始化操作,sizeCtl>0的时候表示扩容的阈值(比如容量为16,扩容因子为0.75时,sizeCtl = 12)。在第一次初始化完,没有做任何操作的时候,sizeCtl是等于2的N次幂的(也就是数组的容量),但当执行过put操作之后,sizeCtl就会变成扩容的阈值而不是容量。
初始化同样分了两种情况:
初始化情况1:
- 如果sizeCtl<0,那么表示当前ConcurrentHashMap已经有线程正在执行初始化操作,那么当前线程就直接yield(),
你可能会奇怪那我这里暂停了之后,我这次操作的数据难道不插入了? (扩展:线程的几种状态)。
所以我试了一下在循环里yield,如下:
while (true){
System.out.println("111");
Thread.yield();
}
所以我们知道,循环并不会因为线程的yield而结束。因此,当前线程只是从运行回到了就绪,然后再次被CPU调用重新执行循环。
初始化情况2:
- 如果sizeCtl>=0,那么首先通过CAS将sizeCtl设置为-1(与上一点对应)。然后初始化一个容量为16的数组,设置扩容阈值sizeCtl 为12。
sc = n - (n >>> 2)在这里等价于 n*0.75(扩容因子)。
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;
}
循环中情况2:
- (f = tabAt(tab, i = (n - 1) & hash)判断当前节点计算出的索引在数组中是否已经存在其他节点,如果不存在那么通过CAS将当前节点塞进去。
(n - 1) & hash计算出当前节点在数组中的位置。
(f = tabAt(tab, i = (n - 1) & hash)是获取tab数组中 i位置的值。
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);
}
((long)i << ASHIFT) + ABASE含义是计算索引为i的元素在内存地址中的偏移量。
循环中情况3:
- 如果索引位置已经存在了节点,那么判断该节点的状态是否是正在被移动或者复制(也就是判断当前ConcurrentHashMap是否正在进行扩容)。如果索引位置的节点处于移动或复制的状态下,那么当前线程帮助扩容。(扩展:ConcurrentHashMap如何实现帮助扩容的?)
循环中情况4:
-
索引位置已经存在了节点,并且不处于扩容状态,那么通过synchronize锁这个节点,然后又有两个判断if (tabAt(tab, i) == f) 和 if (fh >= 0)(扩展:synchronize双重校验锁)。如果上述判断都满足,则执行插入操作。
判断是否ConcurrentHashMap中已经存在了相同Key的Node,如果存在那么根据入参onlyIfAbsent(默认是false,也就是新值会替换旧值)判断是否覆盖值。
如果不存在,那么将当前节点插入到索引位置的链表的最后一个元素。如果是二叉树步骤和链表一样。最后判断链表长度是否>8,如果大于8那么将链表转换为二叉树。
在 putVal 最后调用 addCount方法
addCount方法涉及到的就是CounterCell数组。
CounterCell的设计很巧妙,它的背后其实就是JDK1.8中的LongAdder。核心思想是:在并发较低的场景下直接采用baseCount累加,否则结合counterCells,将不同的线程散列到不同的cell中进行计算,尽可能地确保访问资源的隔离,减少冲突。LongAdder相比较于AtomicLong中无脑CAS的策略,在高并发的场景下,能够减少CAS重试的次数,提高计算效率。(这一句话是抄过来的哈哈 这个文章写的很好很好推荐!)
ConcurrentHashMap通过维护了一个CounterCell[]数组和BASECOUNT来得到Node数组中元素的个数,也就是ConcurrentHashMap.size()方法获取的值。addCount方法维护个数的同时,会重新校验是否需要扩容,并在需要扩容的情况下扩容。
x 表示这次需要在表中增加的元素个数,check 参数表示是否需要进行扩容检查,大于等于 0 都需要进行检查。
在 putVal 最后调用 addCount方法,传递了两个参数,分别是 1 和 binCount(链表长度)。
private final void addCount(long x, int check) {...}
size()方法就是获取BASECOUNT,并且在CounterCell[]数组不为空时遍历数组获取每一个value累加得到最后的Size。
public int size() {
long n = sumCount();
return ((n < 0L) ? 0 :
(n > (long)Integer.MAX_VALUE) ? Integer.MAX_VALUE :
(int)n);
}
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;
}
大家好,我是小羊炒饭。感谢大家的关注,有任何问题可以提出,我们一起学习共同进步。公司的前辈告诉我,想涨工资人情>技术,我不懂人情,所以我想深耕技术,希望有一天能涨工资哈哈。
这个ConcurrentHashMap里面真的各种CAS各种锁标识+状态。一行一行看下来真的太费劲了。
简单归纳一下我学到了啥叭:
1.通过维护一个数组,将多个线程对一个值的CAS转成了对一个值+数组中各个节点的CAS来提高CAS的效率。(CounterCell[] as的作用)
2.分段锁思想。
3.使用CAS的流程,或者说加锁的流程,我们可以考虑从悲观锁到乐观锁,从粗粒度锁(源码中是synchronized锁Node节点),再到细粒度锁(CAS)。
4.偏移量那里我理解是为了加快CAS性能的,但是底层不明白,因为调用的是native方法,还需要再研究研究。
本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。 如若内容造成侵权/违法违规/事实不符,请联系我的编程经验分享网邮箱:veading@qq.com进行投诉反馈,一经查实,立即删除!