高并发下如何提高“锁”性能?
- 前言
- 减小锁持有时间
- 减小锁粒度
- 读写分离锁来替换独占锁
- 锁分离
- 锁粗化
- 总结
前言
在项目中,尤其是电商或者做游戏开发的,高并发是必然的,但在高并发的环境下,大家会经常使用到 锁
。
“锁” 是最常用的同步方法之一。但激烈的锁竞争
会导致程序的性能下降,严重的甚至能导致 “死锁”的产生。
这个时候,可能会有小伙伴会说,可以使用多线程啊。使用多线程的确可以明显地提高系统的性能。但事实上,使用多线程的方式会额外增加系统的开销。对于多线程应用来说, 系统除了处理功能需求外,还需要额外维护多线程环境的特有信息,如线程本身的元数据、线程的调度、线程上下文的切换等。
因此,合理的并发,才能将多核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++) {}
}
总结
性能优化就是根据运行时的真实情况对各个资源, 点进行权衡折中的过程。锁粗 化的思想和减少锁持有时间是相反的,但在不同的场合,它们的效果并不相同。 所以大家需要根据实际情况,进行权衡。