This commit is contained in:
louzefeng
2024-07-11 05:50:32 +00:00
parent bf99793fd0
commit d3828a7aee
6071 changed files with 0 additions and 0 deletions

View File

@@ -0,0 +1,195 @@
<audio id="audio" title="14 | Lock和Condition隐藏在并发包中的管程" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/e5/0e/e5795555e9b590061872a008bce13f0e.mp3"></audio>
Java SDK并发包内容很丰富包罗万象但是我觉得最核心的还是其对管程的实现。因为理论上利用管程你几乎可以实现并发包里所有的工具类。在前面[《08 | 管程:并发编程的万能钥匙》](https://time.geekbang.org/column/article/86089)中我们提到过在并发编程领域,有两大核心问题:一个是**互斥**,即同一时刻只允许一个线程访问共享资源;另一个是**同步**,即线程之间如何通信、协作。这两大问题,管程都是能够解决的。**Java SDK并发包通过Lock和Condition两个接口来实现管程其中Lock用于解决互斥问题Condition用于解决同步问题**。
今天我们重点介绍Lock的使用在介绍Lock的使用之前有个问题需要你首先思考一下Java语言本身提供的synchronized也是管程的一种实现既然Java从语言层面已经实现了管程了那为什么还要在SDK里提供另外一种实现呢难道Java标准委员会还能同意“重复造轮子”的方案很显然它们之间是有巨大区别的。那区别在哪里呢如果能深入理解这个问题对你用好Lock帮助很大。下面我们就一起来剖析一下这个问题。
## 再造管程的理由
你也许曾经听到过很多这方面的传说例如在Java的1.5版本中synchronized性能不如SDK里面的Lock但1.6版本之后synchronized做了很多优化将性能追了上来所以1.6之后的版本又有人推荐使用synchronized了。那性能是否可以成为“重复造轮子”的理由呢显然不能。因为性能问题优化一下就可以了完全没必要“重复造轮子”。
到这里,关于这个问题,你是否能够想出一条理由来呢?如果你细心的话,也许能想到一点。那就是我们前面在介绍[死锁问题](https://time.geekbang.org/column/article/85001)的时候,提出了一个**破坏不可抢占条件**方案但是这个方案synchronized没有办法解决。原因是synchronized申请资源的时候如果申请不到线程直接进入阻塞状态了而线程进入阻塞状态啥都干不了也释放不了线程已经占有的资源。但我们希望的是
>
对于“不可抢占”这个条件,占用部分资源的线程进一步申请其他资源时,如果申请不到,可以主动释放它占有的资源,这样不可抢占这个条件就破坏掉了。
如果我们重新设计一把互斥锁去解决这个问题,那该怎么设计呢?我觉得有三种方案。
1. **能够响应中断**。synchronized的问题是持有锁A后如果尝试获取锁B失败那么线程就进入阻塞状态一旦发生死锁就没有任何机会来唤醒阻塞的线程。但如果阻塞状态的线程能够响应中断信号也就是说当我们给阻塞的线程发送中断信号的时候能够唤醒它那它就有机会释放曾经持有的锁A。这样就破坏了不可抢占条件了。
1. **支持超时**。如果线程在一段时间之内没有获取到锁,不是进入阻塞状态,而是返回一个错误,那这个线程也有机会释放曾经持有的锁。这样也能破坏不可抢占条件。
1. **非阻塞地获取锁**。如果尝试获取锁失败,并不进入阻塞状态,而是直接返回,那这个线程也有机会释放曾经持有的锁。这样也能破坏不可抢占条件。
这三种方案可以全面弥补synchronized的问题。到这里相信你应该也能理解了这三个方案就是“重复造轮子”的主要原因体现在API上就是Lock接口的三个方法。详情如下
```
// 支持中断的API
void lockInterruptibly()
throws InterruptedException;
// 支持超时的API
boolean tryLock(long time, TimeUnit unit)
throws InterruptedException;
// 支持非阻塞获取锁的API
boolean tryLock();
```
## 如何保证可见性
Java SDK里面Lock的使用有一个经典的范例就是`try{}finally{}`需要重点关注的是在finally里面释放锁。这个范例无需多解释你看一下下面的代码就明白了。但是有一点需要解释一下那就是可见性是怎么保证的。你已经知道Java里多线程的可见性是通过Happens-Before规则保证的而synchronized之所以能够保证可见性也是因为有一条synchronized相关的规则synchronized的解锁 Happens-Before 于后续对这个锁的加锁。那Java SDK里面Lock靠什么保证可见性呢例如在下面的代码中线程T1对value进行了+=1操作那后续的线程T2能够看到value的正确结果吗
```
class X {
private final Lock rtl =
new ReentrantLock();
int value;
public void addOne() {
// 获取锁
rtl.lock();
try {
value+=1;
} finally {
// 保证锁能释放
rtl.unlock();
}
}
}
```
答案必须是肯定的。**Java SDK里面锁**的实现非常复杂,这里我就不展开细说了,但是原理还是需要简单介绍一下:它是**利用了volatile相关的Happens-Before规则**。Java SDK里面的ReentrantLock内部持有一个volatile 的成员变量state获取锁的时候会读写state的值解锁的时候也会读写state的值简化后的代码如下面所示。也就是说在执行value+=1之前程序先读写了一次volatile变量state在执行value+=1之后又读写了一次volatile变量state。根据相关的Happens-Before规则
1. **顺序性规则**对于线程T1value+=1 Happens-Before 释放锁的操作unlock()
1. **volatile变量规则**由于state = 1会先读取state所以线程T1的unlock()操作Happens-Before线程T2的lock()操作;
1. **传递性规则**:线程 T1的value+=1 Happens-Before 线程 T2 的 lock() 操作。
```
class SampleLock {
volatile int state;
// 加锁
lock() {
// 省略代码无数
state = 1;
}
// 解锁
unlock() {
// 省略代码无数
state = 0;
}
}
```
所以说后续线程T2能够看到value的正确结果。如果你觉得理解起来还有点困难建议你重温一下前面我们讲过的[《02 | Java内存模型看Java如何解决可见性和有序性问题》](https://time.geekbang.org/column/article/84017)里面的相关内容。
## 什么是可重入锁
如果你细心观察会发现我们创建的锁的具体类名是ReentrantLock这个翻译过来叫**可重入锁**,这个概念前面我们一直没有介绍过。**所谓可重入锁,顾名思义,指的是线程可以重复获取同一把锁**。例如下面代码中当线程T1执行到 ① 处时,已经获取到了锁 rtl ,当在 ① 处调用 get()方法时,会在 ② 再次对锁 rtl 执行加锁操作。此时,如果锁 rtl 是可重入的那么线程T1可以再次加锁成功如果锁 rtl 是不可重入的那么线程T1此时会被阻塞。
除了可重入锁,可能你还听说过可重入函数,可重入函数怎么理解呢?指的是线程可以重复调用?显然不是,所谓**可重入函数,指的是多个线程可以同时调用该函数**,每个线程都能得到正确结果;同时在一个线程内支持线程切换,无论被切换多少次,结果都是正确的。多线程可以同时执行,还支持线程切换,这意味着什么呢?线程安全啊。所以,可重入函数是线程安全的。
```
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();
}
}
}
```
## 公平锁与非公平锁
在使用ReentrantLock的时候你会发现ReentrantLock这个类有两个构造函数一个是无参构造函数一个是传入fair参数的构造函数。fair参数代表的是锁的公平策略如果传入true就表示需要构造一个公平锁反之则表示要构造一个非公平锁。
```
//无参构造函数:默认非公平锁
public ReentrantLock() {
sync = new NonfairSync();
}
//根据公平策略参数创建锁
public ReentrantLock(boolean fair){
sync = fair ? new FairSync()
: new NonfairSync();
}
```
在前面[《08 | 管程:并发编程的万能钥匙》](https://time.geekbang.org/column/article/86089)中,我们介绍过入口等待队列,锁都对应着一个等待队列,如果一个线程没有获得锁,就会进入等待队列,当有线程释放锁的时候,就需要从等待队列中唤醒一个等待的线程。如果是公平锁,唤醒的策略就是谁等待的时间长,就唤醒谁,很公平;如果是非公平锁,则不提供这个公平保证,有可能等待时间短的线程反而先被唤醒。
## 用锁的最佳实践
你已经知道用锁虽然能解决很多并发问题但是风险也是挺高的。可能会导致死锁也可能影响性能。这方面有是否有相关的最佳实践呢还很多。但是我觉得最值得推荐的是并发大师Doug Lea《Java并发编程设计原则与模式》一书中推荐的三个用锁的最佳实践它们分别是
>
<ol>
- 永远只在更新对象的成员变量时加锁
- 永远只在访问可变的成员变量时加锁
- 永远不在调用其他对象的方法时加锁
</ol>
这三条规则前两条估计你一定会认同最后一条你可能会觉得过于严苛。但是我还是倾向于你去遵守因为调用其他对象的方法实在是太不安全了也许“其他”方法里面有线程sleep()的调用也可能会有奇慢无比的I/O操作这些都会严重影响性能。更可怕的是“其他”类的方法可能也会加锁然后双重加锁就可能导致死锁。
**并发问题,本来就难以诊断,所以你一定要让你的代码尽量安全,尽量简单,哪怕有一点可能会出问题,都要努力避免。**
## 总结
Java SDK 并发包里的Lock接口里面的每个方法你可以感受到都是经过深思熟虑的。除了支持类似synchronized隐式加锁的lock()方法外,还支持超时、非阻塞、可中断的方式获取锁,这三种方式为我们编写更加安全、健壮的并发程序提供了很大的便利。希望你以后在使用锁的时候,一定要仔细斟酌。
除了并发大师Doug Lea推荐的三个最佳实践外你也可以参考一些诸如减少锁的持有时间、减小锁的粒度等业界广为人知的规则其实本质上它们都是相通的不过是在该加锁的地方加锁而已。你可以自己体会自己总结最终总结出自己的一套最佳实践来。
## 课后思考
你已经知道 tryLock() 支持非阻塞方式获取锁,下面这段关于转账的程序就使用到了 tryLock(),你来看看,它是否存在死锁问题呢?
```
class Account {
private int balance;
private final Lock lock
= new ReentrantLock();
// 转账
void transfer(Account tar, int amt){
while (true) {
if(this.lock.tryLock()) {
try {
if (tar.lock.tryLock()) {
try {
this.balance -= amt;
tar.balance += amt;
} finally {
tar.lock.unlock();
}
}//if
} finally {
this.lock.unlock();
}
}//if
}//while
}//transfer
}
```
欢迎在留言区与我分享你的想法,也欢迎你在留言区记录你的思考过程。感谢阅读,如果你觉得这篇文章对你有帮助的话,也欢迎把它分享给更多的朋友。

View File

@@ -0,0 +1,191 @@
<audio id="audio" title="15 | Lock和ConditionDubbo如何用管程实现异步转同步" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/a7/48/a7f4aace2b9e3ee6473b670640e7d848.mp3"></audio>
在上一篇文章中我们讲到Java SDK并发包里的Lock有别于synchronized隐式锁的三个特性能够响应中断、支持超时和非阻塞地获取锁。那今天我们接着再来详细聊聊Java SDK并发包里的Condition**Condition实现了管程模型里面的条件变量**。
在[《08 | 管程:并发编程的万能钥匙》](https://time.geekbang.org/column/article/86089)里我们提到过Java 语言内置的管程里只有一个条件变量而Lock&amp;Condition实现的管程是支持多个条件变量的这是二者的一个重要区别。
在很多并发场景下,支持多个条件变量能够让我们的并发程序可读性更好,实现起来也更容易。例如,实现一个阻塞队列,就需要两个条件变量。
**那如何利用两个条件变量快速实现阻塞队列呢?**
一个阻塞队列,需要两个条件变量,一个是队列不空(空队列不允许出队),另一个是队列不满(队列已满不允许入队),这个例子我们前面在介绍[管程](https://time.geekbang.org/column/article/86089)的时候详细说过,这里就不再赘述。相关的代码,我这里重新列了出来,你可以温故知新一下。
```
public class BlockedQueue&lt;T&gt;{
final Lock lock =
new ReentrantLock();
// 条件变量:队列不满
final Condition notFull =
lock.newCondition();
// 条件变量:队列不空
final Condition notEmpty =
lock.newCondition();
// 入队
void enq(T x) {
lock.lock();
try {
while (队列已满){
// 等待队列不满
notFull.await();
}
// 省略入队操作...
//入队后,通知可出队
notEmpty.signal();
}finally {
lock.unlock();
}
}
// 出队
void deq(){
lock.lock();
try {
while (队列已空){
// 等待队列不空
notEmpty.await();
}
// 省略出队操作...
//出队后,通知可入队
notFull.signal();
}finally {
lock.unlock();
}
}
}
```
不过这里你需要注意Lock和Condition实现的管程**线程等待和通知需要调用await()、signal()、signalAll()**它们的语义和wait()、notify()、notifyAll()是相同的。但是不一样的是Lock&amp;Condition实现的管程里只能使用前面的await()、signal()、signalAll()而后面的wait()、notify()、notifyAll()只有在synchronized实现的管程里才能使用。如果一不小心在Lock&amp;Condition实现的管程里调用了wait()、notify()、notifyAll(),那程序可就彻底玩儿完了。
Java SDK并发包里的Lock和Condition不过就是管程的一种实现而已管程你已经很熟悉了那Lock和Condition的使用自然是小菜一碟。下面我们就来看看在知名项目Dubbo中Lock和Condition是怎么用的。不过在开始介绍源码之前我还先要介绍两个概念同步和异步。
## 同步与异步
我们平时写的代码,基本都是同步的。但最近几年,异步编程大火。那同步和异步的区别到底是什么呢?**通俗点来讲就是调用方是否需要等待结果,如果需要等待结果,就是同步;如果不需要等待结果,就是异步**。
比如在下面的代码里有一个计算圆周率小数点后100万位的方法`pai1M()`,这个方法可能需要执行俩礼拜,如果调用`pai1M()`之后,线程一直等着计算结果,等俩礼拜之后结果返回,就可以执行 `printf("hello world")`了,这个属于同步;如果调用`pai1M()`之后,线程不用等待计算结果,立刻就可以执行 `printf("hello world")`,这个就属于异步。
```
// 计算圆周率小说点后100万位
String pai1M() {
//省略代码无数
}
pai1M()
printf(&quot;hello world&quot;)
```
同步是Java代码默认的处理方式。如果你想让你的程序支持异步可以通过下面两种方式来实现
1. 调用方创建一个子线程,在子线程中执行方法调用,这种调用我们称为异步调用;
1. 方法实现的时候创建一个新的线程执行主要逻辑主线程直接return这种方法我们一般称为异步方法。
## Dubbo源码分析
其实在编程领域异步的场景还是挺多的比如TCP协议本身就是异步的我们工作中经常用到的RPC调用**在TCP协议层面发送完RPC请求后线程是不会等待RPC的响应结果的**。可能你会觉得奇怪平时工作中的RPC调用大多数都是同步的啊这是怎么回事呢
其实很简单一定是有人帮你做了异步转同步的事情。例如目前知名的RPC框架Dubbo就给我们做了异步转同步的事情那它是怎么做的呢下面我们就来分析一下Dubbo的相关源码。
对于下面一个简单的RPC调用默认情况下sayHello()方法是个同步方法也就是说执行service.sayHello(“dubbo”)的时候,线程会停下来等结果。
```
DemoService service = 初始化部分省略
String message =
service.sayHello(&quot;dubbo&quot;);
System.out.println(message);
```
如果此时你将调用线程dump出来的话会是下图这个样子你会发现调用线程阻塞了线程状态是TIMED_WAITING。本来发送请求是异步的但是调用线程却阻塞了说明Dubbo帮我们做了异步转同步的事情。通过调用栈你能看到线程是阻塞在DefaultFuture.get()方法上所以可以推断Dubbo异步转同步的功能应该是通过DefaultFuture这个类实现的。
<img src="https://static001.geekbang.org/resource/image/a9/c5/a924d23fc43d31267473f2dc91396ec5.png" alt="">
不过为了理清前后关系还是有必要分析一下调用DefaultFuture.get()之前发生了什么。DubboInvoker的108行调用了DefaultFuture.get()这一行很关键我稍微修改了一下列在了下面。这一行先调用了request(inv, timeout)方法这个方法其实就是发送RPC请求之后通过调用get()方法等待RPC返回结果。
```
public class DubboInvoker{
Result doInvoke(Invocation inv){
// 下面这行就是源码中108行
// 为了便于展示,做了修改
return currentClient
.request(inv, timeout)
.get();
}
}
```
DefaultFuture这个类是很关键我把相关的代码精简之后列到了下面。不过在看代码之前你还是有必要重复一下我们的需求当RPC返回结果之前阻塞调用线程让调用线程等待当RPC返回结果后唤醒调用线程让调用线程重新执行。不知道你有没有似曾相识的感觉这不就是经典的等待-通知机制吗这个时候想必你的脑海里应该能够浮现出管程的解决方案了。有了自己的方案之后我们再来看看Dubbo是怎么实现的。
```
// 创建锁与条件变量
private final Lock lock
= new ReentrantLock();
private final Condition done
= lock.newCondition();
// 调用方通过该方法等待结果
Object get(int timeout){
long start = System.nanoTime();
lock.lock();
try {
while (!isDone()) {
done.await(timeout);
long cur=System.nanoTime();
if (isDone() ||
cur-start &gt; timeout){
break;
}
}
} finally {
lock.unlock();
}
if (!isDone()) {
throw new TimeoutException();
}
return returnFromResponse();
}
// RPC结果是否已经返回
boolean isDone() {
return response != null;
}
// RPC结果返回时调用该方法
private void doReceived(Response res) {
lock.lock();
try {
response = res;
if (done != null) {
done.signal();
}
} finally {
lock.unlock();
}
}
```
调用线程通过调用get()方法等待RPC返回结果这个方法里面你看到的都是熟悉的“面孔”调用lock()获取锁在finally里面调用unlock()释放锁获取锁后通过经典的在循环中调用await()方法来实现等待。
当RPC结果返回时会调用doReceived()方法这个方法里面调用lock()获取锁在finally里面调用unlock()释放锁获取锁后通过调用signal()来通知调用线程,结果已经返回,不用继续等待了。
至此Dubbo里面的异步转同步的源码就分析完了有没有觉得还挺简单的最近这几年工作中需要异步处理的越来越多了其中有一个主要原因就是有些API本身就是异步API。例如websocket也是一个异步的通信协议如果基于这个协议实现一个简单的RPC你也会遇到异步转同步的问题。现在很多公有云的API本身也是异步的例如创建云主机就是一个异步的API调用虽然成功了但是云主机并没有创建成功你需要调用另外一个API去轮询云主机的状态。如果你需要在项目内部封装创建云主机的API你也会面临异步转同步的问题因为同步的API更易用。
## 总结
Lock&amp;Condition是管程的一种实现所以能否用好Lock和Condition要看你对管程模型理解得怎么样。管程的技术前面我们已经专门用了一篇文章做了介绍你可以结合着来学理论联系实践有助于加深理解。
Lock&amp;Condition实现的管程相对于synchronized实现的管程来说更加灵活、功能也更丰富。
结合我自己的经验我认为了解原理比了解实现更能让你快速学好并发编程所以没有介绍太多Java SDK并发包里锁和条件变量是如何实现的。但如果你对实现感兴趣可以参考[《Java并发编程的艺术》](time://mall?url=https%3A%2F%2Fh5.youzan.com%2Fv2%2Fgoods%2F35z7jjvd4r4oo)一书的第5章《Java中的锁》里面详细介绍了实现原理我觉得写得非常好。
另外专栏里对DefaultFuture的代码缩减了很多如果你感兴趣也可以去看看完整版。<br>
Dubbo的源代码在[Github上](https://github.com/apache/incubator-dubbo)DefaultFuture的路径是incubator-dubbo/dubbo-remoting/dubbo-remoting-api/src/main/java/org/apache/dubbo/remoting/exchange/support/DefaultFuture.java。
## 课后思考
DefaultFuture里面唤醒等待的线程用的是signal()而不是signalAll(),你来分析一下,这样做是否合理呢?
欢迎在留言区与我分享你的想法,也欢迎你在留言区记录你的思考过程。感谢阅读,如果你觉得这篇文章对你有帮助的话,也欢迎把它分享给更多的朋友。

View File

@@ -0,0 +1,146 @@
<audio id="audio" title="16 | Semaphore如何快速实现一个限流器" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/55/65/559dfe29a26ff9351fcd69e127609265.mp3"></audio>
Semaphore现在普遍翻译为“信号量”以前也曾被翻译成“信号灯”因为类似现实生活里的红绿灯车辆能不能通行要看是不是绿灯。同样在编程世界里线程能不能执行也要看信号量是不是允许。
信号量是由大名鼎鼎的计算机科学家迪杰斯特拉Dijkstra于1965年提出在这之后的15年信号量一直都是并发编程领域的终结者直到1980年管程被提出来我们才有了第二选择。目前几乎所有支持并发编程的语言都支持信号量机制所以学好信号量还是很有必要的。
下面我们首先介绍信号量模型,之后介绍如何使用信号量,最后我们再用信号量来实现一个限流器。
## 信号量模型
信号量模型还是很简单的,可以简单概括为:**一个计数器,一个等待队列,三个方法**。在信号量模型里计数器和等待队列对外是透明的所以只能通过信号量模型提供的三个方法来访问它们这三个方法分别是init()、down()和up()。你可以结合下图来形象化地理解。
<img src="https://static001.geekbang.org/resource/image/6d/5c/6dfeeb9180ff3e038478f2a7dccc9b5c.png" alt="">
这三个方法详细的语义具体如下所示。
- init():设置计数器的初始值。
- down()计数器的值减1如果此时计数器的值小于0则当前线程将被阻塞否则当前线程可以继续执行。
- up()计数器的值加1如果此时计数器的值小于或者等于0则唤醒等待队列中的一个线程并将其从等待队列中移除。
这里提到的init()、down()和up()三个方法都是原子性的并且这个原子性是由信号量模型的实现方保证的。在Java SDK里面信号量模型是由java.util.concurrent.Semaphore实现的Semaphore这个类能够保证这三个方法都是原子操作。
如果你觉得上面的描述有点绕的话,可以参考下面这个代码化的信号量模型。
```
class Semaphore{
// 计数器
int count;
// 等待队列
Queue queue;
// 初始化操作
Semaphore(int c){
this.count=c;
}
//
void down(){
this.count--;
if(this.count&lt;0){
//将当前线程插入等待队列
//阻塞当前线程
}
}
void up(){
this.count++;
if(this.count&lt;=0) {
//移除等待队列中的某个线程T
//唤醒线程T
}
}
}
```
这里再插一句信号量模型里面down()、up()这两个操作历史上最早称为P操作和V操作所以信号量模型也被称为PV原语。另外还有些人喜欢用semWait()和semSignal()来称呼它们虽然叫法不同但是语义都是相同的。在Java SDK并发包里down()和up()对应的则是acquire()和release()。
## 如何使用信号量
通过上文,你应该会发现信号量的模型还是很简单的,那具体该如何使用呢?其实你想想红绿灯就可以了。十字路口的红绿灯可以控制交通,得益于它的一个关键规则:车辆在通过路口前必须先检查是否是绿灯,只有绿灯才能通行。这个规则和我们前面提到的锁规则是不是很类似?
其实信号量的使用也是类似的。这里我们还是用累加器的例子来说明信号量的使用吧。在累加器的例子里面count+=1操作是个临界区只允许一个线程执行也就是说要保证互斥。那这种情况用信号量怎么控制呢
其实很简单就像我们用互斥锁一样只需要在进入临界区之前执行一下down()操作退出临界区之前执行一下up()操作就可以了。下面是Java代码的示例acquire()就是信号量里的down()操作release()就是信号量里的up()操作。
```
static int count;
//初始化信号量
static final Semaphore s
= new Semaphore(1);
//用信号量保证互斥
static void addOne() {
s.acquire();
try {
count+=1;
} finally {
s.release();
}
}
```
下面我们再来分析一下信号量是如何保证互斥的。假设两个线程T1和T2同时访问addOne()方法当它们同时调用acquire()的时候由于acquire()是一个原子操作所以只能有一个线程假设T1把信号量里的计数器减为0另外一个线程T2则是将计数器减为-1。对于线程T1信号量里面的计数器的值是0大于等于0所以线程T1会继续执行对于线程T2信号量里面的计数器的值是-1小于0按照信号量模型里对down()操作的描述线程T2将被阻塞。所以此时只有线程T1会进入临界区执行`count+=1`
当线程T1执行release()操作也就是up()操作的时候,信号量里计数器的值是-1加1之后的值是0小于等于0按照信号量模型里对up()操作的描述此时等待队列中的T2将会被唤醒。于是T2在T1执行完临界区代码之后才获得了进入临界区执行的机会从而保证了互斥性。
## 快速实现一个限流器
上面的例子我们用信号量实现了一个最简单的互斥锁功能。估计你会觉得奇怪既然有Java SDK里面提供了Lock为啥还要提供一个Semaphore ?其实实现一个互斥锁,仅仅是 Semaphore的部分功能Semaphore还有一个功能是Lock不容易实现的那就是**Semaphore可以允许多个线程访问一个临界区**。
现实中还有这种需求?有的。比较常见的需求就是我们工作中遇到的各种池化资源,例如连接池、对象池、线程池等等。其中,你可能最熟悉数据库连接池,在同一时刻,一定是允许多个线程同时使用连接池的,当然,每个连接在被释放前,是不允许其他线程使用的。
其实前不久我在工作中也遇到了一个对象池的需求。所谓对象池呢指的是一次性创建出N个对象之后所有的线程重复利用这N个对象当然对象在被释放前也是不允许其他线程使用的。对象池可以用List保存实例对象这个很简单。但关键是限流器的设计这里的限流指的是不允许多于N个线程同时进入临界区。那如何快速实现一个这样的限流器呢这种场景我立刻就想到了信号量的解决方案。
信号量的计数器在上面的例子中我们设置成了1这个1表示只允许一个线程进入临界区但如果我们把计数器的值设置成对象池里对象的个数N就能完美解决对象池的限流问题了。下面就是对象池的示例代码。
```
class ObjPool&lt;T, R&gt; {
final List&lt;T&gt; pool;
// 用信号量实现限流器
final Semaphore sem;
// 构造函数
ObjPool(int size, T t){
pool = new Vector&lt;T&gt;(){};
for(int i=0; i&lt;size; i++){
pool.add(t);
}
sem = new Semaphore(size);
}
// 利用对象池的对象调用func
R exec(Function&lt;T,R&gt; func) {
T t = null;
sem.acquire();
try {
t = pool.remove(0);
return func.apply(t);
} finally {
pool.add(t);
sem.release();
}
}
}
// 创建对象池
ObjPool&lt;Long, String&gt; pool =
new ObjPool&lt;Long, String&gt;(10, 2);
// 通过对象池获取t之后执行
pool.exec(t -&gt; {
System.out.println(t);
return t.toString();
});
```
我们用一个List<t>来保存对象实例用Semaphore实现限流器。关键的代码是ObjPool里面的exec()方法这个方法里面实现了限流的功能。在这个方法里面我们首先调用acquire()方法与之匹配的是在finally里面调用release()方法假设对象池的大小是10信号量的计数器初始化为10那么前10个线程调用acquire()方法都能继续执行相当于通过了信号灯而其他线程则会阻塞在acquire()方法上。对于通过信号灯的线程,我们为每个线程分配了一个对象 t这个分配工作是通过pool.remove(0)实现的分配完之后会执行一个回调函数func而函数的参数正是前面分配的对象 t 执行完回调函数之后它们就会释放对象这个释放工作是通过pool.add(t)实现的同时调用release()方法来更新信号量的计数器。如果此时信号量里计数器的值小于等于0那么说明有线程在等待此时会自动唤醒等待的线程。</t>
简言之,使用信号量,我们可以轻松地实现一个限流器,使用起来还是非常简单的。
## 总结
信号量在Java语言里面名气并不算大但是在其他语言里却是很有知名度的。Java在并发编程领域走的很快重点支持的还是管程模型。 管程模型理论上解决了信号量模型的一些不足,主要体现在易用性和工程化方面,例如用信号量解决我们曾经提到过的阻塞队列问题,就比管程模型麻烦很多,你如果感兴趣,可以课下了解和尝试一下。
## 课后思考
在上面对象池的例子中对象保存在了Vector中Vector是Java提供的线程安全的容器如果我们把Vector换成ArrayList是否可以呢
欢迎在留言区与我分享你的想法,也欢迎你在留言区记录你的思考过程。感谢阅读,如果你觉得这篇文章对你有帮助的话,也欢迎把它分享给更多的朋友。

View File

@@ -0,0 +1,198 @@
<audio id="audio" title="17 | ReadWriteLock如何快速实现一个完备的缓存" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/9a/0b/9ad06e966d88f117f54665f266c7640b.mp3"></audio>
前面我们介绍了管程和信号量这两个同步原语在Java语言中的实现理论上用这两个同步原语中任何一个都可以解决所有的并发问题。那Java SDK并发包里为什么还有很多其他的工具类呢原因很简单**分场景优化性能,提升易用性**。
今天我们就介绍一种非常普遍的并发场景:读多写少场景。实际工作中,为了优化性能,我们经常会使用缓存,例如缓存元数据、缓存基础数据等,这就是一种典型的读多写少应用场景。缓存之所以能提升性能,一个重要的条件就是缓存的数据一定是读多写少的,例如元数据和基础数据基本上不会发生变化(写少),但是使用它们的地方却很多(读多)。
针对读多写少这种并发场景Java SDK并发包提供了读写锁——ReadWriteLock非常容易使用并且性能很好。
**那什么是读写锁呢?**
读写锁并不是Java语言特有的而是一个广为使用的通用技术所有的读写锁都遵守以下三条基本原则
1. 允许多个线程同时读共享变量;
1. 只允许一个线程写共享变量;
1. 如果一个写线程正在执行写操作,此时禁止读线程读共享变量。
读写锁与互斥锁的一个重要区别就是**读写锁允许多个线程同时读共享变量**,而互斥锁是不允许的,这是读写锁在读多写少场景下性能优于互斥锁的关键。但**读写锁的写操作是互斥的**,当一个线程在写共享变量的时候,是不允许其他线程执行写操作和读操作。
## 快速实现一个缓存
下面我们就实践起来用ReadWriteLock快速实现一个通用的缓存工具类。
在下面的代码中我们声明了一个Cache&lt;K, V&gt;其中类型参数K代表缓存里key的类型V代表缓存里value的类型。缓存的数据保存在Cache类内部的HashMap里面HashMap不是线程安全的这里我们使用读写锁ReadWriteLock 来保证其线程安全。ReadWriteLock 是一个接口它的实现类是ReentrantReadWriteLock通过名字你应该就能判断出来它是支持可重入的。下面我们通过rwl创建了一把读锁和一把写锁。
Cache这个工具类我们提供了两个方法一个是读缓存方法get()另一个是写缓存方法put()。读缓存需要用到读锁读锁的使用和前面我们介绍的Lock的使用是相同的都是try{}finally{}这个编程范式。写缓存则需要用到写锁,写锁的使用和读锁是类似的。这样看来,读写锁的使用还是非常简单的。
```
class Cache&lt;K,V&gt; {
final Map&lt;K, V&gt; m =
new HashMap&lt;&gt;();
final ReadWriteLock rwl =
new ReentrantReadWriteLock();
// 读锁
final Lock r = rwl.readLock();
// 写锁
final Lock w = rwl.writeLock();
// 读缓存
V get(K key) {
r.lock();
try { return m.get(key); }
finally { r.unlock(); }
}
// 写缓存
V put(K key, V value) {
w.lock();
try { return m.put(key, v); }
finally { w.unlock(); }
}
}
```
如果你曾经使用过缓存的话,你应该知道**使用缓存首先要解决缓存数据的初始化问题**。缓存数据的初始化,可以采用一次性加载的方式,也可以使用按需加载的方式。
如果源头数据的数据量不大就可以采用一次性加载的方式这种方式最简单可参考下图只需在应用启动的时候把源头数据查询出来依次调用类似上面示例代码中的put()方法就可以了。
<img src="https://static001.geekbang.org/resource/image/62/1e/627be6e80f96719234007d0a6426771e.png" alt="">
如果源头数据量非常大那么就需要按需加载了按需加载也叫懒加载指的是只有当应用查询缓存并且数据不在缓存里的时候才触发加载源头相关数据进缓存的操作。下面你可以结合文中示意图看看如何利用ReadWriteLock 来实现缓存的按需加载。
<img src="https://static001.geekbang.org/resource/image/4e/73/4e036a6b38244accfb74a0d18300f073.png" alt="">
## 实现缓存的按需加载
文中下面的这段代码实现了按需加载的功能,这里我们假设缓存的源头是数据库。需要注意的是,如果缓存中没有缓存目标对象,那么就需要从数据库中加载,然后写入缓存,写缓存需要用到写锁,所以在代码中的⑤处,我们调用了 `w.lock()` 来获取写锁。
另外,还需要注意的是,在获取写锁之后,我们并没有直接去查询数据库,而是在代码⑥⑦处,重新验证了一次缓存中是否存在,再次验证如果还是不存在,我们才去查询数据库并更新本地缓存。为什么我们要再次验证呢?
```
class Cache&lt;K,V&gt; {
final Map&lt;K, V&gt; m =
new HashMap&lt;&gt;();
final ReadWriteLock rwl =
new ReentrantReadWriteLock();
final Lock r = rwl.readLock();
final Lock w = rwl.writeLock();
V get(K key) {
V v = null;
//读缓存
r.lock(); ①
try {
v = m.get(key); ②
} finally{
r.unlock(); ③
}
//缓存中存在,返回
if(v != null) { ④
return v;
}
//缓存中不存在,查询数据库
w.lock(); ⑤
try {
//再次验证
//其他线程可能已经查询过数据库
v = m.get(key); ⑥
if(v == null){ ⑦
//查询数据库
v=省略代码无数
m.put(key, v);
}
} finally{
w.unlock();
}
return v;
}
}
```
原因是在高并发的场景下有可能会有多线程竞争写锁。假设缓存是空的没有缓存任何东西如果此时有三个线程T1、T2和T3同时调用get()方法并且参数key也是相同的。那么它们会同时执行到代码⑤处但此时只有一个线程能够获得写锁假设是线程T1线程T1获取写锁之后查询数据库并更新缓存最终释放写锁。此时线程T2和T3会再有一个线程能够获取写锁假设是T2如果不采用再次验证的方式此时T2会再次查询数据库。T2释放写锁之后T3也会再次查询一次数据库。而实际上线程T1已经把缓存的值设置好了T2、T3完全没有必要再次查询数据库。所以再次验证的方式能够避免高并发场景下重复查询数据的问题。
## 读写锁的升级与降级
上面按需加载的示例代码中,在①处获取读锁,在③处释放读锁,那是否可以在②处的下面增加验证缓存并更新缓存的逻辑呢?详细的代码如下。
```
//读缓存
r.lock(); ①
try {
v = m.get(key); ②
if (v == null) {
w.lock();
try {
//再次验证并更新缓存
//省略详细代码
} finally{
w.unlock();
}
}
} finally{
r.unlock(); ③
}
```
这样看上去好像是没有问题的,先是获取读锁,然后再升级为写锁,对此还有个专业的名字,叫**锁的升级**。可惜ReadWriteLock并不支持这种升级。在上面的代码示例中读锁还没有释放此时获取写锁会导致写锁永久等待最终导致相关线程都被阻塞永远也没有机会被唤醒。锁的升级是不允许的这个你一定要注意。
不过虽然锁的升级是不允许的但是锁的降级却是允许的。以下代码来源自ReentrantReadWriteLock的官方示例略做了改动。你会发现在代码①处获取读锁的时候线程还是持有写锁的这种锁的降级是支持的。
```
class CachedData {
Object data;
volatile boolean cacheValid;
final ReadWriteLock rwl =
new ReentrantReadWriteLock();
// 读锁
final Lock r = rwl.readLock();
//写锁
final Lock w = rwl.writeLock();
void processCachedData() {
// 获取读锁
r.lock();
if (!cacheValid) {
// 释放读锁,因为不允许读锁的升级
r.unlock();
// 获取写锁
w.lock();
try {
// 再次检查状态
if (!cacheValid) {
data = ...
cacheValid = true;
}
// 释放写锁前,降级为读锁
// 降级是可以的
r.lock(); ①
} finally {
// 释放写锁
w.unlock();
}
}
// 此处仍然持有读锁
try {use(data);}
finally {r.unlock();}
}
}
```
## 总结
读写锁类似于ReentrantLock也支持公平模式和非公平模式。读锁和写锁都实现了 java.util.concurrent.locks.Lock接口所以除了支持lock()方法外tryLock()、lockInterruptibly() 等方法也都是支持的。但是有一点需要注意那就是只有写锁支持条件变量读锁是不支持条件变量的读锁调用newCondition()会抛出UnsupportedOperationException异常。
今天我们用ReadWriteLock实现了一个简单的缓存这个缓存虽然解决了缓存的初始化问题但是没有解决缓存数据与源头数据的同步问题这里的数据同步指的是保证缓存数据和源头数据的一致性。解决数据同步问题的一个最简单的方案就是**超时机制**。所谓超时机制指的是加载进缓存的数据不是长久有效的,而是有时效的,当缓存的数据超过时效,也就是超时之后,这条数据在缓存中就失效了。而访问缓存中失效的数据,会触发缓存重新从源头把数据加载进缓存。
当然也可以在源头数据发生变化时快速反馈给缓存但这个就要依赖具体的场景了。例如MySQL作为数据源头可以通过近实时地解析binlog来识别数据是否发生了变化如果发生了变化就将最新的数据推送给缓存。另外还有一些方案采取的是数据库和缓存的双写方案。
总之,具体采用哪种方案,还是要看应用的场景。
## 课后思考
有同学反映线上系统停止响应了CPU利用率很低你怀疑有同学一不小心写出了读锁升级写锁的方案那你该如何验证自己的怀疑呢
欢迎在留言区与我分享你的想法,也欢迎你在留言区记录你的思考过程。感谢阅读,如果你觉得这篇文章对你有帮助的话,也欢迎把它分享给更多的朋友。

View File

@@ -0,0 +1,218 @@
<audio id="audio" title="18 | StampedLock有没有比读写锁更快的锁" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/7d/30/7dd89361bb5afcbcdb844e1295617730.mp3"></audio>
在[上一篇文章](https://time.geekbang.org/column/article/88909)中我们介绍了读写锁学习完之后你应该已经知道“读写锁允许多个线程同时读共享变量适用于读多写少的场景”。那在读多写少的场景中还有没有更快的技术方案呢还真有Java在1.8这个版本里提供了一种叫StampedLock的锁它的性能就比读写锁还要好。
下面我们就来介绍一下StampedLock的使用方法、内部工作原理以及在使用过程中需要注意的事项。
## StampedLock支持的三种锁模式
我们先来看看在使用上StampedLock和上一篇文章讲的ReadWriteLock有哪些区别。
ReadWriteLock支持两种模式一种是读锁一种是写锁。而StampedLock支持三种模式分别是**写锁**、**悲观读锁**和**乐观读**。其中写锁、悲观读锁的语义和ReadWriteLock的写锁、读锁的语义非常类似允许多个线程同时获取悲观读锁但是只允许一个线程获取写锁写锁和悲观读锁是互斥的。不同的是StampedLock里的写锁和悲观读锁加锁成功之后都会返回一个stamp然后解锁的时候需要传入这个stamp。相关的示例代码如下。
```
final StampedLock sl =
new StampedLock();
// 获取/释放悲观读锁示意代码
long stamp = sl.readLock();
try {
//省略业务相关代码
} finally {
sl.unlockRead(stamp);
}
// 获取/释放写锁示意代码
long stamp = sl.writeLock();
try {
//省略业务相关代码
} finally {
sl.unlockWrite(stamp);
}
```
StampedLock的性能之所以比ReadWriteLock还要好其关键是StampedLock支持乐观读的方式。ReadWriteLock支持多个线程同时读但是当多个线程同时读的时候所有的写操作会被阻塞而StampedLock提供的乐观读是允许一个线程获取写锁的也就是说不是所有的写操作都被阻塞。
注意这里,我们用的是“乐观读”这个词,而不是“乐观读锁”,是要提醒你,**乐观读这个操作是无锁的**所以相比较ReadWriteLock的读锁乐观读的性能更好一些。
文中下面这段代码是出自Java SDK官方示例并略做了修改。在distanceFromOrigin()这个方法中首先通过调用tryOptimisticRead()获取了一个stamp这里的tryOptimisticRead()就是我们前面提到的乐观读。之后将共享变量x和y读入方法的局部变量中不过需要注意的是由于tryOptimisticRead()是无锁的所以共享变量x和y读入方法局部变量时x和y有可能被其他线程修改了。因此最后读完之后还需要再次验证一下是否存在写操作这个验证操作是通过调用validate(stamp)来实现的。
```
class Point {
private int x, y;
final StampedLock sl =
new StampedLock();
//计算到原点的距离
int distanceFromOrigin() {
// 乐观读
long stamp =
sl.tryOptimisticRead();
// 读入局部变量,
// 读的过程数据可能被修改
int curX = x, curY = y;
//判断执行读操作期间,
//是否存在写操作,如果存在,
//则sl.validate返回false
if (!sl.validate(stamp)){
// 升级为悲观读锁
stamp = sl.readLock();
try {
curX = x;
curY = y;
} finally {
//释放悲观读锁
sl.unlockRead(stamp);
}
}
return Math.sqrt(
curX * curX + curY * curY);
}
}
```
在上面这个代码示例中如果执行乐观读操作的期间存在写操作会把乐观读升级为悲观读锁。这个做法挺合理的否则你就需要在一个循环里反复执行乐观读直到执行乐观读操作的期间没有写操作只有这样才能保证x和y的正确性和一致性而循环读会浪费大量的CPU。升级为悲观读锁代码简练且不易出错建议你在具体实践时也采用这样的方法。
## 进一步理解乐观读
如果你曾经用过数据库的乐观锁可能会发现StampedLock的乐观读和数据库的乐观锁有异曲同工之妙。的确是这样的就拿我个人来说我是先接触的数据库里的乐观锁然后才接触的StampedLock我就觉得我前期数据库里乐观锁的学习对于后面理解StampedLock的乐观读有很大帮助所以这里有必要再介绍一下数据库里的乐观锁。
还记得我第一次使用数据库乐观锁的场景是这样的在ERP的生产模块里会有多个人通过ERP系统提供的UI同时修改同一条生产订单那如何保证生产订单数据是并发安全的呢我采用的方案就是乐观锁。
乐观锁的实现很简单,在生产订单的表 product_doc 里增加了一个数值型版本号字段 version每次更新product_doc这个表的时候都将 version 字段加1。生产订单的UI在展示的时候需要查询数据库此时将这个 version 字段和其他业务字段一起返回给生产订单UI。假设用户查询的生产订单的id=777那么SQL语句类似下面这样
```
select id... version
from product_doc
where id=777
```
用户在生产订单UI执行保存操作的时候后台利用下面的SQL语句更新生产订单此处我们假设该条生产订单的 version=9。
```
update product_doc
set version=version+1...
where id=777 and version=9
```
如果这条SQL语句执行成功并且返回的条数等于1那么说明从生产订单UI执行查询操作到执行保存操作期间没有其他人修改过这条数据。因为如果这期间其他人修改过这条数据那么版本号字段一定会大于9。
你会发现数据库里的乐观锁,查询的时候需要把 version 字段查出来,更新的时候要利用 version 字段做验证。这个 version 字段就类似于StampedLock里面的stamp。这样对比着看相信你会更容易理解StampedLock里乐观读的用法。
## StampedLock使用注意事项
对于读多写少的场景StampedLock性能很好简单的应用场景基本上可以替代ReadWriteLock但是**StampedLock的功能仅仅是ReadWriteLock的子集**,在使用的时候,还是有几个地方需要注意一下。
StampedLock在命名上并没有增加Reentrant想必你已经猜测到StampedLock应该是不可重入的。事实上的确是这样的**StampedLock不支持重入**。这个是在使用中必须要特别注意的。
另外StampedLock的悲观读锁、写锁都不支持条件变量这个也需要你注意。
还有一点需要特别注意那就是如果线程阻塞在StampedLock的readLock()或者writeLock()上时此时调用该阻塞线程的interrupt()方法会导致CPU飙升。例如下面的代码中线程T1获取写锁之后将自己阻塞线程T2尝试获取悲观读锁也会阻塞如果此时调用线程T2的interrupt()方法来中断线程T2的话你会发现线程T2所在CPU会飙升到100%。
```
final StampedLock lock
= new StampedLock();
Thread T1 = new Thread(()-&gt;{
// 获取写锁
lock.writeLock();
// 永远阻塞在此处,不释放写锁
LockSupport.park();
});
T1.start();
// 保证T1获取写锁
Thread.sleep(100);
Thread T2 = new Thread(()-&gt;
//阻塞在悲观读锁
lock.readLock()
);
T2.start();
// 保证T2阻塞在读锁
Thread.sleep(100);
//中断线程T2
//会导致线程T2所在CPU飙升
T2.interrupt();
T2.join();
```
所以,**使用StampedLock一定不要调用中断操作如果需要支持中断功能一定使用可中断的悲观读锁readLockInterruptibly()和写锁writeLockInterruptibly()**。这个规则一定要记清楚。
## 总结
StampedLock的使用看上去有点复杂但是如果你能理解乐观锁背后的原理使用起来还是比较流畅的。建议你认真揣摩Java的官方示例这个示例基本上就是一个最佳实践。我们把Java官方示例精简后形成下面的代码模板建议你在实际工作中尽量按照这个模板来使用StampedLock。
StampedLock读模板
```
final StampedLock sl =
new StampedLock();
// 乐观读
long stamp =
sl.tryOptimisticRead();
// 读入方法局部变量
......
// 校验stamp
if (!sl.validate(stamp)){
// 升级为悲观读锁
stamp = sl.readLock();
try {
// 读入方法局部变量
.....
} finally {
//释放悲观读锁
sl.unlockRead(stamp);
}
}
//使用方法局部变量执行业务操作
......
```
StampedLock写模板
```
long stamp = sl.writeLock();
try {
// 写共享变量
......
} finally {
sl.unlockWrite(stamp);
}
```
## 课后思考
StampedLock支持锁的降级通过tryConvertToReadLock()方法实现和升级通过tryConvertToWriteLock()方法实现但是建议你要慎重使用。下面的代码也源自Java的官方示例我仅仅做了一点修改隐藏了一个Bug你来看看Bug出在哪里吧。
```
private double x, y;
final StampedLock sl = new StampedLock();
// 存在问题的方法
void moveIfAtOrigin(double newX, double newY){
long stamp = sl.readLock();
try {
while(x == 0.0 &amp;&amp; y == 0.0){
long ws = sl.tryConvertToWriteLock(stamp);
if (ws != 0L) {
x = newX;
y = newY;
break;
} else {
sl.unlockRead(stamp);
stamp = sl.writeLock();
}
}
} finally {
sl.unlock(stamp);
}
```
欢迎在留言区与我分享你的想法,也欢迎你在留言区记录你的思考过程。感谢阅读,如果你觉得这篇文章对你有帮助的话,也欢迎把它分享给更多的朋友。

View File

@@ -0,0 +1,217 @@
<audio id="audio" title="19 | CountDownLatch和CyclicBarrier如何让多线程步调一致" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/f7/c1/f7c9640777373dfd09007417872c34c1.mp3"></audio>
前几天老板突然匆匆忙忙过来,说对账系统最近越来越慢了,能不能快速优化一下。我了解了对账系统的业务后,发现还是挺简单的,用户通过在线商城下单,会生成电子订单,保存在订单库;之后物流会生成派送单给用户发货,派送单保存在派送单库。为了防止漏派送或者重复派送,对账系统每天还会校验是否存在异常订单。
对账系统的处理逻辑很简单,你可以参考下面的对账系统流程图。目前对账系统的处理逻辑是首先查询订单,然后查询派送单,之后对比订单和派送单,将差异写入差异库。
<img src="https://static001.geekbang.org/resource/image/06/fe/068418bdc371b8a1b4b740428a3b3ffe.png" alt="">
对账系统的代码抽象之后,也很简单,核心代码如下,就是在一个单线程里面循环查询订单、派送单,然后执行对账,最后将写入差异库。
```
while(存在未对账订单){
// 查询未对账订单
pos = getPOrders();
// 查询派送单
dos = getDOrders();
// 执行对账操作
diff = check(pos, dos);
// 差异写入差异库
save(diff);
}
```
## 利用并行优化对账系统
老板要我优化性能,那我就首先要找到这个对账系统的瓶颈所在。
目前的对账系统由于订单量和派送单量巨大所以查询未对账订单getPOrders()和查询派送单getDOrders()相对较慢,那有没有办法快速优化一下呢?目前对账系统是单线程执行的,图形化后是下图这个样子。对于串行化的系统,优化性能首先想到的是能否**利用多线程并行处理**。
<img src="https://static001.geekbang.org/resource/image/cd/a5/cd997c259e4165c046e79e766abfe2a5.png" alt="">
所以这里你应该能够看出来这个对账系统里的瓶颈查询未对账订单getPOrders()和查询派送单getDOrders()是否可以并行处理呢显然是可以的因为这两个操作并没有先后顺序的依赖。这两个最耗时的操作并行之后执行过程如下图所示。对比一下单线程的执行示意图你会发现同等时间里并行执行的吞吐量近乎单线程的2倍优化效果还是相对明显的。
<img src="https://static001.geekbang.org/resource/image/a5/3b/a563c39ece918578ad2ff33ab5f3743b.png" alt="">
思路有了下面我们再来看看如何用代码实现。在下面的代码中我们创建了两个线程T1和T2并行执行查询未对账订单getPOrders()和查询派送单getDOrders()这两个操作。在主线程中执行对账操作check()和差异写入save()两个操作。不过需要注意的是主线程需要等待线程T1和T2执行完才能执行check()和save()这两个操作为此我们通过调用T1.join()和T2.join()来实现等待当T1和T2线程退出时调用T1.join()和T2.join()的主线程就会从阻塞态被唤醒从而执行之后的check()和save()。
```
while(存在未对账订单){
// 查询未对账订单
Thread T1 = new Thread(()-&gt;{
pos = getPOrders();
});
T1.start();
// 查询派送单
Thread T2 = new Thread(()-&gt;{
dos = getDOrders();
});
T2.start();
// 等待T1、T2结束
T1.join();
T2.join();
// 执行对账操作
diff = check(pos, dos);
// 差异写入差异库
save(diff);
}
```
## 用CountDownLatch实现线程等待
经过上面的优化之后基本上可以跟老板汇报收工了但还是有点美中不足相信你也发现了while循环里面每次都会创建新的线程而创建线程可是个耗时的操作。所以最好是创建出来的线程能够循环利用估计这时你已经想到线程池了是的线程池就能解决这个问题。
而下面的代码就是用线程池优化后的我们首先创建了一个固定大小为2的线程池之后在while循环里重复利用。一切看上去都很顺利但是有个问题好像无解了那就是主线程如何知道getPOrders()和getDOrders()这两个操作什么时候执行完。前面主线程通过调用线程T1和T2的join()方法来等待线程T1和T2退出但是在线程池的方案里线程根本就不会退出所以join()方法已经失效了。
```
// 创建2个线程的线程池
Executor executor =
Executors.newFixedThreadPool(2);
while(存在未对账订单){
// 查询未对账订单
executor.execute(()-&gt; {
pos = getPOrders();
});
// 查询派送单
executor.execute(()-&gt; {
dos = getDOrders();
});
/* ??如何实现等待??*/
// 执行对账操作
diff = check(pos, dos);
// 差异写入差异库
save(diff);
}
```
那如何解决这个问题呢你可以开动脑筋想出很多办法最直接的办法是弄一个计数器初始值设置成2当执行完`pos = getPOrders();`这个操作之后将计数器减1执行完`dos = getDOrders();`之后也将计数器减1在主线程里等待计数器等于0当计数器等于0时说明这两个查询操作执行完了。等待计数器等于0其实就是一个条件变量用管程实现起来也很简单。
不过我并不建议你在实际项目中去实现上面的方案因为Java并发包里已经提供了实现类似功能的工具类**CountDownLatch**我们直接使用就可以了。下面的代码示例中在while循环里面我们首先创建了一个CountDownLatch计数器的初始值等于2之后在`pos = getPOrders();``dos = getDOrders();`两条语句的后面对计数器执行减1操作这个对计数器减1的操作是通过调用 `latch.countDown();` 来实现的。在主线程中,我们通过调用 `latch.await()` 来实现对计数器等于0的等待。
```
// 创建2个线程的线程池
Executor executor =
Executors.newFixedThreadPool(2);
while(存在未对账订单){
// 计数器初始化为2
CountDownLatch latch =
new CountDownLatch(2);
// 查询未对账订单
executor.execute(()-&gt; {
pos = getPOrders();
latch.countDown();
});
// 查询派送单
executor.execute(()-&gt; {
dos = getDOrders();
latch.countDown();
});
// 等待两个查询操作结束
latch.await();
// 执行对账操作
diff = check(pos, dos);
// 差异写入差异库
save(diff);
}
```
## 进一步优化性能
经过上面的重重优化之后,长出一口气,终于可以交付了。不过在交付之前还需要再次审视一番,看看还有没有优化的余地,仔细看还是有的。
前面我们将getPOrders()和getDOrders()这两个查询操作并行了但这两个查询操作和对账操作check()、save()之间还是串行的。很显然,这两个查询操作和对账操作也是可以并行的,也就是说,在执行对账操作的时候,可以同时去执行下一轮的查询操作,这个过程可以形象化地表述为下面这幅示意图。
<img src="https://static001.geekbang.org/resource/image/e6/8b/e663d90f49d9666e618ac1370ccca58b.png" alt="">
那接下来我们再来思考一下如何实现这步优化,两次查询操作能够和对账操作并行,对账操作还依赖查询操作的结果,这明显有点生产者-消费者的意思,两次查询操作是生产者,对账操作是消费者。既然是生产者-消费者模型,那就需要有个队列,来保存生产者生产的数据,而消费者则从这个队列消费数据。
不过针对对账这个项目,我设计了两个队列,并且两个队列的元素之间还有对应关系。具体如下图所示,订单查询操作将订单查询结果插入订单队列,派送单查询操作将派送单插入派送单队列,这两个队列的元素之间是有一一对应的关系的。两个队列的好处是,对账操作可以每次从订单队列出一个元素,从派送单队列出一个元素,然后对这两个元素执行对账操作,这样数据一定不会乱掉。
<img src="https://static001.geekbang.org/resource/image/22/da/22e8ba1c04a3bc2605b98376ed6832da.png" alt="">
下面再来看如何用双队列来实现完全的并行。一个最直接的想法是一个线程T1执行订单的查询工作一个线程T2执行派送单的查询工作当线程T1和T2都各自生产完1条数据的时候通知线程T3执行对账操作。这个想法虽看上去简单但其实还隐藏着一个条件那就是线程T1和线程T2的工作要步调一致不能一个跑得太快一个跑得太慢只有这样才能做到各自生产完1条数据的时候通知线程T3。
下面这幅图形象地描述了上面的意图线程T1和线程T2只有都生产完1条数据的时候才能一起向下执行也就是说线程T1和线程T2要互相等待步调要一致同时当线程T1和T2都生产完一条数据的时候还要能够通知线程T3执行对账操作。
<img src="https://static001.geekbang.org/resource/image/65/ad/6593a10a393d9310a8f864730f7426ad.png" alt="">
## 用CyclicBarrier实现线程同步
下面我们就来实现上面提到的方案。这个方案的难点有两个一个是线程T1和T2要做到步调一致另一个是要能够通知到线程T3。
你依然可以利用一个计数器来解决这两个难点计数器初始化为2线程T1和T2生产完一条数据都将计数器减1如果计数器大于0则线程T1或者T2等待。如果计数器等于0则通知线程T3并唤醒等待的线程T1或者T2与此同时将计数器重置为2这样线程T1和线程T2生产下一条数据的时候就可以继续使用这个计数器了。
同样还是建议你不要在实际项目中这么做因为Java并发包里也已经提供了相关的工具类**CyclicBarrier**。在下面的代码中我们首先创建了一个计数器初始值为2的CyclicBarrier你需要注意的是创建CyclicBarrier的时候我们还传入了一个回调函数当计数器减到0的时候会调用这个回调函数。
线程T1负责查询订单当查出一条时调用 `barrier.await()` 来将计数器减1同时等待计数器变成0线程T2负责查询派送单当查出一条时也调用 `barrier.await()` 来将计数器减1同时等待计数器变成0当T1和T2都调用 `barrier.await()` 的时候计数器会减到0此时T1和T2就可以执行下一条语句了同时会调用barrier的回调函数来执行对账操作。
非常值得一提的是CyclicBarrier的计数器有自动重置的功能当减到0的时候会自动重置你设置的初始值。这个功能用起来实在是太方便了。
```
// 订单队列
Vector&lt;P&gt; pos;
// 派送单队列
Vector&lt;D&gt; dos;
// 执行回调的线程池
Executor executor =
Executors.newFixedThreadPool(1);
final CyclicBarrier barrier =
new CyclicBarrier(2, ()-&gt;{
executor.execute(()-&gt;check());
});
void check(){
P p = pos.remove(0);
D d = dos.remove(0);
// 执行对账操作
diff = check(p, d);
// 差异写入差异库
save(diff);
}
void checkAll(){
// 循环查询订单库
Thread T1 = new Thread(()-&gt;{
while(存在未对账订单){
// 查询订单库
pos.add(getPOrders());
// 等待
barrier.await();
}
});
T1.start();
// 循环查询运单库
Thread T2 = new Thread(()-&gt;{
while(存在未对账订单){
// 查询运单库
dos.add(getDOrders());
// 等待
barrier.await();
}
});
T2.start();
}
```
## 总结
CountDownLatch和CyclicBarrier是Java并发包提供的两个非常易用的线程同步工具类这两个工具类用法的区别在这里还是有必要再强调一下**CountDownLatch主要用来解决一个线程等待多个线程的场景**,可以类比旅游团团长要等待所有的游客到齐才能去下一个景点;而**CyclicBarrier是一组线程之间互相等待**更像是几个驴友之间不离不弃。除此之外CountDownLatch的计数器是不能循环利用的也就是说一旦计数器减到0再有线程调用await(),该线程会直接通过。但**CyclicBarrier的计数器是可以循环利用的**而且具备自动重置的功能一旦计数器减到0会自动重置到你设置的初始值。除此之外CyclicBarrier还可以设置回调函数可以说是功能丰富。
本章的示例代码中有两处用到了线程池你现在只需要大概了解即可因为线程池相关的知识咱们专栏后面还会有详细介绍。另外线程池提供了Future特性我们也可以利用Future特性来实现线程之间的等待这个后面我们也会详细介绍。
## 课后思考
本章最后的示例代码中CyclicBarrier的回调函数我们使用了一个固定大小的线程池你觉得是否有必要呢
欢迎在留言区与我分享你的想法,也欢迎你在留言区记录你的思考过程。感谢阅读,如果你觉得这篇文章对你有帮助的话,也欢迎把它分享给更多的朋友。

View File

@@ -0,0 +1,153 @@
<audio id="audio" title="20 | 并发容器:都有哪些“坑”需要我们填?" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/29/6b/29b1dc67aa9f87fdbfe6666d60fbc46b.mp3"></audio>
Java并发包有很大一部分内容都是关于**并发容器**的,因此学习和搞懂这部分的内容很有必要。
Java 1.5之前提供的**同步容器**虽然也能保证线程安全但是性能很差而Java 1.5版本之后提供的并发容器在性能方面则做了很多优化,并且容器的类型也更加丰富了。下面我们就对比二者来学习这部分的内容。
## 同步容器及其注意事项
Java中的容器主要可以分为四个大类分别是List、Map、Set和Queue但并不是所有的Java容器都是线程安全的。例如我们常用的ArrayList、HashMap就不是线程安全的。在介绍线程安全的容器之前我们先思考这样一个问题如何将非线程安全的容器变成线程安全的容器
在前面[《12 | 如何用面向对象思想写好并发程序?》](https://time.geekbang.org/column/article/87365)我们讲过实现思路其实很简单,只要把非线程安全的容器封装在对象内部,然后控制好访问路径就可以了。
下面我们就以ArrayList为例看看如何将它变成线程安全的。在下面的代码中SafeArrayList内部持有一个ArrayList的实例c所有访问c的方法我们都增加了synchronized关键字需要注意的是我们还增加了一个addIfNotExist()方法这个方法也是用synchronized来保证原子性的。
```
SafeArrayList&lt;T&gt;{
//封装ArrayList
List&lt;T&gt; c = new ArrayList&lt;&gt;();
//控制访问路径
synchronized
T get(int idx){
return c.get(idx);
}
synchronized
void add(int idx, T t) {
c.add(idx, t);
}
synchronized
boolean addIfNotExist(T t){
if(!c.contains(t)) {
c.add(t);
return true;
}
return false;
}
}
```
看到这里你可能会举一反三然后想到所有非线程安全的类是不是都可以用这种包装的方式来实现线程安全呢其实这一点不止你想到了Java SDK的开发人员也想到了所以他们在Collections这个类中还提供了一套完备的包装类比如下面的示例代码中分别把ArrayList、HashSet和HashMap包装成了线程安全的List、Set和Map。
```
List list = Collections.
synchronizedList(new ArrayList());
Set set = Collections.
synchronizedSet(new HashSet());
Map map = Collections.
synchronizedMap(new HashMap());
```
我们曾经多次强调,**组合操作需要注意竞态条件问题**例如上面提到的addIfNotExist()方法就包含组合操作。组合操作往往隐藏着竞态条件问题,即便每个操作都能保证原子性,也并不能保证组合操作的原子性,这个一定要注意。
在容器领域**一个容易被忽视的“坑”是用迭代器遍历容器**例如在下面的代码中通过迭代器遍历容器list对每个元素调用foo()方法,这就存在并发问题,这些组合的操作不具备原子性。
```
List list = Collections.
synchronizedList(new ArrayList());
Iterator i = list.iterator();
while (i.hasNext())
foo(i.next());
```
而正确做法是下面这样锁住list之后再执行遍历操作。如果你查看Collections内部的包装类源码你会发现包装类的公共方法锁的是对象的this其实就是我们这里的list所以锁住list绝对是线程安全的。
```
List list = Collections.
synchronizedList(new ArrayList());
synchronized (list) {
Iterator i = list.iterator();
while (i.hasNext())
foo(i.next());
}
```
上面我们提到的这些经过包装后线程安全容器都是基于synchronized这个同步关键字实现的所以也被称为**同步容器**。Java提供的同步容器还有Vector、Stack和Hashtable这三个容器不是基于包装类实现的但同样是基于synchronized实现的对这三个容器的遍历同样要加锁保证互斥。
## 并发容器及其注意事项
Java在1.5版本之前所谓的线程安全的容器,主要指的就是**同步容器**。不过同步容器有个最大的问题那就是性能差所有方法都用synchronized来保证互斥串行度太高了。因此Java在1.5及之后版本提供了性能更高的容器,我们一般称为**并发容器**。
并发容器虽然数量非常多但依然是前面我们提到的四大类List、Map、Set和Queue下面的并发容器关系图基本上把我们经常用的容器都覆盖到了。
<img src="https://static001.geekbang.org/resource/image/a2/1d/a20efe788caf4f07a4ad027639c80b1d.png" alt="">
鉴于并发容器的数量太多,再加上篇幅限制,所以我并不会一一详细介绍它们的用法,只是把关键点介绍一下。
### List
List里面只有一个实现类就是**CopyOnWriteArrayList**。CopyOnWrite顾名思义就是写的时候会将共享变量新复制一份出来这样做的好处是读操作完全无锁。
那CopyOnWriteArrayList的实现原理是怎样的呢下面我们就来简单介绍一下
CopyOnWriteArrayList内部维护了一个数组成员变量array就指向这个内部数组所有的读操作都是基于array进行的如下图所示迭代器Iterator遍历的就是array数组。
<img src="https://static001.geekbang.org/resource/image/38/10/38739130ee9f34b821b5849f4f15e710.png" alt="">
如果在遍历array的同时还有一个写操作例如增加元素CopyOnWriteArrayList是如何处理的呢CopyOnWriteArrayList会将array复制一份然后在新复制处理的数组上执行增加元素的操作执行完之后再将array指向这个新的数组。通过下图你可以看到读写是可以并行的遍历操作一直都是基于原array执行而写操作则是基于新array进行。
<img src="https://static001.geekbang.org/resource/image/b8/89/b861fb667e94c4e6ea0ca9985e63c889.png" alt="">
使用CopyOnWriteArrayList需要注意的“坑”主要有两个方面。一个是应用场景CopyOnWriteArrayList仅适用于写操作非常少的场景而且能够容忍读写的短暂不一致。例如上面的例子中写入的新元素并不能立刻被遍历到。另一个需要注意的是CopyOnWriteArrayList迭代器是只读的不支持增删改。因为迭代器遍历的仅仅是一个快照而对快照进行增删改是没有意义的。
### Map
Map接口的两个实现是ConcurrentHashMap和ConcurrentSkipListMap它们从应用的角度来看主要区别在于**ConcurrentHashMap的key是无序的而ConcurrentSkipListMap的key是有序的**。所以如果你需要保证key的顺序就只能使用ConcurrentSkipListMap。
使用ConcurrentHashMap和ConcurrentSkipListMap需要注意的地方是它们的key和value都不能为空否则会抛出`NullPointerException`这个运行时异常。下面这个表格总结了Map相关的实现类对于key和value的要求你可以对比学习。
<img src="https://static001.geekbang.org/resource/image/6d/be/6da9933b6312acf3445f736262425abe.png" alt="">
ConcurrentSkipListMap里面的SkipList本身就是一种数据结构中文一般都翻译为“跳表”。跳表插入、删除、查询操作平均的时间复杂度是 O(log n)理论上和并发线程数没有关系所以在并发度非常高的情况下若你对ConcurrentHashMap的性能还不满意可以尝试一下ConcurrentSkipListMap。
### Set
Set接口的两个实现是CopyOnWriteArraySet和ConcurrentSkipListSet使用场景可以参考前面讲述的CopyOnWriteArrayList和ConcurrentSkipListMap它们的原理都是一样的这里就不再赘述了。
### Queue
Java并发包里面Queue这类并发容器是最复杂的你可以从以下两个维度来分类。一个维度是**阻塞与非阻塞**,所谓阻塞指的是当队列已满时,入队操作阻塞;当队列已空时,出队操作阻塞。另一个维度是**单端与双端**单端指的是只能队尾入队队首出队而双端指的是队首队尾皆可入队出队。Java并发包里**阻塞队列都用Blocking关键字标识单端队列使用Queue标识双端队列使用Deque标识**。
这两个维度组合后可以将Queue细分为四大类分别是
1.**单端阻塞队列**其实现有ArrayBlockingQueue、LinkedBlockingQueue、SynchronousQueue、LinkedTransferQueue、PriorityBlockingQueue和DelayQueue。内部一般会持有一个队列这个队列可以是数组其实现是ArrayBlockingQueue也可以是链表其实现是LinkedBlockingQueue甚至还可以不持有队列其实现是SynchronousQueue此时生产者线程的入队操作必须等待消费者线程的出队操作。而LinkedTransferQueue融合LinkedBlockingQueue和SynchronousQueue的功能性能比LinkedBlockingQueue更好PriorityBlockingQueue支持按照优先级出队DelayQueue支持延时出队。
<img src="https://static001.geekbang.org/resource/image/59/83/5974a10f5eb0646fa94f7ba505bfcf83.png" alt="">
2.**双端阻塞队列**其实现是LinkedBlockingDeque。
<img src="https://static001.geekbang.org/resource/image/1a/96/1a58ff20f1271d899b93a4f9d54ce396.png" alt="">
3.**单端非阻塞队列**其实现是ConcurrentLinkedQueue。<br>
4.**双端非阻塞队列**其实现是ConcurrentLinkedDeque。
另外使用队列时需要格外注意队列是否支持有界所谓有界指的是内部的队列是否有容量限制。实际工作中一般都不建议使用无界的队列因为数据量大了之后很容易导致OOM。上面我们提到的这些Queue中只有ArrayBlockingQueue和LinkedBlockingQueue是支持有界的所以**在使用其他无界队列时一定要充分考虑是否存在导致OOM的隐患**。
## 总结
Java并发容器的内容很多但鉴于篇幅有限我们只是对一些关键点进行了梳理和介绍。
而在实际工作中,你不单要清楚每种容器的特性,还要能**选对容器,这才是关键**至于每种容器的用法用的时候看一下API说明就可以了这些容器的使用都不难。在文中我们甚至都没有介绍Java容器的快速失败机制Fail-Fast原因就在于当你选对容器的时候根本不会触发它。
## 课后思考
线上系统CPU突然飙升你怀疑有同学在并发场景里使用了HashMap因为在1.8之前的版本里并发执行HashMap.put()可能会导致CPU飙升到100%,你觉得该如何验证你的猜测呢?
欢迎在留言区与我分享你的想法,也欢迎你在留言区记录你的思考过程。感谢阅读,如果你觉得这篇文章对你有帮助的话,也欢迎把它分享给更多的朋友。

View File

@@ -0,0 +1,283 @@
<audio id="audio" title="21 | 原子类:无锁工具类的典范" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/e3/04/e324fca66b87e0e3f4d42ecd50f1aa04.mp3"></audio>
前面我们多次提到一个累加器的例子示例代码如下。在这个例子中add10K()这个方法不是线程安全的问题就出在变量count的可见性和count+=1的原子性上。可见性问题可以用volatile来解决而原子性问题我们前面一直都是采用的互斥锁方案。
```
public class Test {
long count = 0;
void add10K() {
int idx = 0;
while(idx++ &lt; 10000) {
count += 1;
}
}
}
```
其实对于简单的原子性问题,还有一种**无锁方案**。Java SDK并发包将这种无锁方案封装提炼之后实现了一系列的原子类。不过在深入介绍原子类的实现之前我们先看看如何利用原子类解决累加器问题这样你会对原子类有个初步的认识。
在下面的代码中我们将原来的long型变量count替换为了原子类AtomicLong原来的 `count +=1` 替换成了 count.getAndIncrement()仅需要这两处简单的改动就能使add10K()方法变成线程安全的,原子类的使用还是挺简单的。
```
public class Test {
AtomicLong count =
new AtomicLong(0);
void add10K() {
int idx = 0;
while(idx++ &lt; 10000) {
count.getAndIncrement();
}
}
}
```
无锁方案相对互斥锁方案,最大的好处就是**性能**。互斥锁方案为了保证互斥性,需要执行加锁、解锁操作,而加锁、解锁操作本身就消耗性能;同时拿不到锁的线程还会进入阻塞状态,进而触发线程切换,线程切换对性能的消耗也很大。 相比之下,无锁方案则完全没有加锁、解锁的性能消耗,同时还能保证互斥性,既解决了问题,又没有带来新的问题,可谓绝佳方案。那它是如何做到的呢?
## 无锁方案的实现原理
其实原子类性能高的秘密很简单硬件支持而已。CPU为了解决并发问题提供了CAS指令CAS全称是Compare And Swap即“比较并交换”。CAS指令包含3个参数共享变量的内存地址A、用于比较的值B和共享变量的新值C并且只有当内存中地址A处的值等于B时才能将内存中地址A处的值更新为新值C。**作为一条CPU指令CAS指令本身是能够保证原子性的**。
你可以通过下面CAS指令的模拟代码来理解CAS的工作原理。在下面的模拟程序中有两个参数一个是期望值expect另一个是需要写入的新值newValue**只有当目前count的值和期望值expect相等时才会将count更新为newValue**。
```
class SimulatedCAS{
int count
synchronized int cas(
int expect, int newValue){
// 读目前count的值
int curValue = count;
// 比较目前count值是否==期望值
if(curValue == expect){
// 如果是则更新count的值
count = newValue;
}
// 返回写入前的值
return curValue;
}
}
```
你仔细地再次思考一下这句话,“**只有当目前count的值和期望值expect相等时才会将count更新为newValue。**”要怎么理解这句话呢?
对于前面提到的累加器的例子,`count += 1` 的一个核心问题是基于内存中count的当前值A计算出来的count+=1为A+1在将A+1写入内存的时候很可能此时内存中count已经被其他线程更新过了这样就会导致错误地覆盖其他线程写入的值如果你觉得理解起来还有困难建议你再重新看看[《01 | 可见性、原子性和有序性问题并发编程Bug的源头》](https://time.geekbang.org/column/article/83682)。也就是说只有当内存中count的值等于期望值A时才能将内存中count的值更新为计算结果A+1这不就是CAS的语义吗
使用CAS来解决并发问题一般都会伴随着自旋而所谓自旋其实就是循环尝试。例如实现一个线程安全的`count += 1`操作“CAS+自旋”的实现方案如下所示首先计算newValue = count+1如果cas(count,newValue)返回的值不等于count则意味着线程在执行完代码①处之后执行代码②处之前count的值被其他线程更新过。那此时该怎么处理呢可以采用自旋方案就像下面代码中展示的可以重新读count最新的值来计算newValue并尝试再次更新直到成功。
```
class SimulatedCAS{
volatile int count;
// 实现count+=1
addOne(){
do {
newValue = count+1; //①
}while(count !=
cas(count,newValue) //②
}
// 模拟实现CAS仅用来帮助理解
synchronized int cas(
int expect, int newValue){
// 读目前count的值
int curValue = count;
// 比较目前count值是否==期望值
if(curValue == expect){
// 如果是则更新count的值
count= newValue;
}
// 返回写入前的值
return curValue;
}
}
```
通过上面的示例代码想必你已经发现了CAS这种无锁方案完全没有加锁、解锁操作即便两个线程完全同时执行addOne()方法,也不会有线程被阻塞,所以相对于互斥锁方案来说,性能好了很多。
但是在CAS方案中有一个问题可能会常被你忽略那就是**ABA**的问题。什么是ABA问题呢
前面我们提到“如果cas(count,newValue)返回的值**不等于**count意味着线程在执行完代码①处之后执行代码②处之前count的值被其他线程**更新过**”那如果cas(count,newValue)返回的值**等于**count是否就能够认为count的值没有被其他线程**更新过**呢显然不是的假设count原本是A线程T1在执行完代码①处之后执行代码②处之前有可能count被线程T2更新成了B之后又被T3更新回了A这样线程T1虽然看到的一直是A但是其实已经被其他线程更新过了这就是ABA问题。
可能大多数情况下我们并不关心ABA问题例如数值的原子递增但也不能所有情况下都不关心例如原子化的更新对象很可能就需要关心ABA问题因为两个A虽然相等但是第二个A的属性可能已经发生变化了。所以在使用CAS方案的时候一定要先check一下。
## 看Java如何实现原子化的count += 1
在本文开始部分我们使用原子类AtomicLong的getAndIncrement()方法替代了`count += 1`从而实现了线程安全。原子类AtomicLong的getAndIncrement()方法内部就是基于CAS实现的下面我们来看看Java是如何使用CAS来实现原子化的`count += 1`的。
在Java 1.8版本中getAndIncrement()方法会转调unsafe.getAndAddLong()方法。这里this和valueOffset两个参数可以唯一确定共享变量的内存地址。
```
final long getAndIncrement() {
return unsafe.getAndAddLong(
this, valueOffset, 1L);
}
```
unsafe.getAndAddLong()方法的源码如下该方法首先会在内存中读取共享变量的值之后循环调用compareAndSwapLong()方法来尝试设置共享变量的值直到成功为止。compareAndSwapLong()是一个native方法只有当内存中共享变量的值等于expected时才会将共享变量的值更新为x并且返回true否则返回fasle。compareAndSwapLong的语义和CAS指令的语义的差别仅仅是返回值不同而已。
```
public final long getAndAddLong(
Object o, long offset, long delta){
long v;
do {
// 读取内存中的值
v = getLongVolatile(o, offset);
} while (!compareAndSwapLong(
o, offset, v, v + delta));
return v;
}
//原子性地将变量更新为x
//条件是内存中的值等于expected
//更新成功则返回true
native boolean compareAndSwapLong(
Object o, long offset,
long expected,
long x);
```
另外需要你注意的是getAndAddLong()方法的实现基本上就是CAS使用的经典范例。所以请你再次体会下面这段抽象后的代码片段它在很多无锁程序中经常出现。Java提供的原子类里面CAS一般被实现为compareAndSet()compareAndSet()的语义和CAS指令的语义的差别仅仅是返回值不同而已compareAndSet()里面如果更新成功则会返回true否则返回false。
```
do {
// 获取当前值
oldV = xxxx
// 根据当前值计算新值
newV = ...oldV...
}while(!compareAndSet(oldV,newV);
```
## 原子类概览
Java SDK并发包里提供的原子类内容很丰富我们可以将它们分为五个类别**原子化的基本数据类型、原子化的对象引用类型、原子化数组、原子化对象属性更新器**和**原子化的累加器**。这五个类别提供的方法基本上是相似的,并且每个类别都有若干原子类,你可以通过下面的原子类组成概览图来获得一个全局的印象。下面我们详细解读这五个类别。
<img src="https://static001.geekbang.org/resource/image/00/4a/007a32583fbf519469462fe61805eb4a.png" alt="">
### 1. 原子化的基本数据类型
相关实现有AtomicBoolean、AtomicInteger和AtomicLong提供的方法主要有以下这些详情你可以参考SDK的源代码都很简单这里就不详细介绍了。
```
getAndIncrement() //原子化i++
getAndDecrement() //原子化的i--
incrementAndGet() //原子化的++i
decrementAndGet() //原子化的--i
//当前值+=delta返回+=前的值
getAndAdd(delta)
//当前值+=delta返回+=后的值
addAndGet(delta)
//CAS操作返回是否成功
compareAndSet(expect, update)
//以下四个方法
//新值可以通过传入func函数来计算
getAndUpdate(func)
updateAndGet(func)
getAndAccumulate(x,func)
accumulateAndGet(x,func)
```
### 2. 原子化的对象引用类型
相关实现有AtomicReference、AtomicStampedReference和AtomicMarkableReference利用它们可以实现对象引用的原子化更新。AtomicReference提供的方法和原子化的基本数据类型差不多这里不再赘述。不过需要注意的是对象引用的更新需要重点关注ABA问题AtomicStampedReference和AtomicMarkableReference这两个原子类可以解决ABA问题。
解决ABA问题的思路其实很简单增加一个版本号维度就可以了这个和我们在[《18 | StampedLock有没有比读写锁更快的锁](https://time.geekbang.org/column/article/89456)介绍的乐观锁机制很类似每次执行CAS操作附加再更新一个版本号只要保证版本号是递增的那么即便A变成B之后再变回A版本号也不会变回来版本号递增的。AtomicStampedReference实现的CAS方法就增加了版本号参数方法签名如下
```
boolean compareAndSet(
V expectedReference,
V newReference,
int expectedStamp,
int newStamp)
```
AtomicMarkableReference的实现机制则更简单将版本号简化成了一个Boolean值方法签名如下
```
boolean compareAndSet(
V expectedReference,
V newReference,
boolean expectedMark,
boolean newMark)
```
### 3. 原子化数组
相关实现有AtomicIntegerArray、AtomicLongArray和AtomicReferenceArray利用这些原子类我们可以原子化地更新数组里面的每一个元素。这些类提供的方法和原子化的基本数据类型的区别仅仅是每个方法多了一个数组的索引参数所以这里也不再赘述了。
### 4. 原子化对象属性更新器
相关实现有AtomicIntegerFieldUpdater、AtomicLongFieldUpdater和AtomicReferenceFieldUpdater利用它们可以原子化地更新对象的属性这三个方法都是利用反射机制实现的创建更新器的方法如下
```
public static &lt;U&gt;
AtomicXXXFieldUpdater&lt;U&gt;
newUpdater(Class&lt;U&gt; tclass,
String fieldName)
```
需要注意的是,**对象属性必须是volatile类型的只有这样才能保证可见性**如果对象属性不是volatile类型的newUpdater()方法会抛出IllegalArgumentException这个运行时异常。
你会发现newUpdater()的方法参数只有类的信息,没有对象的引用,而更新**对象**的属性一定需要对象的引用那这个参数是在哪里传入的呢是在原子操作的方法参数中传入的。例如compareAndSet()这个原子操作相比原子化的基本数据类型多了一个对象引用obj。原子化对象属性更新器相关的方法相比原子化的基本数据类型仅仅是多了对象引用参数所以这里也不再赘述了。
```
boolean compareAndSet(
T obj,
int expect,
int update)
```
### 5. 原子化的累加器
DoubleAccumulator、DoubleAdder、LongAccumulator和LongAdder这四个类仅仅用来执行累加操作相比原子化的基本数据类型速度更快但是不支持compareAndSet()方法。如果你仅仅需要累加操作,使用原子化的累加器性能会更好。
## 总结
无锁方案相对于互斥锁方案优点非常多首先性能好其次是基本不会出现死锁问题但可能出现饥饿和活锁问题因为自旋会反复重试。Java提供的原子类大部分都实现了compareAndSet()方法基于compareAndSet()方法,你可以构建自己的无锁数据结构,但是**建议你不要这样做,这个工作最好还是让大师们去完成**,原因是无锁算法没你想象的那么简单。
Java提供的原子类能够解决一些简单的原子性问题但你可能会发现上面我们所有原子类的方法都是针对一个共享变量的如果你需要解决多个变量的原子性问题建议还是使用互斥锁方案。原子类虽好但使用要慎之又慎。
## 课后思考
下面的示例代码是合理库存的原子化实现仅实现了设置库存上限setUpper()方法你觉得setUpper()方法的实现是否正确呢?
```
public class SafeWM {
class WMRange{
final int upper;
final int lower;
WMRange(int upper,int lower){
//省略构造函数实现
}
}
final AtomicReference&lt;WMRange&gt;
rf = new AtomicReference&lt;&gt;(
new WMRange(0,0)
);
// 设置库存上限
void setUpper(int v){
WMRange nr;
WMRange or = rf.get();
do{
// 检查参数合法性
if(v &lt; or.lower){
throw new IllegalArgumentException();
}
nr = new
WMRange(v, or.lower);
}while(!rf.compareAndSet(or, nr));
}
}
```
欢迎在留言区与我分享你的想法,也欢迎你在留言区记录你的思考过程。感谢阅读,如果你觉得这篇文章对你有帮助的话,也欢迎把它分享给更多的朋友。

View File

@@ -0,0 +1,166 @@
<audio id="audio" title="22 | Executor与线程池如何创建正确的线程池" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/98/4b/9804ba239742c26c8e1a8f7c56cfe54b.mp3"></audio>
虽然在Java语言中创建线程看上去就像创建一个对象一样简单只需要new Thread()就可以了但实际上创建线程远不是创建一个对象那么简单。创建对象仅仅是在JVM的堆里分配一块内存而已而创建一个线程却需要调用操作系统内核的API然后操作系统要为线程分配一系列的资源这个成本就很高了所以**线程是一个重量级的对象,应该避免频繁创建和销毁**。
那如何避免呢?应对方案估计你已经知道了,那就是线程池。
线程池的需求是如此普遍所以Java SDK并发包自然也少不了它。但是很多人在初次接触并发包里线程池相关的工具类时多少会都有点蒙不知道该从哪里入手我觉得根本原因在于线程池和一般意义上的池化资源是不同的。一般意义上的池化资源都是下面这样当你需要资源的时候就调用acquire()方法来申请资源用完之后就调用release()释放资源。若你带着这个固有模型来看并发包里线程池相关的工具类时会很遗憾地发现它们完全匹配不上Java提供的线程池里面压根就没有申请线程和释放线程的方法。
```
class XXXPool{
// 获取池化资源
XXX acquire() {
}
// 释放池化资源
void release(XXX x){
}
}
```
## 线程池是一种生产者-消费者模式
为什么线程池没有采用一般意义上池化资源的设计方法呢如果线程池采用一般意义上池化资源的设计方法应该是下面示例代码这样。你可以来思考一下假设我们获取到一个空闲线程T1然后该如何使用T1呢你期望的可能是这样通过调用T1的execute()方法传入一个Runnable对象来执行具体业务逻辑就像通过构造函数Thread(Runnable target)创建线程一样。可惜的是你翻遍Thread对象的所有方法都不存在类似execute(Runnable target)这样的公共方法。
```
//采用一般意义上池化资源的设计方法
class ThreadPool{
// 获取空闲线程
Thread acquire() {
}
// 释放线程
void release(Thread t){
}
}
//期望的使用
ThreadPool pool
Thread T1=pool.acquire();
//传入Runnable对象
T1.execute(()-&gt;{
//具体业务逻辑
......
});
```
所以,线程池的设计,没有办法直接采用一般意义上池化资源的设计方法。那线程池该如何设计呢?目前业界线程池的设计,普遍采用的都是**生产者-消费者模式**。线程池的使用方是生产者线程池本身是消费者。在下面的示例代码中我们创建了一个非常简单的线程池MyThreadPool你可以通过它来理解线程池的工作原理。
```
//简化的线程池,仅用来说明工作原理
class MyThreadPool{
//利用阻塞队列实现生产者-消费者模式
BlockingQueue&lt;Runnable&gt; workQueue;
//保存内部工作线程
List&lt;WorkerThread&gt; threads
= new ArrayList&lt;&gt;();
// 构造方法
MyThreadPool(int poolSize,
BlockingQueue&lt;Runnable&gt; workQueue){
this.workQueue = workQueue;
// 创建工作线程
for(int idx=0; idx&lt;poolSize; idx++){
WorkerThread work = new WorkerThread();
work.start();
threads.add(work);
}
}
// 提交任务
void execute(Runnable command){
workQueue.put(command);
}
// 工作线程负责消费任务,并执行任务
class WorkerThread extends Thread{
public void run() {
//循环取任务并执行
while(true){ ①
Runnable task = workQueue.take();
task.run();
}
}
}
}
/** 下面是使用示例 **/
// 创建有界阻塞队列
BlockingQueue&lt;Runnable&gt; workQueue =
new LinkedBlockingQueue&lt;&gt;(2);
// 创建线程池
MyThreadPool pool = new MyThreadPool(
10, workQueue);
// 提交任务
pool.execute(()-&gt;{
System.out.println(&quot;hello&quot;);
});
```
在MyThreadPool的内部我们维护了一个阻塞队列workQueue和一组工作线程工作线程的个数由构造函数中的poolSize来指定。用户通过调用execute()方法来提交Runnable任务execute()方法的内部实现仅仅是将任务加入到workQueue中。MyThreadPool内部维护的工作线程会消费workQueue中的任务并执行任务相关的代码就是代码①处的while循环。线程池主要的工作原理就这些是不是还挺简单的
## 如何使用Java中的线程池
Java并发包里提供的线程池远比我们上面的示例代码强大得多当然也复杂得多。Java提供的线程池相关的工具类中最核心的是**ThreadPoolExecutor**通过名字你也能看出来它强调的是Executor而不是一般意义上的池化资源。
ThreadPoolExecutor的构造函数非常复杂如下面代码所示这个最完备的构造函数有7个参数。
```
ThreadPoolExecutor(
int corePoolSize,
int maximumPoolSize,
long keepAliveTime,
TimeUnit unit,
BlockingQueue&lt;Runnable&gt; workQueue,
ThreadFactory threadFactory,
RejectedExecutionHandler handler)
```
下面我们一一介绍这些参数的意义,你可以**把线程池类比为一个项目组,而线程就是项目组的成员**。
- **corePoolSize**表示线程池保有的最小线程数。有些项目很闲但是也不能把人都撤了至少要留corePoolSize个人坚守阵地。
- **maximumPoolSize**表示线程池创建的最大线程数。当项目很忙时就需要加人但是也不能无限制地加最多就加到maximumPoolSize个人。当项目闲下来时就要撤人了最多能撤到corePoolSize个人。
- **keepAliveTime &amp; unit**上面提到项目根据忙闲来增减人员那在编程世界里如何定义忙和闲呢很简单一个线程如果在一段时间内都没有执行任务说明很闲keepAliveTime 和 unit 就是用来定义这个“一段时间”的参数。也就是说,如果一个线程空闲了`keepAliveTime &amp; unit`这么久,而且线程池的线程数大于 corePoolSize ,那么这个空闲的线程就要被回收了。
- **workQueue**:工作队列,和上面示例代码的工作队列同义。
- **threadFactory**:通过这个参数你可以自定义如何创建线程,例如你可以给线程指定一个有意义的名字。
<li>**handler**通过这个参数你可以自定义任务的拒绝策略。如果线程池中所有的线程都在忙碌并且工作队列也满了前提是工作队列是有界队列那么此时提交任务线程池就会拒绝接收。至于拒绝的策略你可以通过handler这个参数来指定。ThreadPoolExecutor已经提供了以下4种策略。
<ul>
- CallerRunsPolicy提交任务的线程自己去执行该任务。
- AbortPolicy默认的拒绝策略会throws RejectedExecutionException。
- DiscardPolicy直接丢弃任务没有任何异常抛出。
- DiscardOldestPolicy丢弃最老的任务其实就是把最早进入工作队列的任务丢弃然后把新任务加入到工作队列。
Java在1.6版本还增加了 allowCoreThreadTimeOut(boolean value) 方法,它可以让所有线程都支持超时,这意味着如果项目很闲,就会将项目组的成员都撤走。
## 使用线程池要注意些什么
考虑到ThreadPoolExecutor的构造函数实在是有些复杂所以Java并发包里提供了一个线程池的静态工厂类Executors利用Executors你可以快速创建线程池。不过目前大厂的编码规范中基本上都不建议使用Executors了所以这里我就不再花篇幅介绍了。
不建议使用Executors的最重要的原因是Executors提供的很多方法默认使用的都是无界的LinkedBlockingQueue高负载情境下无界队列很容易导致OOM而OOM会导致所有请求都无法处理这是致命问题。所以**强烈建议使用有界队列**。
使用有界队列当任务过多时线程池会触发执行拒绝策略线程池默认的拒绝策略会throw RejectedExecutionException 这是个运行时异常对于运行时异常编译器并不强制catch它所以开发人员很容易忽略。因此**默认拒绝策略要慎重使用**。如果线程池处理的任务非常重要,建议自定义自己的拒绝策略;并且在实际工作中,自定义的拒绝策略往往和降级策略配合使用。
使用线程池还要注意异常处理的问题例如通过ThreadPoolExecutor对象的execute()方法提交任务时,如果任务在执行的过程中出现运行时异常,会导致执行任务的线程终止;不过,最致命的是任务虽然异常了,但是你却获取不到任何通知,这会让你误以为任务都执行得很正常。虽然线程池提供了很多用于异常处理的方法,但是最稳妥和简单的方案还是捕获所有异常并按需处理,你可以参考下面的示例代码。
```
try {
//业务逻辑
} catch (RuntimeException x) {
//按需处理
} catch (Throwable x) {
//按需处理
}
```
## 总结
线程池在Java并发编程领域非常重要很多大厂的编码规范都要求必须通过线程池来管理线程。线程池和普通的池化资源有很大不同线程池实际上是生产者-消费者模式的一种实现,理解生产者-消费者模式是理解线程池的关键所在。
创建线程池设置合适的线程数非常重要,这部分内容,你可以参考[《10 | Java线程创建多少线程才是合适的](https://time.geekbang.org/column/article/86666)的内容。另外[《Java并发编程实战》](time://mall?url=https%3A%2F%2Fh5.youzan.com%2Fv2%2Fgoods%2F2758xqdzr6uuw)的第7章《取消与关闭》的7.3节“处理非正常的线程终止” 详细介绍了异常处理的方案第8章《线程池的使用》对线程池的使用也有更深入的介绍如果你感兴趣或有需要的话建议你仔细阅读。
## 课后思考
使用线程池,默认情况下创建的线程名字都类似`pool-1-thread-2`这样,没有业务含义。而很多情况下为了便于诊断问题,都需要给线程赋予一个有意义的名字,那你知道有哪些办法可以给线程池里的线程指定名字吗?
欢迎在留言区与我分享你的想法,也欢迎你在留言区记录你的思考过程。感谢阅读,如果你觉得这篇文章对你有帮助的话,也欢迎把它分享给更多的朋友。

View File

@@ -0,0 +1,219 @@
<audio id="audio" title="23 | Future如何用多线程实现最优的“烧水泡茶”程序" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/dc/7b/dc11032defca4b14e9da7021b954787b.mp3"></audio>
在上一篇文章[《22 | Executor与线程池如何创建正确的线程池](https://time.geekbang.org/column/article/90771)中我们详细介绍了如何创建正确的线程池那创建完线程池我们该如何使用呢在上一篇文章中我们仅仅介绍了ThreadPoolExecutor的 `void execute(Runnable command)` 方法利用这个方法虽然可以提交任务但是却没有办法获取任务的执行结果execute()方法没有返回值。而很多场景下我们又都是需要获取任务的执行结果的。那ThreadPoolExecutor是否提供了相关功能呢必须的这么重要的功能当然需要提供了。
下面我们就来介绍一下使用ThreadPoolExecutor的时候如何获取任务执行结果。
## 如何获取任务执行结果
Java通过ThreadPoolExecutor提供的3个submit()方法和1个FutureTask工具类来支持获得任务执行结果的需求。下面我们先来介绍这3个submit()方法这3个方法的方法签名如下。
```
// 提交Runnable任务
Future&lt;?&gt;
submit(Runnable task);
// 提交Callable任务
&lt;T&gt; Future&lt;T&gt;
submit(Callable&lt;T&gt; task);
// 提交Runnable任务及结果引用
&lt;T&gt; Future&lt;T&gt;
submit(Runnable task, T result);
```
你会发现它们的返回值都是Future接口Future接口有5个方法我都列在下面了它们分别是**取消任务的方法cancel()、判断任务是否已取消的方法isCancelled()、判断任务是否已结束的方法isDone()<strong>以及**2个获得任务执行结果的get()和get(timeout, unit)</strong>其中最后一个get(timeout, unit)支持超时机制。通过Future接口的这5个方法你会发现我们提交的任务不但能够获取任务执行结果还可以取消任务。不过需要注意的是这两个get()方法都是阻塞式的如果被调用的时候任务还没有执行完那么调用get()方法的线程会阻塞,直到任务执行完才会被唤醒。
```
// 取消任务
boolean cancel(
boolean mayInterruptIfRunning);
// 判断任务是否已取消
boolean isCancelled();
// 判断任务是否已结束
boolean isDone();
// 获得任务执行结果
get();
// 获得任务执行结果,支持超时
get(long timeout, TimeUnit unit);
```
这3个submit()方法之间的区别在于方法参数不同,下面我们简要介绍一下。
1. 提交Runnable任务 `submit(Runnable task)` 这个方法的参数是一个Runnable接口Runnable接口的run()方法是没有返回值的,所以 `submit(Runnable task)` 这个方法返回的Future仅可以用来断言任务已经结束了类似于Thread.join()。
1. 提交Callable任务 `submit(Callable&lt;T&gt; task)`这个方法的参数是一个Callable接口它只有一个call()方法并且这个方法是有返回值的所以这个方法返回的Future对象可以通过调用其get()方法来获取任务的执行结果。
1. 提交Runnable任务及结果引用 `submit(Runnable task, T result)`这个方法很有意思假设这个方法返回的Future对象是ff.get()的返回值就是传给submit()方法的参数result。这个方法该怎么用呢下面这段示例代码展示了它的经典用法。需要你注意的是Runnable接口的实现类Task声明了一个有参构造函数 `Task(Result r)` 创建Task对象的时候传入了result对象这样就能在类Task的run()方法中对result进行各种操作了。result相当于主线程和子线程之间的桥梁通过它主子线程可以共享数据。
```
ExecutorService executor
= Executors.newFixedThreadPool(1);
// 创建Result对象r
Result r = new Result();
r.setAAA(a);
// 提交任务
Future&lt;Result&gt; future =
executor.submit(new Task(r), r);
Result fr = future.get();
// 下面等式成立
fr === r;
fr.getAAA() === a;
fr.getXXX() === x
class Task implements Runnable{
Result r;
//通过构造函数传入result
Task(Result r){
this.r = r;
}
void run() {
//可以操作result
a = r.getAAA();
r.setXXX(x);
}
}
```
下面我们再来介绍FutureTask工具类。前面我们提到的Future是一个接口而FutureTask是一个实实在在的工具类这个工具类有两个构造函数它们的参数和前面介绍的submit()方法类似,所以这里我就不再赘述了。
```
FutureTask(Callable&lt;V&gt; callable);
FutureTask(Runnable runnable, V result);
```
那如何使用FutureTask呢其实很简单FutureTask实现了Runnable和Future接口由于实现了Runnable接口所以可以将FutureTask对象作为任务提交给ThreadPoolExecutor去执行也可以直接被Thread执行又因为实现了Future接口所以也能用来获得任务的执行结果。下面的示例代码是将FutureTask对象提交给ThreadPoolExecutor去执行。
```
// 创建FutureTask
FutureTask&lt;Integer&gt; futureTask
= new FutureTask&lt;&gt;(()-&gt; 1+2);
// 创建线程池
ExecutorService es =
Executors.newCachedThreadPool();
// 提交FutureTask
es.submit(futureTask);
// 获取计算结果
Integer result = futureTask.get();
```
FutureTask对象直接被Thread执行的示例代码如下所示。相信你已经发现了利用FutureTask对象可以很容易获取子线程的执行结果。
```
// 创建FutureTask
FutureTask&lt;Integer&gt; futureTask
= new FutureTask&lt;&gt;(()-&gt; 1+2);
// 创建并启动线程
Thread T1 = new Thread(futureTask);
T1.start();
// 获取计算结果
Integer result = futureTask.get();
```
## 实现最优的“烧水泡茶”程序
记得以前初中语文课文里有一篇著名数学家华罗庚先生的文章《统筹方法》,这篇文章里介绍了一个烧水泡茶的例子,文中提到最优的工序应该是下面这样:
<img src="https://static001.geekbang.org/resource/image/86/ce/86193a2dba88dd15562118cce6d786ce.png" alt="">
下面我们用程序来模拟一下这个最优工序。我们专栏前面曾经提到并发编程可以总结为三个核心问题分工、同步和互斥。编写并发程序首先要做的就是分工所谓分工指的是如何高效地拆解任务并分配给线程。对于烧水泡茶这个程序一种最优的分工方案可以是下图所示的这样用两个线程T1和T2来完成烧水泡茶程序T1负责洗水壶、烧开水、泡茶这三道工序T2负责洗茶壶、洗茶杯、拿茶叶三道工序其中T1在执行泡茶这道工序时需要等待T2完成拿茶叶的工序。对于T1的这个等待动作你应该可以想出很多种办法例如Thread.join()、CountDownLatch甚至阻塞队列都可以解决不过今天我们用Future特性来实现。
<img src="https://static001.geekbang.org/resource/image/9c/8e/9cf7d188af9119a5e76788466b453d8e.png" alt="">
下面的示例代码就是用这一章提到的Future特性来实现的。首先我们创建了两个FutureTask——ft1和ft2ft1完成洗水壶、烧开水、泡茶的任务ft2完成洗茶壶、洗茶杯、拿茶叶的任务这里需要注意的是ft1这个任务在执行泡茶任务前需要等待ft2把茶叶拿来所以ft1内部需要引用ft2并在执行泡茶之前调用ft2的get()方法实现等待。
```
// 创建任务T2的FutureTask
FutureTask&lt;String&gt; ft2
= new FutureTask&lt;&gt;(new T2Task());
// 创建任务T1的FutureTask
FutureTask&lt;String&gt; ft1
= new FutureTask&lt;&gt;(new T1Task(ft2));
// 线程T1执行任务ft1
Thread T1 = new Thread(ft1);
T1.start();
// 线程T2执行任务ft2
Thread T2 = new Thread(ft2);
T2.start();
// 等待线程T1执行结果
System.out.println(ft1.get());
// T1Task需要执行的任务
// 洗水壶、烧开水、泡茶
class T1Task implements Callable&lt;String&gt;{
FutureTask&lt;String&gt; ft2;
// T1任务需要T2任务的FutureTask
T1Task(FutureTask&lt;String&gt; ft2){
this.ft2 = ft2;
}
@Override
String call() throws Exception {
System.out.println(&quot;T1:洗水壶...&quot;);
TimeUnit.SECONDS.sleep(1);
System.out.println(&quot;T1:烧开水...&quot;);
TimeUnit.SECONDS.sleep(15);
// 获取T2线程的茶叶
String tf = ft2.get();
System.out.println(&quot;T1:拿到茶叶:&quot;+tf);
System.out.println(&quot;T1:泡茶...&quot;);
return &quot;上茶:&quot; + tf;
}
}
// T2Task需要执行的任务:
// 洗茶壶、洗茶杯、拿茶叶
class T2Task implements Callable&lt;String&gt; {
@Override
String call() throws Exception {
System.out.println(&quot;T2:洗茶壶...&quot;);
TimeUnit.SECONDS.sleep(1);
System.out.println(&quot;T2:洗茶杯...&quot;);
TimeUnit.SECONDS.sleep(2);
System.out.println(&quot;T2:拿茶叶...&quot;);
TimeUnit.SECONDS.sleep(1);
return &quot;龙井&quot;;
}
}
// 一次执行结果:
T1:洗水壶...
T2:洗茶壶...
T1:烧开水...
T2:洗茶杯...
T2:拿茶叶...
T1:拿到茶叶:龙井
T1:泡茶...
上茶:龙井
```
## 总结
利用Java并发包提供的Future可以很容易获得异步任务的执行结果无论异步任务是通过线程池ThreadPoolExecutor执行的还是通过手工创建子线程来执行的。Future可以类比为现实世界里的提货单比如去蛋糕店订生日蛋糕蛋糕店都是先给你一张提货单你拿到提货单之后没有必要一直在店里等着可以先去干点其他事比如看场电影等看完电影后基本上蛋糕也做好了然后你就可以凭提货单领蛋糕了。
利用多线程可以快速将一些串行的任务并行化从而提高性能如果任务之间有依赖关系比如当前任务依赖前一个任务的执行结果这种问题基本上都可以用Future来解决。在分析这种问题的过程中建议你用有向图描述一下任务之间的依赖关系同时将线程的分工也做好类似于烧水泡茶最优分工方案那幅图。对照图来写代码好处是更形象且不易出错。
## 课后思考
不久前听说小明要做一个询价应用,这个应用需要从三个电商询价,然后保存在自己的数据库里。核心示例代码如下所示,由于是串行的,所以性能很慢,你来试着优化一下吧。
```
// 向电商S1询价并保存
r1 = getPriceByS1();
save(r1);
// 向电商S2询价并保存
r2 = getPriceByS2();
save(r2);
// 向电商S3询价并保存
r3 = getPriceByS3();
save(r3);
```
欢迎在留言区与我分享你的想法,也欢迎你在留言区记录你的思考过程。感谢阅读,如果你觉得这篇文章对你有帮助的话,也欢迎把它分享给更多的朋友。

View File

@@ -0,0 +1,282 @@
<audio id="audio" title="24 | CompletableFuture异步编程没那么难" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/6c/aa/6cfa35dba8e235d7c8f720d50e360aaa.mp3"></audio>
前面我们不止一次提到,用多线程优化性能,其实不过就是将串行操作变成并行操作。如果仔细观察,你还会发现在串行转换成并行的过程中,一定会涉及到异步化,例如下面的示例代码,现在是串行的,为了提升性能,我们得把它们并行化,那具体实施起来该怎么做呢?
```
//以下两个方法都是耗时操作
doBizA();
doBizB();
```
还是挺简单的就像下面代码中这样创建两个子线程去执行就可以了。你会发现下面的并行方案主线程无需等待doBizA()和doBizB()的执行结果也就是说doBizA()和doBizB()两个操作已经被异步化了。
```
new Thread(()-&gt;doBizA())
.start();
new Thread(()-&gt;doBizB())
.start();
```
**异步化**,是并行方案得以实施的基础,更深入地讲其实就是:**利用多线程优化性能这个核心方案得以实施的基础**。看到这里相信你应该就能理解异步编程最近几年为什么会大火了因为优化性能是互联网大厂的一个核心需求啊。Java在1.8版本提供了CompletableFuture来支持异步编程CompletableFuture有可能是你见过的最复杂的工具类了不过功能也着实让人感到震撼。
## CompletableFuture的核心优势
为了领略CompletableFuture异步编程的优势这里我们用CompletableFuture重新实现前面曾提及的烧水泡茶程序。首先还是需要先完成分工方案在下面的程序中我们分了3个任务任务1负责洗水壶、烧开水任务2负责洗茶壶、洗茶杯和拿茶叶任务3负责泡茶。其中任务3要等待任务1和任务2都完成后才能开始。这个分工如下图所示。
<img src="https://static001.geekbang.org/resource/image/b3/78/b33f823a4124c1220d8bd6d91b877e78.png" alt="">
下面是代码实现你先略过runAsync()、supplyAsync()、thenCombine()这些不太熟悉的方法,从大局上看,你会发现:
1. 无需手工维护线程,没有繁琐的手工维护线程的工作,给任务分配线程的工作也不需要我们关注;
1. 语义更清晰,例如 `f3 = f1.thenCombine(f2, ()-&gt;{})` 能够清晰地表述“任务3要等待任务1和任务2都完成后才能开始”
1. 代码更简练并且专注于业务逻辑,几乎所有代码都是业务逻辑相关的。
```
//任务1洗水壶-&gt;烧开水
CompletableFuture&lt;Void&gt; f1 =
CompletableFuture.runAsync(()-&gt;{
System.out.println(&quot;T1:洗水壶...&quot;);
sleep(1, TimeUnit.SECONDS);
System.out.println(&quot;T1:烧开水...&quot;);
sleep(15, TimeUnit.SECONDS);
});
//任务2洗茶壶-&gt;洗茶杯-&gt;拿茶叶
CompletableFuture&lt;String&gt; f2 =
CompletableFuture.supplyAsync(()-&gt;{
System.out.println(&quot;T2:洗茶壶...&quot;);
sleep(1, TimeUnit.SECONDS);
System.out.println(&quot;T2:洗茶杯...&quot;);
sleep(2, TimeUnit.SECONDS);
System.out.println(&quot;T2:拿茶叶...&quot;);
sleep(1, TimeUnit.SECONDS);
return &quot;龙井&quot;;
});
//任务3任务1和任务2完成后执行泡茶
CompletableFuture&lt;String&gt; f3 =
f1.thenCombine(f2, (__, tf)-&gt;{
System.out.println(&quot;T1:拿到茶叶:&quot; + tf);
System.out.println(&quot;T1:泡茶...&quot;);
return &quot;上茶:&quot; + tf;
});
//等待任务3执行结果
System.out.println(f3.join());
void sleep(int t, TimeUnit u) {
try {
u.sleep(t);
}catch(InterruptedException e){}
}
// 一次执行结果:
T1:洗水壶...
T2:洗茶壶...
T1:烧开水...
T2:洗茶杯...
T2:拿茶叶...
T1:拿到茶叶:龙井
T1:泡茶...
上茶:龙井
```
领略CompletableFuture异步编程的优势之后下面我们详细介绍CompletableFuture的使用首先是如何创建CompletableFuture对象。
## 创建CompletableFuture对象
创建CompletableFuture对象主要靠下面代码中展示的这4个静态方法我们先看前两个。在烧水泡茶的例子中我们已经使用了`runAsync(Runnable runnable)``supplyAsync(Supplier&lt;U&gt; supplier)`它们之间的区别是Runnable 接口的run()方法没有返回值而Supplier接口的get()方法是有返回值的。
前两个方法和后两个方法的区别在于:后两个方法可以指定线程池参数。
默认情况下CompletableFuture会使用公共的ForkJoinPool线程池这个线程池默认创建的线程数是CPU的核数也可以通过JVM option:-Djava.util.concurrent.ForkJoinPool.common.parallelism来设置ForkJoinPool线程池的线程数。如果所有CompletableFuture共享一个线程池那么一旦有任务执行一些很慢的I/O操作就会导致线程池中所有线程都阻塞在I/O操作上从而造成线程饥饿进而影响整个系统的性能。所以强烈建议你要**根据不同的业务类型创建不同的线程池,以避免互相干扰**。
```
//使用默认线程池
static CompletableFuture&lt;Void&gt;
runAsync(Runnable runnable)
static &lt;U&gt; CompletableFuture&lt;U&gt;
supplyAsync(Supplier&lt;U&gt; supplier)
//可以指定线程池
static CompletableFuture&lt;Void&gt;
runAsync(Runnable runnable, Executor executor)
static &lt;U&gt; CompletableFuture&lt;U&gt;
supplyAsync(Supplier&lt;U&gt; supplier, Executor executor)
```
创建完CompletableFuture对象之后会自动地异步执行runnable.run()方法或者supplier.get()方法对于一个异步操作你需要关注两个问题一个是异步操作什么时候结束另一个是如何获取异步操作的执行结果。因为CompletableFuture类实现了Future接口所以这两个问题你都可以通过Future接口来解决。另外CompletableFuture类还实现了CompletionStage接口这个接口内容实在是太丰富了在1.8版本里有40个方法这些方法我们该如何理解呢
## 如何理解CompletionStage接口
我觉得,你可以站在分工的角度类比一下工作流。任务是有时序关系的,比如有**串行关系、并行关系、汇聚关系**等。这样说可能有点抽象,这里还举前面烧水泡茶的例子,其中洗水壶和烧开水就是串行关系,洗水壶、烧开水和洗茶壶、洗茶杯这两组任务之间就是并行关系,而烧开水、拿茶叶和泡茶就是汇聚关系。
<img src="https://static001.geekbang.org/resource/image/e1/9f/e18181998b82718da811ce5807f0ad9f.png" alt="">
<img src="https://static001.geekbang.org/resource/image/ea/d2/ea8e1a41a02b0104b421c58b25343bd2.png" alt="">
<img src="https://static001.geekbang.org/resource/image/3f/3b/3f1a5421333dd6d5c278ffd5299dc33b.png" alt="">
CompletionStage接口可以清晰地描述任务之间的这种时序关系例如前面提到的 `f3 = f1.thenCombine(f2, ()-&gt;{})` 描述的就是一种汇聚关系。烧水泡茶程序中的汇聚关系是一种 AND 聚合关系这里的AND指的是所有依赖的任务烧开水和拿茶叶都完成后才开始执行当前任务泡茶。既然有AND聚合关系那就一定还有OR聚合关系所谓OR指的是依赖的任务只要有一个完成就可以执行当前任务。
在编程领域还有一个绕不过去的山头那就是异常处理CompletionStage接口也可以方便地描述异常处理。
下面我们就来一一介绍CompletionStage接口如何描述串行关系、AND聚合关系、OR聚合关系以及异常处理。
### 1. 描述串行关系
CompletionStage接口里面描述串行关系主要是thenApply、thenAccept、thenRun和thenCompose这四个系列的接口。
thenApply系列函数里参数fn的类型是接口Function&lt;T, R&gt;这个接口里与CompletionStage相关的方法是 `R apply(T t)`这个方法既能接收参数也支持返回值所以thenApply系列方法返回的是`CompletionStage&lt;R&gt;`
而thenAccept系列方法里参数consumer的类型是接口`Consumer&lt;T&gt;`这个接口里与CompletionStage相关的方法是 `void accept(T t)`这个方法虽然支持参数但却不支持回值所以thenAccept系列方法返回的是`CompletionStage&lt;Void&gt;`
thenRun系列方法里action的参数是Runnable所以action既不能接收参数也不支持返回值所以thenRun系列方法返回的也是`CompletionStage&lt;Void&gt;`
这些方法里面Async代表的是异步执行fn、consumer或者action。其中需要你注意的是thenCompose系列方法这个系列的方法会新创建出一个子流程最终结果和thenApply系列是相同的。
```
CompletionStage&lt;R&gt; thenApply(fn);
CompletionStage&lt;R&gt; thenApplyAsync(fn);
CompletionStage&lt;Void&gt; thenAccept(consumer);
CompletionStage&lt;Void&gt; thenAcceptAsync(consumer);
CompletionStage&lt;Void&gt; thenRun(action);
CompletionStage&lt;Void&gt; thenRunAsync(action);
CompletionStage&lt;R&gt; thenCompose(fn);
CompletionStage&lt;R&gt; thenComposeAsync(fn);
```
通过下面的示例代码你可以看一下thenApply()方法是如何使用的。首先通过supplyAsync()启动一个异步流程,之后是两个串行操作,整体看起来还是挺简单的。不过,虽然这是一个异步流程,但任务①②③却是串行执行的,②依赖①的执行结果,③依赖②的执行结果。
```
CompletableFuture&lt;String&gt; f0 =
CompletableFuture.supplyAsync(
() -&gt; &quot;Hello World&quot;) //①
.thenApply(s -&gt; s + &quot; QQ&quot;) //②
.thenApply(String::toUpperCase);//③
System.out.println(f0.join());
//输出结果
HELLO WORLD QQ
```
### 2. 描述AND汇聚关系
CompletionStage接口里面描述AND汇聚关系主要是thenCombine、thenAcceptBoth和runAfterBoth系列的接口这些接口的区别也是源自fn、consumer、action这三个核心参数不同。它们的使用你可以参考上面烧水泡茶的实现程序这里就不赘述了。
```
CompletionStage&lt;R&gt; thenCombine(other, fn);
CompletionStage&lt;R&gt; thenCombineAsync(other, fn);
CompletionStage&lt;Void&gt; thenAcceptBoth(other, consumer);
CompletionStage&lt;Void&gt; thenAcceptBothAsync(other, consumer);
CompletionStage&lt;Void&gt; runAfterBoth(other, action);
CompletionStage&lt;Void&gt; runAfterBothAsync(other, action);
```
### 3. 描述OR汇聚关系
CompletionStage接口里面描述OR汇聚关系主要是applyToEither、acceptEither和runAfterEither系列的接口这些接口的区别也是源自fn、consumer、action这三个核心参数不同。
```
CompletionStage applyToEither(other, fn);
CompletionStage applyToEitherAsync(other, fn);
CompletionStage acceptEither(other, consumer);
CompletionStage acceptEitherAsync(other, consumer);
CompletionStage runAfterEither(other, action);
CompletionStage runAfterEitherAsync(other, action);
```
下面的示例代码展示了如何使用applyToEither()方法来描述一个OR汇聚关系。
```
CompletableFuture&lt;String&gt; f1 =
CompletableFuture.supplyAsync(()-&gt;{
int t = getRandom(5, 10);
sleep(t, TimeUnit.SECONDS);
return String.valueOf(t);
});
CompletableFuture&lt;String&gt; f2 =
CompletableFuture.supplyAsync(()-&gt;{
int t = getRandom(5, 10);
sleep(t, TimeUnit.SECONDS);
return String.valueOf(t);
});
CompletableFuture&lt;String&gt; f3 =
f1.applyToEither(f2,s -&gt; s);
System.out.println(f3.join());
```
### 4. 异常处理
虽然上面我们提到的fn、consumer、action它们的核心方法都**不允许抛出可检查异常,但是却无法限制它们抛出运行时异常**,例如下面的代码,执行 `7/0` 就会出现除零错误这个运行时异常。非异步编程里面我们可以使用try{}catch{}来捕获并处理异常,那在异步编程里面,异常该如何处理呢?
```
CompletableFuture&lt;Integer&gt;
f0 = CompletableFuture.
.supplyAsync(()-&gt;(7/0))
.thenApply(r-&gt;r*10);
System.out.println(f0.join());
```
CompletionStage接口给我们提供的方案非常简单比try{}catch{}还要简单,下面是相关的方法,使用这些方法进行异常处理和串行操作是一样的,都支持链式编程方式。
```
CompletionStage exceptionally(fn);
CompletionStage&lt;R&gt; whenComplete(consumer);
CompletionStage&lt;R&gt; whenCompleteAsync(consumer);
CompletionStage&lt;R&gt; handle(fn);
CompletionStage&lt;R&gt; handleAsync(fn);
```
下面的示例代码展示了如何使用exceptionally()方法来处理异常exceptionally()的使用非常类似于try{}catch{}中的catch{}但是由于支持链式编程方式所以相对更简单。既然有try{}catch{}那就一定还有try{}finally{}whenComplete()和handle()系列方法就类似于try{}finally{}中的finally{}无论是否发生异常都会执行whenComplete()中的回调函数consumer和handle()中的回调函数fn。whenComplete()和handle()的区别在于whenComplete()不支持返回结果而handle()是支持返回结果的。
```
CompletableFuture&lt;Integer&gt;
f0 = CompletableFuture
.supplyAsync(()-&gt;(7/0))
.thenApply(r-&gt;r*10)
.exceptionally(e-&gt;0);
System.out.println(f0.join());
```
## 总结
曾经一提到异步编程大家脑海里都会随之浮现回调函数例如在JavaScript里面异步问题基本上都是靠回调函数来解决的回调函数在处理异常以及复杂的异步任务关系时往往力不从心对此业界还发明了个名词**回调地狱**Callback Hell。应该说在前些年异步编程还是声名狼藉的。
不过最近几年,伴随着[ReactiveX](http://reactivex.io/intro.html)的发展Java语言的实现版本是RxJava回调地狱已经被完美解决了异步编程已经慢慢开始成熟Java语言也开始官方支持异步编程在1.8版本提供了CompletableFuture在Java 9版本则提供了更加完备的Flow API异步编程目前已经完全工业化。因此学好异步编程还是很有必要的。
CompletableFuture已经能够满足简单的异步编程需求如果你对异步编程感兴趣可以重点关注RxJava这个项目利用RxJava即便在Java 1.6版本也能享受异步编程的乐趣。
## 课后思考
创建采购订单的时候需要校验一些规则例如最大金额是和采购员级别相关的。有同学利用CompletableFuture实现了这个校验的功能逻辑很简单首先是从数据库中把相关规则查出来然后执行规则校验。你觉得他的实现是否有问题呢
```
//采购订单
PurchersOrder po;
CompletableFuture&lt;Boolean&gt; cf =
CompletableFuture.supplyAsync(()-&gt;{
//在数据库中查询规则
return findRuleByJdbc();
}).thenApply(r -&gt; {
//规则校验
return check(po, r);
});
Boolean isOk = cf.join();
```
欢迎在留言区与我分享你的想法,也欢迎你在留言区记录你的思考过程。感谢阅读,如果你觉得这篇文章对你有帮助的话,也欢迎把它分享给更多的朋友。

View File

@@ -0,0 +1,219 @@
<audio id="audio" title="25 | CompletionService如何批量执行异步任务" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/02/a5/02b3f358bc9c92a599db387d9e8fe8a5.mp3"></audio>
在[《23 | Future如何用多线程实现最优的“烧水泡茶”程序](https://time.geekbang.org/column/article/91292)的最后我给你留了道思考题如何优化一个询价应用的核心代码如果采用“ThreadPoolExecutor+Future”的方案你的优化结果很可能是下面示例代码这样用三个线程异步执行询价通过三次调用Future的get()方法获取询价结果,之后将询价结果保存在数据库中。
```
// 创建线程池
ExecutorService executor =
Executors.newFixedThreadPool(3);
// 异步向电商S1询价
Future&lt;Integer&gt; f1 =
executor.submit(
()-&gt;getPriceByS1());
// 异步向电商S2询价
Future&lt;Integer&gt; f2 =
executor.submit(
()-&gt;getPriceByS2());
// 异步向电商S3询价
Future&lt;Integer&gt; f3 =
executor.submit(
()-&gt;getPriceByS3());
// 获取电商S1报价并保存
r=f1.get();
executor.execute(()-&gt;save(r));
// 获取电商S2报价并保存
r=f2.get();
executor.execute(()-&gt;save(r));
// 获取电商S3报价并保存
r=f3.get();
executor.execute(()-&gt;save(r));
```
上面的这个方案本身没有太大问题但是有个地方的处理需要你注意那就是如果获取电商S1报价的耗时很长那么即便获取电商S2报价的耗时很短也无法让保存S2报价的操作先执行因为这个主线程都阻塞在了 `f1.get()` 操作上。这点小瑕疵你该如何解决呢?
估计你已经想到了增加一个阻塞队列获取到S1、S2、S3的报价都进入阻塞队列然后在主线程中消费阻塞队列这样就能保证先获取到的报价先保存到数据库了。下面的示例代码展示了如何利用阻塞队列实现先获取到的报价先保存到数据库。
```
// 创建阻塞队列
BlockingQueue&lt;Integer&gt; bq =
new LinkedBlockingQueue&lt;&gt;();
//电商S1报价异步进入阻塞队列
executor.execute(()-&gt;
bq.put(f1.get()));
//电商S2报价异步进入阻塞队列
executor.execute(()-&gt;
bq.put(f2.get()));
//电商S3报价异步进入阻塞队列
executor.execute(()-&gt;
bq.put(f3.get()));
//异步保存所有报价
for (int i=0; i&lt;3; i++) {
Integer r = bq.take();
executor.execute(()-&gt;save(r));
}
```
## 利用CompletionService实现询价系统
不过在实际项目中并不建议你这样做因为Java SDK并发包里已经提供了设计精良的CompletionService。利用CompletionService不但能帮你解决先获取到的报价先保存到数据库的问题而且还能让代码更简练。
CompletionService的实现原理也是内部维护了一个阻塞队列当任务执行结束就把任务的执行结果加入到阻塞队列中不同的是CompletionService是把任务执行结果的Future对象加入到阻塞队列中而上面的示例代码是把任务最终的执行结果放入了阻塞队列中。
**那到底该如何创建CompletionService呢**
CompletionService接口的实现类是ExecutorCompletionService这个实现类的构造方法有两个分别是
1. `ExecutorCompletionService(Executor executor)`
1. `ExecutorCompletionService(Executor executor, BlockingQueue&lt;Future&lt;V&gt;&gt; completionQueue)`
这两个构造方法都需要传入一个线程池如果不指定completionQueue那么默认会使用无界的LinkedBlockingQueue。任务执行结果的Future对象就是加入到completionQueue中。
下面的示例代码完整地展示了如何利用CompletionService来实现高性能的询价系统。其中我们没有指定completionQueue因此默认使用无界的LinkedBlockingQueue。之后通过CompletionService接口提供的submit()方法提交了三个询价操作这三个询价操作将会被CompletionService异步执行。最后我们通过CompletionService接口提供的take()方法获取一个Future对象前面我们提到过加入到阻塞队列中的是任务执行结果的Future对象调用Future对象的get()方法就能返回询价操作的执行结果了。
```
// 创建线程池
ExecutorService executor =
Executors.newFixedThreadPool(3);
// 创建CompletionService
CompletionService&lt;Integer&gt; cs = new
ExecutorCompletionService&lt;&gt;(executor);
// 异步向电商S1询价
cs.submit(()-&gt;getPriceByS1());
// 异步向电商S2询价
cs.submit(()-&gt;getPriceByS2());
// 异步向电商S3询价
cs.submit(()-&gt;getPriceByS3());
// 将询价结果异步保存到数据库
for (int i=0; i&lt;3; i++) {
Integer r = cs.take().get();
executor.execute(()-&gt;save(r));
}
```
## CompletionService接口说明
下面我们详细地介绍一下CompletionService接口提供的方法CompletionService接口提供的方法有5个这5个方法的方法签名如下所示。
其中submit()相关的方法有两个。一个方法参数是`Callable&lt;V&gt; task`前面利用CompletionService实现询价系统的示例代码中我们提交任务就是用的它。另外一个方法有两个参数分别是`Runnable task``V result`这个方法类似于ThreadPoolExecutor的 `&lt;T&gt; Future&lt;T&gt; submit(Runnable task, T result)` ,这个方法在[《23 | Future如何用多线程实现最优的“烧水泡茶”程序](https://time.geekbang.org/column/article/91292)中我们已详细介绍过,这里不再赘述。
CompletionService接口其余的3个方法都是和阻塞队列相关的take()、poll()都是从阻塞队列中获取并移除一个元素;它们的区别在于如果阻塞队列是空的,那么调用 take() 方法的线程会被阻塞,而 poll() 方法会返回 null 值。 `poll(long timeout, TimeUnit unit)` 方法支持以超时的方式获取并移除阻塞队列头部的一个元素,如果等待了 timeout unit时间阻塞队列还是空的那么该方法会返回 null 值。
```
Future&lt;V&gt; submit(Callable&lt;V&gt; task);
Future&lt;V&gt; submit(Runnable task, V result);
Future&lt;V&gt; take()
throws InterruptedException;
Future&lt;V&gt; poll();
Future&lt;V&gt; poll(long timeout, TimeUnit unit)
throws InterruptedException;
```
## 利用CompletionService实现Dubbo中的Forking Cluster
Dubbo中有一种叫做**Forking的集群模式**,这种集群模式下,支持**并行地调用多个查询服务,只要有一个成功返回结果,整个服务就可以返回了**。例如你需要提供一个地址转坐标的服务为了保证该服务的高可用和性能你可以并行地调用3个地图服务商的API然后只要有1个正确返回了结果r那么地址转坐标这个服务就可以直接返回r了。这种集群模式可以容忍2个地图服务商服务异常但缺点是消耗的资源偏多。
```
geocoder(addr) {
//并行执行以下3个查询服务
r1=geocoderByS1(addr);
r2=geocoderByS2(addr);
r3=geocoderByS3(addr);
//只要r1,r2,r3有一个返回
//则返回
return r1|r2|r3;
}
```
利用CompletionService可以快速实现 Forking 这种集群模式比如下面的示例代码就展示了具体是如何实现的。首先我们创建了一个线程池executor 、一个CompletionService对象cs和一个`Future&lt;Integer&gt;`类型的列表 futures每次通过调用CompletionService的submit()方法提交一个异步任务会返回一个Future对象我们把这些Future对象保存在列表futures中。通过调用 `cs.take().get()`,我们能够拿到最快返回的任务执行结果,只要我们拿到一个正确返回的结果,就可以取消所有任务并且返回最终结果了。
```
// 创建线程池
ExecutorService executor =
Executors.newFixedThreadPool(3);
// 创建CompletionService
CompletionService&lt;Integer&gt; cs =
new ExecutorCompletionService&lt;&gt;(executor);
// 用于保存Future对象
List&lt;Future&lt;Integer&gt;&gt; futures =
new ArrayList&lt;&gt;(3);
//提交异步任务并保存future到futures
futures.add(
cs.submit(()-&gt;geocoderByS1()));
futures.add(
cs.submit(()-&gt;geocoderByS2()));
futures.add(
cs.submit(()-&gt;geocoderByS3()));
// 获取最快返回的任务执行结果
Integer r = 0;
try {
// 只要有一个成功返回则break
for (int i = 0; i &lt; 3; ++i) {
r = cs.take().get();
//简单地通过判空来检查是否成功返回
if (r != null) {
break;
}
}
} finally {
//取消所有任务
for(Future&lt;Integer&gt; f : futures)
f.cancel(true);
}
// 返回结果
return r;
```
## 总结
当需要批量提交异步任务的时候建议你使用CompletionService。CompletionService将线程池Executor和阻塞队列BlockingQueue的功能融合在了一起能够让批量异步任务的管理更简单。除此之外CompletionService能够让异步任务的执行结果有序化先执行完的先进入阻塞队列利用这个特性你可以轻松实现后续处理的有序性避免无谓的等待同时还可以快速实现诸如Forking Cluster这样的需求。
CompletionService的实现类ExecutorCompletionService需要你自己创建线程池虽看上去有些啰嗦但好处是你可以让多个ExecutorCompletionService的线程池隔离这种隔离性能避免几个特别耗时的任务拖垮整个应用的风险。
## 课后思考
本章使用CompletionService实现了一个询价应用的核心功能后来又有了新的需求需要计算出最低报价并返回下面的示例代码尝试实现这个需求你看看是否存在问题呢
```
// 创建线程池
ExecutorService executor =
Executors.newFixedThreadPool(3);
// 创建CompletionService
CompletionService&lt;Integer&gt; cs = new
ExecutorCompletionService&lt;&gt;(executor);
// 异步向电商S1询价
cs.submit(()-&gt;getPriceByS1());
// 异步向电商S2询价
cs.submit(()-&gt;getPriceByS2());
// 异步向电商S3询价
cs.submit(()-&gt;getPriceByS3());
// 将询价结果异步保存到数据库
// 并计算最低报价
AtomicReference&lt;Integer&gt; m =
new AtomicReference&lt;&gt;(Integer.MAX_VALUE);
for (int i=0; i&lt;3; i++) {
executor.execute(()-&gt;{
Integer r = null;
try {
r = cs.take().get();
} catch (Exception e) {}
save(r);
m.set(Integer.min(m.get(), r));
});
}
return m;
```
欢迎在留言区与我分享你的想法,也欢迎你在留言区记录你的思考过程。感谢阅读,如果你觉得这篇文章对你有帮助的话,也欢迎把它分享给更多的朋友。

View File

@@ -0,0 +1,188 @@
<audio id="audio" title="26 | Fork/Join单机版的MapReduce" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/2f/1a/2f97aabc0e5a4ae088472a22626b121a.mp3"></audio>
前面几篇文章我们介绍了线程池、Future、CompletableFuture和CompletionService仔细观察你会发现这些工具类都是在帮助我们站在任务的视角来解决并发问题而不是让我们纠缠在线程之间如何协作的细节上比如线程之间如何实现等待、通知等。**对于简单的并行任务,你可以通过“线程池+Future”的方案来解决如果任务之间有聚合关系无论是AND聚合还是OR聚合都可以通过CompletableFuture来解决而批量的并行任务则可以通过CompletionService来解决。**
我们一直讲并发编程可以分为三个层面的问题分别是分工、协作和互斥当你关注于任务的时候你会发现你的视角已经从并发编程的细节中跳出来了你应用的更多的是现实世界的思维模式类比的往往是现实世界里的分工所以我把线程池、Future、CompletableFuture和CompletionService都列到了分工里面。
下面我用现实世界里的工作流程图描述了并发编程领域的简单并行任务、聚合任务和批量并行任务,辅以这些流程图,相信你一定能将你的思维模式转换到现实世界里来。
<img src="https://static001.geekbang.org/resource/image/47/2d/47f3e1e8834c99d9a1933fb496ffde2d.png" alt="">
上面提到的简单并行、聚合、批量并行这三种任务模型,基本上能够覆盖日常工作中的并发场景了,但还是不够全面,因为还有一种“分治”的任务模型没有覆盖到。**分治**,顾名思义,即分而治之,是一种解决复杂问题的思维方法和模式;具体来讲,指的是**把一个复杂的问题分解成多个相似的子问题,然后再把子问题分解成更小的子问题,直到子问题简单到可以直接求解**。理论上来讲,解决每一个问题都对应着一个任务,所以对于问题的分治,实际上就是对于任务的分治。
分治思想在很多领域都有广泛的应用例如算法领域有分治算法归并排序、快速排序都属于分治算法二分法查找也是一种分治算法大数据领域知名的计算框架MapReduce背后的思想也是分治。既然分治这种任务模型如此普遍那Java显然也需要支持Java并发包里提供了一种叫做Fork/Join的并行计算框架就是用来支持分治这种任务模型的。
## 分治任务模型
这里你需要先深入了解一下分治任务模型,分治任务模型可分为两个阶段:一个阶段是**任务分解**,也就是将任务迭代地分解为子任务,直至子任务可以直接计算出结果;另一个阶段是**结果合并**,即逐层合并子任务的执行结果,直至获得最终结果。下图是一个简化的分治任务模型图,你可以对照着理解。
<img src="https://static001.geekbang.org/resource/image/d2/6a/d2649d8db8e5642703aa5563d76eb86a.png" alt="">
在这个分治任务模型里,任务和分解后的子任务具有相似性,这种相似性往往体现在任务和子任务的算法是相同的,但是计算的数据规模是不同的。具备这种相似性的问题,我们往往都采用递归算法。
## Fork/Join的使用
Fork/Join是一个并行计算的框架主要就是用来支持分治任务模型的这个计算框架里的**Fork对应的是分治任务模型里的任务分解Join对应的是结果合并**。Fork/Join计算框架主要包含两部分一部分是**分治任务的线程池ForkJoinPool**,另一部分是**分治任务ForkJoinTask**。这两部分的关系类似于ThreadPoolExecutor和Runnable的关系都可以理解为提交任务到线程池只不过分治任务有自己独特类型ForkJoinTask。
ForkJoinTask是一个抽象类它的方法有很多最核心的是fork()方法和join()方法其中fork()方法会异步地执行一个子任务而join()方法则会阻塞当前线程来等待子任务的执行结果。ForkJoinTask有两个子类——RecursiveAction和RecursiveTask通过名字你就应该能知道它们都是用递归的方式来处理分治任务的。这两个子类都定义了抽象方法compute()不过区别是RecursiveAction定义的compute()没有返回值而RecursiveTask定义的compute()方法是有返回值的。这两个子类也是抽象类,在使用的时候,需要你定义子类去扩展。
接下来我们就来实现一下看看如何用Fork/Join这个并行计算框架计算斐波那契数列下面的代码源自Java官方示例。首先我们需要创建一个分治任务线程池以及计算斐波那契数列的分治任务之后通过调用分治任务线程池的 invoke() 方法来启动分治任务。由于计算斐波那契数列需要有返回值所以Fibonacci 继承自RecursiveTask。分治任务Fibonacci 需要实现compute()方法,这个方法里面的逻辑和普通计算斐波那契数列非常类似,区别之处在于计算 `Fibonacci(n - 1)` 使用了异步子任务,这是通过 `f1.fork()` 这条语句实现的。
```
static void main(String[] args){
//创建分治任务线程池
ForkJoinPool fjp =
new ForkJoinPool(4);
//创建分治任务
Fibonacci fib =
new Fibonacci(30);
//启动分治任务
Integer result =
fjp.invoke(fib);
//输出结果
System.out.println(result);
}
//递归任务
static class Fibonacci extends
RecursiveTask&lt;Integer&gt;{
final int n;
Fibonacci(int n){this.n = n;}
protected Integer compute(){
if (n &lt;= 1)
return n;
Fibonacci f1 =
new Fibonacci(n - 1);
//创建子任务
f1.fork();
Fibonacci f2 =
new Fibonacci(n - 2);
//等待子任务结果,并合并结果
return f2.compute() + f1.join();
}
}
```
## ForkJoinPool工作原理
Fork/Join并行计算的核心组件是ForkJoinPool所以下面我们就来简单介绍一下ForkJoinPool的工作原理。
通过专栏前面文章的学习你应该已经知道ThreadPoolExecutor本质上是一个生产者-消费者模式的实现内部有一个任务队列这个任务队列是生产者和消费者通信的媒介ThreadPoolExecutor可以有多个工作线程但是这些工作线程都共享一个任务队列。
ForkJoinPool本质上也是一个生产者-消费者的实现但是更加智能你可以参考下面的ForkJoinPool工作原理图来理解其原理。ThreadPoolExecutor内部只有一个任务队列而ForkJoinPool内部有多个任务队列当我们通过ForkJoinPool的invoke()或者submit()方法提交任务时ForkJoinPool根据一定的路由规则把任务提交到一个任务队列中如果任务在执行过程中会创建出子任务那么子任务会提交到工作线程对应的任务队列中。
如果工作线程对应的任务队列空了是不是就没活儿干了呢不是的ForkJoinPool支持一种叫做“**任务窃取**”的机制如果工作线程空闲了那它可以“窃取”其他工作任务队列里的任务例如下图中线程T2对应的任务队列已经空了它可以“窃取”线程T1对应的任务队列的任务。如此一来所有的工作线程都不会闲下来了。
ForkJoinPool中的任务队列采用的是双端队列工作线程正常获取任务和“窃取任务”分别是从任务队列不同的端消费这样能避免很多不必要的数据竞争。我们这里介绍的仅仅是简化后的原理ForkJoinPool的实现远比我们这里介绍的复杂如果你感兴趣建议去看它的源码。
<img src="https://static001.geekbang.org/resource/image/e7/31/e75988bd5a79652d8325ca63fcd55131.png" alt="">
## 模拟MapReduce统计单词数量
学习MapReduce有一个入门程序统计一个文件里面每个单词的数量下面我们来看看如何用Fork/Join并行计算框架来实现。
我们可以先用二分法递归地将一个文件拆分成更小的文件,直到文件里只有一行数据,然后统计这一行数据里单词的数量,最后再逐级汇总结果,你可以对照前面的简版分治任务模型图来理解这个过程。
思路有了,我们马上来实现。下面的示例程序用一个字符串数组 `String[] fc` 来模拟文件内容fc里面的元素与文件里面的行数据一一对应。关键的代码在 `compute()` 这个方法里面这是一个递归方法前半部分数据fork一个递归任务去处理关键代码mr1.fork()后半部分数据则在当前任务中递归处理mr2.compute())。
```
static void main(String[] args){
String[] fc = {&quot;hello world&quot;,
&quot;hello me&quot;,
&quot;hello fork&quot;,
&quot;hello join&quot;,
&quot;fork join in world&quot;};
//创建ForkJoin线程池
ForkJoinPool fjp =
new ForkJoinPool(3);
//创建任务
MR mr = new MR(
fc, 0, fc.length);
//启动任务
Map&lt;String, Long&gt; result =
fjp.invoke(mr);
//输出结果
result.forEach((k, v)-&gt;
System.out.println(k+&quot;:&quot;+v));
}
//MR模拟类
static class MR extends
RecursiveTask&lt;Map&lt;String, Long&gt;&gt; {
private String[] fc;
private int start, end;
//构造函数
MR(String[] fc, int fr, int to){
this.fc = fc;
this.start = fr;
this.end = to;
}
@Override protected
Map&lt;String, Long&gt; compute(){
if (end - start == 1) {
return calc(fc[start]);
} else {
int mid = (start+end)/2;
MR mr1 = new MR(
fc, start, mid);
mr1.fork();
MR mr2 = new MR(
fc, mid, end);
//计算子任务,并返回合并的结果
return merge(mr2.compute(),
mr1.join());
}
}
//合并结果
private Map&lt;String, Long&gt; merge(
Map&lt;String, Long&gt; r1,
Map&lt;String, Long&gt; r2) {
Map&lt;String, Long&gt; result =
new HashMap&lt;&gt;();
result.putAll(r1);
//合并结果
r2.forEach((k, v) -&gt; {
Long c = result.get(k);
if (c != null)
result.put(k, c+v);
else
result.put(k, v);
});
return result;
}
//统计单词数量
private Map&lt;String, Long&gt;
calc(String line) {
Map&lt;String, Long&gt; result =
new HashMap&lt;&gt;();
//分割单词
String [] words =
line.split(&quot;\\s+&quot;);
//统计单词数量
for (String w : words) {
Long v = result.get(w);
if (v != null)
result.put(w, v+1);
else
result.put(w, 1L);
}
return result;
}
}
```
## 总结
Fork/Join并行计算框架主要解决的是分治任务。分治的核心思想是“分而治之”将一个大的任务拆分成小的子任务去解决然后再把子任务的结果聚合起来从而得到最终结果。这个过程非常类似于大数据处理中的MapReduce所以你可以把Fork/Join看作单机版的MapReduce。
Fork/Join并行计算框架的核心组件是ForkJoinPool。ForkJoinPool支持任务窃取机制能够让所有线程的工作量基本均衡不会出现有的线程很忙而有的线程很闲的状况所以性能很好。Java 1.8提供的Stream API里面并行流也是以ForkJoinPool为基础的。不过需要你注意的是默认情况下所有的并行流计算都共享一个ForkJoinPool这个共享的ForkJoinPool默认的线程数是CPU的核数如果所有的并行流计算都是CPU密集型计算的话完全没有问题但是如果存在I/O密集型的并行流计算那么很可能会因为一个很慢的I/O计算而拖慢整个系统的性能。所以**建议用不同的ForkJoinPool执行不同类型的计算任务**。
如果你对ForkJoinPool详细的实现细节感兴趣也可以参考[Doug Lea的论文](http://gee.cs.oswego.edu/dl/papers/fj.pdf)。
## 课后思考
对于一个CPU密集型计算程序在单核CPU上使用Fork/Join并行计算框架是否能够提高性能呢
欢迎在留言区与我分享你的想法,也欢迎你在留言区记录你的思考过程。感谢阅读,如果你觉得这篇文章对你有帮助的话,也欢迎把它分享给更多的朋友。

View File

@@ -0,0 +1,205 @@
<audio id="audio" title="27 | 并发工具类模块热点问题答疑" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/6a/4e/6a0bcaf7adc8d254862b78395270bf4e.mp3"></audio>
前面我们用13篇文章的内容介绍了Java SDK提供的并发工具类这些工具类都是久经考验的所以学好用好它们对于解决并发问题非常重要。我们在介绍这些工具类的时候重点介绍了这些工具类的产生背景、应用场景以及实现原理目的就是让你在面对并发问题的时候有思路有办法。只有思路、办法有了才谈得上开始动手解决问题。
当然了,只有思路和办法还不足以把问题解决,最终还是要动手实践的,我觉得在实践中有两方面的问题需要重点关注:**细节问题与最佳实践**。千里之堤毁于蚁穴,细节虽然不能保证成功,但是可以导致失败,所以我们一直都强调要关注细节。而最佳实践是前人的经验总结,可以帮助我们不要阴沟里翻船,所以没有十足的理由,一定要遵守。
为了让你学完即学即用我在每篇文章的最后都给你留了道思考题。这13篇文章的13个思考题基本上都是相关工具类在使用中需要特别注意的一些细节问题工作中容易碰到且费神费力所以咱们今天就来一一分析。
## 1. while(true) 总不让人省心
[《14 | Lock&amp;Condition隐藏在并发包中的管程》](https://time.geekbang.org/column/article/87779)的思考题,本意是通过破坏不可抢占条件来避免死锁问题,但是它的实现中有一个致命的问题,那就是: while(true) 没有break条件从而导致了死循环。除此之外这个实现虽然不存在死锁问题但还是存在活锁问题的解决活锁问题很简单只需要随机等待一小段时间就可以了。
修复后的代码如下所示我仅仅修改了两个地方一处是转账成功之后break另一处是在while循环体结束前增加了`Thread.sleep(随机时间)`
```
class Account {
private int balance;
private final Lock lock
= new ReentrantLock();
// 转账
void transfer(Account tar, int amt){
while (true) {
if(this.lock.tryLock()) {
try {
if (tar.lock.tryLock()) {
try {
this.balance -= amt;
tar.balance += amt;
//新增:退出循环
break;
} finally {
tar.lock.unlock();
}
}//if
} finally {
this.lock.unlock();
}
}//if
//新增sleep一个随机时间避免活锁
Thread.sleep(随机时间);
}//while
}//transfer
}
```
这个思考题里面的while(true)问题还是比较容易看出来的,**但不是所有的while(true)问题都这么显而易见的**,很多都隐藏得比较深。
例如,[《21 | 原子类:无锁工具类的典范》](https://time.geekbang.org/column/article/90515)的思考题本质上也是一个while(true),不过它隐藏得就比较深了。看上去 `while(!rf.compareAndSet(or, nr))` 是有终止条件的而且跑单线程测试一直都没有问题。实际上却存在严重的并发问题问题就出在对or的赋值在while循环之外这样每次循环or的值都不会发生变化所以一旦有一次循环rf.compareAndSet(or, nr)的值等于false那之后无论循环多少次都会等于false。也就是说在特定场景下变成了while(true)问题。既然找到了原因修改就很简单了只要把对or的赋值移到while循环之内就可以了修改后的代码如下所示
```
public class SafeWM {
class WMRange{
final int upper;
final int lower;
WMRange(int upper,int lower){
//省略构造函数实现
}
}
final AtomicReference&lt;WMRange&gt;
rf = new AtomicReference&lt;&gt;(
new WMRange(0,0)
);
// 设置库存上限
void setUpper(int v){
WMRange nr;
WMRange or;
//原代码在这里
//WMRange or=rf.get();
do{
//移动到此处
//每个回合都需要重新获取旧值
or = rf.get();
// 检查参数合法性
if(v &lt; or.lower){
throw new IllegalArgumentException();
}
nr = new
WMRange(v, or.lower);
}while(!rf.compareAndSet(or, nr));
}
}
```
## 2. signalAll() 总让人省心
[《15 | Lock&amp;ConditionDubbo如何用管程实现异步转同步](https://time.geekbang.org/column/article/88487)的思考题是关于signal()和signalAll()的Dubbo最近已经把signal()改成signalAll()了我觉得用signal()也不能说错,但的确是**用signalAll()会更安全**。我个人也倾向于使用signalAll()因为我们写程序不是做数学题而是在搞工程工程中会有很多不稳定的因素更有很多你预料不到的情况发生所以不要让你的代码铤而走险尽量使用更稳妥的方案和设计。Dubbo修改后的相关代码如下所示
```
// RPC结果返回时调用该方法
private void doReceived(Response res) {
lock.lock();
try {
response = res;
done.signalAll();
} finally {
lock.unlock();
}
}
```
## 3. Semaphore需要锁中锁
[《16 | Semaphore如何快速实现一个限流器](https://time.geekbang.org/column/article/88499)的思考题是对象池的例子中Vector能否换成ArrayList答案是不可以的。Semaphore可以允许多个线程访问一个临界区那就意味着可能存在多个线程同时访问ArrayList而ArrayList不是线程安全的所以对象池的例子中是不能够将Vector换成ArrayList的。**Semaphore允许多个线程访问一个临界区这也是一把双刃剑**,当多个线程进入临界区时,如果需要访问共享变量就会存在并发问题,所以必须**加锁**也就是说Semaphore需要锁中锁。
## 4. 锁的申请和释放要成对出现
[《18 | StampedLock有没有比读写锁更快的锁](https://time.geekbang.org/column/article/89456)思考题的Bug出在没有正确地释放锁。锁的申请和释放要成对出现对此我们有一个最佳实践就是使用**try{}finally{}**但是try{}finally{}并不能解决所有锁的释放问题。比如示例代码中锁的升级会生成新的stamp 而finally中释放锁用的是锁升级前的stamp本质上这也属于锁的申请和释放没有成对出现只是它隐藏得有点深。解决这个问题倒也很简单只需要对stamp 重新赋值就可以了,修复后的代码如下所示:
```
private double x, y;
final StampedLock sl = new StampedLock();
// 存在问题的方法
void moveIfAtOrigin(double newX, double newY){
long stamp = sl.readLock();
try {
while(x == 0.0 &amp;&amp; y == 0.0){
long ws = sl.tryConvertToWriteLock(stamp);
if (ws != 0L) {
//问题出在没有对stamp重新赋值
//新增下面一行
stamp = ws;
x = newX;
y = newY;
break;
} else {
sl.unlockRead(stamp);
stamp = sl.writeLock();
}
}
} finally {
//此处unlock的是stamp
sl.unlock(stamp);
}
```
## 5. 回调总要关心执行线程是谁
[《19 | CountDownLatch和CyclicBarrier如何让多线程步调一致](https://time.geekbang.org/column/article/89461)的思考题是CyclicBarrier的回调函数使用了一个固定大小为1的线程池是否合理我觉得是合理的可以从以下两个方面来分析。
第一个是线程池大小是1只有1个线程主要原因是check()方法的耗时比getPOrders()和getDOrders()都要短,所以没必要用多个线程,同时单线程能保证访问的数据不存在并发问题。
第二个是使用了线程池如果不使用直接在回调函数里调用check()方法是否可以呢绝对不可以。为什么呢这个要分析一下回调函数和唤醒等待线程之间的关系。下面是CyclicBarrier相关的源码通过源码你会发现CyclicBarrier是同步调用回调函数之后才唤醒等待的线程如果我们在回调函数里直接调用check()方法那就意味着在执行check()的时候是不能同时执行getPOrders()和getDOrders()的,这样就起不到提升性能的作用。
```
try {
//barrierCommand是回调函数
final Runnable command = barrierCommand;
//调用回调函数
if (command != null)
command.run();
ranAction = true;
//唤醒等待的线程
nextGeneration();
return 0;
} finally {
if (!ranAction)
breakBarrier();
}
```
所以当遇到回调函数的时候你应该本能地问自己执行回调函数的线程是哪一个这个在多线程场景下非常重要。因为不同线程ThreadLocal里的数据是不同的有些框架比如Spring就用ThreadLocal来管理事务如果不清楚回调函数用的是哪个线程很可能会导致错误的事务管理并最终导致数据不一致。
CyclicBarrier的回调函数究竟是哪个线程执行的呢如果你分析源码你会发现执行回调函数的线程是将CyclicBarrier内部计数器减到 0 的那个线程。所以我们前面讲执行check()的时候是不能同时执行getPOrders()和getDOrders()因为执行这两个方法的线程一个在等待一个正在忙着执行check()。
再次强调一下:**当看到回调函数的时候,一定问一问执行回调函数的线程是谁**。
## 6. 共享线程池:有福同享就要有难同当
[《24 | CompletableFuture异步编程没那么难》](https://time.geekbang.org/column/article/91569)的思考题是下列代码是否有问题。很多同学都发现这段代码的问题了例如没有异常处理、逻辑不严谨等等不过我更想让你关注的是findRuleByJdbc()这个方法隐藏着一个阻塞式I/O这意味着会阻塞调用线程。默认情况下所有的CompletableFuture共享一个ForkJoinPool当有阻塞式I/O时可能导致所有的ForkJoinPool线程都阻塞进而影响整个系统的性能。
```
//采购订单
PurchersOrder po;
CompletableFuture&lt;Boolean&gt; cf =
CompletableFuture.supplyAsync(()-&gt;{
//在数据库中查询规则
return findRuleByJdbc();
}).thenApply(r -&gt; {
//规则校验
return check(po, r);
});
Boolean isOk = cf.join();
```
利用共享,往往能让我们快速实现功能,所谓是有福同享,但是代价就是有难要同当。在强调高可用的今天,大多数人更倾向于使用隔离的方案。
## 7. 线上问题定位的利器线程栈dump
[《17 | ReadWriteLock如何快速实现一个完备的缓存](https://time.geekbang.org/column/article/88909)和[《20 | 并发容器:都有哪些“坑”需要我们填?》](https://time.geekbang.org/column/article/90201)的思考题,本质上都是定位线上并发问题,方案很简单,就是通过查看线程栈来定位问题。重点是查看线程状态,分析线程进入该状态的原因是否合理,你可以参考[《09 | Java线程Java线程的生命周期》](https://time.geekbang.org/column/article/86366)来加深理解。
为了便于分析定位线程问题你需要给线程赋予一个有意义的名字对于线程池可以通过自定义ThreadFactory来给线程池中的线程赋予有意义的名字也可以在执行run()方法时通过`Thread.currentThread().setName();`来给线程赋予一个更贴近业务的名字。
## 总结
Java并发工具类到今天为止就告一段落了由于篇幅原因不能每个工具类都详细介绍。Java并发工具类内容繁杂熟练使用是需要一个过程的而且需要多加实践。希望你学完这个模块之后遇到并发问题时最起码能知道用哪些工具可以解决。至于工具使用的细节和最佳实践我总结的也只是我认为重要的。由于每个人的思维方式和编码习惯不同也许我认为不重要的恰恰是你的短板所以这部分内容更多地还是需要你去实践在实践中养成良好的编码习惯不断纠正错误的思维方式。
欢迎在留言区与我分享你的想法,也欢迎你在留言区记录你的思考过程。感谢阅读,如果你觉得这篇文章对你有帮助的话,也欢迎把它分享给更多的朋友。