转载

锁优化的简单思路

作者 | 黄书柏

锁优化的简单思路

杏仁java开发工程师,专注后端开发。

锁优化的简单思路

在多核时代,多线程对系统的性能提升有着巨大的作用,但是多线程对系统也相应的产生了额外的开销,线程本身的数据、线程的调度、线程的申请释放等都对系统的产生额外的负担,锁是在高并发的环境下为了保证数据的正确性而产生的同步手段,为了进一步提升性能,我们就需要对锁进行优化。

锁的优化思路有:从减小锁的持有时间、锁的粒度的优化、锁分离、锁消除、无锁等等。

一、减小锁的持有时间

减小锁的持有时间是为了降低锁的冲突的可能性,提高体系的并发能力。

  • 1.1、只在必要时进行同步加锁操作

例如下的代码:在加锁时先判断是否满足同步代码逻辑的要求,以达到 减小锁的占有几率 的目的。

// 使用条件判断减少锁持有时间提高效率。
public void matcher(Char input) {
    if (!compiled) {
        synchronized(this) {
            if (!compiled) {
                compile();
            }
        }
    }
}
  • 2.1、只在必须加锁的代码段加锁

下面的代码的执行只针对必须要加锁的代码段进行加锁操作, 减少锁的占有的时间

public synchronized void syncMethod() {
  method1();
  method2();
  method3();
}

public void syncMethod() {
  method1();
  synchronized(this) {
    method2();
  }
  method3();
}

二、锁粒度的优化

优化锁的粒度是根据实际的代码逻辑来进行判断,分为锁粒度的细化和锁粒度的粗化 2 种优化方式。

  • 2.1、锁粒度的细化

举个简单的例子,JDK 自带的工具类 ConcurrentHashMap 就是一个典型的实现场景,它 对锁的拆分 方式提高了大大提高了它的 吞吐量 ,ConcurrentHashMap 将自身分成若干个段,每一段都是一个子 HashMap。当需要新增一个的时候,并不是对整个对象进行加锁,而是先根据 hashcode 计算该数据应该被加入到哪个段中,然后对该段加锁,默认情况下 ConcurrentHashMap 有16个段,因此运气足够好的时候可以接受 16 个线程同时插入,大大提高了吞吐量。

但是减小锁的粒度也带来了新的问题,当锁粒度过于小的时候,获取全局锁消耗的资源也相应增加,以 ConcurrentHashMap 为例,如果它需要获取当前的 size 就需要对每一个段都加锁。

  • 2.2、锁粒度的粗化

在一般情况下,为了保证多线程之间的高效并发,会要求线程持有锁的时间尽量 短,但是过度的细化会产生大量的申请和释放锁的操作,这对性能的影响也是非 常大的。 如下所示:

for(int i = 0; i < 10000; i++) {
  synchronized(this) {
    todo();
  }
}
synchronized(this) {
    for(int i = 0; i < 10000; i++) {
      todo();
    }
}

三、锁分离

根据实际的操作来选择加上不同的锁也是提升性能的重要方式之一。

  • 3.1、读写分离锁替代独占锁

ReadWriteLock 使用读写分离锁来替代独占锁,它也是减小锁的粒度的一种方式,上面讲的是对数据结构层面的减小锁持有时间的,这里是根据业务来划分锁的持有,在读多写少的场景使用读写分离锁会大大提高系统的并发性能。

  • 3.2、重入锁和内部锁

重入锁的使用相较于内部锁更加复杂,重入锁必须手动显示释放锁,内部锁则可以自动释放,重入锁提供了一套提高性能的功能和 Condition 机制,重入锁可以设置锁的等待时间 boolean tryLock(long time),锁中断 lockInterruptibly() 和快速锁轮询 tryLock() 等可以有效的避免死锁的产生。内部锁则是通过 wait() 和 notfiy() 实现锁的控制。

  • 3.3、自旋锁

自旋锁是 JVM 为了解决对多线程并发时频繁的挂起和恢复线程的操作问题的锁,当访问共享资源的时候,锁的等待时间可能很短,可能会比线程的挂起和恢复时间还要短,因此在这段时间里做线程的切换时不值得的。自旋锁可以使线程没有取得锁时不被挂起,而去执行一个空的循环,当线程获取了锁就会继续执行代码。

但是自旋锁只适用于线程竞争相对小、锁占用时间短的代码,对于锁竞争激烈的系统中不仅浪费了 CPU 资源,也免不了被挂起。 JVM 可以设置自旋锁的开启和等待次数,防止一直执行空循环。

四、无锁

锁是一种对操作的同步手段,但是也不是唯一的手段,例如使用空间换时间的思路同样可以解决问题,非阻塞的同步方式也可以达到并发的目的。

最简单的一种非阻塞的同步就是 ThreadLocal 了,每个线程有各自独立的 ThreadLocalMap,在并行计算时无需相互等待。另一种更为乐观的方式是使用 CAS 算法,它有 3 个参数(V,E,N),它总是认为自己的操作可以成功,因此只有在V的值等于E时,把V的值设置成 N;当 V 的值不等于 E,就返回 V 的当前值,然后什么也不做,当多个线程同时使用 CAS 时,只有一个线程会执行成功。

在  java.util.concurrent.atomic 包中有很多支持原子操作的类,都是基于无锁算法实现的,它的性能远远超过普通的有锁操作,例如使用CAS算法实现原子操作中的 getAndSet() 方法:

public final int getAndSet(int newValue) {
        for (;;) {                                       // 不停循环直到成功
            int current = get();                         // 获取当前的值
            if (compareAndSet(current, newValue)) {      // 若当前的值未受其他线程影响,则设置为新值
                return current;                          // 返回新值
            }
        }
    }

以时间换空间、以空间换时间都是实现代码的常用思路,在不同的地方应该使用不同的方式去达到业务需求。

小结

  • 减小锁的持有时间和持有几率是有效的提高系统吞吐量的策略。

  • 对于锁的粒度的划分需要根据实际情况来决定,锁粒度的细化和粗化在不同场合的效果不一样,因此需要合理选择。

  • 对比不同的实现场景,读写分离锁优于独占锁、内部锁比重入锁更加简单易用、自旋锁适用于竞争小且占有时间短的代码,达到减少锁的释放和获取。

  • 无锁是非阻塞的锁,CAS 算法和 ThreadLocal 是实现无锁的两种方式,前者以额外空间实现无锁,后者以额外时间去实现无锁,他们都是非阻塞的。

全文完

以下文章您可能也会感兴趣:

原文  http://mp.weixin.qq.com/s?__biz=MzUxOTE5MTY4MQ==&mid=2247484254&idx=1&sn=aed424079ee0cc592b88322117aa8ca4
正文到此结束
Loading...