垃圾回收算法与实现系列-锁在应用层的优化思路

╰+哭是因爲堅強的太久メ 2022-10-12 00:58 170阅读 0赞

导语
  之前的分享中主要介绍了虚拟机内部的对锁机制的优化与具体实现,在实际的开发过程中,还可以通过在应用层的合理优化,达到保证性能的目的,那么下面就学习介绍一下在应用层中如何进行锁的优化。

文章目录

    • 锁在应用层的优化思路
      • 减少持有锁的时间
      • 减小锁粒度
      • 锁分离
      • 锁粗化

锁在应用层的优化思路

减少持有锁的时间

  对于在应用层面上进行的并发控制,在锁竞争的过程中,单个线程对锁的持有时间与系统的性能有直接的关系。线程持有锁的时间过长,那么锁的竞争就越激烈。所以在开发的过程中,应该尽量减少某个锁的占用时间,从而减少线程之间互斥时间。例如

  1. public synchronized void syncMethod(){
  2. othercode1();
  3. mutexMethod();
  4. othercode2();
  5. }

  在syncMethod()中,假设只有mutextMethod()方法有同步的需求,而othercode1()和othercode2() 并不需要做同步操作,如果这两个方法在操作过程中占用的时间较长,就会很耗时间。此时,如果并发量太大,这种对于整个方法进行同步的方式就会导致线程等待增加。因为我们知道,如果一个线程获取到锁之后,需要在所有的任务执行完成之后才会释放锁,在这个过程中,其他线程无法获取到该锁。

  解决这种问题一个比较好的解决方案就是,只需要在又必须要同步的方法上进行同步操作,这样就能明显的减少线程持有锁的时间,达到提高吞吐量的目的。

  1. public void syncMethod(){
  2. othercode1();
  3. synchronized(this){
  4. mutexMethod();
  5. }
  6. othercode2();
  7. }

  在改进的代码中,只对需要同步的方法进行加锁,整体占用锁的时间比较短,因此可以有更高的并行度,这种手段在JDK的源码包中也可以很容易的找到,例如再处理正则表达的式的Pattern类。

  1. public Matcher matcher(CharSequence input) {
  2. if (!compiled) {
  3. synchronized(this) {
  4. if (!compiled)
  5. compile();
  6. }
  7. }
  8. Matcher m = new Matcher(this, input);
  9. return m;
  10. }

  matcher()有条件的进行申请锁,只有在表达式未编译的时候,进行局部加锁,这种处理方式可以大大提升matcher()的执行效率和可靠性。

注意:需要注意的是减少持有时间有助于减小锁冲突的可能性,进而提升系统的并发能力。

减小锁粒度

  减小锁的粒度也是一种削弱多线程锁竞争的有效手段。比较典型的实现场景就是ConcurrentHashMap类的实现。对一个普通的集合对象的多线程同步来说,最常用的方式就是堆get()和add()的进行同步。每当堆集合进行add()操作或者get()操作的时候,总是获得集合对象锁。所以实际上,在同一时间并没有两个线程同时操作集合,如果是高并发的场景中,就会影响吞吐量。

  作为JDK并发包中的重要组成部分,ConcurrentHashMap 类很好的使用到了拆分对象的方式,从而提高了ConcurrentHashMap的吞吐量。ConcurrentHashMap将整个HashMap分成了若干的段,每个段都有对应的子HashMap。

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

  在默认的情况下,ConcurrentHashMap 拥有16个段,有一种特殊的情况就是ConcurrentHashMap同时接受16个线程同时插入,从而大大提升了吞吐量。如下图,6个线程同时对ConcurrentHashMap进行访问,线程1、2、3 分别访问1、2、3 段,由于1,2,3段都有独立的锁进行保护,因此3个线程可以同时访问ConcurrentHashMap ,线程4,5,6 也需要访问1,2,3,但必须等待前面的线程结束访问之后才能进入ConcurrentHashMap。
在这里插入图片描述
  减少锁粒度会引入一个新的问题,就是当系统需要获取到全局锁定的时候,消耗的资源会比较多。以ConcurrentHashMap 类为例,虽然put()方法很好地分离了锁,但是当试图访问ConcurrentHashMap 全局信息的时候,就需要同时获取到所有段的锁才能顺利实现全局加锁。李丽茹ConcurrentHashMap 的size()方法,它将返回ConcurrentHashMap的有效表项,也就是整体表项之和,这个操作就需要将全局加锁。

  1. sum = 0;
  2. for(int i = 0;i<segments.length;++i){
  3. segments[i].lock();
  4. }
  5. for(int i = 0;i<segments.length;++i){
  6. sum+=segments[i].count;
  7. }
  8. for(int i = 0;i<segments.length;++i){
  9. segments[i].unlock();
  10. }

  可以看到,在计算总数的时候,先要获得所有段的锁,然后再求和。但ConcurrentHashMap的size()方法并不总是这样执行的,实际上size()先使用无锁的方式求和,如果失败才会尝试加锁的方式,不管如何,在高并发场合ConcurrentHashMap的size()方法的性能依然要比HashMap要低。

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

锁分离

  锁分离是减小颗粒度的一个特例,它依据应用程序的功能特点,将一个独占锁分成多个锁,比较典型的案例就是java.util.concurrent.LinkedBlockingQueue 的实现。

  在LinkedBlockingQueue的实现中,take() 和 put()分别实现了从队列中取得数据和往队列中增加数据的功能。虽然两个函数都是对当前队列的修改操作,但是由于LinkedBlockingQueue 是基于链表的操作,而两个操作又分别出现在队列的两端,从理论上来说,两者是不冲突的。如图所示,
在这里插入图片描述
  如果使用独占锁,则要求在两个操作进行的时候获取当前队列的独占锁,那么take()和put()就不是真正的并发操作,在运行时,它们会彼此等待锁资源的释放。这种情况下,锁竞争会相对比较激烈,从而影响程序在高并发时的性能。

  在JDK实现中,并不是采用这种方式,取而代之的是用两把不同的锁分离了take()和put()操作。

  1. /** Lock held by take, poll, etc */
  2. private final ReentrantLock takeLock = new ReentrantLock();
  3. /** Wait queue for waiting takes */
  4. private final Condition notEmpty = takeLock.newCondition();
  5. /** Lock held by put, offer, etc */
  6. private final ReentrantLock putLock = new ReentrantLock();
  7. /** Wait queue for waiting puts */
  8. private final Condition notFull = putLock.newCondition();

  上面代码定义了takeLock和putLock,它们分别在take()操作和put()操作中使用。因此,take()和put()就相互独立了,它们之间并不存在锁竞争关系。只需要在take()和take()之间,put()和put()之间分别对takeLock 和putLock进行竞争,从而降低了锁竞争的可能性。

take实现

  1. public E take() throws InterruptedException {
  2. E x;
  3. int c = -1;
  4. final AtomicInteger count = this.count;
  5. final ReentrantLock takeLock = this.takeLock;
  6. takeLock.lockInterruptibly(); // 不能有两个线程同时获取数据
  7. try {
  8. while (count.get() == 0) { // 如果当前没有可用的数据,一直等待put()操作的通知
  9. notEmpty.await();
  10. }
  11. x = dequeue();
  12. c = count.getAndDecrement();
  13. if (c > 1)
  14. notEmpty.signal(); //通知其他未中断的线程
  15. } finally {
  16. takeLock.unlock();
  17. }
  18. if (c == capacity)
  19. signalNotFull();
  20. return x;
  21. }

put实现

  1. public void put(E e) throws InterruptedException {
  2. if (e == null) throw new NullPointerException();
  3. // Note: convention in all put/take/etc is to preset local var
  4. // holding count negative to indicate failure unless set.
  5. int c = -1;
  6. Node<E> node = new Node<E>(e);
  7. final ReentrantLock putLock = this.putLock;
  8. final AtomicInteger count = this.count;
  9. putLock.lockInterruptibly();
  10. try {
  11. while (count.get() == capacity) {
  12. notFull.await();
  13. }
  14. enqueue(node);
  15. c = count.getAndIncrement();
  16. if (c + 1 < capacity)
  17. notFull.signal();
  18. } finally {
  19. putLock.unlock();
  20. }
  21. if (c == 0)
  22. signalNotEmpty();
  23. }

  通过takeLock 和putLock两把锁,LinkedBlockingQueue 实现了取数据和写数据的分离,实现了读写分离,实现了真正意义上的并发操作。

锁粗化

  一般情况下,为了保证多线程的有效并发,会要求每个线程持有锁的时间尽量短,也就是说在使用完公共资源之后,应该立即释放资源,只有这样,才能保证后续线程能尽快的获取到资源从而保证任务可以正常执行。但是一切的事务都需要有个度,如果对于同样的锁不断的请求释放,本身就是一种资源的消耗。这样反而不利于性能优化,另外说到偏向锁,它也是一种获取释放的过程。

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

  1. public void demoMethod(){
  2. synchronized(lock){
  3. do sth.
  4. }
  5. //做其他不需要同步的操作,但能很快的完毕
  6. synchronized(lock){
  7. do sth
  8. }
  9. }

  上面的操作会被整合为如下的新式

  1. public void demoMethod(){
  2. synchronized(lock){
  3. do sth.
  4. }
  5. }

  在软件开发过程中,开发人员应该在合理的场景中进行锁的粗化,尤其是在循环内使用锁操作的时候。

  1. for{
  2. synchronized(lock)
  3. }

  在上面操作优化的时候可以修改为

  1. synchronized(lock){
  2. for()
  3. }

 &emps;显然,第一种情况请求锁过于频繁,第二种情况只需要请求一次锁,因此后者的性能会高于前者,随着循环次数的增加,这种差异会越来越明显。

注意:性能优化就是根据运行时的真实情况对各个资源点进行权衡的过程。锁的粗化的思想和减少锁的持有时间相反,在不同的场景下效果是不同的,开发者需要根据实际的情况进行权衡,之前博客中提到的偏向锁、自旋锁作为虚拟机内部的锁优化策略,也不是绝对能提高系统性能的,对于锁的优化应该是在不同的场景,多种方案的权衡考虑。

发表评论

表情:
评论列表 (有 0 条评论,170人围观)

还没有评论,来说两句吧...

相关阅读