Java多线程进阶-JUC之ReentrantLock与Callable
文章目录
- Java多线程进阶:JUC之ReentrantLock与Callable
- 一、`Callable`, `Future` 与 `FutureTask`:优雅地获取线程返回值
- 1. `Runnable` 的局限性:无法带回结果的“信使”
- 2. `Callable` 与 `Future`:定义任务与获取凭证
- 3. 回顾:`wait/notify` 的传统实现方式
- 4. `FutureTask<V>`:连接 `Callable` 与 `Thread` 的关键桥梁
- 5. 代码实战:优雅地计算 1 到 1000 的和
- 二、`ReentrantLock`:一个功能更强大的显式锁
- 深入 `ReentrantLock` 的高级功能
- 1. 可中断与可限时的锁等待
- 2. 公平锁 vs 非公平锁
- 3. `Condition` 对象:实现精准的线程唤醒
- 本篇核心要点总结 (Key Takeaways)
Java多线程进阶:JUC之ReentrantLock与Callable
在前两篇笔记中,我们深入探讨了锁的底层策略以及 synchronized
的实现。从本篇笔记开始,我们将正式进入 java.util.concurrent
(JUC) 的 常见类,去领略其提供的更强大、更灵活的并发工具。
本文将聚焦于两个核心组件:
Callable
与Future
:一套优雅的异步任务与结果获取机制,完美解决了Runnable
的固有缺陷。ReentrantLock
:一个功能远超synchronized
的显式锁。
掌握它们,将让你的并发编程能力提升到一个新的层次。
一、Callable
, Future
与 FutureTask
:优雅地获取线程返回值
1. Runnable
的局限性:无法带回结果的“信使”
在之前的学习中,我们知道通过 Runnable
接口可以定义一个任务,并交由线程去执行。但这存在一个很常见的问题:run()
方法没有返回值。
这就像我们派了一个信使(子线程)去远方执行一个复杂的计算任务,但他完成任务后,却没有办法把计算结果带回来告诉我们。如果需要获取结果,我们就不得不借助共享变量、wait/notify
等复杂的线程通信机制,这不仅麻烦,还容易出错。
为了解决这个问题,JUC 提供了一套更优雅的解决方案——Callable
和 Future
。
2. Callable
与 Future
:定义任务与获取凭证
-
Callable<V>
接口:它像是一个增强版的Runnable
。它的核心方法call()
不仅可以返回一个泛型结果V
,还能抛出异常。这解决了Runnable
的两大痛点。public interface Callable<V> {V call() throws Exception; }
-
Future<V>
接口:如果说Callable
是“会产生结果的任务”,那么Future
就是“获取这个未来结果的凭证”。当你把一个Callable
任务提交给线程池或线程去执行时,你会立刻得到一个Future
对象。这个对象代表着一个未来的、尚未完成的计算结果。你可以通过这个
Future
凭证,在未来的任意时刻:V get()
: 阻塞式地等待,直到任务完成并返回结果。V get(long timeout, TimeUnit unit)
: 限时等待,如果在指定时间内任务还未完成,就抛出TimeoutException
。boolean isDone()
: 查询任务是否已经完成。boolean cancel(boolean mayInterruptIfRunning)
: 尝试取消任务的执行。
3. 回顾:wait/notify
的传统实现方式
为了更好地理解 Callable
的价值,我们先回顾一下,如果只用 Runnable
和 wait/notify
机制,要实现“获取子线程计算结果”这个需求,代码会是怎样的。
需求: 创建一个子线程计算 1 + 2 + … + 1000 的和,主线程等待计算完成后获取并打印结果。
实现思路:
- 创建一个共享对象
Result
,用于存储计算结果sum
和一个用于同步的锁对象lock
。 - 在
main
方法中创建Result
实例,并创建一个子线程t
。 - 子线程
t
负责执行累加计算。计算完成后,它会获取result.lock
锁,将结果存入result.sum
,然后调用notify()
唤醒可能正在等待的主线程。 - 主线程启动子线程后,会立刻尝试获取
result.lock
锁。进入同步块后,它会使用while
循环来检查result.sum
是否已经被计算出来。如果还没有(sum
仍为0),则调用wait()
方法,释放锁并进入等待状态。 - 当子线程调用
notify()
后,主线程被唤醒,再次检查while
条件。此时条件不满足,循环退出,主线程打印最终结果。
代码示例:
// 辅助类,用于封装结果和锁
static class Result {public int sum = 0;public final Object lock = new Object();
}public static void main(String[] args) throws InterruptedException {Result result = new Result();Thread t = new Thread(new Runnable() {@Overridepublic void run() {int sum = 0;for (int i = 1; i <= 1000; i++) {sum += i;}// 计算完成后,锁定共享对象并更新结果synchronized (result.lock) {result.sum = sum;// 通知正在等待的主线程result.lock.notify();}}});t.start();// 主线程锁定同一个对象synchronized (result.lock) {// 使用 while 是为了防止“虚假唤醒”// 并且,如果子线程执行得非常快,在主线程wait之前就已完成,// 那么这个循环条件会直接不满足,避免了主线程不必要的等待。while (result.sum == 0) {result.lock.wait();}System.out.println("计算结果是: " + result.sum);}
}
小思考:传统方式的痛点
正如你所看到的,仅仅是为了在线程间传递一个简单的整数结果,我们就不得不:
- 额外定义一个
Result
类。- 小心翼翼地使用
synchronized
、wait
和notify
。- 处理复杂的线程状态同步问题,比如
wait
的时机和虚假唤醒。整个过程代码繁琐,逻辑复杂,且极易出错。这正是
Callable
和Future
致力于解决的问题。
4. FutureTask<V>
:连接 Callable
与 Thread
的关键桥梁
现在问题来了:Thread
类的构造方法只接受 Runnable
对象,并不直接支持 Callable
。那么如何让一个 Thread
去执行 Callable
任务呢?
答案就是使用 FutureTask
。FutureTask
是一个非常巧妙的适配器类,它扮演着双重角色:
- 它是一个
Runnable
:FutureTask
实现了Runnable
接口,所以它可以被直接传入Thread
的构造函数中。 - 它也是一个
Future
:FutureTask
也实现了Future
接口,所以它能像凭证一样,被用来获取任务的执行结果。
它的工作流程是:
- 用你的
Callable
任务来构造一个FutureTask
对象。 - 将这个
FutureTask
对象当作Runnable
传递给一个Thread
。 - 启动线程,线程会执行
FutureTask
的run()
方法,而run()
方法内部会去调用你传入的Callable
的call()
方法。 - 在主线程中,你可以通过
FutureTask
对象自身的get()
方法来等待并获取最终结果。
一个比喻:麻辣烫店的取餐小票
我们可以把这套机制想象成在一家现代化的麻辣烫店点餐:
Callable
任务:就是你的点餐需求——“一份微辣、多加麻酱的麻辣烫”。FutureTask
对象:就是你下单后,前台递给你的那张取餐小票。这张小票既是厨房(子线程)执行任务的依据(因为它包含了点餐信息,像个Runnable
),也是你将来取餐的唯一凭证(因为它能兑换结果,像个Future
)。Thread
:就是后厨里那位专门为你制作麻辣烫的厨师。他从你手里接过小票,然后开始工作。futureTask.get()
:就是你拿着小票,站在出餐口等待叫号。这个过程是阻塞的,在你的麻辣烫做好之前,你只能在这里等。如果厨师不小心做坏了(任务抛出异常),他会通过窗口告诉你(get()
方法会将异常抛出)。futureTask.isDone()
:你不想干等,于是每隔几分钟就抬头看一眼电子屏幕,查询你的取餐号是否出现在“已完成”列表里。futureTask.cancel()
:你突然不想吃了,于是拿着小票去前台说:“麻烦帮我取消订单”。
5. 代码实战:优雅地计算 1 到 1000 的和
import java.util.concurrent.Callable;
import java.util.concurrent.ExecutionException;
import java.util.concurrent.FutureTask;public class CallableExample {public static void main(String[] args) throws ExecutionException, InterruptedException {// 1. 定义一个 Callable 任务,它描述了“计算 1-1000 的和”这个操作,并能返回一个 Integer 结果。Callable<Integer> callable = () -> {System.out.println("子线程开始计算...");int sum = 0;for (int i = 1; i <= 1000; i++) {sum += i;}Thread.sleep(1000); // 模拟耗时计算System.out.println("子线程计算完成!");return sum;};// 2. 将 Callable 任务包装成一个 FutureTask 对象。// FutureTask 既是任务(Runnable),也是结果凭证(Future)。FutureTask<Integer> futureTask = new FutureTask<>(callable);// 3. 创建并启动线程。将 FutureTask 作为任务交给 Thread。Thread t = new Thread(futureTask);t.start();System.out.println("主线程已提交任务,正在做其他事情...");// 4. 在主线程中,调用 get() 方法获取子线程的计算结果。// 这个方法会阻塞,直到子线程的 call() 方法执行完毕并返回结果。Integer result = futureTask.get();System.out.println("主线程获取到结果:" + result);}
}
通过 Callable
、Future
和 FutureTask
的组合,我们无需手动编写复杂的 wait/notify
代码,就能轻松实现线程间的异步协作和结果传递,代码逻辑也变得更加清晰直观。
二、ReentrantLock
:一个功能更强大的显式锁
ReentrantLock
是一个可重入互斥锁,和 synchronized
定位类似,但它是一个纯粹的Java类,提供了更丰富、更灵活的功能。
“Reentrant” 这个单词的原意就是 “可重⼊”
基本用法:
ReentrantLock lock = new ReentrantLock();
// 必须在finally块中解锁,这是至关重要的规范!
lock.lock();
try {// 受保护的业务逻辑
} finally {lock.unlock();
}
ReentrantLock
与 synchronized
的核心区别:
特性 | synchronized | ReentrantLock |
---|---|---|
实现层面 | JVM关键字,由JVM层面实现 | JDK库中的一个类,由API层面实现 |
锁的释放 | 自动释放(退出同步块时) | 必须手动调用 unlock() ,否则会造成死锁 |
等待机制 | 获取不到锁会死等 | 提供了 tryLock() ,可限时等待或立即返回,避免无限期阻塞 |
公平性 | 非公平锁 | 默认为非公平,但可创建公平锁 new ReentrantLock(true) |
唤醒机制 | 配合 wait/notify/notifyAll ,随机或全部唤醒 | 配合 Condition 对象,可实现分组唤醒,精确控制 |
深入 ReentrantLock
的高级功能
synchronized
像是一把简单可靠的自动挡汽车钥匙,而 ReentrantLock
则更像是一辆手动挡性能跑车,它赋予了驾驶员(程序员)更大的控制权。下面我们来深入了解一下表格中提到的几个核心高级功能。
1. 可中断与可限时的锁等待
synchronized
在获取锁时,如果锁被占用,线程会进入阻塞状态,并且这个等待过程是无法被中断的,只能死等。ReentrantLock
提供了更灵活的选择:
tryLock()
: 尝试获取锁,如果锁未被占用,则立即获取成功并返回true
;如果锁已被占用,则不会等待,立即返回false
。这在某些“尝试一下,不行就算了”的场景中非常有用。tryLock(long timeout, TimeUnit unit)
:tryLock
的限时版本。线程会在指定的时间内尝试获取锁,如果超时仍未获取到,则返回false
。这可以有效避免线程无限期等待,防止死锁。lockInterruptibly()
: 这是ReentrantLock
的一个重要特性。调用此方法的线程在等待获取锁的过程中,可以响应中断请求(例如,另一个线程调用了它的interrupt()
方法)。这使得我们可以设计出能够取消或提前终止的锁等待逻辑,而synchronized
是做不到的。
2. 公平锁 vs 非公平锁
- 非公平锁 (默认): 当锁被释放时,所有等待的线程会蜂拥而上进行争抢,谁抢到就是谁的。这种方式吞吐量较大,因为减少了线程切换的开销,但可能导致某些线程长时间获取不到锁,即“饥饿”现象。
synchronized
就是一种非公平锁。 - 公平锁:
new ReentrantLock(true)
。它会维护一个等待队列,当锁被释放时,会从队列头部唤醒等待时间最长的线程。这种方式保证了先来后到,非常公平,但由于需要频繁地进行线程上下文切换,其性能通常低于非公平锁。
小思考:如何选择公平性?
除非你有非常明确的业务需求,需要保证所有线程都能最终获得锁(例如,防止资源饥饿),否则在绝大多数追求性能的场景下,默认的非公平锁是更好的选择。
3. Condition
对象:实现精准的线程唤醒
synchronized
配合的 wait/notify/notifyAll
机制有两个痛点:
notify
是随机唤醒一个等待线程,无法指定唤醒哪一个。notifyAll
会唤醒所有等待线程,造成“惊群效应”,即大量被唤醒的线程再次尝试获取锁,导致激烈的竞争和不必要的上下文切换。
ReentrantLock
通过 Condition
对象解决了这个问题。一个 Lock
可以创建多个 Condition
对象 (lock.newCondition()
),每个 Condition
都可以看作一个独立的等待队列。
condition.await()
: 类似于object.wait()
,使当前线程进入等待状态,并释放锁。condition.signal()
: 类似于object.notify()
,唤醒该Condition
等待队列中的一个线程。condition.signalAll()
: 类似于object.notifyAll()
,唤醒该Condition
等待队列中的所有线程。
通过为不同的业务逻辑创建不同的 Condition
对象,我们可以实现“分组唤醒”或“精准唤醒”,只唤醒那些需要被唤醒的线程,极大地提升了效率和代码的可控性。
如何选择?
- 在锁竞争不激烈、功能简单的场景下, 优先使用
synchronized
, 因为它语法简洁,不易出错,且JVM对其有持续的优化。 - 在需要高级功能(如公平锁、可中断的锁获取、限时等待、精确唤醒)或锁竞争非常激烈的场景下, 使用
ReentrantLock
能提供更强的控制力和性能。
本篇核心要点总结 (Key Takeaways)
Callable
与Future
是对Runnable
的增强:Callable
允许任务返回结果并抛出异常,而Future
则提供了一个获取这个“未来”结果的凭证,极大地简化了异步编程。FutureTask
是关键适配器:它同时扮演Runnable
和Future
的角色,是连接Callable
任务与Thread
执行实体的桥梁。ReentrantLock
是synchronized
的进阶版:它提供了更丰富的功能,如可中断的锁等待、限时等待、公平性选择和精确唤醒,适用于更复杂的并发场景。lock()
与unlock()
必须配对使用:使用ReentrantLock
时,必须在finally
块中调用unlock()
来释放锁,这是保证程序健壮性的铁律。
Runnable和
Future的角色,是连接
Callable任务与
Thread`执行实体的桥梁。ReentrantLock
是synchronized
的进阶版:它提供了更丰富的功能,如可中断的锁等待、限时等待、公平性选择和精确唤醒,适用于更复杂的并发场景。lock()
与unlock()
必须配对使用:使用ReentrantLock
时,必须在finally
块中调用unlock()
来释放锁,这是保证程序健壮性的铁律。