本篇文章通过服务器通信
和页面渲染
两个功能的实现来加深多线程中Future
和Executor
的理解。
服务器通信
串行执行任务
任务执行最简单的策略就是在单线程中串行执行各项任务,并不会涉及多线程。
以创建通讯服务为例,我们可以这样实现(很low)
@Test
public void singleThread() throws IOException {
ServerSocket serverSocket= new ServerSocket(8088);
while (true){
Socket conn = serverSocket.accept();
handleRequest(conn);
}
}
代码很简单,理论上没什么毛病,但是实际使用中只能处理一个请求。但是当处理任务很耗时并且在多次请求时会阻塞无法及时响应。
由此可见串行处理机制通常都无法提供高吞吐率或快速响应性。
显式的为任务创建线程
串行执行任务这么 low,我们来通过多线程来处理请求吧:当接收到请求后创建新的线程去执行任务。new Thread()应该就能实现。
初级版本:
@Test
public void perThreadTask() throws IOException {
ServerSocket serverSocket = new ServerSocket(8088);
while (true) {
Socket conn = serverSocket.accept();
Runnable r = new Runnable() {
@Override
public void run() {
handleRequest(conn);
}
};
new Thread(r).start();
}
}
微弱的优点
- 对于每个请求,都创建了一个线程来处理,达到多线程并行效果
- 任务处理从主线程分离出来,使得主循环能更快的处理下一个请求
为每个任务分配一个线程存在一些缺陷,尤其当需要创建大量的线程时
- 线程生命周期的开销非常高。根据平台的不同,实际的开销也不同。但是线程的创建过程都会需要时间,并且需要 JVM 和操作系统提供一些辅助操作。
- 资源消耗。活跃的线程会消耗系统资源,尤其是内存。如果可运行的线程数量多余可用处理器的数量,那么有些线程将闲置。大量闲置的线程会占用许多内存,给垃圾回收器带来压力。如果你已经拥有足够多的线程使所有 CPU 保持忙碌状态,那么多余的线程反而会降低性能。
- 稳定性。随着平台的不同,可创建线程数量的限制是不同的,并受多个因素制约,包括 JVM 的启动参数、Thread 构造函数中请求的栈大小,以及底层操作系统对线程的限制等。如果破坏了这些限制,很可能抛出 OOM 异常。
<h5>
上面两种方式都存在一些问题:单线程串行的问题在于其糟糕的响应性和吞吐量;而为每个任务分配线程的问题在于资源消耗和管理的复杂性。</h5>
<h5>
在 Java 类库中,任务执行的主要抽象不是 Thread,而是 Executor
</h5>
public interface Executor {
void execute(Runnable command);
}
Executor 框架
Executor 基于生产者-消费者模式,提交任务的操作相当于生产者,执行任务的线程相当于消费者。
通讯优化
对于以前的通讯服务我们可以用 Executor
进一步优化一下
@Test
public void limitExecutorTask() throws IOException {
final int nThreads = 100;
ExecutorService exec = Executors.newFixedThreadPool(nThreads);
ServerSocket serverSocket = new ServerSocket(8088);
while (true) {
Socket conn = serverSocket.accept();
Runnable r = new Runnable() {
@Override
public void run() {
handleRequest(conn);
}
};
exec.execute(r);
}
}
线程池
线程池从字面来看时指管理一组同构工作线程的资源池。它与工作队列密切相关,它在工作队列中保存了所有等待执行的任务。
线程池通过重用现有的线程而不是创建新线程,可以在处理多个请求时分摊在线程创建和销毁过程中产生的巨大开销。另一个额外的好处是,当请求到达时,工作线程已经存在,因此不会由于等待创建线程而延迟任务的执行,挺高响应性。
JAVA 类库中提供了一个灵活的线程池以及一些有用的默认配置。可以通过 Executors 中的静态工厂方法来创建。
newFixedThreadPool
将创建一个固定长度的线程池,每当提交一个任务时就创建一个线程,直到达到线程的最大数量。newCacheedThreadPool
将创建一个可缓存的线程池,如果线程池的当前规模超过了处理需求时,那么将回收空闲的线程,而当需求增加时,则可以添加新的线程,线程池的规模则不存在限制。newSingleThreadPool
是一个单线程的 Executor,它创建单个工作者线程来执行任务,如果这个线程异常结束,会创建另一个线程来替代。newSingleThreadPool
能确保依照任务在队列中的顺序来串行执行。newScheduledThreadPool
创建一个固定长度的线程池,而且以延迟或定时的方式来执行任务,类似于 Timer。
Executor 生命周期
为了解决执行服务的声明周期问题,Executor
扩展了 ExecutorService
接口,添加了一些用于管理生命周期的方法shutdown()
,shutdownNow()
,isShutdown()
,isTerminated()
,awaitTermination()
。
ExecutorService
的生命周期有3中状态:运行、关闭和已终止。初始创建时处于运行状态。
-
shutdown()
方法将执行平缓的关闭过程:不再接受新的任务,同时等待已经提交的任务执行完成,包括那些还未开始执行的任务。 -
shutdownNow()
方法将执行粗暴的关闭过程:它将尝试取消所有运行中任务,并且不再启动队列中尚未开始执行的任务。
等待所有任务完成后,ExecutorService
将转入终止状态。可以调用awaitTermination
来等待到达终止状态,或者通过isTerminated
来轮询是否已终止。
服务器通讯初步牛批版本
class LifecycleWebServer {
private ExecutorService exec;
public void start() throws IOException {
ServerSocket socket = new ServerSocket(80);
while (!exec.isShutdown()) {
try {
Socket conn = socket.accept();
exec.execute(new Runnable() {
@Override
public void run() {
handleRequest(conn);
}
});
}catch (RejectedExecutionException e){
if (!exec.isShutdown()){
System.out.println("task submission reject::"+e);
}
}
}
}
public void stop(){
exec.shutdown();
}
void handleRequest(Socket conn) {
Request req = readRequest(conn);
if(isShutdownRequest(req)){
stop();
}else {
dispatchRequest(req);
}
}
private void dispatchRequest(Request req) {
//......分发请求
}
private boolean isShutdownRequest(Request req) {
//......判断是否是 shutdown 请求
}
private Request readRequest(Socket conn) {
//......解析请求
}
}
通过 ExecutorService
增加对任务生命周期的管理。
延迟任务与生命周期
Timer
是作者使用较多的任务类,主要用来管理延迟任务以及周期任务。因为 Timer
本身还是存在一些缺陷:
-
Timer
在执行所有定时任务时只会创建一个线程。如果某个任务的执行时间过长,那么将破坏其他TimerTask
的定时精确性。public void timerTest() { Timer timer = new Timer(); System.out.println("Timer Test Start " +new Date()); timer.schedule(new TimerTask() { @Override public void run() { System.out.println("001 working current " +new Date()); try { Thread.sleep(4*1000); } catch (InterruptedException e) { e.printStackTrace(); } System.out.println("001 working current " +new Date()); } },1000); timer.schedule(new TimerTask() { @Override public void run() { try { Thread.sleep(1000); System.out.println("002 working current " +new Date()); Thread.sleep(1000); System.out.println("002 working current " +new Date()); Thread.sleep(1000); System.out.println("002 working current " +new Date()); Thread.sleep(1000); System.out.println("002 working current " +new Date()); } catch (InterruptedException e) { e.printStackTrace(); } } },2000); }
打印 log:
Timer Test Start Tue Dec 10 11:52:44 CST 2019
001 working current Tue Dec 10 11:52:45 CST 2019
001 working current Tue Dec 10 11:52:49 CST 2019
002 working current Tue Dec 10 11:52:50 CST 2019
002 working current Tue Dec 10 11:52:51 CST 2019
002 working current Tue Dec 10 11:52:52 CST 2019
002 working current Tue Dec 10 11:52:53 CST 2019
从时间戳上可以看出两个 TimerTask 是串行执行的。时间调度出现了问题
- 另一个是线程泄露问题:当 TimerTask 抛出一个未检查的异常,那么 Timer 将表现出糟糕的行为。Timer 线程并不捕获异常,因此当 TimerTask 抛出未检查的异常时将终止定时线程,并且不会恢复线程的执行。
请尽量减少或者停止 Timer 的使用,ScheduledThreadPoolExecutor
能够正确处理这些表现出错误行为的任务。
public void testScheduled(){
ScheduledThreadPoolExecutor executor = (ScheduledThreadPoolExecutor) Executors.newScheduledThreadPool(10);
System.out.println("scheduled test " + new Date());
ScheduledFuture<?> work1 = executor.schedule(new Callable<String>() {
@Override
public String call() throws Exception {
try {
Thread.sleep(4000);
} catch (InterruptedException e) {
e.printStackTrace();
}
System.out.println("001 Worker " + new Date());
return "work1 finish";
}
}, 1, TimeUnit.SECONDS);
ScheduledFuture<?> work2 = executor.schedule(new Callable<String>() {
@Override
public String call() throws Exception {
try {
Thread.sleep(1000);
System.out.println("002 Worker " + new Date());
Thread.sleep(1000);
System.out.println("002 Worker " + new Date());
Thread.sleep(1000);
System.out.println("002 Worker " + new Date());
Thread.sleep(1000);
System.out.println("002 Worker " + new Date());
} catch (InterruptedException e) {
e.printStackTrace();
}
return "work2 Finish";
}
}, 2, TimeUnit.SECONDS);
}
输出 log:
scheduled test Tue Dec 10 15:54:10 CST 2019
002 Worker Tue Dec 10 15:54:13 CST 2019
002 Worker Tue Dec 10 15:54:14 CST 2019
001 Worker Tue Dec 10 15:54:15 CST 2019
002 Worker Tue Dec 10 15:54:15 CST 2019
002 Worker Tue Dec 10 15:54:16 CST 2019
从 log 来看,时间调度上符合我们的预期,棒棒哒。
页面渲染
来自面试官的提问:浏览器是怎样加载网页的?
方法一:使用简单串行
最简单的方法是对HTML文档进行串行处理。当遇到文本标签时,将其绘制到图像缓存中。当遇到图像引用时,先通过网络获取,然后再将其绘制到图像缓存中。这种方式算是一种思路,但是可能会令使用者感到方案,他们必须等待很长时间,直到显示所有的文本。
@Test
public void singleThreadRender() {
CharSequence source = "";
renderText(source);
List<ImageData> imageDatas = new ArrayList<>();
for (ImageInfo imageInfo : scanForImageInfo(source)) {
imageDatas.add(imageInfo.downloadImage());
}
for (ImageData imageData : imageDatas) {
renderImage(imageData);
}
}
了解 Callable
和 Future
Executor
框架使用 Runnable
作为其基本的任务表示形式。Runnable
是一种有很大局限的抽象,虽然能够异步执行任务,但是它不能返回一个值或者抛出受检查的异常。
许多任务实际上都是存在延迟的计算(像执行数据库查询、从网络上获取资源、或者计算某个复杂的功能)。对于这些任务,Callable
是一种更好的抽象:它认为主入口点应该返回一个值,并可能抛出一个异常。
Runnable
和Callable
描述的都是抽象的计算任务。这些任务通常都应该有一个明确的起始点,并且最终会结束。Executor
执行任务有4个生命周期阶段:创建、提交、开始和完成。由于有些任务可能需要很长的时间,因此通常希望能够及时取消。再 Executor
框架中,已提交但尚未开始的任务可以取消,但是对于那些已经开始的任务,只有当它们能响应中断时,才能取消。
Future
表示一个任务的生命周期,并提供了相应的方法来判断任务是否已经完成或取消,以及获取任务的结果和取消任务等。在 Future
规范中包含的隐含意义是,任务的声明周期只能前进,不能后腿,就像ExcutorService
的生命周期一样。当某个任务完成后,它就永远停留在完成
状态上。
Future 包含如下方法:
interface Future{
boolean cancel()
boolean get()
boolean isCancelled()
boolean isDone()
}
get()
方法的行为取决于任务的状态(尚未开始、正在运行、已完成)。如果任务已完成,方法会立即返回或者抛出一个异常;如果任务没有完成,方法 将阻塞直到任务完成。
可以通过多种方法创建一个Future
来描述任务。ExecutorService
中的所有的 submit 方法都将返回一个Future
,从而将一个Runnable
或者Callable
提交给 Executor
,并得到一个 Future
用来获取任务的执行结果或者取消任务。
方法二:使用Future
实现渲染
为了使页面渲染具有更高的并发性,我们分解成两个任务:一个是渲染所有的文本(
CPU 密集型
);另一个是下载所有的图像(I/O 密集型
)。
Callable
和 Future
有助于协同任务之间的交互。
@Test
public void futureRender() {
CharSequence source = "";
ExecutorService executor = Executors.newFixedThreadPool(10);
List<ImageInfo> imageInfos = scanForImageInfo(source);
Callable<List<ImageData>> task = new Callable<List<ImageData>>() {
@Override
public List<ImageData> call() throws Exception {
List<ImageData> result = new ArrayList<>();
for (ImageInfo imageInfo : imageInfos) {
result.add(imageInfo.downloadImage());
}
return result;
}
};
Future<List<ImageData>> future = executor.submit(task);
renderText(source);
try {
List<ImageData> imageDatas = future.get();
for (ImageData imageData : imageDatas) {
renderImage(imageData);
}
} catch (InterruptedException e) {
e.printStackTrace();
Thread.currentThread().interrupt();
future.cancel(true);
} catch (ExecutionException e) {
e.printStackTrace();
}
}
futureRender
使得渲染文本与下载图像数据的任务并发执行,当所有图像下载完成后,会显示到页面上。对比串行版本已经提高了效率和用户体验。但我们还可以做得更好,我们不必等到所有的图像都下载完成,而是希望没下载完一副图像就显示出来。
了解CompletionService
CompletionService
的实现类是ExecutorCompletionService
,它将Executor
和BlockingQueue
的功能融合在一起。
如果想及时获取任计算的结果,按照前面的思路我们可以先保留任务提交Executor
后返回的 Future
,然后不断的调用get()
方法来获取。这种方式虽然可行,但是不够优雅。幸运的是有CompletionService
。
请仔细阅读take()
方法说明:
/**
* Retrieves and removes the Future representing the next
* completed task, waiting if none are yet present.
*
* @return the Future representing the next completed task
* @throws InterruptedException if interrupted while waiting
*/
Future<V> take() throws InterruptedException;
take()
会取出并从队列移除已完成的任务。so,我们可以这样实现:
使用CompletionService
实现页面渲染
@Test
public void completionServiceRender(ExecutorService executor, CharSequence source) {
List<ImageInfo> info = scanForImageInfo(source);
CompletionService<ImageData> completionService = new ExecutorCompletionService<>(executor);
for (ImageInfo imageInfo : info) {
completionService.submit(new Callable<ImageData>() {
@Override
public ImageData call() throws Exception {
return imageInfo.downloadImage();
}
});
}
renderText(source);
try {
int taskSize = info.size();
for (int i = 0; i < taskSize; i++) {
Future<ImageData> f = completionService.take();
ImageData data = f.get();
renderImage(data);
}
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
} catch (ExecutionException e) {
e.printStackTrace();
}
}
为任务设置时限
新需求:对于耗时任务,等待特定时间后仍未完成,则取消任务。
需求合情合理。这种情况下,我们可以使用Future
的get()
方法,官方描述如下:
/**
* Waits if necessary for at most the given time for the computation
* to complete, and then retrieves its result, if available.
*
* @param timeout the maximum time to wait
* @param unit the time unit of the timeout argument
* @return the computed result
* @throws CancellationException if the computation was cancelled
* @throws ExecutionException if the computation threw an
* exception
* @throws InterruptedException if the current thread was interrupted
* while waiting
* @throws TimeoutException if the wait timed out
*/
V get(long timeout, TimeUnit unit)
throws InterruptedException, ExecutionException, TimeoutException;
- 两个参数:等待的时间、时间单位。
- 请注意抛出的异常,我们可以通过捕获
TimeoutException
来处理超时情况。