36、Lock(上)
上两节我们学习了一种互斥锁:Java synchronized 内置锁,尽管 synchronized 的底层原理比较复杂,但是使用起来却非常简单
从本节开始,我们来学习另外一种互斥锁:JUC 并发包提供的 Lock 锁
相对于 synchronized 内置锁,JUC Lock 锁提供了更加丰富的特性,比如:支持公平锁、可中断锁、非阻塞锁、可超时锁等
本节我们就详细介绍一下 JUC Lock 锁的各种特性及其用法,下一节我们再结合 AQS,对这些特性的实现原理做深入讲解
1、JUC 锁概述
JUC 提供了几种不同的锁,继承和实现层次关系如下图所示
本节重点讲解 Lock 锁(也就是 Lock 接口及其实现类 ReentrantLock 可重入锁)
下下节讲解读写锁(也就是 ReadWriteLock 接口及其实现类 ReentrantReadWriteLock 可重入读写锁)以及读写锁的升级版 StampedLock
我们先来看 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 会对没有加锁成功的锁进行解锁,这显然是不对的
本文来自博客园,作者:lidongdongdong~,转载请注明原文链接:https://www.cnblogs.com/lidong422339/p/17484511.html
【推荐】国内首个AI IDE,深度理解中文开发场景,立即下载体验Trae
【推荐】编程新体验,更懂你的AI,立即体验豆包MarsCode编程助手
【推荐】抖音旗下AI助手豆包,你的智能百科全书,全免费不限次数
【推荐】轻量又高性能的 SSH 工具 IShell:AI 加持,快人一步