细品Java并发--Lock接口及其实现类

细品Java并发--Lock接口及其实现类

困而学,学而知

在前面文章synchronized底层原理中介绍了JVM提供的锁机制synchronized。文章开头提到了Java语言JDK提供的锁接口Locks,比如说:可重入锁:ReentrantLock和可重入读写锁:ReentrantReadWriteLock

Lock接口

要看一个接口,当然要从该接口提供了那些方法开始啦。

image-20191130135626321

Lock接口提供了6个接口方法,分别是:

  • lock() :获取锁,也就是加锁
  • lockInterruptibly(): 如果当前线程被中断,则获取锁
  • tryLock() :尝试获取锁,如果获取锁成功,返回true,否则返回false
  • tryLock time, unit) : 超时尝试获取锁,如果获取失败或者超过了等待时间,返回false。
  • unlock():释放锁,也就是解锁。
  • newCondition():创建一个Condition类实例。具体我们可以在后面讨论讨论。

相比较于synchronized能用在方法和代码块,Lock接口提供了一些列方法,使得开发人员能够灵活的控制加锁和解锁的时机。更灵活同时也意味着需要开发人员时刻记着手动释放锁,如果不手动释放锁,则会造成死锁。一般我们会在finally块中调用lock.unlock释放锁。

接口只是提供了一些方法,并没有具体的实现,所以我们先来看看可重入锁ReentrantLock

ReentrantLock

ReentrantLock类是Lock接口一个最典型的实现,也是平常开发中,出镜率最高的一个。他的含义是可重入锁,意思就是同一个线程可以重复获取同一把锁。请注意这里是可重入是指的是同一个线程,其他线程则是不可重入的。

reentrantLock

我们将ReentrantLock类抽象成上图的结构,可以看到ReentrantLock中有一个等待队列ownercount。其中等待队列和owner在synchronized底层原理中说到对象监视器也有这么一个东西,对象监视器还要比ReentrantLock多一个等待池。

我们现在来模拟线程加锁和解锁的场景。现在锁没有被任何一个线程占有,此时两个线程t1和t2来抢锁。

  • 首先,t1和t2线程回去检查count值是否为0
  • 如果count值为0,那么两个线程都会通过CAS算法去更新count值,当然了这里就是更新为1。
    • 如果t2线程更新成功了,这时候count值变成了1,同时owner的值会变成t2线程的线程ID
    • 这时由于t1线程更新失败了,则会进入等待队列。
  • 如果count值不为0,那这时候,线程回去看owner的值是什么
    • 如果owner值不为当前线程ID,则当前线程会进入等待池
    • 如果owner值为当前线程ID,则count+1。

线程争抢锁的流程大致就就是上面的流程了。下面我们来说说解锁(lock.unlock())的流程。当前锁的拥有者是t2。

  • 首先,更新count的值为count - 1
  • 然后,判断当前释放锁的线程是否和owner的值是否相同,如果不相同,则抛错IllegalMonitorStateException
  • 判断count值是否为0
    • 如果count值不为0 ,则释放锁失败,还需要调用lock.unlock()
    • 如果count值为0,设置owner=null。这时候,会紧接着唤醒等待队列头部的线程来抢锁。其实这时候count=0且owner=null,只要有新线程都可以来抢锁。

上面就是ReentrantLock的大致流程,里面有些细节问题就需要看源码来分析。这里需要说明一点的就是,在ReentrantLock中,并没有等待队列、owner和count这三个属性。等待队列对应了AbstractQueuedSynchronizer类中的Node,count对应了AbstractQueuedSynchronizer中的state, owner对应AbstractOwnableSynchronizer类中的exclusiveOwnerThread

ReentrantLock的简单使用

//公平锁
//static Lock lock = new ReentrantLock(true);

//非公平锁
static Lock lock = new ReentrantLock();

public static void main(String args[]) throws InterruptedException {
  lock.lock();
  
  try {
    // do something;
  } finally{
    lock.unlock();
  }
}

ReentrantLock造成死锁的原因

如果加锁和解锁的次数不匹配,就会造成死锁或报错。

如果加锁次数大于解锁次数,就有可能造成死锁。

如果解锁次数雨大加锁次数,就有可能造成报错。

//非公平锁
static Lock lock = new ReentrantLock();

public static void main(String args[]) throws InterruptedException {
  lock.lock();
  System.out.println("get lock 1...");
  
  lock.lock();
  
  System.out.println("get lock 2...");
  lock.lock();
  
  System.out.println("get lock 3...");
  
  lock.unlock();
  
  lock.unlock();
  
  // 不加上会死锁
  lock.unlock();
}

简单总结一下,ReentrantLock实现加锁和解锁都是通过更新count和owner来实现的。加锁和解锁都需要先去更新count的值,加锁时是有更新成功的线程才能抢到锁,解锁时之后将count更新为0的是,才可以真正释放锁。同时,加锁需要更新owner值为获取锁的线程ID,解锁成功后需要更新owner值为null。没有获取到锁的线程会进入等待队列,在等待队列中的线程的状态的是WAITING,只有锁释放后,等待队列中的线程才能出来争抢锁。最后还有一点就是,上图中的结构是抽象出来的,源码中没有这几个属性,但我们也可以在源码中找到符合条件的属性。

Condition

在Condition之前,如果要挂起一个或全部线程(单个等待集),可以使用waitparksupspend(弃用)等。如果要唤醒一个或全部线程(单个等待集),可以使用notifynotifyAllunparkresume(弃用)。就如同下表所示。

协作方式死锁方式1(锁)死锁方式2(先唤醒,再挂起)备注
suspend/resume死锁死锁弃用
wait/notify不死锁死锁只能与synchronized关键字
park/unpark死锁不死锁

而本文需要介绍的Condition,就是另一种挂起和唤醒线程的方式。Condition需要与Lock配合使用,提供多个等待集合,更精确控制。

Condition听了一个挂起线程的方法:await(),也提供了两个唤醒线程的方法:signalsignalAll。这些方法使用的时候都需要加锁,和wait、notify方法使用类似。

Condition的正常用法

// 可重入锁    
private static Lock lock = new ReentrantLock();
// 创建一个Condition实例
private static Condition condition = lock.newCondition();

public static void main(String[] args) throws InterruptedException {

  Thread th = new Thread(new Runnable() {
    @Override
    public void run() {
    
      lock.lock();
      try {
        System.out.println("获得锁,调用condition.await()");
        // 挂起线程,等同于this.wait、LockSupport.park()
        condition.await();      // waiting  park
        System.out.println("唤醒了...");
      } catch (InterruptedException e) {
        e.printStackTrace();
      } finally {
        lock.unlock();
      }
    }
  });
  
  th.start();
  // 睡眠2秒
  Thread.sleep(2000L);
  lock.lock();
  // 唤醒线程
  condition.signal();
  lock.unlock();
}

Condition的死锁用法

// 可重入锁    
private static Lock lock = new ReentrantLock();
// 创建一个Condition实例
private static Condition condition = lock.newCondition();

public static void main(String[] args) throws InterruptedException {

  Thread th = new Thread(new Runnable() {
    @Override
    public void run() {
       try {
         Thread.sleep(2000L);
       } catch (InterruptedException e) {
         e.printStackTrace();
       }
    
      lock.lock();
      try {
        System.out.println("获得锁,调用condition.await()");
        // 挂起线程,等同于this.wait、LockSupport.park()
        condition.await();      
        System.out.println("唤醒了...");
      } catch (InterruptedException e) {
        e.printStackTrace();
      } finally {
        // 养成一个好习惯,记得释放锁。
        lock.unlock();
      }
    }
  });
  
  th.start();
  // 这里不睡眠
  // Thread.sleep(2000L);
  lock.lock();
  // 唤醒线程
  condition.signal();
  lock.unlock();
}

看出这个示例和之前的示例有什么不同吗?这个示例的condition.signal()方法调用在condition.await()方法之前。其实也很好理解,如果都没有线程挂起,调用 signal方法有什么用呢?然后又去调用了await方法挂起线程,然而后面并没有机会来唤醒这个线程。

简单总结一下,Condition类提供了一组与wait和notify方法类似的方法挂起和唤醒线程,且用的时候也都需要加锁,只是前者使用synchronized,后者使用Lock.lock和Lock.unlock。Condition的signal一定要在await方法之后执行,不然容易造成死锁

本文总结

本文介绍了Lock接口,和他典型的实现类ReentrantLock,讲了ReentrantLock类加锁和解锁的原理,并分析了步骤。最后讲了和Lock接口类配合使用的Condition类,用来挂起和唤醒线程。

但是这里还有一个Lock的实现类并没有讲:ReentrantReadWriteLock,这也是一个很有意思的类,如果有机会,再聊聊这个类。

Copyright: 采用 知识共享署名4.0 国际许可协议进行许可

Links: https://baozi.fun/archives/lock-reentrantlock

Buy me a cup of coffee ☕.