【Spring】高并发下如何提高“锁”性能?

举报
叶 秋 发表于 2023/02/03 10:44:30 2023/02/03
【摘要】 在项目中,尤其是电商或者做游戏开发的,高并发是必然的,但在高并发的环境下,大家会经常使用到 `锁` 。 “锁” 是最常用的同步方法之一。但激烈的`锁竞争`会导致程序的性能下降,严重的甚至能导致 “死锁”的产生。

前言

在项目中,尤其是电商或者做游戏开发的,高并发是必然的,但在高并发的环境下,大家会经常使用到

“锁” 是最常用的同步方法之一。但激烈的锁竞争会导致程序的性能下降,严重的甚至能导致 “死锁”的产生。

这个时候,可能会有小伙伴会说,可以使用多线程啊。使用多线程的确可以明显地提高系统的性能。但事实上,使用多线程的方式会额外增加系统的开销。对于多线程应用来说, 系统除了处理功能需求外,还需要额外维护多线程环境的特有信息,如线程本身的元数据、线程的调度、线程上下文的切换等。

因此,合理的并发,才能将多核CPU 的性能发挥到极致。为了将这种副作用降到最低,我这里提出一些关于使用锁的建议,希望可以帮助大家写出性能更为优越的程序 。

减小锁持有时间

大家都知道,在锁竞争过程中,单个线程对锁的持有时间与系统性能有着直接的关系

比如要求100个人填写自己的身份信息,但是只给他们一支笔。那么所需的总时间,取决于每个人填写的时间。如果每个人事先都想好所填的内容后再拿笔填写,这样每个人都会大大减少自己的填写时间,这样所需的整体时间也会大大降低。

如果把这支笔比作锁,那么减少每个人持有笔的时间,就是减小锁持有时间。大家可以看下这段代码:

public synchronized void syncMethod() { 
	othercodel ();
	mutextMethod () ; 
	othercode2 () ;
}

syncMethod()同步方法块中,如果只有mutextMethod()方法需要同步,othercodel和othercode2 不需要同步方法块控制。如果这时并发量很大,使用这种对整个方法做同步,那么会导致花费较长的CPU时间,等待线程大大增加。因为 一个线程,在进入该方法时获得内部锁,只有在所有任务都执行完后, 才会释放锁。

一个较为优化的解决方案是,只在必要时进行同步,这样就能明显减少线程持有锁的时间, 提高系统的吞吐量。

public void syncMethod2 () {
	othercode1 ();
	synchronized (this) { 
		mutextMethod () ;
	}
	othercode2 () ;
}

在改进的代码中,只针对mutextMethod方法做了同 步 ,锁占用的时间相对较短 , 因此能有更高的并行度。

减少锁的持有时间有助于降低锁冲突的可能性,进而提升系统的并发能力。

减小锁粒度

大家应该还记得ConcurrentHashMap这个类吧。相信大家已经了解了它的原理了。它内部细分了若干个小的HashMap,称之为段(SEGMENT)。 默认情况下,一个ConcurrentHashMap 被进一步细分为 16 个段。为什么在这里提到这个类呢? 大家先思考下这个问题:

如果需要在ConcurrentHashMap 中增加 一个新的表项,并不是将整个HashMap 加锁,而是 首先根据hashcode 得到该表项应该被存放到哪个段中,然后对该段加锁,并完成put()操作。在 多线程环境中,如果多个线程同时进行put 操作,只要被加入的表项不存放在同 一个段中,则 线程间便可以做到真正的并行。

由于默认有16 个段,因此,如果够幸运的话,ConcurrentHashMap 可以同时接受 16 个线程同时插入(如果都插入不同的段中),从而大大提供其吞吐量 。

这个就是减小锁粒度的经典应用场景。

所谓减少锁粒度,就是指缩小锁定对象的范围,从而减少锁冲突的可能性,进而提高系统的并发能力。

读写分离锁来替换独占锁

使用读写锁ReadWriteLock 也可以提高系统的性能,它是使用读写分离锁 来替代独占锁是减小锁粒度的一种特殊情况。如果减少锁粒度是通过分割数据 结构实现的,那么,读写锁则是对系统功能点的分割。

在读多写少的场合,读写锁对系统性能是很有好处的。因为如果系统在读写数据时均只使 用独占锁,那么读操作和写操作间、读操作和读操作间、写操作和写操作间均不能做到真正的 并发,并且需要相互等待。而读操作本身不会影响数据的完整性和一致性。因此,理论上讲, 在大部分情况下,应该可以允许多线程同时读,读写锁正是实现了这种功能。

在读多写少的场合,使用读写锁可以有效提升系统的并发能力。

锁分离

如果将读写锁的思想做进一步的延伸,就是锁分离。 读写锁根据读写操作功能 上的不同, 进行了有效的锁分离。依据应用程序的功能特点,使用类似的分离思想,也可以对独占锁进行 分离。一个典型的案例就是java.util.concurrent.LinkedBlockingQueue 的实现。

LinkedBlockingQueue 的实现中,take()函数和put()函数分别实现了从队列中取得数据和 往队列中增加数据的功能。虽然两个函数都对当前队列进行了修改操作,但由于 LinkedBlockingQueue 是基于链表的,因此,两个操作分别作用于队列的前端和尾端,从理论上 说,两者并不冲突。

如果使用独占锁,则要求在两个操作进行时获取当前队列的独占锁,那么take()和put()操作就不可能真正的并发。

因此,LinkedBlockingQueue 实现了取数据和写数据的分离,使 两者在真正意义上成为可并发的操作。

锁粗化

刚刚讲了减小锁的持有时间,会要求每个线程持有锁的时间尽量短,即在使用完公共资源后,应该立即释放锁。只有这样,等待在这个锁 上的其他线程才能尽早地获得 资源执行任务。

但是,凡事都有一个度,如果对同一个锁不停地进行请求、同步和释放,其本身也会消耗系 统宝贵的资源,反而不利于性能的优化 。

为此,虚拟机在遇到 一连串连续地对同一锁不断进行请求和释放的操作时,便会把所有的 锁操作整合成对锁的一次请求,从而减少对锁的请求同步次数,这个操作叫做锁的粗化。比如 代码段:

for(int i=0;i<CIRCLE;i++){
 	synchronized (lock) {
 	}
}

在循环内请求锁时。在这种情况下,意味着每次循环都有申请锁和释放锁的 操作。但在这种情况下,显然是没有必要的。

所以,一种更加合理的做法应该是在外层只请求一次锁:

synchronized (lock) {
	for (int i=0;i<CIRCLE;i++) {
	}
}

总结

性能优化就是根据运行时的真实情况对各个资源, 点进行权衡折中的过程。锁粗 化的思想和减少锁持有时间是相反的,但在不同的场合,它们的效果并不相同。 所以大家需要根据实际情况,进行权衡。

【版权声明】本文为华为云社区用户原创内容,未经允许不得转载,如需转载请自行联系原作者进行授权。如果您发现本社区中有涉嫌抄袭的内容,欢迎发送邮件进行举报,并提供相关证据,一经查实,本社区将立刻删除涉嫌侵权内容,举报邮箱: cloudbbs@huaweicloud.com
  • 点赞
  • 收藏
  • 关注作者

评论(0

0/1000
抱歉,系统识别当前为高风险访问,暂不支持该操作

全部回复

上滑加载中

设置昵称

在此一键设置昵称,即可参与社区互动!

*长度不超过10个汉字或20个英文字符,设置后3个月内不可修改。

*长度不超过10个汉字或20个英文字符,设置后3个月内不可修改。