Java系统吞吐量起不来?可选用正确的Lock实现

原文链接: mp.weixin.qq.com

纷争不可怕,通过锁建立合适的流程协调,从而在既有资源情况下产出效率大幅提升。

    上篇文章(Java系统吞吐量起不来?可能是Java Concurrency使用不当(一))中, 我们讨论了Java synchronization机制(synchronized, volatile和atmoic)及其优缺点。

    本篇文章中, 将讨论其它一些高级别组件, 这些组件都是基于volatile、原子类和Compare-And-Swap构建的。 

    我们知道,使用多线程的目的是为了提升系统的吞吐能力。这种思维情况下,我们是在代码复杂性、排错难易性和监控方面做了让步,以尽可能地提升性能。当有一个共享对象时, 不可避免地将用到锁,随之而来,这个共享对象也就成为性能瓶颈点。于是, 选择一个适合的锁将对性能提升有举足轻重的作用。 

    可是synchronized为啥还不够? 

  1. Synchronized是一个严格的排他锁, 这就导致 在大多数情况下,不能定制剪裁以满足性能的进一步提升。

  2. 公平性方面, 不能再进一步让JVM来保 证公平性。 

  3. 使用Synchronized后, 系统发生死锁和线程饥饿时, 唯一的解救方式是终止业务系统重启,因为这时线程处于无休止的阻塞状态,也没有什么办法来中断阻塞的线程。  

锁机制探究

    高版本中JDK自带的java.util.concurrent.locks.Lock接口, 定义了极大的灵活性,以满足不同使用场景下定制优化的需求,同时也避免synchronized的缺点。这个接口定义了如下方法:

  • lock(): 方法调 用后,线程在获得锁之前将一直阻塞, 没有办法中断阻塞的锁。

  • lockInterruptibly(): 方法 调用后,线程在获得锁之前将一直阻塞, 不过,可以中断因未获得锁而阻塞的线程。 

  • tryLock(): 使用此方法时, 调用线程不会阻塞,立即返回: 如果能获得锁的话将返回true,否则返回fasle。

  • tryLock(long, TimeUnit): 调用此方法时,线程会阻塞一直等到有锁获得或者指定的时间超时。 如果获得锁后,此方法返回true。此情况下阻塞的线程,也可以中断。  

  • un lock():释放已获得的锁,一定在在finally代码块中调用这个方法来释放锁。

  • newC ondition(): 这是一个最有用的锁特性。 在某些使用场景下, 线程需要等一些条件满足,而在等待期间,很有必要释放已持有的锁让其它线程继续执行。 这个机制,跟synchronized配对的Object.wait和Object.notify/notifyAll很像。

   

ReentrantLock

    ReentrantLock实现了上面的接口。ReentrantLock类有两个构造方法,可以选择是公平锁还是非公平锁。 

    使用ReentrantLock时, 很重要的一点是需要注意, 公平锁或非公平锁都使用了一个等待队列来持有阻塞线程, 这也就意味着: 

  • 线程优先级不再起 作用。 这样,业务逻辑中不能再依赖线程优先级。  

  • 公平性的实现机制也很简单。 这里没有复杂的调度机制, 这也就意味着某个线程没释放锁或长时间地持有锁后,会引起线程的饥饿发生。  

    公平锁或非公平锁都使用到了AbstractQueuedSynchronizer, 它实现了等待队列, 这个等待队列是基于CLH锁队列衍生而来的。 

    分配锁的逻辑:

  • 公平锁: 三种情况下会分配给线程锁, 一是如果当前线程已有持有想获得的锁, 二是无其他线程申请锁,三是当前线程处于等待队列中第一的位置。  

  • 非公平锁: 先尝试地获取下 锁,如果没有获取到的话,当前线程放入线程的等待队列中。 

使用方面的重要指导:

  • tryLock()的使用 。 跟其它方法不同, 不管是公平实现或非公平实现中, 这个方法实质上都一个非公平的。 这样,在公平锁情况下使用时, 要留意可能跟预期的不一致。 

  • 使用ReentrantLock锁时, 尽可能少使用lock()方法。 而选用别的方法,并且据当前的使用场景,我们可能需要添加一个指数超时或最大重试次数,或者多种方法组合使用。即便是当前场景下需要阻塞的话,如等某个业务数据就位,使用lockInterruptibly()或tryLock(long, Timeout)方法。 代码如下: 

    

    

    boolean acquired = false;     long wait = 100;    int retries = 0;    int maxRetries = 10;    try {        while (!acquired && retries < maxRetries) {            acquired = lock.tryLock(wait, TimeUnit.MILLISECONDS);            wait *= 2;            ++retries;        }        if (!acquired) {            // log error or throw exception        }    } catch (InterruptedException e) {        // log error or throw exception    } finally {        lock.unlock();    }
  • 一个不好情况是,忽略了阻塞线程的打断。阻塞线程的打断应该恰当地处理,以避免跟业务系统或线程池相关的问题。即便是判断后认为可以忽略的话, 也日志打印下异常,而不是直接忽略掉。 

  • ReentrantLock类中提供了其它一些方 法,这些方法只适合用在排查Bug或显示运行情况,而不适合用到线程协调。我的建议是,代码中坚持只使用java.util.concurrent.locks.Lock接口中声明的方法,而不要在自己的代码中使用前面提到的排查Bug之类的方法,让自己的代码受污染, 毕竟bug排查的问题可以使用其它更为优雅的工具。  

ReentrantReadWriteLock

    ReentrantReadWriteLock背后有两个重入锁:一个读锁和一个写锁。读锁可以共享(即写锁没有被持有情况下,读锁可以被多个线程持有);写锁有些不同,写锁是严格的排他锁,只能在读锁非分配出去情况下,最多一个线程可以获得。这个锁在大多场合下很有用, 可以很大程度地提升同步性能。 

    这个锁实现也有公平和非公平两种策略。 

其它一些线程协调(Synchronization)工具:

  • CountDownLatch: 在多个线程需要等一组操作完成后才继续时再往下进行时,这个工具很有用。使用时,传给构造方法一个计数,每个操作完成后调用countDown()方法。当传入的计数值为0时,外面等待的线程可以再往下继续。 

  • CyclicBarrier: 在多个线程需 要等到某一个共同点时才往下进行的业务场景下, 这个工具很有用。当然同样的效果可以使用前面的CountDownLatch方法变相地达到,不过使用CyclicBarrier有其它的一些好处:

    • CyclicBarrier中的计数 可以重置。 

    • CyclicBarrier可以设置一个Runnable实现, 这 个实现可以barrier到达后,触发执行。 

    • 如果管理的线程中 有一个离开后,barrier就认为损坏了, 造成损坏的原因有很多,如线程中断、失败或等待超时。barrier损坏后,其它等待的线程由BarrierBrokenException通知后离开barrier。 barrier的状态可以通过isBroker()方法检测。barrier的状态会一直持有到reset()方法调用后。

  • StampedLock: 这是另一个读写锁。不过这个锁没有实现前面提到的java.util.concurrent.locks.Lock接口。相比于ReentrantReadWriteLock,StampedLock使用起来也复杂些。不过,它有一个很有意义的特性,即乐观读,不过使用起来很棘手,也比较脆弱。强烈建议使用ReentrantReadWriteLock替代StampedLock。

---------

往期推荐

  1. Java系统吞吐量起不来?可能是Java Concurrency使用不当(一)

  2. 国学思维与软件建设

  3. 软件架构师必备的几个思维套路(一)

  4. 软件架构师必备的几个思维套路(二)

~~~~~~~~~~~~~

长按二维码,关注公众号

一起推进电商业务信息化