Java并发编程 | Lock和Condition(1)

持续创作,加速成长!这是我参与「掘金日新计划 · 6 月更文挑战」的第2天,点击查看活动详情

本系列专栏 Java并发编程专栏 - 元浩875的专栏 - 掘金 (juejin.cn)

前言

我们回顾下前面文章所说的Java并发编程的万能钥匙:管程,管程是把共享变量和对共享变量的操作给封装起来实现互斥,然后管程内部的条件变量以及条件变量的等待队列实现了线程的同步。

同时Java语言的synchronized是管程的一种实现,只不过只有一个条件变量的管程模型;那Java并发包为什么还给出其他的管程实现呢

就比如本篇内容就是通过Lock和Condition这俩个接口来实现管程,其中Lock用于解决互斥问题,Condition用于解决同步问题。

所以理解为什么Java并发包要开发这俩个工具很关键。

正文

这里我们就先来思考一下,为什么Java并发包要重复造轮子,又实现一遍管程。

再造管程的理由

在前面文章说死锁的时候,当时提出了一个解决方案,叫做破坏不可抢占条件。

思路是这样的,当一个线程需要A、B俩个资源时,即需要获取俩把锁,这时获取到锁A时,再尝试获取锁B时,发现获取不到,正常来说就是进入阻塞状态,而这时破坏不可抢占条件就是把锁A给释放了。

但是这个方法使用synchronized是没有办法解决的,原因是synchronized申请资源的时候,如果申请不到,线程进入阻塞状态了,而线程进入阻塞状态,啥也干不了了,也释放不了线程所持有的资源,这也就无法实现上面所说的。

如果我们重新设计一把互斥锁来解决这个问题,该如何设计呢 可以有下面方案:

  1. 能够响应中断。synchronized的问题是持有锁A后,如果尝试获取锁B失败,则线程进入阻塞状态,一旦发生死锁,就没有任何机会来唤醒这个线程了。假如阻塞的线程能够响应中断信号,即我们可以给阻塞中的线程发送中断信号,这样来唤醒它,那它就有机会释放持有的锁A。
  2. 支持超时。如果线程在一段时间内没有获取到锁,不是进入阻塞状态,而是返回一个错误,那么这个线程也有机会释放曾经持有的锁,从而破坏不可抢占条件。
  3. 非阻塞地获取锁。如果尝试获取锁失败,则不进入阻塞状态,而是直接返回,那这个线程也有机会释放曾经持有的锁。

这3个方案全面弥补了synchronized的问题,所以这也就是重复造轮子的原因,体现在API上,就是Lock接口的3个方法,如下:

// 支持中断的API
void lockInterruptibly() 
  throws InterruptedException;
// 支持超时的API
boolean tryLock(long time, TimeUnit unit) 
  throws InterruptedException;
// 支持非阻塞获取锁的API
boolean tryLock();
复制代码

如何保证可见性

在前面介绍Java内存模型的文章中,我们说为了解决并发编程中的可见性和有序性问题,Java给出了Happens-Before规则。其中就有一条是关于synchronized:synchronized的解锁Happens-Before于后续对这个锁的加锁,这正是由于这个规则保证了synchronized关键字所保护临界区是可见的。

那Java SDK中的Lock是如何保证可见性的呢 比如下面代码:

class X {
  private final Lock rtl =  new ReentrantLock();
  int value;
  public void addOne() {
    // 获取锁
    rtl.lock();  
    try {
      value+=1;
    } finally {
      // 保证锁能释放
      rtl.unlock();
    }
  }
}
复制代码

这里线程T1对value进行了+1操作,然后线程T2获取锁,能够看到value的正确结果吗 答案必须是肯定的,这个也是我们熟知的,但是为什么能这样呢 Happens-Before对可见性的描述并没有对Lock的限制。

具体的实现很复杂就不细说了,但是可以简单说一下。它是利用了volatile相关的Happens-Before规则。Java SDK中的ReentrantLoack,其内部持有一个volatile的成员变量state,获取锁的时候,会读写state的值;解锁的时候,也会读写state的值。简化代码如下:


class SampleLock {
  volatile int state;
  // 加锁
  lock() {
    // 省略代码无数
    state = 1;
  }
  // 解锁
  unlock() {
    // 省略代码无数
    state = 0;
  }
}
复制代码

也就是说在前面代码线程T1执行完value+=1之后,程序会读写一次volatile变量state,在执行完value+=1之后,又读写一次volatile变量state,根据相关的Happens-Before规则:

  1. 顺序行规则,对于线程T1,value+=1 HB 释放锁的操作unlock();
  2. voatile变量规则:由于state=1会读取state,所以线程T1的unlock()操作HB线程T2的lock()操作;这是因为堆volatile变量的写操作,会HB对这个变量的读操作。
  3. 传递性规则:线程T1的value+=1就HB线程T2的lock()操作。

所以这里可重入锁是非常巧妙地利用volatile关键字的可见性规则来实现自己的可见性。

什么是可重入锁

我们会发现本章的代码中使用的锁是ReentrantLock,即可重入锁,从字面翻译来看就是线程可以重复获取同一把锁。这是啥意思呢,比如下面代码:

class X {
  private final Lock rtl = new ReentrantLock();
  int value;
  public int get() {
    // 获取锁
    rtl.lock();         ②
    try {
      return value;
    } finally {
      // 保证锁能释放
      rtl.unlock();
    }
  }
  public void addOne() {
    // 获取锁
    rtl.lock();  
    try {
      value = 1 + get(); ①
    } finally {
      // 保证锁能释放
      rtl.unlock();
    }
  }
}
复制代码

比如这里线程T1执行到1处时,说明已经获取了rtl锁了,这时去调用get()方法,会在2出再次对rtl执行加锁。这时,如果rtl是可重入的,那么线程T1可以再次加锁成功;如果rtl是不可重入的,那么线程T1这时就会被阻塞。

公平锁和非公平锁

在使用ReentrantLock时,可以发现ReentrantLock这个类有2个构造函数,一个是无参构造函数,一个是传入fair参数的构造函数。其中fair就是代表着锁的公平策略,如果传入true就表示需要构造一个公平锁,反之则要构造一个非公平锁。

//无参构造函数:默认非公平锁
public ReentrantLock() {
    sync = new NonfairSync();
}
//根据公平策略参数创建锁
public ReentrantLock(boolean fair){
    sync = fair ? new FairSync() 
                : new NonfairSync();
}
复制代码

在前面介绍管程的文章中,我们说过入口等待队列这个概念。每个锁都对应这一个等待队列,如果一个线程没有获得锁,就会进入等待队列,当有线程释放锁的时候,就需要从等待队列中唤醒一个等待的线程。如果是公平锁,唤醒的策略就是谁等待时间长,就唤醒谁,很公平;如果是非公平锁,则不提供这个公平保证,有可能等待时间段的线程反而先被唤醒。

总结

本篇文章主要说了为什么Java SDK在Java语言内置有synchronized实现管程的前提下又创造了Lock和Condition来实现管程,其中Lock的3个API就分别解释了原因。

使用Lock进行加锁和解锁,可以在获取不到锁的情况下,按需释放所持有的资源,避免造成死锁。下篇文章我们继续说Lock和Condition的更多细节,欢迎大家点赞、收藏、评论,一起进步。

猜你喜欢

转载自juejin.im/post/7101843042641903629