Lock和Condition(上):隐藏在并发包中的管程
Java SDK 并发包通过 Lock 和 Condition 两个接口来实现管程,其中 Lock 用于解决互斥问题,Condition 用于解决同步问题
Java 语言本身提供的 synchronized 也是管程的一种实现
再造管程的理由
JDK 1.5 -> JDK 1.6 的性能问题 ×
发生死锁的四个条件:
互斥,共享资源 X 和 Y 只能被一个线程占用;
占有且等待,线程 T1 已经取得共享资源 X,在等待共享资源 Y 的时候,不释放共享资源 X;
不可抢占,其他线程不能强行抢占线程 T1 占有的资源;
循环等待,线程 T1 等待线程 T2 占有的资源,线程 T2 等待线程 T1 占有的资源,就是循环等待。
死锁问题, 破坏不可抢占条件
方案, 占用部分资源的线程进一步申请其他资源时,如果申请不到,可以主动释放它占有的资源
但是synchronized 没有办法解决. synchronized 申请资源的时候,如果申请不到,线程直接进入阻塞状态了
,而线程进入阻塞状态,啥都干不了,也释放不了线程已经占有的资源
重新设计一把互斥锁的方案:
- 能够响应中断。synchronized 的问题是,持有锁 A 后,如果尝试获取锁 B 失败,那么线程就进入阻塞状态,一旦发生死锁,就没有任何机会来唤醒阻塞的线程。但如果
阻塞状态的线程能够响应中断信号
,也就是说当我们给阻塞的线程发送中断信号
的时候,能够唤醒它,那它就有机会释放曾经持有的锁 A。这样就破坏了不可抢占条件了 - 支持超时。如果线程在一段时间之内没有获取到锁,
不是进入阻塞状态,而是返回一个错误
,那这个线程也有机会释放曾经持有的锁。这样也能破坏不可抢占条件 - 非阻塞地获取锁。如果尝试获取锁失败,
并不进入阻塞状态,而是直接返回
,那这个线程也有机会释放曾经持有的锁。这样也能破坏不可抢占条件
Lock接口的三个方法:
// 支持中断的API
void lockInterruptibly() throws InterruptedException;
// 支持超时的API
boolean tryLock(long time, TimeUnit unit) throws InterruptedException;
// 支持非阻塞获取锁的API
boolean tryLock();
如何保证可见性
-
Java 里多线程的可见性是通过 Happens-Before 规则保证的,
-
而 synchronized 之所以能够保证可见性,也是因为有一条 synchronized 相关的规则:
synchronized 的解锁 Happens-Before 于后续对这个锁的加锁 -
Java SDK 里面
Lock
保证可见性:
利用了 volatile 相关的 Happens-Before 规则
Java SDK 里面的 ReentrantLock,内部持有一个 volatile 的成员变量 state,获取锁的时候,会读写 state 的值;解锁的时候,也会读写 state 的值
class SampleLock {
volatile int state;
// 加锁
lock() {
// 省略代码无数
state = 1;
}
// 解锁
unlock() {
// 省略代码无数
state = 0;
}
}
Java SDK 里面 Lock 的使用,有一个经典的范例,就是try{}finally{},需要重点关注的是`在 finally 里面释放锁`。
class X {
private final Lock rtl = new ReentrantLock();
int value;
public void addOne() {
// 获取锁
rtl.lock();
try {
value+=1;
} finally {
// 保证锁能释放
rtl.unlock();
}
}
}
上面的代码, 在执行 value+=1 之前,程序先读写了一次 volatile 变量 state,在执行 value+=1 之后,又读写了一次 volatile 变量 state。
根据相关的 Happens-Before 规则:
- 顺序性规则:对于线程 T1,value+=1 Happens-Before 释放锁的操作 unlock();
- volatile 变量规则:由于 state = 1 (加锁后)会先读取 state,所以线程 T1 的 unlock() 操作 Happens-Before 线程 T2 的 lock() 操作;
- 传递性规则:线程 T1 的 value+=1 Happens-Before 线程 T2 的 lock() 操作。
所以说,后续线程 T2 能够看到 value 的正确结果。
Happens-Before 规则: 前面一个操作的结果对后续操作是可见的, 约束了编译器的优化行为
1.程序的顺序性规则
前面的操作 Happens-Before 于后续的任意操作
2.volatile 变量规则
对一个 volatile 变量的写操作, Happens-Before 于后续对这个 volatile 变量的读操作
3.传递性
如果 A Happens-Before B,且 B Happens-Before C,那么 A Happens-Before C
4.管程synchronized中锁的规则
对一个锁的解锁 Happens-Before 于后续对这个锁的加锁
5.线程 start() 规则
如果线程 A 调用线程 B 的 start() 方法(即在线程 A 中启动线程 B),
那么该 start() 操作 Happens-Before 于线程 B 中的任意操作
6.线程 join() 规则
如果在线程 A 中,调用线程 B 的 join() 并成功返回,那么线程 B 中的任意操作 Happens-Before 于该 join() 操作的返回
可重入锁
ReentrantLock, 可重入锁,线程可以重复获取同一把锁
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();
}
}
}
当线程 T1 执行到 ① 处时,已经获取到了锁 rtl ,当在 ① 处调用 get() 方法时,会在 ② 再次对锁 rtl 执行加锁操作。
此时,如果锁 rtl 是可重入的,那么线程 T1 可以再次加锁成功;
如果锁 rtl 是不可重入的,那么线程 T1 此时会被阻塞。
可重入函数,指的是多个线程可以同时调用该函数, 每个线程都能得到正确结果
同时在一个线程内支持线程切换,无论被切换多少次,结果都是正确的。
多线程可以同时执行,还支持线程切换,–> 线程安全
所以,可重入函数是线程安全的。
公平锁与非公平锁
ReentrantLock 的两个构造函数
//无参构造函数:默认非公平锁
public ReentrantLock() {
sync = new NonfairSync();
}
//根据公平策略参数创建锁
public ReentrantLock(boolean fair){
sync = fair ? new FairSync() : new NonfairSync();
}
`入口等待队列`,锁都对应着一个等待队列,如果一个线程没有获得锁,就会进入等待队列,
当有线程释放锁的时候,就需要从等待队列中唤醒一个等待的线程。
- 如果是公平锁,唤醒的策略就是谁`等待的时间`长,就唤醒谁,很公平; --- 等待时间
- 如果是非公平锁,则不提供这个公平保证,有可能等待时间短的线程反而先被唤醒。
锁の实践
Java 并发编程:设计原则与模式:
- 永远只在
更新对象的成员变量
时加锁 - 永远只在
访问可变的成员变量
时加锁 - 永远
不在调用其他对象的方法
时加锁
不安全, 也许“其他”方法里面有线程 sleep() 的调用,也可能会有奇慢无比的 I/O 操作,这些都会严重影响性能
更可怕的是,“其他”类的方法可能也会加锁,然后双重加锁就可能导致死锁
Summary
Java SDK 并发包里的 Lock 接口
除了支持类似 synchronized 隐式加锁的 lock() 方法外,还支持超时、非阻塞、可中断
的方式获取锁,这三种方式为我们编写更加安全、健壮的并发程序提供了很大的便利
用锁的其他注意事项:
减少锁的持有时间、减小锁的粒度
思考
是否存在死锁?
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
}
- 互斥,共享资源 X 和 Y 只能被一个线程占用;
- 占有且等待,线程 T1 已经取得共享资源 X,在等待共享资源 Y 的时候,不释放共享资源 X;
- 不可抢占,其他线程不能强行抢占线程 T1 占有的资源;
- 循环等待,线程 T1 等待线程 T2 占有的资源,线程 T2 等待线程 T1 占有的资源,就是循环等待。
- 对于“占用且等待”这个条件,我们可以一次性申请所有的资源,这样就不存在等待了。
- 对于“不可抢占”这个条件,占用部分资源的线程进一步申请其他资源时,
如果申请不到,可以主动释放它占有的资源,这样不可抢占这个条件就破坏掉了。
- 对于“循环等待”这个条件,可以靠按序申请资源来预防。所谓按序申请,是指资源是有线性顺序的,
申请的时候可以先申请资源序号小的,再申请资源序号大的,这样线性化后自然就不存在循环了
有可能活锁,A,B两账户相互转账,各自持有自己lock的锁,都一直在尝试获取对方的锁,形成了活锁
成功转账后应该跳出循环。加个随机重试时间避免活锁
官方解析
本意是通过破坏不可抢占条件来避免死锁问题,但是它的实现中有一个致命的问题,那就是:
while(true) 没有 break 条件,从而导致了死循环
除此之外,这个实现虽然不存在死锁问题,但还是存在活锁问题的,
解决活锁问题很简单,只需要随机等待一小段时间就可以了
class Account {
private final Lock lock = new ReentrantLock();
private int balance;
// 转账
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
}
Lock和Condition(下):Dubbo如何用管程实现异步转同步?
Lock 有别于 synchronized 隐式锁的三个特性:
- 能够响应中断
- 支持超时
- 非阻塞地获取锁
Java 语言内置的管程里只有一个条件变量
Lock&Condition 实现的管程是支持多个条件变量
利用两个条件变量实现阻塞队列
public class BlockedQueue<T>{
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(); // signal()使用条件: 1,相同的等待条件 2,唤醒后执行相同的操作 3,只需要唤醒一个线程
}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&Condition 实现的管程里只能使用前面的 await()、signal()、signalAll(),
而后面的 wait()、notify()、notifyAll() 只有在 synchronized 实现的管程里才能使用
使用错误, 程序嗝屁
同步与异步
通俗讲: 调用方是否需要等待结果,如果需要等待结果,就是同步; 如果不需要等待结果,就是异步。
同步,是 Java 代码默认的处理方式。
如果你想让你的程序支持异步,可以通过下面两种方式来实现:
- 调用方创建一个子线程,在子线程中执行方法调用,这种调用我们称为
异步调用
; - 方法实现的时候,创建一个新的线程执行主要逻辑,主线程直接 return,这种方法一般称为
异步方法
。
Dubbo 源码分析
异步的场景很多的,比如TCP 协议本身就是异步的
工作中经常用到的 RPC 调用,在 TCP 协议层面,发送完 RPC 请求后,线程是不会等待 RPC 的响应结果的
目前知名的 RPC 框架 Dubbo 就给我们做了异步转同步的事情
DemoService service = 初始化部分省略
String message = service.sayHello("dubbo");
System.out.println(message);
// 默认情况下 sayHello() 方法,是个同步方法,
// 也就是说,执行 service.sayHello(“dubbo”) 的时候,线程会停下来等结果
将调用线程 dump
调用线程阻塞了,线程状态是 TIMED_WAITING
本来发送请求是异步的,但是调用线程却阻塞了
,说明 Dubbo 帮我们做了异步转同步
的事情
通过调用栈,你能看到线程是阻塞在 DefaultFuture.get() 方法上,
所以可以推断:Dubbo 异步转同步的功能应该是通过 DefaultFuture 这个类实现的
调用栈信息:
DubboInvoker 的 108 行调用了 DefaultFuture.get()
这一行先调用了 request(inv, timeout) 方法,这个方法其实就是发送 RPC 请求,
之后通过调用 get() 方法等待 RPC 返回结果
public class DubboInvoker{
Result doInvoke(Invocation inv){
// 下面这行就是源码中108行
// 为了便于展示,做了修改
return currentClient
.request(inv, timeout)
.get();
}
}
需求:
1. 当 RPC 返回结果之前,阻塞调用线程,让调用线程等待;
2. 当 RPC 返回结果后,唤醒调用线程,让调用线程重新执行。 -- 经典的等待 - 通知机制
DefaultFuture 类
// 创建锁与条件变量
private final Lock lock = new ReentrantLock();
private final Condition done = lock.newCondition();
调用线程通过调用 get() 方法等待 RPC 返回结果,这个方法里面
调用 lock() 获取锁,在 finally 里面调用 unlock() 释放锁;
获取锁后,通过经典的在循环中调用 await() 方法来实现等待。
// 调用方通过该方法等待结果
Object get(int timeout){
long start = System.nanoTime();
lock.lock();
try {
while (!isDone()) {
done.await(timeout); // 范式: while(条件不满足), 条件.wait()
long cur=System.nanoTime();
if (isDone() || cur-start > timeout){
break;
}
}
} finally {
lock.unlock();
}
if (!isDone()) {
throw new TimeoutException();
}
return returnFromResponse();
}
// RPC结果是否已经返回
boolean isDone() {
return response != null;
}
当 RPC 结果返回时,会调用 doReceived() 方法,
这个方法里面,调用 lock() 获取锁,在 finally 里面调用 unlock() 释放锁,
获取锁后通过调用 signal() 来通知调用线程,结果已经返回,不用继续等待了
// RPC结果返回时调用该方法
private void doReceived(Response res) {
lock.lock();
try {
response = res;
// ------ 注意本节思考题
if (done != null) {
done.signal(); // signal()使用条件: 1,相同的等待条件 2,唤醒后执行相同的操作 3,只需要唤醒一个线程
}
} finally {
lock.unlock();
}
}
summary
《Java 并发编程的艺术》一书的第 5 章《Java 中的锁》
dubbo源码: 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(),你来分析一下,这样做是否合理呢?
官方解析
-- 用 signalAll() 会更安全 --
// RPC结果返回时调用该方法
private void doReceived(Response res) {
lock.lock();
try {
response = res;
done.signalAll();
} finally {
lock.unlock();
}
}
signal()使用条件: 1,相同的等待条件 2,唤醒后执行相同的操作 3,只需要唤醒一个线程
不合理,会导致很多请求超时,源码是调用signalAll()
in the method of org.apache.dubbo.remoting.exchange.support.DefaultFuture#doReceived, I think we should call done.signalAll()
instead of done.signal() ,and it’s unnecessary to check done != null
because it’s always true
如果每个request对应一个线程,似乎并没有用到共享的资源,那么为什么要加锁呢?
这里只是利用管程实现线程的阻塞和唤醒
Semaphore:如何快速实现一个限流器?
Semaphore, 信号量, 线程能不能执行,也要看信号量是不是允许
信号量是由大名鼎鼎的计算机科学家迪杰斯特拉(Dijkstra)于 1965 年提出,在这之后的 15 年,信号量一直都是并发编程领域的终结者,直到 1980 年管程被提出来,才有了第二选择。目前几乎所有支持并发编程的语言都支持信号量机制,所以学好信号量还是很有必要的
信号量模型
信号量模型图
一个计数器,一个等待队列,三个方法
在信号量模型里,计数器和等待队列对外是透明的,所以只能通过信号量模型提供的三个方法来访问它们,这三个方法分别是:init()、down() 和 up()
- init():设置计数器的初始值。
- down():计数器的值减 1;如果此时计数器的值小于 0,则当前线程将被阻塞,否则当前线程可以继续执行。
- up():计数器的值加 1;如果此时计数器的值小于或者等于 0,则唤醒等待队列中的一个线程,并将其从等待队列中移除。
在 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<0){
如果此时计数器的值小于 0,则当前线程将被阻塞,否则当前线程可以继续执行。
//将当前线程插入等待队列
//阻塞当前线程
}
}
void up(){
this.count++;
if(this.count<=0) {
如果此时计数器的值小于或者等于 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 执行完临界区代码之后才获得了进入临界区执行的机会,从而保证了互斥性。
- down():计数器的值减 1;如果此时计数器的值小于 0,则当前线程将被阻塞,否则当前线程可以继续执行。
- up():计数器的值加 1;如果此时计数器的值小于或者等于 0,则唤醒等待队列中的一个线程,并将其从等待队列中移除。
快速实现一个限流器 – 允许多个线程访问一个临界区
上面的例子,用信号量实现了一个最简单的互斥锁功能。
既然有 Java SDK 里面提供了 Lock,为啥还要提供一个 Semaphore ?
其实实现一个互斥锁,仅仅是 Semaphore 的部分功能,
Semaphore 还有一个功能是 Lock 不容易实现的,那就是:Semaphore 可以允许多个线程访问一个临界区
。
用于各种池化资源,例如连接池、对象池、线程池等等。
其中,你可能最熟悉数据库连接池,在同一时刻,一定是允许多个线程同时使用连接池的,
当然,每个连接在被释放前,是不允许其他线程使用的
所谓对象池呢,指的是一次性创建出 N 个对象,之后所有的线程重复利用这 N 个对象
,
当然对象在被释放前,也是不允许其他线程使用的。
对象池,可以用 List 保存实例对象,这个很简单。
但关键是限流器的设计
,这里的限流,指的是不允许多于 N 个线程同时进入临界区
。
那如何快速实现一个这样的限流器呢?这种场景,应该联想到了信号量的解决方案。
信号量的计数器,在上面的例子中设置成了 1,这个 1 表示只允许一个线程进入临界区,但如果把计数器的值设置成对象池里对象的个数 N,就能完美解决对象池的限流问题了。
对象池示例代码
package com.example.demo.objectpool;
import java.util.List;
import java.util.Vector;
import java.util.concurrent.Semaphore;
import java.util.function.Function;
/**
* 对象池, 使用 信号量 实现限流器
* 允许多个线程访问临界区
*
* @param <T>
* @param <R>
*/
public class ObjectPool<T, R> {
// 用一个 List来保存对象实例,用 Semaphore 实现限流器
final List<T> pool;
final Semaphore sem;
ObjectPool(int size, T t) {
pool = new Vector<T>();
for (int i = 0; i < size; i++) {
pool.add(t);
}
sem = new Semaphore(size);
}
// 利用对象池的对象, 调用func
// 关键的代码是 ObjPool 里面的 exec() 方法,这个方法里面实现了限流的功能
R exec(Function<T, R> func) throws InterruptedException {
T t = null;
// 先调用 acquire() 方法(与之匹配的是在 finally 里面调用 release() 方法)
// 假设对象池的大小是 10,信号量的计数器初始化为 10
// 那么前 10 个线程调用 acquire() 方法,都能继续执行,相当于通过了信号灯
// 而其他线程则会阻塞在 acquire() 方法上
sem.acquire(); -- 对应信号量的 down() 操作, < 0, 阻塞当前线程
try {
// 对于通过信号灯的线程,为每个线程分配了一个对象 t(这个分配工作是通过 pool.remove(0) 实现的)
// 分配完之后会执行一个回调函数 func,而函数的参数正是前面分配的对象 t
t = pool.remove(0);
return func.apply(t);
} finally {
// 执行完回调函数之后,它们就会释放对象(这个释放工作是通过 pool.add(t) 实现的)
// 同时调用 release() 方法来更新信号量的计数器
// 如果此时信号量里计数器的值小于等于 0,那么说明有线程在等待,此时会自动唤醒等待的线程
pool.add(t);
sem.release(); -- 对应信号量的 up() 操作, <= 0, 唤醒等待队列的一个线程
}
}
public static void main(String[] args) throws InterruptedException {
// 创建对象池
ObjectPool<Long, String> pool = new ObjectPool<Long, String>(10, 2L);
// 通过对象池获取t, 之后执行
pool.exec(t -> {
System.out.println(t);
return t.toString();
});
}
}
summary
信号量在 Java 语言里面名气并不算大,但是在其他语言里却是很有知名度的。Java 在并发编程领域走的很快,重点支持的还是管程模型。 管程模型理论上解决了信号量模型的一些不足,主要体现在易用性和工程化方面
思考
对象池的例子中,对象保存在了 Vector 中,Vector 是 Java 提供的线程安全的容器,是否可以把 Vector 换成 ArrayList呢?
官方答疑
Semaphore 可以允许多个线程访问一个临界区,那就意味着可能存在多个线程同时访问 ArrayList,
而 ArrayList 不是线程安全的,所以对象池的例子中是不能够将 Vector 换成 ArrayList 的
当多个线程进入临界区时,如果需要访问共享变量就会存在并发问题,所以必须加锁,也就是说 Semaphore 需要 --> 锁中锁
我理解的和管程相比,信号量可以实现的独特功能就是同时允许多个线程进入临界区
,但是信号量不能做的就是同时唤醒多个线程去争抢锁,只能唤醒一个阻塞中的线程
,而且信号量模型是没有Condition的概念的,即阻塞线程被醒了直接就运行了而不会去检查此时临界条件是否已经不满足
了,基于此考虑信号量模型才会设计出只能让一个线程被唤醒,否则就会出现因为缺少Condition检查而带来的线程安全问题。正因为缺失了Condition,所以用信号量来实现阻塞队列就很麻烦,因为要自己实现类似Condition的逻辑。
需要用线程安全的vector,因为信号量支持多个线程进入临界区,执行list的add和remove方法时可能是多线程并发执行
ReadWriteLock:如何快速实现一个完备的缓存?-- 读多写少
前面介绍了管程和信号量这两个同步原语在 Java 语言中的实现,理论上用这两个同步原语中任何一个都可以解决所有的并发问题。
那 Java SDK 并发包里为什么还有很多其他的工具类呢?原因很简单:分场景优化性能,提升易用性
读多写少场景, 为了优化性能,经常会使用缓存,例如缓存元数据、缓存基础数据等
缓存之所以能提升性能,一个重要的条件就是缓存的数据一定是读多写少
的,例如元数据和基础数据基本上不会发生变化(写少),但是使用它们的地方却很多(读多)
针对读多写少
这种并发场景,Java SDK 并发包提供了读写锁——ReadWriteLock,非常容易使用,并且性能很好
什么是读写锁呢?
读写锁遵守以下三条基本原则:
- 允许多个线程同时读共享变量;
- 只允许一个线程写共享变量;
- 如果一个写线程正在执行写操作,此时禁止读线程读共享变量。
读写锁与互斥锁的一个重要区别就是读写锁允许多个线程同时读共享变量
,而互斥锁是不允许的,这是读写锁在读多写少场景下性能优于互斥锁的关键。
但读写锁的写操作是互斥的,当一个线程在写共享变量的时候,是不允许其他线程执行写操作和读操作
。
快速实现一个缓存
package com.example.demo.cache;
import java.util.HashMap;
import java.util.Map;
import java.util.concurrent.locks.Lock;
import java.util.concurrent.locks.ReadWriteLock;
import java.util.concurrent.locks.ReentrantReadWriteLock;
public class Cache<K, V> {
// HashMap 不是线程安全的,这里我们使用读写锁 ReadWriteLock 来保证其线程安全
final Map<K, V> map = new HashMap<>();
// ReadWriteLock 是一个接口,它的实现类是 ReentrantReadWriteLock,通过名字可以判断它是支持可重入的
final ReadWriteLock rwl = new ReentrantReadWriteLock();
// 读锁
final Lock r = rwl.readLock();
// 写锁
final Lock w = rwl.writeLock();
// 读缓存
V get(K key) {
r.lock();
try {
return map.get(key);
} finally {
r.unlock();
}
}
// 写缓存
V put(K key, V value) {
w.lock();
try {
return map.put(key, value);
} finally {
w.unlock();
}
}
}
使用缓存首先要解决缓存数据的初始化
问题
-
一次性加载的方式
源头数据的数据量不大, 只需在应用启动的时候把源头数据查询出来
-
使用按需加载的方式
源头数据量非常大,那么就需要按需加载了,按需加载也叫懒加载,指的是只有当应用查询缓存,并且数据不在缓存里的时候,才触发加载源头相关数据进缓存的操作
实现缓存的按需加载
package com.example.demo.cache;
import java.util.HashMap;
import java.util.Map;
import java.util.concurrent.locks.Lock;
import java.util.concurrent.locks.ReadWriteLock;
import java.util.concurrent.locks.ReentrantReadWriteLock;
class Cache<K, V> {
final Map<K, V> map = new HashMap<>();
final ReadWriteLock rwl = new ReentrantReadWriteLock();
final Lock r = rwl.readLock();
final Lock w = rwl.writeLock();
V get(K key) {
V value = null;
// 读缓存
r.lock();
try {
value = map.get(key);
} finally {
r.unlock();
}
// 缓存中存在,返回
if (value != null) {
return value;
}
// 缓存中不存在,查询数据库
w.lock(); ------ ①
try {
// 再次验证
// 其他线程可能已经查询过数据库 (竞争①的线程中, 第一个获取写锁的)
value = map.get(key);
if (value == null) {
// 查询数据库
value=... // 省略查询数据库代码
map.put(key, value);
}
} finally {
w.unlock();
}
return value;
}
}
再次验证的原因:
在高并发的场景下,有可能会有多线程竞争写锁
假设缓存是空的,没有缓存任何东西,此时有三个线程 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 的官方示例
package com.example.demo.cache;
import java.util.concurrent.locks.Lock;
import java.util.concurrent.locks.ReadWriteLock;
import java.util.concurrent.locks.ReentrantReadWriteLock;
public class CacheData {
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();
}
}
}
Summary
读写锁类似于 ReentrantLock,也支持公平模式和非公平模式
。
读锁和写锁都实现了 java.util.concurrent.locks.Lock 接口,所以除了支持 lock() 方法外,tryLock()、lockInterruptibly() 等方法
也都是支持的。
但是有一点需要注意,那就是只有写锁支持条件变量,读锁是不支持条件变量的,读锁调用 newCondition() 会抛出 UnsupportedOperationException 异常
。
– 快速实现一个缓存
解决了缓存的初始化问题,但是没有解决缓存数据与源头数据的同步问题
数据同步指的是保证缓存数据和源头数据的一致性: 使用超时机制
超时机制: 加载进缓存的数据不是长久有效的,而是有时效的,当缓存的数据超过时效,也就是超时之后,这条数据在缓存中就失效
访问缓存中失效的数据,会触发缓存重新从源头把数据加载进缓存
也可以在源头数据发生变化时,快速反馈给缓存,但这个就要依赖具体的场景了。
例如 MySQL 作为数据源头,可以通过近实时地解析 binlog 来识别数据是否发生了变化,如果发生了变化就将最新的数据推送给缓存。
另外,还有一些方案采取的是数据库和缓存的双写方案
思考
线上系统停止响应了,CPU 利用率很低,如何验证是不是读锁升级写锁
考虑到是线上应用,可采用以下方法
1. 源代码分析。查找ReentrantReadWriteLock在项目中的引用,看下写锁是否在读锁释放前尝试获取
2. 如果线上是Web应用,应用服务器比如说是Tomcat,并且开启了JMX,则可以通过JConsole等工具远程查看下线上死锁的具体情况
①ps -ef | grep java查看pid
②top -p查看java中的线程
③使用jstack将其堆栈信息保存下来,查看是否是锁升级导致的阻塞问题。
调用下有获取只有读锁的接口,看下是否会阻塞,如果没有阻塞可以在调用下写锁的接口,如果阻塞表明有读锁
获取写锁的前提是读锁和写锁均未被占用
获取读锁的前提是没有其他线程占用写锁 -- 只支持降级不支持升级
申请写锁时不中断其他线程申请读锁
公平锁如果过有写申请,能禁止读锁
StampedLock:有没有比读写锁更快的锁?
读多写少的场景, 更快的锁, StampedLock
StampedLock 支持的三种锁模式
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 的读锁,乐观读的性能更好一些。
import java.util.concurrent.locks.StampedLock;
class Point {
private int x, y;
final StampedLock sl = new StampedLock();
//计算到原点的距离
double distanceFromOrigin() {
// 乐观读
long stamp = sl.tryOptimisticRead();
// 读入局部变量,读的过程数据可能被修改
// ① -- 由于 tryOptimisticRead() 是无锁的,所以共享变量 x 和 y 读入方法局部变量时,x 和 y 有可能被其他线程修改了
int curX = x, curY = y;
// 判断执行读操作期间,是否存在写操作,如果存在,则sl.validate返回false
// ① -- 因此最后读完之后,还需要再次验证一下是否存在写操作,这个验证操作是通过调用 validate(stamp) 来实现的。
// 无锁化操作时通过版本号来判断是否在操作过程中存在其他线程的修改操作
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
升级为悲观读锁,代码简练且不易出错
进一步理解乐观读
数据库的乐观读
乐观锁的实现很简单,在表里增加了一个数值型版本号字段 version,每次更新这个表的时候,都将 version 字段加 1
数据库里的乐观锁,查询的时候需要把 version 字段查出来,更新的时候要利用 version 字段做验证
版本号 or 时间戳
StampedLock 使用注意事项
StampedLock 的功能仅仅是 ReadWriteLock 的子集
StampedLock 在命名上并没有增加 Reentrant,StampedLock 不支持重入
StampedLock 的悲观读锁、写锁都不支持条件变量
如果线程阻塞在 StampedLock 的 readLock() 或者 writeLock() 上时,此时调用该阻塞线程的 interrupt() 方法,会导致 CPU 飙升
final StampedLock lock = new StampedLock();
Thread T1 = new Thread(()->{
// 获取写锁
lock.writeLock();
// 永远阻塞在此处,不释放写锁
LockSupport.park();
});
T1.start();
// 保证T1获取写锁
Thread.sleep(100);
Thread T2 = new Thread(()->
//阻塞在悲观读锁
lock.readLock()
);
T2.start();
// 保证T2阻塞在读锁
Thread.sleep(100);
//中断线程T2
//会导致线程T2所在CPU飙升
T2.interrupt();
T2.join();
使用 StampedLock 一定不要调用中断操作,如果需要支持中断功能,一定使用可中断的悲观读锁 readLockInterruptibly() 和写锁 writeLockInterruptibly()
summary
使用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() 方法实现)
,但是建议你要慎重使用
找代码中隐藏的bug
public class Test {
private double x, y;
final StampedLock stampedLock = new StampedLock();
// 存在问题的方法
void moveIfAtOrigin(double newX, double newY){
long stamp = stampedLock.readLock();
try {
while(x == 0.0 && y == 0.0){
long writeStamp = stampedLock.tryConvertToWriteLock(stamp);
if (writeStamp != 0L) { // 锁升级成功
x = newX;
y = newY;
// !!! 问题所在, 要释放升级后的写锁, 需要加上下面这一行
// stamp = writeStamp;
break;
} else {
stampedLock.unlockRead(stamp);
stamp = stampedLock.writeLock();
}
}
} finally {
stampedLock.unlock(stamp);
}
}
}
CountDownLatch和CyclicBarrier:如何让多线程步调一致?
Thread.join()
while(存在未对账订单){
// 查询未对账订单
Thread T1 = new Thread(()->{
pos = getPOrders();
});
T1.start();
// 查询派送单
Thread T2 = new Thread(()->{
dos = getDOrders();
});
T2.start();
// 等待T1、T2结束
T1.join();
T2.join();
// 执行对账操作
diff = check(pos, dos);
// 差异写入差异库
save(diff);
}
主线程需要等待线程 T1 和 T2 执行完才能执行 check() 和 save() 这两个操作,
通过调用 T1.join() 和 T2.join() 来实现等待,
当 T1 和 T2 线程退出时,调用 T1.join() 和 T2.join() 的--主线程--就会从阻塞态被唤醒
用 CountDownLatch 实现线程等待
美中不足:
while 循环里面每次都会创建新的线程,而创建线程可是个耗时的操作
–>
线程池
// 创建2个线程的线程池
Executor executor = Executors.newFixedThreadPool(2);
while(存在未对账订单){
// 查询未对账订单
executor.execute(()-> {
pos = getPOrders();
});
// 查询派送单
executor.execute(()-> {
dos = getDOrders();
});
/* ??如何实现等待??*/
计数器,初始值设置成 2,
当执行完pos = getPOrders();这个操作之后将计数器减 1,
执行完dos = getDOrders();之后也将计数器减 1,
在主线程里,等待计数器等于 0;
当计数器等于 0 时,说明这两个查询操作执行完了。
等待计数器等于 0 其实就是一个条件变量,用管程实现起来也很简单。
// 执行对账操作
diff = check(pos, dos);
// 差异写入差异库
save(diff);
}
// 创建2个线程的线程池
Executor executor = Executors.newFixedThreadPool(2);
while(存在未对账订单){
// 计数器初始化为2
CountDownLatch latch = new CountDownLatch(2);
// 查询未对账订单
executor.execute(()-> {
pos = getPOrders();
latch.countDown();
});
// 查询派送单
executor.execute(()-> {
dos = getDOrders();
latch.countDown();
});
// 等待两个查询操作结束
latch.await();
// 执行对账操作
diff = check(pos, dos);
// 差异写入差异库
save(diff);
}
此时的并行执行示意图
进一步优化性能
完全并行:
两个查询操作和对账操作也是可以并行的,也就是说,在执行对账操作的时候,可以同时去执行下一轮的查询操作
两次查询操作能够和对账操作并行,对账操作还依赖查询操作的结果,类似于生产者 - 消费者,
两次查询操作是生产者,对账操作是消费者
既然是生产者 - 消费者模型,那就需要有个队列,来保存生产者生产的数据,而消费者则从这个队列消费数据
设计双队列:
这两个队列的元素之间是有一一对应的关系的
对这两个元素执行对账操作,这样数据一定不会乱掉
如何用双队列来实现完全的并行:
一个线程 T1 执行订单的查询工作,一个线程 T2 执行派送单的查询工作,
当线程 T1 和 T2 都各自生产完 1 条数据的时候,通知线程 T3 执行对账操作
线程 T1 和线程 T2 的工作要步调一致
同步执行示意图
用CyclicBarrier 实现线程同步
方案实现难点:
- 线程 T1 和 T2 要做到步调一致
- 要能够通知到线程 T3
计数器
计数器初始化为 2,线程 T1 和 T2 生产完一条数据都将计数器减 1,
- 如果计数器大于 0 则线程 T1 或者 T2 等待。
- 如果计数器等于 0,则通知线程 T3,并唤醒等待的线程 T1 或者 T2,
与此同时,将计数器重置为 2,这样线程 T1 和线程 T2 生产下一条数据的时候就可以继续使用这个计数器了
CyclicBarrier
public class Test {
// 订单队列
Vector<P> pos;
// 派送单队列
Vector<D> dos;
// 执行回调的线程池 -- 单线程
Executor executor = Executors.newFixedThreadPool(1);
// 创建 CyclicBarrier 的时候,传入了一个回调函数,当计数器减到 0 的时候,会调用这个回调函数
// CyclicBarrier 的计数器有自动重置的功能,当减到 0 的时候,会自动重置你设置的初始值
final CyclicBarrier barrier = new CyclicBarrier(2, () -> {
executor.execute(() -> check());
});
// 回调函数
void check() {
P p = pos.remove(0);
D d = dos.remove(0);
// 执行对账操作
diff = check(p, d);
// 差异写入差异库
save(diff);
}
void checkAll() {
// 循环查询订单库
// 线程 T1 负责查询订单,当查出一条时,调用 barrier.await() 来将计数器减 1,同时等待计数器变成 0
Thread T1 = new Thread(() -> {
while(存在未对账订单) {
// 查询订单库
pos.add(getPOrders());
// 等待
barrier.await();
}
});
T1.start();
// 循环查询运单库
// 线程 T2 负责查询派送单,当查出一条时,也调用 barrier.await() 来将计数器减 1,同时等待计数器变成 0
Thread T2 = new Thread(() -> {
while(存在未对账订单) {
// 查询运单库
dos.add(getDOrders());
// 等待
barrier.await();
}
});
T2.start();
当 T1 和 T2 都调用 barrier.await() 的时候,计数器会减到 0,
此时 T1 和 T2 就可以执行下一条语句了,同时会调用 barrier 的回调函数来执行对账操作
}
}
summary
CountDownLatch
- 主要用来解决一个线程等待多个线程的场景, 可以类比旅游团团长要等待所有的游客到齐才能去下一个景点
- 计数器是不能循环利用的,也就是说一旦计数器减到 0,再有线程调用 await(),该线程会直接通过
CyclicBarrier
- 一组线程之间互相等待,更像是几个驴友之间不离不弃
- 计数器是可以循环利用的,而且具备自动重置的功能,一旦计数器减到 0 会自动重置到你设置的初始值
- 可以设置回调函数
思考 – 回调一定要关心执行线程是谁
CyclicBarrier示例代码,回调函数使用了一个固定大小的线程池,是否有必要呢
官方解析
合理, 有必要
1.第一个是线程池大小是 1,只有 1 个线程,
主要原因是 check() 方法的耗时比 getPOrders() 和 getDOrders() 都要短,
所以没必要用多个线程,同时单线程能保证访问的数据不存在并发问题
2.第二个是使用了线程池,如果不使用,直接在回调函数里调用 check() 方法是否可以呢?
绝对不可以。为什么呢?这个要分析一下回调函数和唤醒等待线程之间的关系。
查看 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()
所以
当看到回调函数的时候,一定注意执行回调函数的线程是谁
- 为啥要用线程池,而不是在回调函数中直接调用?
使用线程池是为了异步操作,否则回掉函数是同步调用的,也就是本次对账操作执行完才能进行下一轮的检查。 - 线程池为啥使用单线程的?
线程数量固定为1,防止了多线程并发导致的数据不一致,因为订单和派送单是两个队列,只有单线程去两个队列中取消息才不会出现消息不匹配的问题。
注意:
CyclicBarrier的回调函数在哪个线程执行啊?
CyclicBarrier的回调函数执行在一个回合里最后执行await()的线程上
,而且同步调用回调函数check()
,调用完check之后,才会开始第二回合。所以check如果不另开一线程异步执行,就起不到性能优化的作用了。
注意:
回调函数总是在计数器归0时候执行, 但是线程T1 T2要等回调函数执行结束之后才会再次执行
源码
int index = --count;
if (index == 0) { // tripped
boolean ranAction = false;
try {
final Runnable command = barrierCommand;
if (command != null)
command.run(); // 当内部计数器 index == 0时候, 没有开启子线程
ranAction = true;
nextGeneration();
return 0;
} finally {
if (!ranAction)
breakBarrier();
}
}
也就是说 对账还是同步执行的, 结束之后才是下一次的查询
注意:
推荐使用ThreadPoolExecutor去实现线程池,并且实现里面的RejectedExecutionHandler和ThreadFactory,这样可以方便当调用订单查询和派送单查询的时候出现full gc的时候 dump文件 可以快速定位出现问题的线程是哪个业务线程,如果是CountDownLatch,建议设置超时时间,避免由于业务死锁没有调用countDown()导致现线程睡死的情况
并发容器的一些坑
同步容器及其注意事项
如何将非线程安全的容器变成线程安全的容器?
把非线程安全的容器封装在对象内部,然后控制好访问路径
所有访问 ArrayList 的方法都增加了 synchronized 关键字
class SafeArrayList<T> {
// 封装ArrayList
List<T> c = new ArrayList<>();
// 控制访问路径
synchronized T get(int idx) {
return c.get(idx);
}
synchronized void add(int idx, T t) {
c.add(idx, t);
}
// 用 synchronized 来保证原子性
// 组合操作需要注意竞态条件问题: 即便每个操作都能保证原子性,也并不能保证组合操作的原子性
synchronized boolean addIfNotExist(T t) {
if (!c.contains(t)) {
c.add(t);
return true;
}
return false;
}
}
把 ArrayList、HashSet 和 HashMap 包装成了线程安全的 List、Set 和 Map
List list = Collections.synchronizedList(new ArrayList());
Set set = Collections.synchronizedSet(new HashSet());
Map map = Collections.synchronizedMap(new HashMap());
一个容易被忽视的“坑”:
用迭代器遍历容器
List list = Collections.synchronizedList(new ArrayList());
Iterator i = list.iterator();
while (i.hasNext())
foo(i.next());
通过迭代器遍历容器 list,对每个元素调用 foo() 方法
存在并发问题,这些组合的操作不具备原子性
正确做法:
锁住 list 之后再执行遍历操作
List list = Collections.synchronizedList(new ArrayList());
synchronized (list) {
Iterator i = list.iterator();
while (i.hasNext())
foo(i.next());
}
Collections 内部的包装类源码,包装类的公共方法锁的是对象的 this,
其实就是这里的 list,所以锁住 list 绝对是线程安全的
同步容器
:
经过包装后线程安全容器,都是基于 synchronized 这个同步关键字实现的
Java 提供的同步容器还有 Vector、Stack 和 Hashtable
,这三个容器不是基于包装类实现的,但同样是基于 synchronized 实现的,对这三个容器的遍历,同样要加锁保证互斥
并发容器及其注意事项
Java 在 1.5 版本之前所谓的线程安全的容器,主要指的就是同步容器
所有方法都用 synchronized 来保证互斥,串行度高, 性能差
因此 Java 在 1.5 及之后版本提供了性能更高的容器,一般称为并发容器
并发容器关系图
(一) List
List 里面只有一个实现类就是 CopyOnWriteArrayList
顾名思义就是写的时候会将共享变量新复制一份出来,这样做的好处是读操作完全无锁
CopyOnWriteArrayList 内部维护了一个数组,成员变量 array 就指向这个内部数组,所有的读操作都是基于 array 进行的,如下图所示,迭代器 Iterator 遍历的就是 array 数组
执行迭代的内部结构图
如果在遍历 array 的同时,还有一个写操作,例如增加元素,CopyOnWriteArrayList 是如何处理的呢?
CopyOnWriteArrayList 会将 array 复制一份,然后在新复制处理的数组上执行增加元素的操作
,执行完之后再将 array 指向这个新的数组。
通过下图可以看到,读写是可以并行的
,遍历操作一直都是基于原 array 执行,而写操作则是基于新 array 进行。
执行增加元素的内部结构图
使用 CopyOnWriteArrayList 需要注意的“坑”主要有两个方面。
- 一个是应用场景,CopyOnWriteArrayList 仅适用于
写操作非常少
的场景,而且能够容忍读写的短暂不一致。例如上面的例子中,写入的新元素并不能立刻被遍历到。 - 另一个需要注意的是,
CopyOnWriteArrayList 迭代器是只读的,不支持增删改
。因为迭代器遍历的仅仅是一个快照,而对快照进行增删改是没有意义的。
(二)Map
Map 接口的两个实现是
- ConcurrentHashMap,key 是无序的
- ConcurrentSkipListMap,
key 是有序的
注意, 他们的key 和 value 都不能为空,否则会抛出NullPointerException
这个运行时异常
ConcurrentSkipListMap 里面的 SkipList 本身就是一种数据结构,中文一般都翻译为“跳表”。
跳表插入、删除、查询
操作平均的时间复杂度是 O(log n)
,理论上和并发线程数没有关系,所以在并发度非常高的情况下,若对 ConcurrentHashMap 的性能还不满意,可以尝试一下 ConcurrentSkipListMap
。
如果key冲突比较大,hashmap还是要靠链表或者tree来解决冲突的,所以O(1)是理想值。
同时增删改操作很多也影响hashmap性能。这个也是要看冲突情况。
也就是说hashmap的稳定性差,如果很不幸正好偶遇它的稳定性问题,同时又接受不了,
就可以尝试skiplistmap,它能保证稳定性,无论你的并发量是多大,也没有key冲突的问题
(三)Set
Set 接口的两个实现是 CopyOnWriteArraySet 和 ConcurrentSkipListSet,
使用场景可以参考前面讲述的 CopyOnWriteArrayList 和 ConcurrentSkipListMap,
它们的原理都是一样的,这里就不再赘述了。
(四)Queue
Java 并发包里面 Queue 这类并发容器是最复杂的,你可以从以下两个维度来分类。
一个维度是阻塞与非阻塞
,所谓阻塞指的是当队列已满时,入队操作阻塞;当队列已空时,出队操作阻塞。
另一个维度是单端与双端
,单端指的是只能队尾入队,队首出队;而双端指的是队首队尾皆可入队出队。
Java 并发包里阻塞队列都用 Blocking 关键字标识,单端队列使用 Queue 标识,双端队列使用 Deque 标识
。
1.单端阻塞队列:
其实现有 ArrayBlockingQueue、LinkedBlockingQueue、SynchronousQueue、LinkedTransferQueue、PriorityBlockingQueue 和 DelayQueue
。
- 内部一般会持有一个队列,这个队列可以是数组(其实现是 ArrayBlockingQueue)也可以是链表(其实现是 LinkedBlockingQueue)
- 甚至还可以
不持有队列
(其实现是 SynchronousQueue),此时生产者线程的入队操作必须等待消费者线程的出队操作 - 而 LinkedTransferQueue 融合 LinkedBlockingQueue 和 SynchronousQueue 的功能,性能比 LinkedBlockingQueue 更好
- PriorityBlockingQueue 支持按照优先级出队;DelayQueue 支持延时出队
单端阻塞队列示意图
2.双端阻塞队列:
其实现是 LinkedBlockingDeque
双端阻塞队列示意图 – 注意箭头都是双向的
3.单端非阻塞队列:
其实现是 ConcurrentLinkedQueue
4.双端非阻塞队列:
其实现是 ConcurrentLinkedDeque
另外,使用队列时,需要格外注意队列是否支持有界
(所谓有界指的是内部的队列是否有容量限制)。
实际工作中,一般都不建议使用无界的队列,因为数据量大了之后很容易导致 OOM。
上面提到的这些 Queue 中,只有 ArrayBlockingQueue 和 LinkedBlockingQueue 是支持有界的
,所以在使用其他无界队列时,一定要充分考虑是否存在导致 OOM 的隐患
。
summary
选对容器
自行了解:
Java 容器的快速失败机制(Fail-Fast)
思考
在 1.8 之前的版本里并发执行 HashMap.put() 可能会导致 CPU 飙升到 100%
Java7中的HashMap在执行put操作时会涉及到扩容,由于扩容时链表并发操作会造成链表成环,所以可能导致cpu飙升100%
传送门:
Java笔记-----(2)Java容器
原子类:无锁工具类的典范
public class Test {
long count = 0;
// 方法非线程安全, 问题就出在变量 count 的可见性和 count+=1 的原子性上
// 可见性问题可以用 volatile 来解决,而原子性问题我们前面一直都是采用的互斥锁方案
void add10K() {
int idx = 0;
while(idx++ < 10000) {
count += 1; // 两个线程都执行到这儿, 之前是0, 之后期望是2, 结果却是1.
}
}
}
无锁方案, 此时 add10K() 线程安全
public class Test {
AtomicLong count = new AtomicLong(0); // 使用 AtomicLong 原子类
void add10K() {
int idx = 0;
while(idx++ < 10000) {
count.getAndIncrement(); // 替换 count += 1
}
}
}
无锁方案相对互斥锁方案,最大的好处就是性能
。
互斥锁方案为了保证互斥性,需要执行加锁、解锁操作
,而加锁、解锁操作本身就消耗性能;同时拿不到锁的线程还会进入阻塞状态
,进而触发线程切换,线程切换对性能的消耗也很大
。
无锁方案的实现原理 — 硬件支持
CPU 为了解决并发问题,提供了 CAS 指令(CAS,全称是 Compare And Swap,即“比较并交换”)。
CAS 指令包含 3 个参数:
共享变量的内存地址 A、用于比较的值 B 和共享变量的新值 C;
并且只有当内存中地址 A 处的值等于 B 时,才能将内存中地址 A 处的值更新为新值 C。
作为一条 CPU 指令
,CAS 指令本身是能够保证原子性的
CAS 指令的模拟代码
class SimulatedCAS{
int count;
synchronized int cas(int expect, int newValue){ // 期望值 expect,需要写入的新值 newValue
// 读目前count的值
int curValue = count;
// 比较目前count值是否==期望值
// 只有当目前 count 的值和期望值 expect 相等时,才会将 count 更新为 newValue !!!!
if(curValue == expect){
// 如果是,则更新count的值
count = newValue;
}
// 返回写入前的值
return curValue;
}
}
使用 CAS 来解决并发问题,一般都会伴随着自旋,而所谓自旋,其实就是循环尝试
实现一个线程安全的count += 1操作,“CAS+ 自旋”的实现方案:
class SimulatedCAS{
volatile int count; // 使用volatile保证可见性
// 实现count+=1
// 首先计算 newValue = count+1,如果 cas(count,newValue) 返回的值不等于 count,
// 则意味着线程在执行完代码①处之后,执行代码②处之前,count 的值被其他线程更新过
// 自旋方案: 可以重新读 count 最新的值来计算 newValue 并尝试再次更新,直到成功
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() 方法,也不会有线程被阻塞,
所以相对于互斥锁方案来说,性能好了很多
ABA 的问题:
假设 count 原本是 A,线程 T1 在执行完代码①处之后,执行代码②处之前,
有可能 count 被线程 T2 更新成了 B,
之后又被 T3 更新回了 A,
这样线程 T1 虽然看到的一直是 A,但是其实已经被其他线程更新过了,这就是 ABA 问题
可能大多数情况下并不关心 ABA 问题,例如数值的原子递增
,但也不能所有情况下都不关心,例如原子化的更新对象
很可能就需要关心 ABA 问题,因为两个 A 虽然相等,但是第二个 A 的属性可能已经发生变化了。所以在使用 CAS 方案的时候,一定要先 check 一下。
ABA问题的解决思路:
时间戳 or 版本号
每次执行 CAS 操作,附加再更新一个版本号,只要保证版本号是递增的,那么即便 A 变成 B 之后再变回 A,版本号也不会变回来(版本号递增的)
看 Java 如何实现原子化的 count += 1
原子类 AtomicLong 的 getAndIncrement() 方法替代了count += 1
final long getAndIncrement() {
// 这里 this 和 valueOffset 两个参数可以唯一确定共享变量的内存地址
return unsafe.getAndAddLong(this, valueOffset, 1L);
}
// 首先会在内存中读取共享变量的值,
// 之后循环调用 compareAndSwapLong() 方法来尝试设置共享变量的值,直到成功为止
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;
}
// native 方法,只有当内存中共享变量的值等于 expected 时,才会将共享变量的值更新为 x,并且返回 true;否则返回 fasle
// compareAndSwapLong 的语义和 CAS 指令的语义的差别仅仅是返回值不同而已
// 原子性地将变量更新为x, 条件是内存中的值等于expected, 更新成功则返回true
native boolean compareAndSwapLong(Object o, long offset, long expected, long x);
无锁程序经典范例
do {
// 获取当前值
oldV = xxxx;
// 根据当前值计算新值
newV = ...oldV...
}while(!compareAndSet(oldV,newV);
Java 提供的原子类里面 CAS 一般被实现为 compareAndSet(),
compareAndSet() 的语义和 CAS 指令的语义的差别仅仅是返回值不同而已,
compareAndSet() 里面如果更新成功,则会返回 true,否则返回 false
原子类概览
Java SDK 并发包里提供的原子类内容可以分为五个类别:
原子化的基本数据类型、
原子化的对象引用类型、
原子化数组、
原子化对象属性更新器
和原子化的累加器
这五个类别提供的方法基本上是相似的,并且每个类别都有若干原子类
原子类组成概览图
- 原子化的基本数据类型
相关实现有 AtomicBoolean、AtomicInteger 和 AtomicLong
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)
- 原子化的对象引用类型
相关实现有 AtomicReference、AtomicStampedReference 和 AtomicMarkableReference,利用它们可以实现对象引用的原子化更新。
对象引用的更新需要重点关注 ABA 问题,AtomicStampedReference 和 AtomicMarkableReference 这两个原子类
可以解决 ABA 问题。
每次执行 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)
- 原子化数组
相关实现有 AtomicIntegerArray、AtomicLongArray 和 AtomicReferenceArray,利用这些原子类,我们可以原子化地更新数组里面的每一个元素
。
这些类提供的方法和原子化的基本数据类型
的区别仅仅是:每个方法多了一个数组的索引参数
,所以这里也不再赘述了。
- 原子化对象属性更新器
相关实现有 AtomicIntegerFieldUpdater、AtomicLongFieldUpdater 和 AtomicReferenceFieldUpdater
,利用它们可以原子化地更新对象的属性
,这三个方法都是利用反射机制
实现的,创建更新器的方法如下
public static <U> AtomicXXXFieldUpdater<U> newUpdater(Class<U> tclass, String fieldName)
对象属性必须是 volatile 类型的,只有这样才能保证可见性;
如果对象属性不是 volatile 类型的,newUpdater() 方法会抛出 IllegalArgumentException 这个运行时异常
newUpdater() 的方法参数只有类的信息,没有对象的引用,
而更新对象的属性,一定需要对象的引用,那这个参数是在哪里传入的呢?是在原子操作的方法参数中传入的。
boolean compareAndSet(T obj, int expect, int update)
例如 compareAndSet() 这个原子操作,相比原子化的基本数据类型多了一个对象引用 obj。
原子化对象属性更新器相关的方法,相比原子化的基本数据类型仅仅是多了对象引用参数,所以这里也不再赘述了
- 原子化的累加器
DoubleAccumulator、DoubleAdder、LongAccumulator 和 LongAdder
,这四个类仅仅用来执行累加操作,相比原子化的基本数据类型,速度更快,但是不支持 compareAndSet() 方法
。如果你仅仅需要累加操作,使用原子化的累加器性能会更好。
总结
无锁方案相对于互斥锁方案,优点非常多,首先性能好,其次是基本不会出现死锁问题(但可能出现饥饿和活锁问题,因为自旋会反复重试)。
Java 提供的原子类大部分都实现了 compareAndSet()
方法,基于 compareAndSet() 方法,可以构建自己的无锁数据结构,但是建议不要这样做,这个工作最好还是让大师们去完成,原因是无锁算法没想象的那么简单
Java 提供的原子类能够解决一些简单的原子性问题,但你可能会发现,上面我们所有原子类的方法都是针对一个共享变量
的,如果你需要解决多个变量的原子性问题,建议还是使用互斥锁方案
。原子类虽好,但使用要慎之又慎
课后思考
合理库存的原子化实现
设置库存上限 setUpper() 方法是否合理?
public class SafeWM {
final AtomicReference<WMRange> rf = new AtomicReference<>(new WMRange(0, 0));
// 设置库存上限
void setUpper(int v) {
WMRange nr;
// 问题所在
// 如果线程1 运行到 WMRange or = rf.get();停止,切换到线程2 更新了值,切换回到线程1,
// 进入循环将永远比较失败死循环,解决方案是将读取的那一句放入循环里,CAS每次自旋必须要重新检查新的值才有意义
WMRange or = rf.get();
do {
// 检查参数合法性
if (v < or.lower) {
throw new IllegalArgumentException();
}
nr = new WMRange(v, or.lower);
} while (!rf.compareAndSet(or, nr));
}
class WMRange {
final int upper;
final int lower;
WMRange(int upper, int lower) {
this.upper = upper;
this.lower = lower;
}
}
}
Executor与线程池:如何创建正确的线程池?
创建对象,仅仅是在 JVM 的堆里分配一块内存而已;
而创建一个线程,却需要调用操作系统内核的 API,然后操作系统要为线程分配一系列的资源,这个成本就很高了,
所以线程是一个重量级的对象,应该避免频繁创建和销毁。
一般意义上的池化资源,当你需要资源的时候就调用 acquire() 方法来申请资源,用完之后就调用 release() 释放资源
class XXXPool{
// 获取池化资源
XXX acquire() {
}
// 释放池化资源
void release(XXX x){
}
}
不同于Java并发包里线程池相关的工具类
Java 提供的线程池里面压根就没有申请线程和释放线程的方法
线程池是一种生产者 - 消费者模式
//采用一般意义上池化资源的设计方法
class ThreadPool{
// 获取空闲线程
Thread acquire() {
}
// 释放线程
void release(Thread t){
}
}
//期望的使用
ThreadPool pool;
Thread T1=pool.acquire();
//传入Runnable对象
T1.execute(()->{
//具体业务逻辑
......
});
目前业界线程池的设计,普遍采用的都是生产者 - 消费者模式。
线程池的使用方是生产者,线程池本身是消费者。
public class ThreadPoolDemo {
/** 下面是使用示例 * */
public static void main(String[] args) {
// 创建有界阻塞队列
BlockingQueue<Runnable> workQueue = new LinkedBlockingQueue<>(2);
// 创建线程池
MyThreadPool pool = new MyThreadPool(10, workQueue);
// 提交任务
pool.execute(
() -> {
System.out.println("hello");
});
}
}
// 简化的线程池,仅用来说明工作原理
class MyThreadPool {
// 利用阻塞队列实现生产者-消费者模式
BlockingQueue<Runnable> workQueue;
// 保存内部工作线程
List<WorkerThread> threads = new ArrayList<>();
// 构造方法
MyThreadPool(int poolSize, BlockingQueue<Runnable> workQueue) {
this.workQueue = workQueue;
// 创建工作线程
for (int idx = 0; idx < poolSize; idx++) {
WorkerThread work = new WorkerThread();
work.start();
threads.add(work);
}
}
// 提交Runnable任务
void execute(Runnable command) {
try {
workQueue.put(command);
} catch (InterruptedException e) {
e.printStackTrace();
}
}
// 工作线程负责消费workQueue中的任务,并执行任务
class WorkerThread extends Thread {
@Override
public void run() {
// 循环取任务并执行
while (true) {
Runnable task = null;
try {
task = workQueue.take();
} catch (InterruptedException e) {
e.printStackTrace();
}
task.run();
}
}
}
}
如何使用 Java 中的线程池
ThreadPoolExecutor: 强调的是 Executor,而不是一般意义上的池化资源
ThreadPoolExecutor构造函数
ThreadPoolExecutor(
int corePoolSize, -- 线程池保有的最小线程数
int maximumPoolSize, -- 表示线程池创建的最大线程数
long keepAliveTime, -- 一个线程空闲了keepAliveTime & unit这么久,
TimeUnit unit, -- 而且线程池的线程数大于 corePoolSize ,那么这个空闲的线程就要被回收了
BlockingQueue<Runnable> workQueue, -- 工作队列
ThreadFactory threadFactory, -- 自定义如何创建线程
RejectedExecutionHandler handler) -- 自定义任务的拒绝策略
线程池中所有的线程都在忙碌,并且工作队列也满了(前提是工作队列是有界队列),那么此时提交任务,线程池就会拒绝接收
ThreadPoolExecutor 已经提供了以下 4 种策略。
- CallerRunsPolicy:提交任务的线程自己去执行该任务。
- AbortPolicy:默认的拒绝策略,会 throws RejectedExecutionException。
- DiscardPolicy:直接丢弃任务,没有任何异常抛出。
- DiscardOldestPolicy:丢弃最老的任务,其实就是把最早进入工作队列的任务丢弃,然后把新任务加入到工作队列。
Java 在 1.6 版本还增加了 allowCoreThreadTimeOut(boolean value) 方法,它可以让所有线程都支持超时
表意允许核心线程超时,也就是核心线程也可以被销毁
使用线程池要注意些什么
不建议使用 Executors
的最重要的原因是:Executors 提供的很多方法默认使用的都是无界的 LinkedBlockingQueue
,
高负载情境下,无界队列很容易导致 OOM,而 OOM 会导致所有请求都无法处理,这是致命问题。所以强烈建议使用有界队列
使用有界队列,当任务过多时,线程池会触发执行拒绝策略,线程池默认的拒绝策略会 throw RejectedExecutionException
这是个运行时异常,对于运行时异常编译器并不强制 catch 它,所以开发人员很容易忽略。
因此默认拒绝策略要慎重使用。如果线程池处理的任务非常重要,建议自定义自己的拒绝策略
;并且在实际工作中,自定义的拒绝策略往往和降级策略配合使用
。
使用线程池,还要注意异常处理的问题,
例如通过 ThreadPoolExecutor 对象的 execute() 方法提交任务时,如果任务在执行的过程中出现运行时异常,会导致执行任务的线程终止;
不过,最致命的是任务虽然异常了,但是你却获取不到任何通知,这会让你误以为任务都执行得很正常。
虽然线程池提供了很多用于异常处理的方法,但是最稳妥和简单的方案还是捕获所有异常并按需处理
,可以参考下面的示例代码。
try {
//业务逻辑
} catch (RuntimeException x) {
//按需处理
} catch (Throwable x) {
//按需处理
}
总结
线程池在 Java 并发编程领域非常重要,很多大厂的编码规范都要求必须通过线程池来管理线程。
线程池和普通的池化资源有很大不同,线程池实际上是生产者 - 消费者模式的一种实现,理解生产者 - 消费者模式是理解线程池的关键所在。
创建线程池设置合适的线程数非常重要,这部分内容,你可以参考上篇博客Java 线程(中):创建多少线程才是合适的?
的内容。
待办:
另外《Java 并发编程实战》的第 7 章《取消与关闭》的 7.3 节“处理非正常的线程终止” 详细介绍了异常处理的方案,第 8 章《线程池的使用》对线程池的使用也有更深入的介绍,如果你感兴趣或有需要的话,建议你仔细阅读。
思考
使用线程池,默认情况下创建的线程名字都类似pool-1-thread-2这样,没有业务含义。
而很多情况下为了便于诊断问题,都需要给线程赋予一个有意义的名字,有哪些办法可以给线程池里的线程指定名字?
当线程池中无可用线程,且阻塞队列已满,那么此时就会触发拒绝策略。对于采用何种策略,具体要看执行的任务重要程度。如果是一些不重要任务,可以选择直接丢弃。但是如果为重要任务,可以采用降级处理
,例如将任务信息插入数据库或者消息队列,启用一个专门用作补偿的线程池去进行补偿。所谓降级就是在服务无法正常提供功能的情况下,采取的补救措施
。具体采用何种降级手段,这也是要看具体场景
public static void main(String[] args) {
// 1. 给线程池设置名称前缀
ThreadPoolTaskExecutor threadPoolTaskExecutor = new ThreadPoolTaskExecutor();
threadPoolTaskExecutor.setThreadNamePrefix("CUSTOM_NAME_PREFIX");
// 2. 在ThreadFactory中自定义名称前缀
class CustomThreadFactory implements ThreadFactory {
@Override
public Thread newThread(Runnable r) {
Thread thread = new Thread("CUSTOM_NAME_PREFIX");
return thread;
}
}
ThreadPoolExecutor threadPoolExecutor = new ThreadPoolExecutor(10,
100,
120,
TimeUnit.SECONDS,
new LinkedBlockingQueue<>(),
new CustomThreadFactory(),
new ThreadPoolExecutor.AbortPolicy()
);
}
Future:如何用多线程实现最优的“烧水泡茶”程序?
如何获取任务执行结果
Java 通过 ThreadPoolExecutor 提供的 3 个 submit() 方法和 1 个 FutureTask 工具类来支持获得任务执行结果的需求。
// 1. 提交Runnable任务
Future<?> submit(Runnable task);
这个方法的参数是一个 Runnable 接口,Runnable 接口的 run() 方法是没有返回值的,
所以 submit(Runnable task) 这个方法返回的 Future 仅可以用来断言任务已经结束了,类似于 Thread.join()
// 2. 提交Callable任务
<T> Future<T> submit(Callable<T> task);
这个方法的参数是一个 Callable 接口,它只有一个 call() 方法,并且这个方法是有返回值的,
所以这个方法返回的 Future 对象可以通过调用其 get() 方法来获取任务的执行结果
// 3. 提交Runnable任务及结果引用
<T> Future<T> submit(Runnable task, T result);
假设这个方法返回的 Future 对象是 f,f.get() 的返回值就是传给 submit() 方法的参数 result
Future 接口方法
提交的任务不但能够获取任务执行结果,还可以取消任务
// 取消任务
boolean cancel(boolean mayInterruptIfRunning);
// 判断任务是否已取消
boolean isCancelled();
// 判断任务是否已结束
boolean isDone();
// 获得任务执行结果
get();
// 获得任务执行结果,支持超时机制
get(long timeout, TimeUnit unit);
这两个 get() 方法都是阻塞式的,如果被调用的时候,任务还没有执行完,
那么调用 get() 方法的线程会阻塞,直到任务执行完才会被唤醒
<T> Future<T> submit(Runnable task, T result); 示例代码
public class Test {
public static void main(String[] args) {
ExecutorService executor = Executors.newFixedThreadPool(1);
// 创建Result对象r
Result r = new Result();
r.setAAA(1);
r.setXXX(2);
// 提交任务
Future<Result> future = executor.submit(new Task(r), r);
Result fr = null;
try {
System.out.println("任务执行完成1");
fr = future.get();
System.out.println("任务执行完成2");
} catch (InterruptedException e) {
e.printStackTrace();
} catch (ExecutionException e) {
e.printStackTrace();
}
// 下面等式成立
if (fr == r) {
System.out.println(true);
}
System.out.println(fr);
// ----------------------------打印结果----------------------------
任务执行完成1
Result{a=1, x=2}
任务执行完成2
true
Result{a=3, x=4}
// ----------------------------打印结果----------------------------
}
}
class Task implements Runnable {
Result r;
// 通过构造函数传入result
// Runnable 接口的实现类 Task 声明了一个有参构造函数 Task(Result r) ,
// 创建 Task 对象的时候传入了 result 对象,这样就能在类 Task 的 run() 方法中对 result 进行各种操作了
// result 相当于主线程和子线程之间的桥梁,通过它主子线程可以共享数据
Task(Result r) {
this.r = r;
}
@Override
public void run() {
// 可以操作result
System.out.println(r);
r.setAAA(3);
r.setXXX(4);
}
}
class Result {
private int a;
private int x;
public int getAAA() {
return a;
}
public void setAAA(int a) {
this.a = a;
}
public int getXXX() {
return x;
}
public void setXXX(int x) {
this.x = x;
}
@Override
public String toString() {
return "Result{" + "a=" + a + ", x=" + x + '}';
}
}
Future 是一个接口,而 FutureTask 是一个实实在在的工具类
FutureTask 实现了 Runnable 和 Future 接口,
public class FutureTask<V> implements RunnableFuture<V>
public interface RunnableFuture<V> extends Runnable, Future<V>
由于实现了 Runnable 接口,所以可以将 FutureTask 对象作为任务提交给 ThreadPoolExecutor 去执行,也可以直接被 Thread 执行
又因为实现了 Future 接口,所以也能用来获得任务的执行结果
FutureTask(Callable<V> callable);
FutureTask(Runnable runnable, V result);
下面的示例代码是将 FutureTask 对象提交给 ThreadPoolExecutor 去执行
// 创建FutureTask
FutureTask<Integer> futureTask = new FutureTask<>(() -> 1 + 2);
// 创建线程池
ExecutorService es = Executors.newCachedThreadPool();
// 提交FutureTask
es.submit(futureTask);
// 获取计算结果
try {
Integer result = futureTask.get();
System.out.println(result);
} catch (InterruptedException e) {
e.printStackTrace();
} catch (ExecutionException e) {
e.printStackTrace();
}
FutureTask 对象直接被 Thread 执行的示例代码如下所示,利用 FutureTask 对象可以很容易获取子线程的执行结果
// 创建FutureTask
FutureTask<Integer> futureTask = new FutureTask<>(() -> 1 + 2);
// 创建并启动线程
Thread T1 = new Thread(futureTask);
T1.start();
// 获取计算结果
try {
Integer result = futureTask.get();
System.out.println(result);
} catch (InterruptedException e) {
e.printStackTrace();
} catch (ExecutionException e) {
e.printStackTrace();
}
实现最优的“烧水泡茶”程序
烧水泡茶最优工序
并发编程可以总结为三个核心问题:分工、同步和互斥
- 分工指的是如何高效地拆解任务并分配给线程
用两个线程 T1 和 T2 来完成烧水泡茶程序
其中 T1 在执行泡茶这道工序时需要等待
T2 完成拿茶叶的工序
对于 这个等待动作,使用 Thread.join()、CountDownLatch,甚至阻塞队列
都可以解决,现在用 Future 特性来实现
package com.example.demo.concurrent;
import java.util.concurrent.Callable;
import java.util.concurrent.ExecutionException;
import java.util.concurrent.FutureTask;
import java.util.concurrent.TimeUnit;
public class Tea {
public static void main(String[] args) {
// 创建任务T2的FutureTask
FutureTask<String> ft2 = new FutureTask<>(new T2Task());
// 创建任务T1的FutureTask
FutureTask<String> ft1 = new FutureTask<>(new T1Task(ft2));
// 线程T1执行任务ft1
Thread T1 = new Thread(ft1);
T1.start();
// 线程T2执行任务ft2
Thread T2 = new Thread(ft2);
T2.start();
// 等待线程T1执行结果
try {
String resultStr = ft1.get();
System.out.println(resultStr);
} catch (InterruptedException e) {
e.printStackTrace();
} catch (ExecutionException e) {
e.printStackTrace();
}
}
// 一次执行结果:
// T1:洗水壶...
// T2:洗茶壶...
// T1:烧开水...
// T2:洗茶杯...
// T2:拿茶叶...
// T1:拿到茶叶:龙井
// T1:泡茶...
// 上茶:龙井
}
// T1Task 需要执行的任务:洗水壶、烧开水、泡茶
class T1Task implements Callable<String> {
FutureTask<String> ft2;
// T1任务需要T2任务的FutureTask
T1Task(FutureTask<String> ft2) {
this.ft2 = ft2;
}
@Override
public String call() throws Exception {
System.out.println("T1:洗水壶...");
TimeUnit.SECONDS.sleep(1);
System.out.println("T1:烧开水...");
TimeUnit.SECONDS.sleep(15);
// 获取T2线程的茶叶
// ft1 这个任务在执行泡茶任务前,需要等待 ft2 把茶叶拿来,
// 所以 ft1 内部需要引用 ft2,并在执行泡茶之前,调用 ft2 的 get() 方法实现等待
String tf = ft2.get();
System.out.println("T1:拿到茶叶:" + tf);
System.out.println("T1:泡茶...");
return "上茶:" + tf;
}
}
// T2Task 需要执行的任务: 洗茶壶、洗茶杯、拿茶叶
class T2Task implements Callable<String> {
@Override
public String call() throws Exception {
System.out.println("T2:洗茶壶...");
TimeUnit.SECONDS.sleep(1);
System.out.println("T2:洗茶杯...");
TimeUnit.SECONDS.sleep(2);
System.out.println("T2:拿茶叶...");
TimeUnit.SECONDS.sleep(1);
return "龙井";
}
}
总结
利用 Java 并发包提供的 Future 可以很容易获得异步任务的执行结果,无论异步任务是通过线程池 ThreadPoolExecutor 执行的,还是通过手工创建子线程来执行的
利用多线程可以快速将一些串行的任务并行化,从而提高性能;
如果任务之间有依赖关系,比如当前任务依赖前一个任务的执行结果,这种问题基本上都可以用 Future 来解决。
在分析这种问题的过程中,建议用有向图描述一下任务之间的依赖关系,同时将线程的分工也做好,类似于烧水泡茶最优分工方案那幅图。
对照图来写代码,好处是更形象,且不易出错。
课后思考 – 异步询价并保存
一个询价应用,这个应用需要从三个电商询价,然后保存在自己的数据库里。
核心示例代码如下所示,由于是串行的,所以性能很慢,试着优化一下
// 向电商S1询价,并保存
r1 = getPriceByS1();
save(r1);
// 向电商S2询价,并保存
r2 = getPriceByS2();
save(r2);
// 向电商S3询价,并保存
r3 = getPriceByS3();
save(r3);
注意:
尽量少使用System.out.println, 因为其实现有使用隐式锁,一些情况还会有锁粗化产生
CompletableFuture:异步编程没那么难
多线程优化性能: 将串行操作变成并行操作
//以下两个方法都是耗时操作
doBizA();
doBizB();
主线程无需等待 doBizA() 和 doBizB() 的执行结果,也就是说 doBizA() 和 doBizB() 两个操作已经被异步化了
new Thread(()->doBizA()).start();
new Thread(()->doBizB()).start();
异步化,是并行方案得以实施的基础,更深入地讲其实就是:利用多线程优化性能
这个核心方案得以实施的基础。
Java 在 1.8 版本提供了 CompletableFuture
来支持异步编程
CompletableFuture 的核心优势
烧水泡茶重新分工
优势:
1. 无需手工维护线程,没有繁琐的手工维护线程的工作,给任务分配线程的工作也不需要我们关注;
2. 语义更清晰,例如 f3 = f1.thenCombine(f2, ()->{}) 能够清晰地表述“任务 3 要等待任务 1 和任务 2 都完成后才能开始”;
3. 代码更简练并且专注于业务逻辑,几乎所有代码都是业务逻辑相关的
public class Tea2 {
public static void main(String[] args) {
// 任务1:洗水壶->烧开水
CompletableFuture<Void> f1 =
CompletableFuture.runAsync( -- runAsync 无返回值
() -> {
System.out.println("T1:洗水壶...");
sleep(1, TimeUnit.SECONDS);
System.out.println("T1:烧开水...");
sleep(15, TimeUnit.SECONDS);
});
// 任务2:洗茶壶->洗茶杯->拿茶叶
CompletableFuture<String> f2 =
CompletableFuture.supplyAsync( -- supplyAsync 有返回值
() -> {
System.out.println("T2:洗茶壶...");
sleep(1, TimeUnit.SECONDS);
System.out.println("T2:洗茶杯...");
sleep(2, TimeUnit.SECONDS);
System.out.println("T2:拿茶叶...");
sleep(1, TimeUnit.SECONDS);
return "龙井";
});
// 任务3:任务1和任务2完成后执行:泡茶
CompletableFuture<String> f3 =
f1.thenCombine(
f2,
(__, tf) -> {
System.out.println("T1:拿到茶叶:" + tf);
System.out.println("T1:泡茶...");
return "上茶:" + tf;
});
// 等待任务3执行结果
System.out.println(f3.join());
// 一次执行结果:
// T1:洗水壶...
// T2:洗茶壶...
// T1:烧开水...
// T2:洗茶杯...
// T2:拿茶叶...
// T1:拿到茶叶:龙井
// T1:泡茶...
// 上茶:龙井
}
public static void sleep(int t, TimeUnit u) {
try {
u.sleep(t);
} catch (InterruptedException e) {
}
}
// TimeUnit 中调用 Thread.sleep() 方法
public void sleep(long timeout) throws InterruptedException {
if (timeout > 0) {
long ms = toMillis(timeout);
int ns = excessNanos(timeout, ms);
Thread.sleep(ms, ns);
}
}
}
创建 CompletableFuture 对象
// 使用默认线程池
static CompletableFuture<Void> runAsync(Runnable runnable)
static <U> CompletableFuture<U> supplyAsync(Supplier<U> supplier)
区别: Runnable 接口的 run() 方法没有返回值,而 Supplier 接口的 get() 方法是有返回值的。
// 可以指定线程池
static CompletableFuture<Void> runAsync(Runnable runnable, Executor executor)
static <U> CompletableFuture<U> supplyAsync(Supplier<U> supplier, Executor executor)
默认情况下 CompletableFuture 会使用公共的 ForkJoinPool 线程池,
这个线程池默认创建的线程数是 CPU 的核数
(也可以通过 ??? 来设置 ForkJoinPool 线程池的线程数)。
JVM option:-Djava.util.concurrent.ForkJoinPool.common.parallelism
如果所有 CompletableFuture 共享一个线程池,那么一旦有任务执行一些很慢的 I/O 操作,
就会导致线程池中所有线程都阻塞在 I/O 操作上,从而造成线程饥饿,进而影响整个系统的性能。
所以,强烈建议要根据不同的业务类型创建不同的线程池,以避免互相干扰。
创建完 CompletableFuture 对象之后,会自动地异步执行 runnable.run() 方法
或者 supplier.get() 方法
,对于一个异步操作,需要关注两个问题:
一个是异步操作什么时候结束,另一个是如何获取异步操作的执行结果。
因为 CompletableFuture 类实现了 Future 接口,所以这两个问题都可以通过 Future 接口来解决。另外,CompletableFuture 类还实现了 CompletionStage 接口
,这个接口内容实在是太丰富了,在 1.8 版本里有 40 个方法,这些方法该如何理解呢?
如何理解 CompletionStage 接口
站在分工的角度类比一下工作流。任务是有时序关系的,比如有串行关系、并行关系、汇聚关系
等。
串行关系
并行关系
汇聚关系
CompletionStage 接口可以清晰地描述任务之间的这种时序关系,例如前面提到的 f3 = f1.thenCombine(f2, ()->{})
描述的就是一种汇聚关系。
烧水泡茶程序中的汇聚关系是一种 AND 聚合关系,这里的 AND 指的是所有依赖的任务(烧开水和拿茶叶)都完成后才开始执行当前任务(泡茶)。
既然有 AND 聚合关系
,那就一定还有 OR 聚合关系
,所谓 OR 指的是依赖的任务只要有一个完成就可以执行当前任务
。
CompletionStage 接口也可以方便地描述异常处理
- 描述串行关系CompletionStage 接口里面描述串行关系,主要是
thenApply、thenAccept、thenRun 和 thenCompose
这四个系列的接口。
CompletionStage<R> thenApply(fn);
CompletionStage<R> thenApplyAsync(fn);
thenApply 系列函数里参数 fn 的类型是接口 Function,
这个接口里与 CompletionStage 相关的方法是 R apply(T t),
这个方法既能接收参数也支持返回值,所以 thenApply 系列方法返回的是CompletionStage
CompletionStage<Void> thenAccept(consumer);
CompletionStage<Void> thenAcceptAsync(consumer);
thenAccept 系列方法里参数 consumer 的类型是接口Consumer,
这个接口里与 CompletionStage 相关的方法是 void accept(T t),
这个方法虽然支持参数,但却不支持回值,所以 thenAccept 系列方法返回的是CompletionStage
CompletionStage<Void> thenRun(action);
CompletionStage<Void> thenRunAsync(action);
thenRun 系列方法里 action 的参数是 Runnable,
所以 action 既不能接收参数也不支持返回值,所以 thenRun 系列方法返回的也是CompletionStage
CompletionStage<R> thenCompose(fn);
CompletionStage<R> thenComposeAsync(fn);
注意 thenCompose 系列方法,这个系列的方法会新创建出一个子流程,最终结果和 thenApply 系列是相同的
这些方法里面 Async 代表的是异步执行 fn、consumer 或者 action。
thenApply()示例代码:
CompletableFuture<String> f0 =
CompletableFuture.supplyAsync(() -> "Hello World") // ① 首先通过 supplyAsync() 启动一个异步流程, 有返回值
.thenApply(s -> s + " QQ") // ② 之后是两个串行操作
.thenApply(String::toUpperCase); // ③
System.out.println(f0.join());
// 输出结果: HELLO WORLD QQ
虽然这是一个异步流程,但任务①②③却是串行执行的,②依赖①的执行结果,③依赖②的执行结果。
- 描述 AND 汇聚关系
CompletionStage 接口里面描述 AND 汇聚关系
区别也是源自 fn、consumer、action 这三个核心参数不同
CompletionStage<R> thenCombine(other, fn);
CompletionStage<R> thenCombineAsync(other, fn);
CompletionStage<Void> thenAcceptBoth(other, consumer);
CompletionStage<Void> thenAcceptBothAsync(other, consumer);
CompletionStage<Void> runAfterBoth(other, action);
CompletionStage<Void> runAfterBothAsync(other, action);
- 描述 OR 汇聚关系
区别也是源自 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<String> f1 =
CompletableFuture.supplyAsync(
() -> {
int t = getRandom(5, 10);
sleep(t, TimeUnit.SECONDS);
return String.valueOf(t);
});
CompletableFuture<String> f2 =
CompletableFuture.supplyAsync(
() -> {
int t = getRandom(5, 10);
sleep(t, TimeUnit.SECONDS);
return String.valueOf(t);
});
CompletableFuture<String> f3 = f1.applyToEither(f2, s -> s);
System.out.println(f3.join());
- 异常处理
fn、consumer、action 它们的核心方法都不允许抛出可检查异常,但是却无法限制它们抛出运行时异常
运行时异常: 除零错误 7/0
CompletableFuture<Integer> f0 =
CompletableFuture.supplyAsync(() -> (7 / 0)).thenApply(r -> r * 10);
System.out.println(f0.join());
支持链式编程
CompletionStage exceptionally(fn);
CompletionStage<R> whenComplete(consumer);
CompletionStage<R> whenCompleteAsync(consumer);
CompletionStage<R> handle(fn);
CompletionStage<R> handleAsync(fn);
示例代码, 使用exceptionally() 方法来处理异常
类似于 try{}catch{}中的 catch{}
支持链式编程
CompletableFuture<Integer> f0 =
CompletableFuture.supplyAsync(() -> (7 / 0)).thenApply(r -> r * 10).exceptionally(e -> 0);
System.out.println(f0.join());
whenComplete() 和 handle() 系列方法
类似于 try{}finally{}中的 finally{}
无论是否发生异常都会执行 whenComplete() 中的回调函数 consumer 和 handle() 中的回调函数 fn
whenComplete() 和 handle() 的区别在于 whenComplete() 不支持返回结果,而 handle() 是支持返回结果的
summary
回调地狱(Callback Hell)
ReactiveX, Java 语言的实现版本是 RxJava
CompletableFuture 已经能够满足简单的异步编程需求,如果对异步编程感兴趣,可以重点关注 RxJava 这个项目,
利用 RxJava,即便在 Java 1.6 版本也能享受异步编程的乐趣
思考 – 共享线程池有难同当, 使用隔离的线程池
创建采购订单的时候,需要校验一些规则,例如最大金额是和采购员级别相关的。
有同学利用 CompletableFuture 实现了这个校验的功能,逻辑很简单,首先是从数据库中把相关规则查出来,然后执行规则校验。你觉得他的实现是否有问题呢?
//采购订单
PurchersOrder po;
CompletableFuture<Boolean> cf =
CompletableFuture.supplyAsync(()->{
//在数据库中查询规则
return findRuleByJdbc();
}).thenApply(r -> {
//规则校验
return check(po, r);
});
Boolean isOk = cf.join();
官方解析:
没有异常处理、逻辑不严谨等等..
重点关注 findRuleByJdbc() 这个方法隐藏着一个阻塞式 I/O,这意味着会阻塞调用线程
默认情况下所有的 CompletableFuture 共享一个 ForkJoinPool,
当有阻塞式 I/O 时,可能导致所有的 ForkJoinPool 线程都阻塞,进而影响整个系统的性能
1,读数据库属于io操作,应该放在单独线程池,避免线程饥饿
2,异常未处理
3, 如果检查和查询都比较耗时,那么应该像之前的对账系统一样,采用生产者和消费者模式,让上一次的检查和下一次的查询并行起来
4, 查出来的结果做为下一步处理的条件,若结果为空呢,没有对应处理
并发包里面 countdownLatch , 或者 threadPoolExecutor
优先使用CompletableFuture
,当然前提是你的jdk是1.8
CompletionService:如何批量执行异步任务?
Future:如何用多线程实现最优的“烧水泡茶”程序?
思考题
如果采用“ThreadPoolExecutor+Future”的方案
用三个线程异步执行询价,通过三次调用 Future 的 get() 方法获取询价结果,之后将询价结果保存在数据库中
// 创建线程池
ExecutorService executor = Executors.newFixedThreadPool(3);
// 异步向电商S1询价
Future<Integer> f1 = executor.submit(() -> getPriceByS1());
// 异步向电商S2询价
Future<Integer> f2 = executor.submit(() -> getPriceByS2());
// 异步向电商S3询价
Future<Integer> f3 = executor.submit(() -> getPriceByS3());
// 获取电商S1报价并保存
r = f1.get();
executor.execute(() -> save(r));
// 获取电商S2报价并保存
r = f2.get();
executor.execute(() -> save(r));
// 获取电商S3报价并保存
r = f3.get();
executor.execute(() -> save(r));
小瑕疵: 如果获取电商 S1 报价的耗时很长,那么即便获取电商 S2 报价的耗时很短,也无法让保存 S2 报价的操作先执行,
因为这个主线程都阻塞在了 f1.get() 操作上
解决方案:
增加一个阻塞队列,获取到 S1、S2、S3 的报价都进入阻塞队列,然后在主线程中消费阻塞队列,这样就能保证先获取到的报价先保存到数据库
// 创建阻塞队列
BlockingQueue<Integer> bq = new LinkedBlockingQueue<>();
// 电商S1报价异步进入阻塞队列
executor.execute(() -> bq.put(f1.get()));
// 电商S2报价异步进入阻塞队列
executor.execute(() -> bq.put(f2.get()));
// 电商S3报价异步进入阻塞队列
executor.execute(() -> bq.put(f3.get()));
// 异步保存所有报价
for (int i = 0; i < 3; i++) {
Integer r = bq.take();
executor.execute(() -> save(r));
}
利用 CompletionService 实现询价系统 – 异步询价并保存
不但能解决先获取到的报价先保存到数据库的问题,而且还能让代码更简练
实现原理:
内部维护了一个阻塞队列,当任务执行结束就把任务的执行结果加入到阻塞队列中,
不同的是 CompletionService 是把任务执行结果的 Future 对象
加入到阻塞队列中,
而上面的示例代码是把任务最终的执行结果放入了阻塞队列中
CompletionService 接口的实现类是 ExecutorCompletionService,
这个实现类的构造方法有两个,分别是:
ExecutorCompletionService(Executor executor)
ExecutorCompletionService(Executor executor, BlockingQueue> completionQueue)
如果不指定 completionQueue,那么默认会使用无界的 LinkedBlockingQueue -- 加入任务执行结果的 Future 对象
// 创建线程池
ExecutorService executor = Executors.newFixedThreadPool(3);
// 创建CompletionService, 默认使用无界的 LinkedBlockingQueue
CompletionService<Integer> cs = new ExecutorCompletionService<>(executor);
// 异步向电商S1询价
cs.submit(() -> getPriceByS1());
// 异步向电商S2询价
cs.submit(() -> getPriceByS2());
// 异步向电商S3询价
cs.submit(() -> getPriceByS3());
// 将询价结果异步保存到数据库
for (int i = 0; i < 3; i++) {
Integer r = null;
try {
r = cs.take().get();
} catch (InterruptedException e) {
e.printStackTrace();
} catch (ExecutionException e) {
e.printStackTrace();
}
executor.execute(() -> save(r));
}
CompletionService 接口说明
Future<V> submit(Callable<V> task);
Future<V> submit(Runnable task, V result);
-- 类似于 ThreadPoolExecutor 的 Future submit(Runnable task, T result)
take()、poll() 都是从阻塞队列中获取并移除一个元素;
区别在于如果阻塞队列是空的,那么调用 take() 方法的线程会被阻塞,而 poll() 方法会返回 null 值。
Future<V> take() throws InterruptedException;
Future<V> poll();
Future<V> poll(long timeout, TimeUnit unit) throws InterruptedException;
poll(long timeout, TimeUnit unit) 方法支持以超时的方式获取并移除阻塞队列头部的一个元素,
如果等待了 timeout unit 时间,阻塞队列还是空的,那么该方法会返回 null 值。
利用 CompletionService 实现 Dubbo 中的 Forking Cluster
Dubbo, Forking 的集群模式:
支持并行地调用多个查询服务,只要有一个成功返回结果,整个服务就可以返回
geocoder(addr) {
//并行执行以下3个查询服务,
r1=geocoderByS1(addr);
r2=geocoderByS2(addr);
r3=geocoderByS3(addr);
//只要r1,r2,r3有一个返回
//则返回
return r1|r2|r3;
}
// 创建线程池
ExecutorService executor = Executors.newFixedThreadPool(3);
// 创建CompletionService
CompletionService<Integer> cs = new ExecutorCompletionService<>(executor);
// 用于保存Future对象
List<Future<Integer>> futures = new ArrayList<>(3);
// 提交异步任务,并保存future到futures
futures.add(cs.submit(() -> geocoderByS1()));
futures.add(cs.submit(() -> geocoderByS2()));
futures.add(cs.submit(() -> geocoderByS3()));
// 获取最快返回的任务执行结果
Integer r = 0;
try {
// 只要有一个成功返回,则break
for (int i = 0; i < 3; ++i) {
try {
r = cs.take().get();
} catch (InterruptedException e) {
e.printStackTrace();
} catch (ExecutionException e) {
e.printStackTrace();
}
// 简单地通过判空来检查是否成功返回
if (r != null) {
break;
}
}
} finally {
// 取消所有任务
for (Future<Integer> f : futures) {
f.cancel(true); -- 只要拿到一个正确返回的结果,就可以取消所有任务并且返回最终结果
}
}
// 返回结果
return r;
总结
批量提交异步任务的时候建议使用 CompletionService:
将线程池 Executor 和阻塞队列 BlockingQueue 的功能融合在了一起,能够让批量异步任务的管理更简单
CompletionService 能够让异步任务的执行结果有序化,先执行完的先进入阻塞队列,
利用这个特性,可以轻松实现后续处理的有序性,避免无谓的等待,同时还可以快速实现诸如 Forking Cluster 这样的需求
CompletionService 的实现类 ExecutorCompletionService,需要你自己创建线程池,虽看上去有些啰嗦,但好处是你可以让多个 ExecutorCompletionService 的线程池隔离,这种隔离性能避免几个特别耗时的任务拖垮整个应用的风险。
课后思考
本章使用 CompletionService 实现了一个询价应用的核心功能,后来又有了新的需求,需要计算出最低报价并返回,下面的示例代码是否存在问题呢?
// 创建线程池
ExecutorService executor = Executors.newFixedThreadPool(3);
// 创建CompletionService
CompletionService<Integer> cs = new ExecutorCompletionService<>(executor);
// 异步向电商S1询价
cs.submit(() -> getPriceByS1());
// 异步向电商S2询价
cs.submit(() -> getPriceByS2());
// 异步向电商S3询价
cs.submit(() -> getPriceByS3());
// 将询价结果异步保存到数据库, 并计算最低报价
AtomicReference<Integer> m = new AtomicReference<>(Integer.MAX_VALUE);
for (int i = 0; i < 3; i++) {
executor.execute(
() -> {
Integer r = null;
try {
r = cs.take().get();
} catch (Exception e) {
}
save(r);
m.set(Integer.min(m.get(), r));
});
}
return m;
需要等待三个线程执行完成
AtomicReference<Integer> m = new AtomicReference<>(Integer.MAX_VALUE);
CountDownLatch latch = new CountDownLatch(3);
for (int i = 0; i < 3; i++) {
executor.execute(
() -> {
Integer r = null;
try {
r = cs.take().get();
} catch (Exception e) {
}
save(r);
m.set(Integer.min(m.get(), r));
latch.countDown();
});
latch.await();
return m;
Fork/Join:单机版的MapReduce
对于简单的并行任务,你可以通过“线程池 +Future”的方案来解决;
如果任务之间有聚合关系,无论是 AND 聚合还是 OR 聚合,都可以通过 CompletableFuture 来解决;
而批量的并行任务,则可以通过 CompletionService 来解决。
分工 – 线程池、Future、CompletableFuture 和 CompletionService
、协作和互斥
从上到下,依次为简单并行任务、聚合任务和批量并行任务示意图
没有覆盖到分治模型:
分治,顾名思义,即分而治之,是一种解决复杂问题的思维方法和模式;具体来讲,指的是把一个复杂的问题分解成多个相似的子问题,然后再把子问题分解成更小的子问题,直到子问题简单到可以直接求解
Java 并发包里提供了一种叫做 Fork/Join 的并行计算框架,就是用来支持分治这种任务模型的
分治任务模型
分治任务阶段:
-
任务分解,也就是将任务迭代地分解为子任务,直至子任务可以直接计算出结果;
-
结果合并,即逐层合并子任务的执行结果,直至获得最终结果
简版分治任务模型图
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() 方法是有返回值的。
- 这两个子类也是抽象类,在使用的时候,需要你定义子类去扩展。
public static void main(String[] args) {
// 创建分治任务线程池
ForkJoinPool fjp = new ForkJoinPool(4);
// 创建分治任务
Fibonacci fib = new Fibonacci(30);
// 通过调用分治任务线程池的 invoke() 方法来启动分治任务
Integer result = fjp.invoke(fib);
// 输出结果
System.out.println(result);
}
// 递归任务
// 需要有返回值,所以 Fibonacci 继承自 RecursiveTask
public static class Fibonacci extends RecursiveTask<Integer> {
final int n;
Fibonacci(int n) {
this.n = n;
}
@Override
protected Integer compute() {
if (n <= 1) {
return n;
}
Fibonacci f1 = new Fibonacci(n - 1);
// 通过 f1.fork() 实现, 创建异步子任务, 计算 Fibonacci(n - 1)
f1.fork();
Fibonacci f2 = new Fibonacci(n - 2);
// 等待子任务结果,并合并结果
return f2.compute() + f1.join();
}
}
ForkJoinPool 工作原理
Fork/Join 并行计算的核心组件是 ForkJoinPool
- ThreadPoolExecutor 本质上是一个生产者 - 消费者模式的实现,内部有一个任务队列,这个
任务队列是生产者和消费者通信的媒介
;ThreadPoolExecutor 可以有多个工作线程,但是这些工作线程都共享一个任务队列
ForkJoinPool 工作原理图
ThreadPoolExecutor 内部只有一个任务队列,而 ForkJoinPool 内部有多个任务队列
,当通过 ForkJoinPool 的 invoke() 或者 submit() 方法
提交任务时,ForkJoinPool 根据一定的路由规则
把任务提交到一个任务队列中,如果任务在执行过程中会创建出子任务,那么子任务会提交到工作线程对应的任务队列中
任务窃取”机制:
如果工作线程空闲了,那它可以“窃取”其他工作任务队列里的任务
如上图,线程 T2 对应的任务队列已经空了,它可以“窃取”线程 T1 对应的任务队列的任务
如此一来,所有的工作线程都不会闲下来了
ForkJoinPool 中的任务队列采用的是双端队列
,工作线程正常获取任务和“窃取任务”分别是从任务队列不同的端
消费,这样能避免很多不必要的数据竞争
模拟 MapReduce 统计单词数量
学习 MapReduce 有一个入门程序,统计一个文件里面每个单词的数量,
如何用 Fork/Join 并行计算框架实现?
先用二分法递归地将一个文件拆分成更小的文件,直到文件里只有一行数据,
然后统计这一行数据里单词的数量,最后再逐级汇总结果
package com.example.demo.concurrent;
import java.util.HashMap;
import java.util.Map;
import java.util.concurrent.ForkJoinPool;
import java.util.concurrent.RecursiveTask;
// MR模拟类, 继承RecursiveTask, 有返回值
class MR extends RecursiveTask<Map<String, Long>> {
// 用一个字符串数组 String[] fc 来模拟文件内容,fc 里面的元素与文件里面的行数据一一对应
private String[] fc;
private int start, end;
// 构造函数
MR(String[] fc, int fr, int to) {
this.fc = fc;
this.start = fr;
this.end = to;
}
// 递归方法,前半部分数据 fork 一个递归任务去处理(关键代码 mr1.fork()),
// 后半部分数据则在当前任务中递归处理(mr2.compute()
@Override
protected Map<String, Long> 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<String, Long> merge(Map<String, Long> r1, Map<String, Long> r2) {
Map<String, Long> result = new HashMap<>();
result.putAll(r1);
// 合并结果
r2.forEach(
(k, v) -> {
Long c = result.get(k);
if (c != null) {
result.put(k, c + v);
} else {
result.put(k, v);
}
});
return result;
}
// 统计单词数量
private Map<String, Long> calc(String line) {
Map<String, Long> result = new HashMap<>();
// 分割单词, \s 匹配任何空白字符,包括空格、制表符、换页符等等, + 前面的字符出现1次或多次
String[] words = line.split("\\s+");
// 统计单词数量
for (String w : words) {
Long v = result.get(w);
if (v != null) {
result.put(w, v + 1);
} else {
result.put(w, 1L);
}
}
return result;
}
} // class MR
public class ForkJoin2 {
public static void main(String[] args) {
String[] fc = {"hello world", "hello me", "hello fork", "hello join", "fork join in world"};
// 创建ForkJoin线程池
ForkJoinPool fjp = new ForkJoinPool(3);
// 创建任务
MR mr = new MR(fc, 0, fc.length);
// 启动任务
Map<String, Long> result = fjp.invoke(mr);
// 输出结果
result.forEach((k, v) -> System.out.println(k + ":" + v));
}
}
总结
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 的论文
思考
对于一个 CPU 密集型计算程序,在单核 CPU 上,使用 Fork/Join 并行计算框架是否能够提高性能呢?
答: CPU同一时间只能处理一个线程,所以理论上,纯cpu密集型计算任务单线程就够了。多线程的话,线程上下文切换带来的线程现场保存和恢复也会带来额外开销
。但实际上可能要经过测试才知道。
额外:
用两次fork()在join的时候,需要用这样的顺序:a.fork(); b.fork(); b.join(); a.join();这个要求在JDK官方文档里有说明。
如果是一不小心写成a.fork(); b.fork(); a.join(); b.join();就会有大神廖雪峰说的问题。
建议还是用fork()+compute(),这种方式的执行过程普通人还是能理解的,fork()+fork()内部做了很多优化,我这个普通人看的实在是头痛。
用这篇文章的例子理解fork()+compute()很到位。
线上问题定位的利器:线程栈 dump
本质上都是定位线上并发问题,方案很简单,就是通过查看线程栈来定位问题。重点是查看线程状态,分析线程进入该状态的原因是否合理
为了便于分析定位线程问题,你需要给线程赋予一个有意义的名字,对于线程池可以通过自定义 ThreadFactory 来给线程池中的线程赋予有意义的名字,也可以在执行 run() 方法时通过Thread.currentThread().setName();来给线程赋予一个更贴近业务的名字