只是不愿随波逐流 ...|

lidongdongdong~

园龄:2年7个月粉丝:14关注:8

36、Lock(上)

内容来自王争 Java 编程之美

上两节我们学习了一种互斥锁:Java synchronized 内置锁,尽管 synchronized 的底层原理比较复杂,但是使用起来却非常简单
从本节开始,我们来学习另外一种互斥锁:JUC 并发包提供的 Lock 锁
相对于 synchronized 内置锁,JUC Lock 锁提供了更加丰富的特性,比如:支持公平锁、可中断锁、非阻塞锁、可超时锁等
本节我们就详细介绍一下 JUC Lock 锁的各种特性及其用法,下一节我们再结合 AQS,对这些特性的实现原理做深入讲解

1、JUC 锁概述

JUC 提供了几种不同的锁,继承和实现层次关系如下图所示
本节重点讲解 Lock 锁(也就是 Lock 接口及其实现类 ReentrantLock 可重入锁)
下下节讲解读写锁(也就是 ReadWriteLock 接口及其实现类 ReentrantReadWriteLock 可重入读写锁)以及读写锁的升级版 StampedLock
image

我们先来看 Lock 接口,其接口定义如下所示,因为在平时的开发中,我们用到的锁都是可重入锁,所以 Lock 接口只有一个可重入的实现类 ReentrantLock

// 都是可重入锁, 默认非公平锁
public interface Lock {
// 阻塞锁
void lock();
// 阻塞锁, 可以被中断
void lockInterruptibly() throws InterruptedException;
// 非阻塞锁
boolean tryLock();
// 可超时锁, 也可以被中断
boolean tryLock(long time, TimeUnit unit) throws InterruptedException;
// 解锁
void unlock();
Condition newCondition();
}

2、可重入锁

可重入锁指的是:可以被同一个线程多次加锁的锁,注意:这里说的多次加锁,并不是说解锁之后再次加锁,而是在锁没有解锁前再次加锁

如下代码所示,为了保证线程安全,getEvenSeq() 函数和 increment() 函数中的代码都加了锁
getEvenSeq() 函数调用 increment() 函数,导致 getEvenSeq() 函数在锁释放前再次加锁
如果 JUC 提供的锁不支持可重入特性,那么 getEvenSeq() 中的第二次加锁需要等待锁释放,而锁释放又需要加锁之后才能执行,于是 getEvenSeq() 就会出现死锁

public class Demo {
private int seq = 0;
Lock lock = new ReentrantLock();
// 获取偶数
public int getEvenSeq() {
lock.lock();
try {
// ... 省略其他操作 ...
if (seq % 2 == 1) {
increment();
}
return seq;
} finally {
lock.unlock();
}
}
public void increment() {
lock.lock();
seq++;
lock.unlock();
}
}

对于上述代码,我们稍微解释一下,之所以 getEvenSeq() 函数使用 finally 来释放锁,是为了避免代码抛出异常而导致锁无法正常释放
而之所以 increment() 函数没有使用 finally 来释放锁,是因为代码比较简单,我们可以确定代码不会抛出异常

JUC 提供的锁都是可重入锁,实际上 Java synchronized 内置锁也是可重入锁,从侧面上我们也可以得出,"可重入" 是对锁的基本要求
为了实现可重入特性,可重入锁中需要有一个变量来记录重入的次数

  • 每重入一次,变量就增一
  • 每解锁一次(调用 unlock() 或退出 synchronized 代码块),变量就减一,直到变量值为 0 时,才会释放锁唤醒其他线程执行

3、公平锁

对于公平锁来说,线程会按照请求锁的先后顺序来获得锁,也就是我们经常说的 FIFO
对于非公平锁,多个线程请求锁时,非公平锁无法保证这些线程获取锁的先后顺序,有可能后申请锁的线程先获取到锁

Java 将 synchronized 设计为只支持非公平锁,而 JUC 提供的 ReentrantLock 既支持公平锁,也支持非公平锁,默认情况下,ReentrantLock 为非公平锁
如果需要创建公平锁,那么我们只需要在创建 ReentrantLock 对象时,将构造函数的参数设置为 true 即可,如下代码所示

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

我们再来看下:公平锁和非公平锁的实现原理

在讲解 synchronized 的底层实现原理时,我们讲到
多个线程竞争锁,竞争到锁的线程就去执行任务了,没有竞争到锁的线程会放入 Monitor 锁的 _cxq 队列中等待锁,并且还需要调用 park() 函数阻塞自己
当持有锁的线程释放锁时,它会从 _EntryList 队列中取一个线程,取消阻塞状态,让它去重新竞争锁,而不是直接将锁给它
而此时如果有新来的线程也要竞争这个锁,新来的线程有可能不需要排队,就能直接获取锁,显然这是一种 "插队" 的行为

当然我们也可以让 synchronzied 支持公平锁,实现起来也很简单
新来的线程在申请获取锁时,如果 _EntryList 队列和 _cxq 队列中有排队等待锁的线程
那么不管此时锁有没有释放,新来的线程都直接放入 _cxq 队列中排队,按照先来后到的顺序等待锁,以避免新来线程的 "插队" 行为,这样实现的锁就是公平锁

实际上,ReentrantLock 实现公平锁和非公平锁的方法,跟上述 synchronized 的实现方法,其基本原理是一致的
区别在于:ReentrantLock 使用 AQS(抽象队列同步器)来存储排队等待锁的线程,关于 AQS,我们在下一节中详细讲解

既然实现公平锁并不复杂,而且从直觉上,公平锁比非公平锁更加合理,但是 synchronized 为什么只支持非公平锁?主要原因有以下 3 个方面

  • 历史的原因:synchronized 早期开发时没有考虑那么全面
  • 需求的原因:绝大部分业务场景都不需要严格规定线程的执行顺序,如果真的需要,我们可以通过条件变量(wait()、notify() 等)等同步工具来实现
  • 性能的原因:非公平锁的性能比公平锁的性能更好,我们知道,加入等待队列并调用 park() 函数阻塞线程,涉及到用户态和内核态的切换,比较耗时
    对于非公平锁来说,新来的线程直接竞争锁,这样就有可能避免加入等待队列并调用费时的 park() 函数

不过非公平锁也有缺点,在极端情况下,线程竞争锁激烈,频繁有新来的线程插队,就有可能导致:排在等待队列中的线程,迟迟无法获取到锁

4、可中断锁

对于 synchronized 锁来说,线程在阻塞等待 synchronized 锁时是无法响应中断的
而 JUC Lock 接口提供了 lockInterruptibly() 函数,支持可响应中断的方式来请求锁,示例代码如下所示

  • 主线程先获取到了锁并一直持有,之后线程 t1 调用 lockInterruptibly() 请求锁,因为锁被主线程持有,所以线程 t1 阻塞等待
  • 主线程调用 interrupt() 函数向线程 t1 发起中断请求,线程 t1 响应中断请求,退出阻塞等待锁,并打印 "I am interrupted"
public class Demo {
private static Lock lock = new ReentrantLock();
public static void main(String[] args) {
Thread t1 = new Thread(new Runnable() {
@Override
public void run() {
try {
lock.lockInterruptibly(); // t1 请求锁
} catch (InterruptedException e) {
System.out.println("I am interrupted");
return;
}
System.out.println("I got lock");
lock.unlock();
}
});
lock.lock(); // 主线程先获取到了锁并一直持有
t1.start(); // 开启 t1 线程
t1.interrupt(); // 向 t1 发起中断请求
lock.unlock(); // 主线程释放锁
}
}
// 输出结果:I am interrupted

可中断锁一般用于线程管理中,方便关闭正在执行的线程
比如:Nginx 服务器采用多线程来执行请求,当我们调用 stop 命令关闭 Nginx 服务器时
Nginx 服务器可以采用中断的方式,将阻塞等待锁的线程中止,然后合理的释放资源和妥善处理未执行完成的请求,以实现服务器的优雅关闭

5、非阻塞锁

对于 synchronized 锁来说,一个线程去请求一个 synchronized 锁时,如果锁已经被另一个线程获取,那么这个线程就需要阻塞等待
JUC Lock 接口提供了 tryLock() 函数,支持非阻塞的方式获取锁:如果锁已经被其他线程获取,那么调用 tryLock() 函数会直接返回错误码而非阻塞等待,示例代码如下所示
非阻塞锁的实现原理非常简单,竞争锁失败的线程不放入队列排队即可实现非阻塞锁

public class Demo {
private Lock lock = new ReentrantLock();
public void useTryLock() {
if (lock.tryLock()) {
try {
// ... 执行业务代码 ...
} finally {
lock.unlock();
}
} else {
// ... 没有获取锁, 执行其他业务代码 ...
}
}
}

6、可超时锁

除了提供不带参数的 tryLock() 函数之外,JUC Lock 接口还提供给了带时间参数的 tryLock() 函数,支持非阻塞获取锁的同时设置超时时间
也就是说,一个线程在请求锁时

  • 如果这个锁被其他线程持有:那么这个线程会阻塞等待一段时间
  • 如果超过了设定的超时时间,线程仍然没有获取到锁:那么 tryLock() 函数将会返回错误码而不再阻塞等待

示例代码如下所示,从示例代码中,我们还可以发现,tryLock() 跟 lockInterruptibly() 一样,也可以被中断,这样是为了避免 tryLock() 阻塞过长时间

public class Demo {
private Lock lock = new ReentrantLock();
public void useTryLockWithTimeout() {
boolean locked = false;
try {
locked = lock.tryLock(100, TimeUnit.MILLISECONDS);
} catch (InterruptedException e) {
System.out.println("I am interrupted");
}
if (locked) {
try {
// ... 执行业务代码 ...
} finally {
lock.unlock();
}
} else {
// ... 没有获取锁, 执行其他业务代码 ...
}
}
}

接下来,我们再来看看可超时锁的应用场景

在很多对响应时间比较敏感的系统中,比如面向用户的系统,从用户体验上说:请求失败给予提示,要远好于响应超时而没有反应

我们拿 Tomcat 等 Web 服务器来举例,Tomcat 采用线程池的方式多线程执行用户请求
如果某个 "特殊请求" 不能并发执行,并且请求执行时间比较长,那么请求的处理代码就需要加锁
当多个线程同时执行多个特殊请求时,有些线程就有可能因为迟迟无法获取到锁而无法执行请求

  • 一方面:这样会导致用户请求超时,给用户带来不好的体验
  • 另一方面:线程一直等待锁,长期被占用(阻塞),无法执行其他任务,剩余可以执行用户请求的线程变少,从而加重了系统负担,导致更多请求超时

针对以上问题,我们就可以使用带超时时间的 tryLock() 函数来请求锁,如果在设定的超时时间内未获取到锁,那么线程就中止执行用户请求,返回错误信息给用户
当然这只是保护措施,毕竟以上问题只有在无法并发执行的 "特殊请求" 集中大量到来时才会发生

7、课后思考题

在本节的示例代码中,我们把 lock()、tryLock()、lockInterruptibly() 函数的调用,都放置于 try-finally 代码块之外,这是为什么?是否可以移到 try-finally 代码块之内呢?
这 3 个函数抛出异常说明加锁失败,如果我们将 lock()、tryLock()、lockInterruptibly() 放置于 try 代码块内,那么执行 finally 会对没有加锁成功的锁进行解锁,这显然是不对的

posted @   lidongdongdong~  阅读(34)  评论(0编辑  收藏  举报
点击右上角即可分享
微信分享提示
评论
收藏
关注
推荐
深色
回顶
展开