如果在开发中,你需要对某个共享资源加锁,但是你希望在这个获取锁的过程中,最多只等5秒钟,如果超时了拿不到就算了,直接去执行降级逻辑。这时候,你应该怎么做?用原生的synchronized关键字可以吗?
不行的,synchronized 只能死等,拿不到锁就会一直阻塞在那。
又或者,一个线程正在苦苦等待获取锁,但业务上突然发生了变化(比如用户取消了请求),你想中断这个线程的等待,让它别再干了。用 synchronized 可以响应这个中断吗?
同样也是不行的,它对外部的中断信号完全无动于衷。
这就是我们在高并发编程中经常遇到的痛点:synchronized 作为 JVM 内置的锁,用起来确实简单,但它太死板了。出于灵活性的考虑,我们需要一种更可控、更具弹性的加锁和释放锁机制。
这也是为什么 Java 引入了今天要聊的:public interface Lock。哪怕到了Java 17,虽然 JVM 对 synchronized 做了无数次底层的锁膨胀优化,但在遇到需要精细化控制并发的场景时,Lock 依然是不可替代的利器。
作为 API 层面的锁,Lock 把控制权交给了开发者。我们来看看它的几个核心特性,以及最简单的代码实现(通常我们都会使用它的经典实现类 ReentrantLock)。
基础加锁与释放(替代 synchronized 的基本操作)
和 synchronized 自动释放锁不同,Lock 最大的特点就是:加了锁,就必须手动释放。所以代码结构永远是配合 try-finally 出现的。
Java
Lock lock = new ReentrantLock();
lock.lock(); // 阻塞式获取锁
try {
// 操作共享资源
} finally {
lock.unlock(); // 铁律:必须在 finally 中释放,防止死锁
}
尝试非阻塞获取锁 / 带超时的获取(解决死等问题)
这是 Lock 极大提升灵活性的地方。你可以尝试去拿锁,拿到就干活,拿不到就走人;或者像开头说的那样,最多等一段时间。
同时,这也是提升性能的一种机制,不会死等,白白浪费资源。
Java
// 尝试获取,最多等待 5 秒
if (lock.tryLock(5, TimeUnit.SECONDS)) {
try {
// 获取到了锁,执行核心业务
} finally {
lock.unlock();
}
} else {
// 等了5秒还是没拿到,执行降级或快速失败逻辑
System.out.println("系统繁忙,请稍后再试");
}
响应中断的加锁(解决无法打断的问题)
如果一个线程A在等待锁的过程中,被其他线程(比如主线程)调用了A线程的 interrupt(),lockInterruptibly() 会立刻抛出 InterruptedException,让你有机会终止当前任务,而不是变成一个永远挂起的僵尸线程。
这块不太容易理解,我们写一个完整的例子来协助理解这个知识点。
Java
public static void main(String[] args) throws InterruptedException {
Lock lock = new ReentrantLock();
// 1. 主线程先直接把锁占了
lock.lock();
System.out.println("主线程:我已经拿到了锁,我先占着不放。");
// 2. 此时,开启一个工作线程A去干活
Thread workerThreadA = new Thread(() -> {
System.out.println("工作线程:我准备去获取锁,我可以等,但也接受中断...");
try {
// 因为锁在主线程手里,工作线程执行到这里会被直接阻塞(卡住)
lock.lockInterruptibly();
try {
System.out.println("工作线程:终于拿到锁了,开始干活!");
} finally {
lock.unlock();
}
} catch (InterruptedException e) {
// 4. 工作线程正在等锁的过程中,收到了来自其他线程的中断信号,抛出异常,并醒过来。
System.out.println("工作线程:等锁的过程中,突然被叫醒了,准备退出!");
// 恢复中断标志位,这是一种良好的并发编程习惯
Thread.currentThread().interrupt();
}
});
workerThreadA.start();
// 主线程假装处理了一些业务,耗时 2 秒。在这2秒内,workerThread一直在阻塞等锁
Thread.sleep(2000);
// 3. 业务上发生了变化,主线程决定打断工作线程的等待
System.out.println("主线程:业务有变,不需要工作线程继续等锁干活了,我现在就去中断它!");
workerThreadA.interrupt(); // 关键动作:由主线程调用了工作线程的interrupt()
// 主线程最后释放自己的锁
lock.unlock();
}
这段代码几个要点要说明一下:
-
是谁在调用 **
interrupt()****?是**主线程(或者说是脱离了工作线程之外的其他线程)。在这里,工作线程是执行者,主线程是控制者。 -
**具体的业务场景是什么样的?**实际开发中,这个主线程往往对应的是接收外部指令的线程。比如,你在前端页面点了一个生成报表的按钮,后台启动了一个工作线程去拿锁、取数据。过了两秒钟,你觉得太慢了,点了一下取消。这时候,后台接收取消请求的线程,就会去找到刚才那个工作线程,调用它的
interrupt()。如果那个工作线程正在用
synchronized死等数据库连接池的锁,它是停不下来的;但如果用的是lockInterruptibly(),它就会立刻抛出异常,结束自己,不浪费系统资源。
公平锁与非公平锁的选择
顺带一提,synchronized 永远是非公平锁(大家涌入进来一起抢,谁抢到算谁的)。而 Lock 允许你选择是否要排队。虽然绝大多数时候为了吞吐量我们用非公平锁,但有些强顺序要求的业务,公平锁就派上用场了。
Java
// 传入 true,这就是一把公平锁,先来后到,按排队顺序拿锁
Lock fairLock = new ReentrantLock(true);
说说synchronized 和 Lock 的区别,这是一道经典的Java并发编程面试题,下面我把注意区别列成一个表格,方便你复习。
| 对比维度 | synchronized | Lock(ReentrantLock) |
|---|---|---|
| 实现层面 | JVM 关键字,monitorenter/monitorexit 字节码 | JDK 层面的接口和类 |
| 锁的释放 | 自动释放 | 手动释放,必须在 finally 中 unlock() |
| 超时获取 | 不支持 | tryLock(timeout, unit) |
| 响应中断 | 不支持 | lockInterruptibly() |
| 非阻塞获取 | 不支持 | tryLock() |
| 公平锁 | 不支持,只有非公平锁 | 支持,构造方法可指定 |
| 可重入 | 是 | 是(ReentrantLock 的名字就说明了一切) |
那如果面试官问你,两者应该如何选择呢? 怎么回答?
你可以简单的这么说,synchronized是粗粒度的,如果要细粒度的做控制,可以使用Lock。
如果场景很简单,只是简单的互斥,就用:
Java
synchronized (lock) {
// 修改共享数据
}
可以了,且锁会自动释放。
如果需要超时获取、响应中断、非阻塞尝试获取,或者需要Condition这样的机制,就可以考虑 Lock。