Java并发类库提供的线程池有哪几种?

既然创建或销毁线程存在一定的开销,所以利用线程池技术来提高系统资源利用效率,并简化线程管理,已经是非常成熟的选择。

典型回答

通常开发者都是利用Executors提供的通用线程池创建方法,去创建不同配置的线程池,主要区别在于不同的ExecutorService类型或者不同的初始参数。Executors目前提供了5种不同的线程池创建配置:

  • newCachedThreadPool(),它是一种用来处理大量短时间工作任务的线程池。具有几个鲜明特点:它会试图缓存线程并重用,当无缓存线程可用时,就会创建新的工作线程;如果线程闲置的时间超过60秒,则被终止并移出缓存;长时间闲置时,这种线程池,不会消耗什么资源。其内部使用SychronousQueue作为工作队列。
  • newFixedThreadPool(int nThreads),重用指定数目(nThreads)的线程,其背后使用的是无界的工作队列,任何时候最多有nThreads个工作线程是活动的。这意味着,如果任务数量超过了活动队列数目,将在工作队列中等待空闲线程出现;如果有工作线程退出,将会有新的工作线程被创建,以补足指定的数目nThreads。
  • newSingleThreadExecutor(),它的特点在于工作线程数目被限制为1,操作一个无界的工作队列,所以它保证了所有任务都是被顺序执行,最多会有一个任务处于活动状态,并且不允许使用者改动线程池实例,因此可以避免其改变线程数目。
  • newSingleThreadScheduledExecutor()和newScheduledThreadPool(int corePoolSize),创建的是个ScheduledThreadPoolExecutor,可以进行定时或周期性的工作调度,区别在于单一工作线程还是多个工作线程。
  • newWorkStealingPool(int parallelism),这是一个经常被人忽略的线程池,Java 8才加入这个创建方法,其内部会构建ForkJoinPool,利用Work-Stealing算法,并行地处理任务,不保证处理顺序。

扩展知识

1、Executor框架概述

首先我们来看看Executor框架的基本组成,参考以下类图:

Executor是一个基础的接口,其初衷是将任务提交和任务执行细节解耦,这一点可以体会其定义的唯一方法。

void execute(Runnable command);

Executor的设计是源于Java早期线程API使用的教训,开发者在实现应用逻辑时,被太多线程创建、调度等不相关细节所打扰。导致开发效率低下,质量也难以保证。

ExecutorService则更加完善。不仅提供service的管理功能,比如shutdown等方法;也提供了更加全面的提交任务机制。如返回Future而不是void的submit方法。

<T> Future<T> submit(Callable<T> task);

注意,这个例子输入的是Callable,它解决了Runnable无法返回结果的困扰。

Java标准类库提供了几种基础实现,比如ThreadPoolExecutor、ScheduledThreadPoolExecutor、ForkJoinPool。这些线程池的设计特点在于其高度的可调节性和灵活性,以尽量满足复杂多变的实际应用场景。

Executors则从简化使用的角度,为我们提供了各种方便的静态工厂方法。

2、ThreadPoolExecutor

理解应用与线程池的交互和线程池的内部工作过程,你可以参考下图:


工作队列负责存储用户提交的各个任务。这个工作队列,可以是容量为0的SynchronousQueue(使用newCachedThreadPool),也可以是像固定大小线程池(newFixedThreadPool)那样使用LinkedBlockingQueue。

private final BlockingQueue<Runnable> workQueue;

内部的“线程池”,这是指保持工作线程的集合,线程池需要在运行过程中管理线程创建、销毁。例如,对于带缓存的线程池,当任务压力较大时,线程池会创建新的工作线程;当业务压力退去,线程池会在闲置一段时间(默认60秒)后结束线程。

private final HashSet<Worker> workers = new HashSet<>();

线程池的工作线程被抽象为静态内部类Worker,基于AbstractQueuedSynchronizer实现。

ThreadFactory提供上面所需要的创建线程逻辑。

如果任务提交时被拒绝,比如线程池已经处于SHUTDOWN状态,需要为其提供处理逻辑。Java标准库提供了类似ThreadPoolExecutor.AbortPolicy等默认实现,也可以按照实际需要自定义。

从上面的分析,就可以看出线程池的几个基本组成部分,一起都体现在线程池的构造函数中,从字面我们就可以大概猜测到其用意:

  • corePoolSize,所谓的核心线程数,可以大致理解为长期驻留的线程数目(除非设置了allowCoreThreadTimeOut)。对于不同的线程池,这个值可能会有很大的区别,比如newFixedThreadPool会将其设置为nThreads,而对于newCachedThreadPool则为0。
  • maximumPoolSize,顾名思义,就是线程不够时能够创建的最大线程数。同样进行对比,对于newFixedThreadPool,当然就是nThreads,因为其要求是固定大小,而newCachedThreadPool则是Integer.MAX_VALUE。
  • keepAliveTime和TimeUnit,这两个参数指定了额外的线程能够闲置多久,显然有些线程池不需要它。
  • workQueue,工作队列,必须是BlockingQueue。

3、线程池实践

线程池虽然提供了非常强大、方便的功能,但是也不是银弹,使用不当同样会导致问题。我这里介绍些典型情况,经过前面的分析,很多方面可以自然地推导出来。

  • 避免任务堆积。前面说过newFixedThreadPool是创建指定数目的线程,但是其工作队列是无界的。如果工作线程数目太少,导致处理跟不上入队的速度,这就很有可能占用大量系统内存,甚至出现OutOfMemoryError。诊断时,可以使用jmap之类的工具,查看是否有大量的任务对象入队。
  • 避免过度扩展线程。我们通常在处理大量短时任务时,使用缓存的线程池。
  • 另外,如果线程数目不断增长(可以使用jstack等工具检查),也需要警惕另外一种可能,就是线程泄露。这种情况往往是因为任务逻辑有问题,导致工作线程迟迟不能被释放。建议你排查一下线程栈,很有可能多个线程都是卡在近似的代码处。
  • 避免死锁等同步问题,对于死锁的场景和排查,详见本专栏第19讲
  • 尽量避免在使用线程池时操作ThreadLocal。

4、线程池大小的选择策略

上面已经介绍过,线程池大小不合适,太多或太少,都会导致麻烦,所以我们需要去考虑一个合适的线程池大小。虽然不能完全确定,但是有一些相对普适的规则和思路。

  • 如果我们的任务主要是进行计算,那么就意味着CPU的处理能力是稀缺的资源,我们能够通过大量增加线程数提供计算能力吗?往往是不能的,如果线程太多,反倒可能导致大量的上下文切换开销。所以,在这种情况下,通常建议按照CPU核的数目N或者N+1。
  • 如果是需要较多等待的任务,例如I/O操作比较多,可以考虑参考Brain Goetz推荐的计算方法:
    线程数 = CPU核数 * (1 + 平均等待时间 / 平均工作时间)

    这些时间并不能精准预测,需要根据采样或者概要分析等方式进行计算,然后在实际中验证和调整。

  • 上面是仅仅考虑了CPU等限制,实际还可能受各种系统资源限制影响。如果我们不能调整资源的容量,那么就只能限制工作线程的数目了。这里的资源可以是文件句柄,内存等。

另外,在实际工作中,不要把解决问题的思路全部指望到调整线程池上,很多时候架构上的改变更能解决问题。

猜你喜欢

转载自blog.csdn.net/qweqwruio/article/details/81359842