七、线程池的使用
发布日期:2021-09-12 09:57:58 浏览次数:32 分类:技术文章

本文共 12964 字,大约阅读时间需要 43 分钟。

文章目录

线程池的使用

本章介绍线程池的配置与调优的一些高级选项,并分析在任务执行框架时需要注意的各种危险,以及一些使用Executor的高级用法。

一、在任务与执行策略之间的隐性耦合

虽然Executor框架为制定、修改执行策略都提供了相当大的灵活性,但是并不是所有的任务都能适用于一般的执行策略,有些任务与执行策略之间存在隐形耦合,需要明确指定执行策略。

  • 依赖性任务:当在线程池中执行独立的任务时,可以随意修改线程池的大小和配置,这些修改只会对执行性能产生影响,但是如果提交给线程池的任务与其他任务之间有依赖关系,那么必须小心维持这些执行策略以避免产生活跃性问题。

  • 使用线程封闭机制的任务:与线程池相比,单线程的Executor能够对并发作出更强的承诺。他们能确保任务不会并发执行,使你能够放宽代码对线程安全的使用,对象可以封闭在任务线程中,使得在该线程中执行的任务在访问该对象时不需要同步,即使这些资源不是线程安全的也没有问题。这种情形将在任务与执行策略之间形成隐式的耦合----任务要求其执行所在的Executor是单线程的,如果将Executor从单线程环境改为线程池环境,那么会失去线程安全性。

  • 对响应时间敏感的任务:GUI应用程序对于响应时间是敏感的:如果用户在点击按钮后需要很长延迟才能得到可见的反馈,那么他们会很不满。如果讲一个运行时间很长的任务提交到单线程的Executor中,或许将多个运行时间较长的任务提交到一个只包含少量线程的线程池中,那么将降低由该Executor管理的服务的响应性。

  • 使用ThreadLocal的任务:ThreadLocal使每个线程都可以拥有某个变量的一个私有"版本",而线程池中的线程是重复使用的,即一次使用完后,会被重新放回线程池,可被重新分配使用。

如果运行时间较长和运行时间较短的任务混合在一起,那么除非线程池很大,否则将造成阻塞,如果提交的任务依赖于其他任务,除非线程池无限大,否则将造成死锁。

1、线程饥饿死锁

在线程池中,如果任务依赖于其他任务,那么可能产生死锁。在单线程的Executor中,如果一个任务将另外一个任务提交到同一个Executor,并且等待这个被提交任务的结果,通常会产生死锁。第二个任务停留在工作队列中,并等待第一个任务完成,而第一个任务又无法完成,因为它在等待第二个任务的完成。在更大的线程池中,如果所有正在执行任务的线程都由于等待其他仍处于工作队列中的任务而阻塞,那么会发生同样的问题,这种现象叫做线程饥饿死锁。

例子:RenderPageTask向Exector提交了两个任务来获取网页的页眉和页脚,绘制页面,等待获取页眉和页脚任务的结果,然后将页眉、页脚组合起来并形成最终的页面。如果是单线程的Exector,那么ThreadDeadlock会经常发生死锁。

下面的代码中,相当于一个单线程的线程池提交了三个任务,第一个任务不执行完,其他两个任务无法执行,但是第一个任务又需要其他两个任务的结果。也就是造成了task里面套task。

public class ThreadDeadlock{
ExecutorService exec = Executors.newSingleThreadExecutor(); public class RenderPageTask implements Callable
{
public String call() throws Exception {
Future
header,footer; header = exec.submit(new LoadFileTask("header.html")); footer = exec.submit(new LoadFileTask("footer.html")); String page = renderBody(); //将发生死锁---由于任务在等待子任务的结果 return header.get()+page+footer.get(); } }}

每当提交一个有依赖性的Executor任务时,要清楚知道可能会出现线程饥饿死锁。因此需要在代码或配置Executor的配置文件中记录线程池的大小限制或配置限制。

2、运行时间较长的任务

如果线程池中线程的数量远小于在稳定状态下执行时间较长任务的数量,那么到最后可能所有的线程都会运行这些执行时间较长的任务,从而影响整体的响应性。此种情形,执行策略可以采用缓解策略:限定任务等待资源的时间,如果等待超时,那么可以把任务标示为失败,然后中止任务或者将任务重新返回队列中以便随后执行。这样,无论任务的最终结果是否成功,这种方法都能确保任务总能继续执行下去,并将线程释放出来以执行一些能更快完成的任务。在平台类库的大部分可阻塞方法中,都同时定义了限时版本和无限时版本,例如Thread.join、BlockingQueue.put、CountDownLatch.await以及Selector.select等。

3、设置线程池的大小

线程池的理想大小取决于被提交任务的类型及所部署系统的特性,代码中通常不会固定线程池的大小,应该通过某种配置机制提供,或者根据Runtime.availableProcessor来计算。

幸运的是,要设置线程池的大小也并不困难,只需要避免“过大”和“过小”这两种极端的情况。

  • 如果线程池过大,那么大量的线程将在相对很少的CPU和内存资源上发生竞争,这不仅会导致更高的内存使用量,而且还可能耗尽资源
  • 如果线程池过小,那么将导致许多空闲的处理器无法执行工作,从而降低吞吐量

要想正确设置线程池的大小,必须分析计算环境、资源预算和任务的特性。再部署的系统中有多少个CPU?多大的内存?任务是计算密集型还是I/O密集型还是两者皆可?如果需要执行不同类型的任务,那么应该考虑使用多个线程池,从而使得每个线程池可以根据各自的工作负载来调整。

  • 对于计算密集型的任务,在拥有N(cpu)个处理器的系统上,当线程池的大小为Ncpu+1时,通常能实现最优的利用率(即使当计算密集型的线程偶尔由于页缺失故障或者其他原因而暂停时,这个“额外”的线程也能确保CPU的时钟周期不会被浪费);
  • 对于包含I/O操作或者其他阻塞操作的任务,由于线程并不会一直执行,因此线程池的规模应该更大。要正确设置线程池的大小,你必须估算出任务的等待时间与计算时间的比值。这种估算不需要太精确,并且可以通过一些分析或监控工具来获得。

N(cpu)=CPU的数量=Runtime.getRuntime().availableProcessors();

U(cpu)= 目标CPU的使用率
W/C=等待时间与运行时间的比率
线程池最优大小为:N(threads)=N(cpu)U(cpu)(1+W/C)
CPU周期并不是唯一影响线程池大小的资源。除此之外,内存、文件句柄、套接字句柄、数据库连接、CPU周期都是影响线程池大小的资源。

三、配置ThreadPoolExecutor

ThreadPoolExecutor为一些Executor提供了基本的实现,这些Executor是由Executors中的newCachedThreadPool、newFixedThreadPool和newScheduledThreadExecutor等工厂方法返回的,ThreadPoolExecutor是一个灵活的、稳定的线程池,运行进行各种定制。

如果默认的执行策略不能满足需求,那么可以通过ThreadPoolExecutor的构造函数来实例化一个对象,并根据自己的需要来制定,并且可以参考Executors的源代码来了解默认配置下的执行策略,然后再以这些执行策略为基础进行修改。

AbstractExecutorService实现了ExecutorService 、Executor,而ThreadPoolExecutor实现了AbstractExecutorService。ThreadPoolExecutor是一个灵活的、稳定的线程池,允许进行各种定制。

public ThreadPoolExecutor(int corePoolSize,                          int maximumPoolSize,                          long keepAliveTime,                          TimeUnit unit,                          BlockingQueue
workQueue)参数: corePoolSize - 池中所保存的线程数,包括空闲线程。 maximumPoolSize - 池中允许的最大线程数。 keepAliveTime - 当线程数大于核心时,此为终止前多余的空闲线程等待新任务的最长时间。 unit - keepAliveTime 参数的时间单位。 workQueue - 执行前用于保持任务的队列。此队列仅保持由 execute 方法提交的 Runnable 任务。 抛出: IllegalArgumentException - 如果 corePoolSize 或 keepAliveTime 小于 0,或者 maximumPoolSize 小于等于 0,或者 corePoolSize 大于 maximumPoolSize。 NullPointerException - 如果 workQueue 为 null

1、线程的创建与销毁

corePoolSize:线程池的基本大小,也就是线程池的目标大小,即在没有任务执行时线程池的大小,并且只有在工作队列满的时候才会创建超出这个数量的线程。

maximumPoolSize:线程池的最大大小表示可同时活动的线程数量的上限。
存活时间:如果某个线程的空闲时间超过了存活时间,那么将被标记为可回收的,并且线程池的当前大小超过了基本大小时,这个线程将被终止。

通过调节线程池的基本大小和存活时间,可以帮助线程池回收空闲线程占有的资源,从而使得这些资源可用于执行其他工作。(显然,这是一种折中:回收空闲线程会产生额外的延迟,因为当需求增加时,必须创建新的线程来满足需求。)

几种线程池细节介绍

newFixedThreadPool工厂方法将线程池的基本大小和最大大小设置为参数中指定的值,而且创建的线程池不会超时。

newCachedThreadPool工厂方法将线程池的最大大小设置为Integer.MAX_VALUE,而将基本大小设置为0,并设置超时时间为1分钟,这种方法创建出来的线程池可以被无限扩展,并且需求降低会自动收缩。其他形式的线程池可以通过显式的ThreadPoolExecutor构造函数来构造。

2、管理队列任务

在有限的线程池中会限制可并发执行的任务数量。(单线程的Executor是一种值得注意的特例:它们能确保不会有任务并发执行,因为它们通过线程封闭来实现线程安全性)。

在前面章节中讲到无限的创建线程,那么将导致不稳定,并采用固定大小的线程池来解决这个问题(而不是每收到一个请求就创建一个新的线程),然而这个方案并不完整。
如果新请求的到达速率超过了线程池的处理速率,那么新到的线程将累积起来,在线程池中,这些请求会在一个由Executor管理的Runnable队列中等待,而不会像线程那样区竞争CPU资源,通过一个Runnable和一个链表节点来表示一个等待中的任务,当然比使用线程来表示的开销低很多,但是如果客户提交给服务器请求的速率超过了服务器的处理速率,那么仍可能会耗尽资源。

ThreadPoolExecutor允许提供一个BlockingQueue来保存等待执行的任务。基本的任务排队方法有三种:无界队列、有界队列和同步移交。

newFixedThreadPool和newSingleThreadExecutor在默认情况下将使用一个无界的LinkedBlockingQueue。如果所有工作者线程都处于忙碌状态,那么任务将在队列中等候,如果任务持续快速的到达,并且超过了线程池处理他们的速度,那么队列将无限制的增加。

一种更稳妥的资源管理策略是使用有界队列,例如ArrayBlockingQueue、有界的LinkedBlockingQueue、PriorityBlockingQueue。有界队列有助于避免资源耗尽的情况发生,但是它又带来了新的问题:当队列填满后,新的任务该怎么办?(有许多饱和策略可以解决这个问题)。

对于非常大的或者无界的线程池,可以通过使用SynchronousQueue来避免任务排队,以及直接将任务从生产者移交给工作者线程。SynchronousQueue不是一个真正的队列,而是一种在线程之间进行移交的机制。要将一个元素放入SynchronousQueue中,必须有另一个线程正在等待接受这个元素,如果没有线程正在等待,并且线程池的当前大小小于最大值,那么ThreadPoolExecutor将创建一个新的线程,否则根据饱和策略,这个任务将被拒绝。使用直接移交将更高效,因为任务会直接移交给执行它的线程,而不是被首先放在队列中,只有当线程池无界或者可以拒绝任务的时候,SynchronousQueue才有实际价值,在newCachedThreadPool工厂方法中就使用了SynchronousQueue。

SynchronousQueue是一个内部只能包含一个元素的队列。插入元素到队列的线程被阻塞,直到另一个线程从队列中获取了队列中存储的元素。同样,如果线程尝试获取元素并且当前不存在任何元素,则该线程将被阻塞,直到线程将元素插入队列。

当使用像LinkedBlockingQueue或者ArrayBlockingQueue这样的FIFO队列,任务执行顺序与他们的到达顺序相同,如果想进一步控制执行顺序,可以使用PriorityBlockingQueue,这个队列将根据优先级来安排任务。任务的优先级是通过自然顺序或者Comparator(如果任务实现了Comparable)来定义的。

只有当任务相互独立的时候,为线程池或者工作队列设置界限才是合理的,如果任务之间存在依赖性,那么有界的线程池或者队列就可能导致线程饥饿死锁问题。此时应该是有无界的线程池,例如newCachedThreadPool。

3、饱和策略

当有界队列被填满后,饱和策略开始发挥作用,ThreadPoolExecutor的饱和策略可以通过调用setRejectedExecutionHandler来修改。饱和策略就是制定了任务舍弃的规则。

中止(Abort)策略是默认的饱和策略:该策略会抛出未检查的RejectedExecutionException异常。调用者可以捕获异常,然后根据自己的需求编写自己的处理代码。当新提交的任务无法保存到队列中等待执行时,中止策略分普通抛弃和抛弃下一个(抛弃最旧),普通抛弃偷偷抛弃这个任务。抛弃下一个,如果队列是优先策略,优先级最高的任务反而会抛弃。因此最好不要将“抛弃最旧”饱和策略和优先级队列一起使用。

调用者运行策略:实现了一种调节机制,既不抛弃任务,也不抛出异常,而是将某些任务回退到调用者,从而降低新任务的流量,也不会再线程池的某个线程执行新提交的任务,而是在一个调用了execute的线程中执行该任务。

例如:当我们的WebServer采用有界队列和“调用者运行”饱和策略,当线程池中所有的线程都被占用,并且任务队列也被填满,下一个任务在调用execute时在主线程中执行。由于执行任务需要一定时间,因此主线程至少在一段时间内无法提交任务,因此到达的请求最终保存在TCP层的队中,而不是应用程序的队列中。如果持续过载,那么TCP层最终发现请求队列被填满,会抛异常。

4、线程工厂

当线程池需要创建一个线程时,都是通过线程工厂方式。默认的线程工厂方法可以创建一个新的、非守护的线程,并且不包含特殊的配置信息。在ThreadFactory中只定义了一个方法newThread,每当线程池需要创建一个新线程都会调用这个方法。

public interface ThreadFactory{
Thread newThread(Runnable r);}

很多时候,我们都需要自己定制线程工厂方法,例如希望为线程池中的线程指定一个UncaughtExceptionHandler,或者实例化一个定制的Thread类用于执行调试信息的记录。

public class MyThreadFactory implements ThreadFactory {
private final String poolName; public MyThreadFactory(String poolName) {
this.poolName = poolName; } @Override public Thread newThread(Runnable runnable) {
return new MyAppThread(runnable,poolName); }}

在MyAppThread中还可以定义其他行为。指定名字,设置自定义UncaughtExceptionHandler向Logger中写入信息,维护一些统计信息,以及在线程被创建或者终止把调试信息写入日志。

public class MyAppThread extends Thread {
public static final String DEFAULT_NAME="MyAppThread"; private static volatile boolean debugLifecycle = false; private static final AtomicInteger created = new AtomicInteger(); private static final AtomicInteger alive = new AtomicInteger(); private static final Logger log = Logger.getAnonymousLogger(); public MyAppThread(Runnable r) {
this(r, DEFAULT_NAME); } public MyAppThread(Runnable r, String name) {
super(r, name+ "-" + created.incrementAndGet()); setUncaughtExceptionHandler( //设置未捕获的异常发生时的处理器 new Thread.UncaughtExceptionHandler() {
@Override public void uncaughtException(Thread t, Throwable e) {
log.log(Level.SEVERE, "UNCAUGHT in thread " + t.getName(), e); } }); } @Override public void run() {
boolean debug = debugLifecycle; if (debug) log.log(Level.FINE, "running thread " + getName()); try {
alive.incrementAndGet(); super.run(); } finally {
alive.decrementAndGet(); if (debug) log.log(Level.FINE, "existing thread " + getName()); } }}

5、在构造函数后定制ThreadPoolExecutor

在调用完ThreadPoolExecutor的构造函数后,仍然可以通过许多设置函数(setter)来修改传递给它的构造函数参数,例如线程池基本大小、最大最小、存活时间、线程工厂以及拒绝执行处理器。如果Executor是通过Exexutors中的某个个工厂方法创建的(newSingleThreadExecutor),那么可以将结果的类型转换为ThreadPoolExecutor以访问设置器。

ExecutorService exec = Executors.newCachedThreadPool();if(exec instance of ThreadPoolExecutor){
((ThreadPoolExecutor)exec).setCorePoolSize(10);}else throw new AssertionError("Oops,bad assumption");

在Executors中包含了一个unconfigurableExecutorService工厂方法,该方法对一个现有的ExecutorService进行包装,使其只暴露出ExecutorService的方法,因此不能对它进行配置。newSingleThreadExecutor返回按这种方式封装的ExecutorService,而不是最初的ThreadPoolExecutor。虽然单线程的Executor实际上被实现为一个只包含唯一线程的线程池,但它同样确保了不会并发地执行任务。如果在代码中增加单线程Executor的线程池大小,那么将破坏它的执行语义。

四、扩展ThreadPoolExecutor

ThreadPoolExecutor是可扩展的,提供了几个可以在子类中改写的方法:beforeExecute、afterExecute、和terminated,这些方法可以扩展ThreadPoolExecutor的行为。

  • 线程执行前调用beforeExecute(如果beforeExecute抛出了一个RuntimeException,那么任务将不会被执行)
  • 线程执行后调用afterExecute(抛出异常也会调用,如果任务在完成后带有一个Error,那么就不会调用afterExecute)
  • 在线程池完成关闭操作时调用terminated,也就是所有任务都已经完成并且所有工作者线程也已经关闭后。

例如TimingThreadPoolExecutor,可以看出由于要测量任务的运行时间,startTime以ThreadLocal方式保存以便afterExecute可以访问,并且numTask、totalTime采用AtomicLong变量用于记录已处理的任务数和总的处理时间。

public class TimingThreadPoolExecutor extends ThreadPoolExecutor {
private final ThreadLocal
startTime = new ThreadLocal
();//任务执行开始时间 private final Logger log = Logger.getAnonymousLogger(); private final AtomicLong numTasks = new AtomicLong(); //统计任务数 private final AtomicLong totalTime = new AtomicLong(); //线程池运行总时间 public TimingThreadPoolExecutor(int corePoolSize, int maximumPoolSize, long keepAliveTime, TimeUnit unit, BlockingQueue
workQueue) {
super(corePoolSize, maximumPoolSize, keepAliveTime, unit, workQueue); } @Override protected void beforeExecute(Thread t, Runnable r) {
super.beforeExecute(t, r); log.fine(String.format("Thread %s: start %s", t, r)); startTime.set(System.nanoTime()); } @Override protected void afterExecute(Runnable r, Throwable t) {
try {
long endTime = System.nanoTime(); long taskTime = endTime - startTime.get(); numTasks.incrementAndGet(); totalTime.addAndGet(taskTime); log.fine(String.format("Thread %s: end %s, time=%dns", t, r, taskTime)); } finally {
super.afterExecute(r, t); } } @Override protected void terminated() {
try {
//任务执行平均时间 log.info(String.format("Terminated: average time=%dns", totalTime.get() / numTasks.get())); } finally {
super.terminated(); } }}

五、递归算法的并行性

如果循环中的迭代操作都是独立的,并且不需要等待所有的迭代操作都完成再继续执行,那么就可以使用Executor将串行循环转化为并行循环。

顺序执行时,只有当循环中的所有任务全部执行完毕后才会返回。而在并行执行中,只要将任务添加到了Executor执行队列中就可以返回了,任务之后会并发的执行。节省了等待时间。当有任务集时,可以使用ComplectionService。

void processSequentially(List
elements){
for(Element e : elements) process(e);}//并行执行void processInparallel(Executor executor,List
elements){
for (final Element e:elements) {
executor.execute(new Runnable() {
@Override public void run() {
process(e); } }); }}

将串行递归转化为并行递归

//顺序递归   void sequentialRecursive(List
> nodes, Collection
results){
for (Node
node:nodes) {
results.add(node.compute);//任务计算 sequentialRecursive(nodes.getChildren(),results); } } //并行递归 void parallelRecursive(final Executor executor,List
> nodes, Collection
results){ for (Node
node:nodes) { executor.execute(new Runnable() { @Override public void run() { results.add(node.compute);//任务计算 } }); sequentialRecursive(nodes.getChildren(),results); } }

遍历的过程仍然是顺序的,但是对遍历过程中出现的可能会等待的任务进行了并行执行。可通过以下方法获取计算结果。

ExecutorService executorService = Executors.newCachedThreadPool();       executorService.shutdown();       executorService.awaitTermination(Long.MAX_VALUE, TimeUnit.SECONDS);

小结:Executor框架是一个强大且灵活的框架,它提供了大量可调节的选项,例如创建线程和关闭线程的策略,处理队列任务的策略,处理多任务的策略,并且提供了几个钩子方法来扩展它的行为。然而,与大多数的功能强大的框架一样,其中有些设置参数不能很好的工作,某些任务需要特定的执行策略,一些参数组合可能会产生奇怪的结果

转载地址:https://blog.csdn.net/weixin_38367817/article/details/103846210 如侵犯您的版权,请留言回复原文章的地址,我们会给您删除此文章,给您带来不便请您谅解!

上一篇:ThreadLocal讲解
下一篇:深入理解Java虚拟机(可以不看)

发表评论

最新留言

第一次来,支持一个
[***.219.124.196]2024年04月11日 11时36分01秒

关于作者

    喝酒易醉,品茶养心,人生如梦,品茶悟道,何以解忧?唯有杜康!
-- 愿君每日到此一游!

推荐文章

【C++】攻克哈希表(unordered_map) 2019-04-27
转:【答学员问】- 该如何根据岗位学习相关技能 2019-04-27
转:【答学员问】有什么经验教训,是你在面试很多次之后才知道的? 2019-04-27
消息队列:解耦、异步、削峰,现有MQ对比以及新手入门该如何选择MQ? 2019-04-27
【奇技淫巧】-- 三角形最小路径和 2019-04-27
【小技巧】argc和argv的用法 2019-04-27
学不下去了怎么办? 2019-04-27
二叉树的前中后序遍历(迭代法)(带动画) 2019-04-27
【小技巧】【XShell】【Xftp】Windows桌面与Linux虚拟机互传文件 2019-04-27
【redis入门】Centos下安装redis 2019-04-27
【redis入门】redis安装后相关知识串讲 2019-04-27
【redis】来吧,展示一下redis 发布-订阅模式 2019-04-27
讲通C/C++预编译/条件编译指令 #ifdef,#ifndef,#endif,#define,… 2019-04-27
【redis6.0.6】redis源码慢慢学,慢慢看 -- 第二天:空间配置(zmalloc) 2019-04-27
当下热点词再学:redis缓存预热、更新、降级,限流 2019-04-27
【redis6.0.6】redis源码慢慢学,慢慢看 -- 第五天:adlist 2019-04-27
别抖,OK? 操作系统抖动现象、网络抖动与延迟、函数抖动之防抖与节流,串讲 2019-04-27
第六天:网络处理(anet部分)-- redis源码慢慢学,慢慢看【redis6.0.6】 2019-04-27
通过域名获取主机IP -- struct addrinfo 2019-04-27
【C++】算法集锦(8):从两数和问题拓展到一百数和问题 2019-04-27