文章目录
前言
我对线程、并发、异步还没有清晰的认识,在书的15、16和17章讲了Java的并发,第17章讲了Java9才有的FlowApi,暂时不记录,也不会完全按照书上的内容写,相当于筛选一些我想记的信息。
一.什么是并发?什么是并行?
首先,CPU在处理多个任务时是通过调度和上下文切换实现的,只不过切换的速度非常快,所以看起来是多个任务在同时进行,这就是并发,在一个处理器上处理多个任务,给你一个同时运行的假象。
而并行是在多台处理器上同时处理多个任务,多核处理器可以实现。
二.Java8并发的背景
近年来(指2016年之前),程序员受到两股潮流的影响。
一是硬件平台的升级对程序运行产生了影响,由于多核处理器的出现,我们可以把一个大的任务分成很多小的子任务,让它们独立并发运行于多个核上,比如fork/join框架和并行流,其效率甚至比使用线程还高。
二是互联网应用的新潮流,使用微服务架构的应用越来越多,应用被分为很多小型服务,彼此之间互相通信,我们开发个应用可以去调用某些企业提供的API,比如你需要一个翻译的功能,那么你完全可以去申请使用谷歌翻译的API。
这两个潮流是相辅相成的,当你使用第三方API,请求并等待外部服务的相应时,其他的任务肯定是不能停止的。
那么并行是怎么做的呢,书的第7章介绍了fork/join框架和并行流,把任务切分为子任务,分配到不同的核、CPU或机器上并行运行。
那我要是去做并发呢,我们的任务是当等待远程服务响应时,尽可能让核都忙碌起来,从而最大化应用的吞吐量,避免浪费计算资源。
为了解决这个问题,Java8提供了Future
接口,还有CompletableFuture
接口。
三.为了支持并发而不断演进的Java
1. 线程以及更高层的抽象
线程是什么?进程是什么?
一个单CPU的计算机支持多个用户,因为操作系统为每个用户分配了一个进程,这些进程由独立的虚拟地址空间。
一个进程可以请求操作系统给它一个或多个线程——它们跟主进程之间共享地址空间,因此可以并发执行并相互协调。
那么在一个多核的环境中呢?如果你的程序没有使用多线程,那就只能用到一个核。
在以前,我们想用多线程,就得声明使用,现在我们可以直接用Stream:
int sum = Arrays.stream(stats).parallel().sum;
这里想说的意思是,对并行流的迭代是比显示使用线程更高级的概念。换句话说,流是一种对线程使用模式的抽象。
还有java.util.concurrent.RecurisiveTask,它是对fork/join的抽象,写起来更方便。
在学习更多的线程抽象方法之前,我们先来看看Java5引入的ExecutorService,以及这些方法的基础——线程池。
2.执行器和线程池
Java的线程直接访问系统的线程,所以有很多问题,比如创建删除线程的代价很大,线程数量是有上限的,因此Java5提供了线程池的方法。
Java的ExecutorService
提供了接口,我们可以创建一个线程池:
ExecutorService newFixedThreadPool(int nThreads)
这个东西的作用就是管理线程,新创建的线程被放入线程池,有新任务请求时,按照先来先到的策略去线程池中选取为被使用的线程来执行任务,用完再放回去,这种方法可以以很低的成本向线程池提交上千个任务,同时保证硬件匹配的任务执行。
请注意这里的术语:程序员提供任务(可以是一个Runnable或者Callable),由线程负责执行。
在大多数情况下,用线程池比直接操纵线程要好,但是线程池也有问题:
(1)任务超出池的容量,就会排队等候,有阻塞式I/O任务时,或线程进入了睡眠,会有长时间的等待,程序的吞吐量大大降低;如果任务需要后续的任务响应,那就会死锁。
(2)通常情况下,Java从main返回前,会等待所有线程执行完毕,避免误杀线程。一个好习惯是,推出程序执行之前,确保关闭所有线程池。实战中经常用一个长时间运行的线程池管理需要运行的网络服务。Java也提供了Thread.setDaemon
方法来控制这种行为。
3.非嵌套方法调用
让我们再来看看并发和并行的不同。
先来看看并行:
无论什么时候,任何任务(或者线程)在方法调用启动时,都会在其返回之前调用同一个方法。换句话说,线程创建以及与其匹配的join()在调用返回的嵌套方法调用中都以嵌套的方式成对出现。这种思想被称为严格fork/join。
这是什么意思?
大概就是说fork/join是嵌套的方法调用,线程的创建和执行的方法是严格组合在一切的。
那就有非嵌套的方法:异步方法
异步方法中,用户调用方法创建的线程可能比调用方法的声明周期还长,意思就是这个方法所派生出的任务会继续执行调用方法来完成工作。
不过异步方法也有弊端:
(1)子线程与执行方法调用的代码会并发执行,因此需要避免出现数据竞争的情况。
(2)怎么处理线程的结束和程序执行的结束?要么等待所有线程结束再结束程序,但如果有线程无法顺利结束就有问题;要么杀死有问题的线程,但这样可能会导致数据出问题。所以需要保证你能有效地追踪线程,且退出程序(包括线程的关闭)之前必须加入这些线程。
依据有没有执行setDaemon()方法,Java线程可以被划分为守护进程以及非守护进程。守护进程的线程在退出时就被终止,而从主程序返回的线程还得等待,直到所有非守护线程都终止了,应用才能推出程序。
四.同步和异步API
在前面我们学过Stream流,流使用内部迭代替换外部迭代(for循环),还可以用parallel()并发处理,程序员不用再去想着怎么自己处理线程了。
并行操作除了能给迭代带来优化,还有一个很重要的好处,那就是异步API。
让我们来看看:
int f(int x);
int g(int y);
首先,这两个函数签名都是同步API,因为它们物理上返回时,执行结果也一起返回了。
当我们调用这两个函数时,我们想要进行优化,减少运行的时间,一个做法是让它们并行执行,这样实际时间就取两个函数中较长的那个。
让我们来看看怎么实现:
int left, right;
Thread t1 = new Thread(() -> {left = f(x)});
Thread t2 = new Thread(() -> {right = g(x)});
t1.start();
t2.start();
t1.join();
t2.join();
我们可以进行优化,比如使用Future
和ExecutorService
的线程池:
ExecutorService executorService = ExecutorService.newFixedThreadPool(2);
Future<Integer> y = executorService.submit(() -> f(x));
Future<Integer> z = executorService.submit(() -> f(x));
left = y.get();
right = z.get();
executorService.shutdoun();
想想Stream,我们用内部迭代替代了显示的外部迭代,而这里我们写的是显示调用线程,能不能不这么写?
答案是将同步API转化为异步API。
注:由于书上这部分看的我摸不着头脑,所以跳过了
后面还有一些东西,也跳过了
先看一下后面16章给出的定义吧:
同步API其实只是对传统调用的另一种称呼:你调用了某个方法,调用方在方法执行的过程中会等待,被调用方执行结束返回,调用方取得被调用方的返回值并继续运行。即使调用方和被调用方可以在不同的线程中运行,调用方还是要等待被调用方返回,这就是阻塞式调用名字的由来。
这很好理解,再来看看异步API:
异步API在被调用方计算完成之前,将它剩余的计算任务交给另一个线程去做,该线程和调用方是异步的——这就是非阻塞调用的由来。
执行计算的线程会将结果返回给调用方。返回方式要么是通过回调函数,要么由调用方发起一个“等待,指导计算完成”的方法调用。
异步API不会在等着被调用方返回结果才继续执行,可以发起个请求,然后就干别的事去了,需要得到结果的时候,可以再发个请求,也可以提前设个回调函数返回。
五. 实现异步编程
1.使用Future接口
采用Future接口可以对异步计算进行建模,返回一个指向执行结果的引用,运算结束后,调用方可以通过该引用访问执行的结果。在Future中触发那些可能耗时的调用,可以将调用线程解放出来,让它们继续执行其他有价值的工作。
// 使用Future以异步的方式执行一个耗时的操作。
ExecutorService executorService = Executors.newCachedThreadPool();
Future<Double> future = executorService.submit(new Callable<Double>() {
@Override
public Double call() throws Exception {
return doSomeLongComputation();
}
});
doSomethingElse(); // 异步操作进行时,可以干别的事
try {
Double result = future.get(1, TimeUnit.SECONDS); // 获取异步操作的结果 如果阻塞了,等待1秒
}catch (Exception ee){
// 计算抛出一个异常
}catch (InterruptedException ie){
// 当前线程在等待过程中被中断
}catch (TimeoutException te){
// 在Future对象完成前超过已过期
}
这样的方式实现了异步运行,两个计算相互独立,但又伴随着依赖。
在这里,为了避免不返回结果导致的阻塞,使用get()
方法返回,除此之外还有isDone()
方法检测异步操作是否结束。
但是显然,这样的写法不够简洁。
2.使用CompletableFyture接口
public Future<Double> getPriceAsync(String product){ // 一个最佳价格查询方法,为了实现异步,将返回值写为Future<Double>
CompletableFuture<Double> futurePrice = new CompletableFuture<>(); // 用来包含计算的结果
new Thread(() -> {
double price = calculatePrice(product); //一个要计算很久的方法
futurePrice.complete(price); // 对于需要长时间计算得到的结果,用CompletableFuture接收
}).start();
return futurePrice; // 直接返回,不用等待计算结束
}
//使用上述方法
Future<Double> futurePrice = getPriceAsync("apple");
//干点别的
doSomethingElse();
try {
double price = futurePrice.get(); //获取结果,如果计算不出来,就会阻塞
}catch(Exception e){
throw new RuntimeException(e);
}
异步API体现在getPriceAsync方法会直接返回,但实际计算的结果在请求时才会返回。
相比于Future,使用CompleFuture将异步操作的结果封装了起来。
显然,这段代码有阻塞的问题,怎么去管理异步任务执行时可能存在的问题呢?
public Future<Double> getPriceAsync(String product){
CompletableFuture<Double> futurePrice = new CompletableFuture<>();
new Thread(() -> {
try{
double price = calculatePrice(product);
futurePrice.complete(price);
}catch (Exception ex){
futurePrice.completableExceptionally(ex); // 抛出异常
}).start();
return futurePrice;
}
好了,这下这段代码看起来十分臃肿,我们可以使用supplyAsync
public Future<Double> getPriceAsync(String product){
return CompletableFuture.supplyAsync( () -> calculatePrice(product));
}
supplyAsync
接收一个生产者方法,返回一个CompletableFuture
对象,该对象完成异步执行后会读取生产者方法的返回值。
生产者方法交由ForkJoinPool
池中的某个执行线程,你也可以使用supplyAsync
的重载方法传入第二个参数指定不同执行线程的生产者方法。
supplyAsync
方法中就有错误管理机制,避免阻塞情况。
3.CompletableFuture与并行流
public List<String> findPrice(String product) {
// 根据商品名称从shop列表中获取该商品在不同shop的价格
return shop.parallelStream() // 并行流
.map(shop -> String.format("%s price is %f", shop.getName(), shop.getPrice(product)))
.collect(toList());
}
public List<String> findPrice(String product) {
// 并发实现
List<CompletableFuture<String>> priceFutures = shop.stream()
.map(shop -> CompletableFuture.supplyAsync(
() -> String.format("%s price is %f", shop.getName(), shop.getPrice(product))))
.collect(toList());
return priceFutures.stream()
.map(CompletableFuture::join) // 获取结果 等待所有异步执行结束
.collect(toList());
/**
get() 返回经过经检查的异常,可被捕获,自定义处理或者直接抛出。
join() 会抛出未经检查的异常。
*/
}
那么,使用并行流还是并发呢?
如果你进行的是计算密集型的操作,并且没有I/O,那么推荐使用Stream。
如果设计I/O操作(还有网络等待),那么使用并发灵活性更好。
4.定制执行器
private final Executor exrcutor =
// 创建一个由守护线程构成的线程池 线程数目为100和shop数中较小的值
Executor.newFixedThreadPool(Math.min(shops.size(), 100),
(Runnable r) -> {
Thread t = new Thread(r);
t.setDaemon(true);
return t;
}
};
public Future<Double> getPriceAsync(String product){
// 使用守护线程
return CompletableFuture.supplyAsync(() -> calculatePrice(product), executor);
}
当一个普通线程在执行时,如果程序无法终止或者退出,最后剩下的线程会由于一直等待结果而阻塞,如果将其标记为守护线程,则意味着程序退出时它也会被回收。
六.对多个异步任务进行流水线操作
书上以一个例子来解释如何用,这里省略了一部分具体的功能实现,主要想关注于异步操作
public List<String> findPrices(String product){
return shops.stream()
.map(shop -> shop.getPrice(product)) // 获取shop名称+商品的价格+折扣率
.map(Quotr::parse) // 转换成一个Quotr对象,包含shop名称,商品折扣前价格,折扣率
.map(Discount::applyDiscount) // Discount是个远程服务,用来申请折扣,但有时间延迟,最后返回字符串
.collect(toList());
}
显然,由于有延迟的Discount服务,使用stream
流水线式的执行,必然导致很长的时间消耗。
1.构造同步和异步操作
使用CompletableFuture
解决问题:
public List<String> findPrices(String product){
List<CompletableFuture<String>> priceFutures =
shops.stream()
.map(shop -> ComptableFuture.supplyAsync( // 以异步方式获取
() -> shop.getPrice(product), executor) // 使用Executor执行器
.map(future -> future.thenApply(Quotr::parse)) // 使用thenApply
.map(future -> future.thenCompose( // 使用thenCompose
quote -> ComptableFuture.supplyAsync(
() -> Discount.applyDiscount(quote), executor)
.collect(toList());
return priceFutures.stream().map(CompletablFuture::join).collect(toList); // 返回结果
}
用图来解释发生了什么:
1.Executor
执行器提供了很多个线程来使用
2.thenApply
是异步的,不会阻塞代码的执行
3.thenCompose
允许你对两个CompletableFuture
进行流水线操作,前一个的结果作为后一个的参数。在这里,前一个任务是shop.getPrice()和转化为Quote,后一个任务是将Quote传给Discount
2.整合两个CompletableFuture对象
当我们需要使用上一个任务的结果来执行下一个任务,我们可以使用thenCompose
但有的时候,我们需要将两个任务的结果合并计算,这时候我们可以使用thenCombine
Future<Double> futurePriceInUSD =
CompletableFuture.supplyAsync( () -> shop.getPrice(product)) // 获取价格
.thenCombine(
CompletableFuture.supplyAsync( () -> exchangeService.getRate(Money.EUR, Money.USD)), // 转换货币
(price, rate) -> price * rate // 合并结果
);
相比于申请future的结果,写起来更方便了
注:thenCompose
和thenCombine
都有并发版本:thenComposeAsync
、thenCombineAsync
3.添加超时
Future<Double> futurePriceInUSD =
CompletableFuture.supplyAsync( () -> shop.getPrice(product)) // 获取价格
.thenCombine(
CompletableFuture.supplyAsync( () -> exchangeService.getRate(Money.EUR, Money.USD)), // 转换货币
(price, rate) -> price * rate // 合并结果
)
.orTimeout(3, TimeUnit.SECONDS); // 如果无法在3秒内完成,就抛出一个TimeException超时异常
4.响应CompletableFuture的completion事件
在实际中,调用远程服务必然有延迟,那该如何优化呢
一个方法是避免List,使用流获取远程服务的结果
// 修改前面的findPrice,直接返回流
public Stream<CompletableFuture<String>> findPriceStream(String product){
retrurn shop.stream()
shops.stream()
.map(shop -> ComptableFuture.supplyAsync(
() -> shop.getPrice(product), executor)
.map(future -> future.thenApply(Quotr::parse))
.map(future -> future.thenCompose(
quote -> ComptableFuture.supplyAsync(
() -> Discount.applyDiscount(quote), executor)));
}
// 然后,使用thenAccept
findPriceStream("HuaWei").map(f -> f.thenAccept(System.out::println));
thenAccept
获取了CompletableFuture
返回的结果,然后返回一个CompletableFuture<Void>
,对于一个这样的对象,我们能做的事非常有限,只能等着它结束,为了更好地操作,可以把所有对象放入数组中。
注:thenAccept
也有并发版本:thenAcceptAsync
CompletableFuture [] futures = findPriceStream("HuaWei")
.map(f -> f.thenAccept(System.out::println))
.toArray(size -> new CompletableFuture[size]);
// allOf等待所有CompletableFuture执行结束,返回一个CompletableFuture<Void>,再使用join()获取结果
CompletableFuture.allOf(futures).join();
小结
1.执行耗时的操作,尤其是依赖远程服务的操作,使用异步可以改善性能
2.CompletableFuture也提供了异常机制
3.将同步API的调用写入CompletableFuture中,可以以异步的方式使用其结果
4.我们可以合并异步任务
5.我们可以控制什么时候结束程序的运行