Java基础之ConcurrentHashMap

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<K,V>[] tab = table;;) {    //
		Node<K,V> 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<K,V>(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<K,V> 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<K,V> pred = e;
							if ((e = e.next) == null) {    //如果不是同样的hash,同样的key的时候,则判断该节点的下一个节点是否为空,
								pred.next = new Node<K,V>(hash, key,        //为空的话把这个要加入的节点设置为当前节点的下一个节点
														  value, null);
								break;
							}
						}
					}
					else if (f instanceof TreeBin) {    //表示已经转化成红黑树类型了
						Node<K,V> p;
						binCount = 2;
						if ((p = ((TreeBin<K,V>)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高效。

  • 0
    点赞
  • 1
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值