ConcurrentHashMap专门用于多线程(并发)场景下的 Map实现类,其大大优化了多线程下的性能。
这个 Map实现类是在 jdk1.5中加入的,其在 jdk1.6/1.7中的主要实现原理是 segment分段锁,而每个Segment 都继承了 ReentrantLock 类,也就是说每个Segment类本身就是一个锁。使用 put 方法的时候,是对我们的 key进行 hash拿到一个整型,然后将整型对16取模,拿到对应的Segment,之后调用 Segment的 put方法,然后上锁,这里lock()的时候其实是 this.lock(),也就是说,每个 Segment的锁是分开的。它不再使用和 HashTable一样的 synchronize一样的关键字对整个方法进行加锁,而是转而利用segment 段落锁来对其进行加锁,以保证 Map的多线程安全。其实可以理解为,一个 ConcurrentHashMap 是由多个 HashTable组成,所以它允许获取到不同段锁的线程同时持有该资源,也就是说 segment有多少个,理论上就可以同时有多少个线程来持有它这个资源。其默认的 segment是一个数组,默认长度为16。也就是说理论上可以提高16倍的性能。在 Java 的 jdk1.8中则对ConcurrentHashMap又再次进行了大的修改,取消了 segment段锁字段,采用了CAS+Synchronize技术来保障线程安全。具体7中有介绍
Java8 的 ConcurrentHashMap 为什么放弃了分段锁,有什么问题吗,如果你来设计,你如何设计?
ConcurrentHashMap 分段锁中存在一个分段锁个数的问题,既 Segment[] 的数组长度。当长度设置小了,数据结构根据额外的竞争,从而导致线程试图写入当前锁定的段,导致阻塞。相反,如果高估了并发级别,当遇到过大的膨胀(大量的并发),由于段产生的不必要数量,这种膨胀会导致性能的下降。因为高速缓存未命中。而 Java8中仅仅是为了兼容旧版本而保留。唯一的作用就是保证构造 Map时初始容量不小于 concurrencyLevel。
在 Java的 jdk1.8中则对 ConcurrentHashMap采用 CAS+Synchronize(取代 Segment+ReentrantLock)技术来保障线程安全,底层采用数组+链表+红黑树[当链表长度为8时,使用红黑树]的存储结构,也就是和 HashMap一样。这里注意 Node其实就是保存一个键值对的最基本对象。其中 value和 next都是使用的 volatile关键字进行了修饰,以确保线程安全。
volatile修改变量后,此变量就具有可见性,一旦该变量修改,其他线程立马就会知道,立马放弃自己在自己工作内存中持有的该变量值。转而重主内存中获取该变量最新的值。
在插入元素时,会首先进行 CAS判定,如果 OK就是插入其中,并将 size+1,但是如果失败了,就会通过自旋锁自旋后再次尝试插入,直到成功。所谓 CAS也就是 Compare And Swap,既在更改前先对内存中的变量值和你指定的那个变量值进行比较,如果相同就说明再次期间没有被修改,而如果不一样了,则就要停止修改,否则就会影响到其他人的修改,将其覆盖掉。举例:内存值a,旧值b,和要修改后的值c,如果这里a=b,那么就可以进行更改,就可以将内存值a=c。否则就要终止该更新操作。如果链表中存储的Entry超过了8个则就会自动转换链表为红黑树,提高查询效率。源码如下:
/*
* 当添加一对键值对的时候,首先会去判断保存这些键值对的数组是不是初始化了,
* 如果没有的话就初始化数组
* 然后通过计算hash值来确定放在数组的哪个位置
* 如果这个位置为空则直接添加,如果不为空的话,则取出这个节点来
* 如果取出来的节点的hash值是MOVED(-1)的话,则表示当前正在对这个数组进行扩容,复制到新的数组,则当前线程也去帮助复制
* 最后一种情况就是,如果这个节点,不为空,也不在扩容,则通过synchronized来加锁,进行添加操作
* 然后判断当前取出的节点位置存放的是链表还是树
* 如果是链表的话,则遍历整个链表,直到取出来的节点的key来个要放的key进行比较,如果key相等,并且key的hash值也相等的话,
* 则说明是同一个key,则覆盖掉value,否则的话则添加到链表的末尾
* 如果是树的话,则调用putTreeVal方法把这个元素添加到树中去
* 最后在添加完成之后,会判断在该节点处共有多少个节点(注意是添加前的个数),如果达到8个以上了的话,
* 则调用treeifyBin方法来尝试将处的链表转为树,或者扩容数组
*/
final V putVal(K key, V value, boolean onlyIfAbsent) {
if (key == null || value == null) throw new NullPointerException();//K,V都不能为空,否则的话跑出异常
int hash = spread(key.hashCode()); //取得key的hash值
int binCount = 0; //用来计算在这个节点总共有多少个元素,用来控制扩容或者转移为树
for (Node[] tab = table;;) { //
Node f; int n, i, fh;
if (tab == null || (n = tab.length) == 0)
tab = initTable(); //第一次put的时候table没有初始化,则初始化table
else if ((f = tabAt(tab, i = (n - 1) & hash)) == null) { //通过哈希计算出一个表中的位置因为n是数组的长度,所以(n-1)&hash肯定不会出现数组越界
if (casTabAt(tab, i, null, //如果这个位置没有元素的话,则通过cas的方式尝试添加,注意这个时候是没有加锁的
new Node(hash, key, value, null))) //创建一个Node添加到数组中区,null表示的是下一个节点为空
break; // no lock when adding to empty bin
}
/*
* 如果检测到某个节点的hash值是MOVED,则表示正在进行数组扩张的数据复制阶段,
* 则当前线程也会参与去复制,通过允许多线程复制的功能,一次来减少数组的复制所带来的性能损失
*/
else if ((fh = f.hash) == MOVED)
tab = helpTransfer(tab, f);
else {
/*
* 如果在这个位置有元素的话,就采用synchronized的方式加锁,
* 如果是链表的话(hash大于0),就对这个链表的所有元素进行遍历,
* 如果找到了key和key的hash值都一样的节点,则把它的值替换到
* 如果没找到的话,则添加在链表的最后面
* 否则,是树的话,则调用putTreeVal方法添加到树中去
*
* 在添加完之后,会对该节点上关联的的数目进行判断,
* 如果在8个以上的话,则会调用treeifyBin方法,来尝试转化为树,或者是扩容
*/
V oldVal = null;
synchronized (f) {
if (tabAt(tab, i) == f) { //再次取出要存储的位置的元素,跟前面取出来的比较
if (fh >= 0) { //取出来的元素的hash值大于0,当转换为树之后,hash值为-2
binCount = 1;
for (Node e = f;; ++binCount) { //遍历这个链表
K ek;
if (e.hash == hash && //要存的元素的hash,key跟要存储的位置的节点的相同的时候,替换掉该节点的value即可
((ek = e.key) == key ||
(ek != null && key.equals(ek)))) {
oldVal = e.val;
if (!onlyIfAbsent) //当使用putIfAbsent的时候,只有在这个key没有设置值得时候才设置
e.val = value;
break;
}
Node pred = e;
if ((e = e.next) == null) { //如果不是同样的hash,同样的key的时候,则判断该节点的下一个节点是否为空,
pred.next = new Node(hash, key, //为空的话把这个要加入的节点设置为当前节点的下一个节点
value, null);
break;
}
}
}
else if (f instanceof TreeBin) { //表示已经转化成红黑树类型了
Node p;
binCount = 2;
if ((p = ((TreeBin)f).putTreeVal(hash, key, //调用putTreeVal方法,将该元素添加到树中去
value)) != null) {
oldVal = p.val;
if (!onlyIfAbsent)
p.val = value;
}
}
}
}
if (binCount != 0) {
if (binCount >= TREEIFY_THRESHOLD) //当在同一个节点的数目达到8个的时候,则扩张数组或将给节点的数据转为tree
treeifyBin(tab, i);
if (oldVal != null)
return oldVal;
break;
}
}
}
addCount(1L, binCount); //计数
return null;
}
Synchronized 是靠对象的对象头和此对象对应的 monitor来保证上锁的,也就是对象头里的重量级锁标志指向了monitor,而monitor呢,内部则保存了一个当前线程,也就是抢到了锁的线程。
那么这里的这个f是什么呢?它是 Node链表里的每一个Node,也就是说,Synchronized是将每一个 Node对象作为了一个锁,这样做的好处是将锁细化了。也就是说,除非两个线程同时操作一个Node,注意是一个 Node而不是一个Node链表哦,那么才会争抢同一把锁。
如果使用 ReentrantLock其实也可以将锁细化成这样的,只要让 Node类继承 ReentrantLock就行了,这样的话调用f.lock()就能做到和Synchronized(f)同样的效果,但为什么不这样做呢?
请大家试想一下,锁已经被细化到这种程度了,那么出现并发争抢的可能性还高吗?还有就是,哪怕出现争抢了,只要线程可以在30到50次自旋里拿到锁,那么Synchronized就不会升级为重量级锁,而等待的线程也就不用被挂起,我们也就少了挂起和唤醒这个上下文切换的过程开销。
但如果是 ReentrantLock呢?它则只有在线程没有抢到锁,然后新建 Node节点后再尝试一次而已,不会自旋,而是直接被挂起,这样一来,我们就很容易会多出线程上下文开销的代价。当然,你也可以使用tryLock(),但是这样又出现了一个问题,你怎么知道 tryLock的时间呢?在时间范围里还好,假如超过了呢?
所以,在锁被细化到如此程度上,使用 Synchronized是最好的选择了。这里再补充一句,Synchronized 和ReentrantLock他们的开销差距是在释放锁时唤醒线程的数量,Synchronized是唤醒锁池里所有的线程+刚好来访问的线程,而 ReentrantLock则是当前线程后进来的第一个线程+刚好来访问的线程。
如果是线程并发量不大的情况下,那么 Synchronized因为自旋锁、偏向锁、轻量级锁的原因,不用将等待线程挂起,偏向锁甚至不用自旋,所以在这种情况下要比 ReentrantLock高效。