使用的教材是java核心技术卷1,我将跟着这本书的章节同时配合视频资源来进行学习基础java知识。
day098 并发(八)(执行器:线程池、预定执行、控制任务组、Fork-Join框架、可完成Future)
构建一个新的线程是有一定代价的,因为涉及与操作系统的交互。如果程序中创建了大量的生命期很短的线程,应该使用线程池(thread pool)。一个线程池中包含许多准备运行的空闲线程。将Runnable对象交给线程池,就会有一个线程调用run方法。当run方法退出时,线程不会死亡,而是在池中准备为下一个请求提供服务。
另一个使用线程池的理由是减少并发线程的数目。创建大量线程会大大降低性能甚至使虚拟机崩溃。如果有一个会创建许多线程的算法,应该使用一个线程数“固定的”线程池以限制并发线程的总数。
执行器(Executor)类有许多静态工厂方法用来构建线程池,下表中对这些方法进行了汇总。
1.线程池
newCachedThreadPool方法构建了一个线程池,对于每个任务,如果有空闲线程可用,立即让它执行任务,如果没有可用的空闲线程,则创建一个新线程。newFixedThreadPool方法构建一个具有固定大小的线程池。如果提交的任务数多于空闲的线程数,那么把得不到服务的任务放置到队列中。当其他任务完成以后再运行它们。newSingleThreadExecutor是一个退化了的大小为1的线程池:由一个线程执行提交的任务,一个接着一个。这3个方法返回实现了ExecutorService接口的ThreadPoolExecutor类的对象。
可用下面的方法之一将一个Runnable对象或Callable对象提交给ExecutorService:
Future<?>submit(Runnable task)
Future<T>submit(Runnable task, T result)
Future<T>submit(Callable<T> task)
该池会在方便的时候尽早执行提交的任务。调用submit时,会得到一个Future对象,可用来查询该任务的状态。
第一个submit方法返回一个奇怪样子的Future。可以使用这样一个对象来调用isDone、cancel或isCancelled。但是,get方法在完成的时候只是简单地返回null。
第二个版本的Submit也提交一个Runnable,并且Future的get方法在完成的时候返回指定的result对象。
第三个版本的Submit提交一个Callable,并且返回的Future对象将在计算结果准备好的时候得到它。
当用完一个线程池的时候,调用shutdown。该方法启动该池的关闭序列。被关闭的执行器不再接受新的任务。当所有任务都完成以后,线程池中的线程死亡。另一种方法是调用shutdownNow。该池取消尚未开始的所有任务并试图中断正在运行的线程。下面总结了在使用连接池时应该做的事:
1)调用Executors类中静态的方法newCachedThreadPool或newFixedThreadPool。
2)调用submit提交Runnable或Callable对象。
3)如果想要取消一个任务,或如果提交Callable对象,那就要保存好返回的Future对象。
4)当不再提交任何任务时,调用shutdown。
例如,前面的程序例子产生了大量的生命期很短的线程,每个目录产生一个线程。下面的的程序使用了一个线程池来运行任务。出于信息方面的考虑,这个程序打印出执行中池中最大的线程数。但是不能通过ExecutorService这个接口得到这一信息。因此,必须将该pool对象强制转换为ThreadPoolExecutor类对象。
/**
*@author zzehao
*/
import java.io.*;
import java.util.*;
import java.util.concurrent.*;
public class ThreadPoolTest
{
public static void main(String[] args)
{
try(Scanner in = new Scanner(System.in))
{
System.out.print("Enter base directory (e.g. /opt/jdkl.8.0/src): ");
String directory = in.nextLine();
System.out.print("Enter keyword (e.g. volatile): ");
String keyword= in.nextLine();
ExecutorService pool = Executors.newCachedThreadPool();
MatchCounter counter = new MatchCounter(new File(directory), keyword,pool);
Future<Integer> result = pool.submit(counter);
try
{
System.out.println(result.get()+ " matching files.");
}
catch (ExecutionException e)
{
e.printStackTrace();
}
catch (InterruptedException e)
{
}
pool.shutdown();
int largestPoolSize = ((ThreadPoolExecutor) pool).getLargestPoolSize();
System.out.println("largest pool size=" + largestPoolSize);
}
}
}
//This task counts the files in a directory and its subdirectories that contain a given keyword.
class MatchCounter implements Callable<Integer>
{
private File directory;
private String keyword;
private ExecutorService pool;
private int count;
//Constructs a MatchCounter.
public MatchCounter(File directory, String keyword, ExecutorService pool)
{
this.directory = directory;
this.keyword = keyword;
this.pool = pool;
}
public Integer call()
{
int count = 0;
try
{
File[] files =directory.listFiles();
List<Future<Integer>> results = new ArrayList<>();
for (File file : files)
{
if (file.isDirectory())
{
MatchCounter counter = new MatchCounter(file, keyword,pool);
Future<Integer> result = pool.submit(counter);
results.add(result);
}
else
{
if (search(file))
count++;
}
}
for (Future<Integer> result : results)
{
try
{
count += result.get();
}
catch (ExecutionException e)
{
e.printStackTrace();
}
}
}
catch (InterruptedException e)
{
}
return count;
}
//Searches a file for a given keyword.
public boolean search(File file)
{
try
{
try (Scanner in = new Scanner(file, "UTF-8"))
{
boolean found = false;
while (!found && in.hasNextLine())
{
String line = in.nextLine();
if (line.contains(keyword))
found = true;
}
return found;
}
}
catch (IOException e)
{
return false;
}
}
}
运行的结果是(自行输入):
2.预定执行
ScheduledExecutorService接口具有为预定执行(ScheduledExecution)或重复执行任务而设计的方法。它是一种允许使用线程池机制的java.util.Timer的泛化。Executors类的newScheduledThreadPool和newSingleThreadScheduledExecutor方法将返回实现了Scheduled¬ExecutorService接口的对象。
可以预定Runnable或Callable在初始的延迟之后只运行一次。也可以预定一个Runnable对象周期性地运行。详细内容见API文档。
3.控制任务组
了解了如何将一个执行器服务作为线程池使用,以提高执行任务的效率。有时,使用执行器有更有实际意义的原因,控制一组相关任务。例如,可以在执行器中使用shutdownNow方法取消所有的任务。
invokeAny方法提交所有对象到一个Callable对象的集合中,并返回某个已经完成了的任务的结果。无法知道返回的究竟是哪个任务的结果,也许是最先完成的那个任务的结果。对于搜索问题,如果你愿意接受任何一种解决方案的话,你就可以使用这个方法。例如,假定你需要对一个大整数进行因数分解计算来解码RSA密码。可以提交很多任务,每一个任务使用不同范围内的数来进行分解。只要其中一个任务得到了答案,计算就可以停止了。
invokeAll方法提交所有对象到一个Callable对象的集合中,并返回一个Future对象的列表,代表所有任务的解决方案。当计算结果可获得时,可以像下面这样对结果进行处理:
List<Callab1e<T>> tasks = . ..;
List<Future<T>> results = executor.invokeAll(tasks);
for (Future<T> result : results)
processFurther(result.get());
这个方法的缺点是如果第一个任务恰巧花去了很多时间,则可能不得不进行等待。将结果按可获得的顺序保存起来更有实际意义。可以用ExecutorCompletionService来进行排列。
用常规的方法获得一个执行器。然后,构建一个ExecutorCompletionService,提交任务给完成服务(completionservice)。该服务管理Future对象的阻塞队列,其中包含已经提交的任务的执行结果(当这些结果成为可用时)。这样一来,相比前面的计算,一个更有效的组织形式如下:
ExecutorCompletionService<T> service = new ExecutorCompletionServiceo(executor):
for (Callable<T> task : tasks) service.submit(task);
for (int i = 0; i < tasks.size();i++)
processFurther(service.take().get());
4.Fork-Join框架
有些应用使用了大量线程,但其中大多数都是空闲的。举例来说,一个Web服务器可能会为每个连接分别使用一个线程。另外一些应用可能对每个处理器内核分别使用一个线程,来完成计算密集型任务,如图像或视频处理。JavaSE7中新引入了fork-join框架,专门用来支持后一类应用。假设有一个处理任务,它可以很自然地分解为子任务,如下所示:
if (problemSize < threshold)
solve problem directly
else
{
break problem into subproblems
recursively solveeach subproblem
combine the results
}
图像处理就是这样一个例子。要增强一个图像,可以变换上半部分和下部部分。如果有足够多空闲的处理器,这些操作可以并行运行。(除了分解为两部分外,还需要做一些额外的工作,不过这属于技术细节,我们不做讨论)。
在这里,我们将讨论一个更简单的例子。假设想统计一个数组中有多少个元素满足某个特定的属性。可以将这个数组一分为二,分别对这两部分进行统计,再将结果相加。
要采用框架可用的一种方式完成这种递归计算,需要提供一个扩展RecursiveTask<T>的类(如果计算会生成一个类型为T的结果)或者提供一个扩展RecursiveActicm的类(如果不生成任何结果)。再覆盖compute方法来生成并调用子任务,然后合并其结果。
public Counter(double[] values,int from,int to,DoublePredicate filter)
{
this.values = values;
this.from = from;
this.to = to;
this.filter = filter;
}
protected Integer compute()
{
if(to - from < THRESHOLD)
{
int count = 0;
for (int i = from;i < to;i++)
{
if (filter.test(values[i]))
count++;
}
return count;
}
else
{
int mid = (from + to)/2;
Counter first = new Counter(values, from, mid, filter);
Counter second = new Counter(values, mid, to, filter);
invokeAll(first, second);
return first.join()+second.join();
}
}
在这里,invokeAll方法接收到很多任务并阻塞,直到所有这些任务都已经完成。join方法将生成结果。我们对每个子任务应用了join,并返回其总和。
下面是完整的代码。在后台,fork-join框架使用了一种有效的智能方法来平衡可用线程的工作负载,这种方法称为工作密取(workstealing)。每个工作线程都有一个双端队列(deque)来完成任务。一个工作线程将子任务压人其双端队列的队头。(只有一个线程可以访问队头,所以不需要加锁。)一个工作线程空闲时,它会从另一个双端队列的队尾“密取”一个任务。由于大的子任务都在队尾,这种密取很少出现。
/**
*@author zzehao
*/
import java.util.concurrent.*;
import java.util.function.*;
//This program demonstrates the fork-join framework.
public class ForkJoinTest
{
public static void main(String[] args)
{
final int SIZE = 10000000;
double[] numbers = new double[SIZE];
for(int i=0;i < SIZE;i++)
numbers[i] = Math.random();
Counter counter = new Counter(numbers,0,numbers.length,x -> x > 0.5);
ForkJoinPool pool = new ForkJoinPool();
pool.invoke(counter);
System.out.println(counter.join());
}
}
class Counter extends RecursiveTask<Integer>
{
public static final int THRESHOLD = 1000;
private double[] values;
private int from;
private int to;
private DoublePredicate filter;
public Counter(double[] values,int from,int to,DoublePredicate filter)
{
this.values = values;
this.from = from;
this.to = to;
this.filter = filter;
}
protected Integer compute()
{
if(to - from < THRESHOLD)
{
int count = 0;
for (int i = from;i < to;i++)
{
if (filter.test(values[i]))
count++;
}
return count;
}
else
{
int mid = (from + to)/2;
Counter first = new Counter(values, from, mid, filter);
Counter second = new Counter(values, mid, to, filter);
invokeAll(first, second);
return first.join()+second.join();
}
}
}
运行的结果是:
5.可完成Future
处理非阻塞调用的传统方法是使用事件处理器,程序员为任务完成之后要出现的动作注册一个处理器。当然,如果下一个动作也是异步的,在它之后的下一个动作会在一个不同的事件处理器中。尽管程序员会认为“先做步骤1,然后是步骤2,再完成步骤3”,但实际上程序逻辑会分散到不同的处理器中。如果必须增加错误处理,情况会更糟糕。假设步骤2是“用户登录”。可能需要重复这个步骤,因为用户输入凭据时可能会出错。要尝试在一组事件处理器中实现这样一个控制流,或者想要理解所实现的这样一组事件处理器,会很有难度。
JavaSE8的CompletableFuture类提供了一种候选方法。与事件处理器不同,“可完成future"可以“组合”(composed)。
例如,假设我们希望从一个Web页面抽取所有链接来建立一个网络爬虫。下面假设有这样一个方法:
public void CorapletableFuture<String> readPage(URL url)
Web页面可用时这会生成这个页面的文本。如果方法:
public static List<URL> getLinks(String page)
生成一个HTML页面中的URL,可以调度当页面可用时再调用这个方法:
ConipletableFuture<String> contents=readPage(url);
CompletableFuture<List<URL>> links = contents.thenApply(Parser::getLinks);
thenApply方法不会阻塞。它会返回另一个fiiture。第一个fiiture完成时,其结果会提供给getLinks方法,这个方法的返回值就是最终的结果。
利用可完成fiiture,可以指定你希望做什么,以及希望以什么顺序执行这些工作。当然,这不会立即发生,不过重要的是所有代码都放在一处。
从概念上讲,CompletableFuture是一个简单API,不过有很多不同方法来组合可完成fiiture。下面先来看处理单个fiiture的方法(如表所示)。(对于这里所示的每个方法,还有两个Async形式,不过这里没有给出,其中一种形式使用一个共享ForkJoinPool,另一种形式有一个Executor参数)。在这个表中,使用了简写记法来表示复杂的函数式接口,这里会把Function写为T->U。当然这并不是真正的Java类型。
你已经见过thenApply方法。以下调用:
CompletableFuture<U> future.thenApply(f);
CompletableFuture<U> future.thenApplyAsync(f);
会返回一个future,可用时会对future的结果应用f。第二个调用会在另一个线程中运行f。
thenCompose方法没有取函数T->U,而是取函数T->CompletableFuture。这听上去相当抽象,不过实际上也很A然。考虑从一个给定URL读取一个Web页面的动作。不用提供方法:
public String blockingReadPage(URL url)
更精巧的做法是让方法返回一个future:
public CompletableFuture<String> readPage(URL url)
现在,假设我们还有一个方法可以从用户输入得到URL,这可能从一个对话框得到,而在用户点击OK按钮之前不会得到答案。这也是将来的一个事件:
public CompletableFuture<URL> getURLInput(Stringprompt)
这里我们有两个函数T->CompletableFuture<U>和U->CompletableFuture<V>。显然,如果第二个函数在第一个函数完成时调用,它们就可以组合为一个函数T->CompletableFuture<V>。这正是thenCompose所做的。
表中的第3个方法强调了目前为止一直忽略的另一个方面:失败(failure)。CompletableFuture中拋出一个异常时,会捕获这个异常并在调用get方法时包装在一个受查异常ExecutionException中。不过,可能get永远也不会被调用。要处理异常,可以使用handle方法。调用指定的函数时要提供结果(如果没有则为null)和异常(如果没有则为null),这种情况下就有意义了。
其余的方法结果都为void,通常用在处理管线的最后。下面来看组合多个future的方法。
前3个方法并行运行一个CompletableFuture<T>和一个CompletableFuture<U>动作,并组合结果。
接下来3个方法并行运行两个CompletableFuture<T>动作。一旦其中一个动作完成,就传递它的结果,并忽略另一个结果。
最后的静态allOf和anyOf方法取一组可完成fiiture(数目可变),并生成一个CompletableFuture<Void>,它会在所有这些fiiture都完成时或者其中任意一个future完成时结束。不会传递任何结果。