Java 线程池详解(一):线程池实现原理及使用

  • Post author:
  • Post category:java




为什么要使用线程池?

在面向对象编程中,创建和销毁对象是很费时间的,因为创建一个对象要获取内存资源或者其它更多资源。在 Java 中更是如此,虚拟机将试图跟踪每一个对象,以便能够在对象销毁后进行垃圾回收。所以提高服务程序效率的一个手段就是尽可能减少创建和销毁对象的次数,特别是一些很耗资源的对象创建和销毁。

如何利用已有对象来服务就是一个需要解决的关键问题,其实这就是一些


“池化资源”


技术产生的原因。比如数据库连接池,它在很大程度上减少了连接建立和释放带来的性能开销!

具体的来讲,我们来考虑这样一种场景,对于一个 web 项目,每次过来一个请求,都要在服务端创建一个新线程来处理请求,请求处理完成后销毁线程,我们将其简单的量化一下:

  • 创建线程耗时:T1
  • 处理请求耗时:T2
  • 销毁线程耗时:T3

那么对于处理这样的一个请求,总耗时便是 T = T1 + T2 + T3,那我们来想一想如果这个请求非常简单呢?(事实上,现在很多 web 接受的都是这样的短小且大量的请求!),T2 耗时很短,则 T1 + T3 > T2,这样使用多线程技术不但没有提高 CPU 的吞吐量,反而降低了。

可以看出 T1,T3 是多线程本身的带来的开销,我们渴望减少 T1,T3 所用的时间,从而减少总耗时 T 的时间。但一些线程的使用者并没有注意到这一点,所以在程序中频繁的创建或销毁线程,这导致 T1 和 T3 在 T 中占有相当比例。显然这是突出了线程的弱点(T1,T3),而不是优点(并发性)。

线程池技术的提出,正是为了解决上述的问题!它与数据库连接池是同样的道理,使用线程池技术有如下几个优点:


  • 降低资源消耗:

    通过重复利用已创建的线程降低线程创建和销毁造成的消耗

  • 提高响应速度:

    当任务到达时,任务可以不需要等到线程创建就能立即执行

  • 提高线程的可管理性:

    线程是稀缺资源,如果无限制地创建,不仅会消耗系统资源,还会降低系统的稳定性,使用线程池可以进行统一分配、调优和监控。



线程池的实现原理

接下来我们来看一下,当向线程池提交一个任务之后,线程池是如何处理这个任务的,如下图所示

在这里插入图片描述

(1)线程池判断核心线程池里的线程是否都在执行任务。如果不是,则创建一个新的工作线程来执行任务。如果核心线程池里的线程都在执行任务,则进入下个流程。

(2)线程池判断工作队列是否已经满。如果工作队列没有满,则将新提交的任务存储在这个工作队列里。如果工作队列满了,则进入下个流程。

(3)线程池判断线程池的线程是否都处于工作状态。如果没有,则创建一个新的工作线程来执行任务。如果已经满了,则交给饱和策略来处理这个任务

接下来,拿 J.U.C 包中的 ThreadPoolExecutor 执行 execute() 方法来举例,看一下 Java 中的线程池是如何工作的(ThreadPoolExecutor 是 Executor 框架中最核心的类,是线程池的实现类)

在这里插入图片描述

(1)如果当前运行的线程少于 corePoolSize,则创建新线程来执行任务(注意,执行这一步骤需要获取全局锁)

(2)如果运行的线程等于或多于 corePoolSize,则将任务加入 BlockingQueue

(3)如果无法将任务加入 BlockingQueue(队列已满),则创建新的线程来处理任务(注意,执行这一步骤需要获取全局锁)

(4)如果创建新线程将使当前运行的线程超出 maximumPoolSize,任务将被拒绝,并调用 RejectedExecutionHandler.rejectedExecution() 方法

ThreadPoolExecutor 采取上述步骤的总体设计思路,是为了在执行 execute() 方法时,尽可能地避免获取全局锁(那将会是一个严重的可伸缩瓶颈)。在 ThreadPoolExecutor 完成预热之后(当前运行的线程数大于等于 corePoolSize),几乎所有的 execute() 方法调用都是执行步骤(2) ,而步骤(2)不需要获取全局锁。



线程池的使用



一、线程池的创建

其实在 J.U.C 包下已经提供了 Executors 类,它已经封装实现了四种创建线程池的方式,它暴露出几个简单的方法供开发者调用。我们可以根据需要,得到我们想要的线程池类型。这样做其实有利有弊,好的是我们不用关心那么多参数,只需要简单的指定一两个参数就可以;不好的是,这样一来又屏蔽了很多细节,如果有些参数使用默认的,而开发者又不了解原理的情况下,可能会造成 OOM 等问题。

在这里插入图片描述
上述这四种线程池,归根结底都是从 ThreadPoolExecutor 的构造函数创建出来的,只不过传入的参数不同罢了。

比如,CacheThreadPool

在这里插入图片描述

比如,FixedThreadPool

在这里插入图片描述

比如,SingleThreadExecutor

在这里插入图片描述
至于 ScheduledThreadPool,虽然它是由 ScheduledThreadPoolExecutor 的构造函数创建出来的。

在这里插入图片描述
但我们一路往上找,发现它是调用了父类的构造方法,而且相信你也猜到了,它的父类就是 ThreadPoolExecutor

在这里插入图片描述

在这里插入图片描述

所以,最核心的是要搞懂 ThreadPoolExecutor!

很多公司都不建议或者强制不允许直接使用 Executors 类提供的方法来创建线程池,例如阿里巴巴 Java 开发手册里就明确不允许这样创建线程池,一定要通过 ThreadPoolExecutor(xx,xx,xx…) 来明确线程池的运行规则,指定更合理的参数。

我们现在来看一下 ThreadPoolExecutor 的几个参数和它们的意义,先来看一下它最完整参数的重载。

在这里插入图片描述
一共 7 个参数,接下来本文将依次详细介绍一下



corePoolSize

核心线程数,当有任务进来的时候,如果当前线程数还未达到 corePoolSize 个数,则创建核心线程,核心线程有几个特点:

  • 当线程数未达到核心线程最大值的时候,新任务进来,即使有空闲线程,也不会复用,仍然新建核心线程
  • 核心线程一般不会被销毁,即使是空闲的状态,但是如果通过方法 allowCoreThreadTimeOut(boolean value) 设置为 true 时,超时也同样会被销毁
  • 生产环境首次初始化的时候,可以调用 prestartCoreThread() 方法来预先创建所有核心线程,避免第一次调用缓慢



maximumPoolSize

除了有核心线程外,有些策略是当核心线程完全无空闲的时候,还会创建一些临时的线程来处理任务,maximumPoolSize 就是核心线程 + 临时线程的最大上限。临时线程有一个超时机制,超过了设置的空闲时间没有事儿干,就会被销毁。



keepAliveTime

这个就是上面两个参数里所提到的超时时间,也就是线程的最大空闲时间,默认用于非核心线程,通过 allowCoreThreadTimeOut(boolean value) 方法设置后,也会用于核心线程。



unit

这个参数配合上面的 keepAliveTime ,指定超时的时间单位,秒、分、时等。



workQueue

首先应该注意到,它是一个 BlockingQueue,即阻塞队列。

它是一个等待执行的任务队列,如果核心线程没有空闲的了,新来的任务就会被放到这个等待队列中。这个参数其实一定程度上决定了线程池的运行策略,为什么这么说呢,因为队列分为有界队列和无界队列。


  • 有界队列:

    队列的长度有上限,当核心线程满载的时候,新任务进来进入队列,当达到上限,如果没有核心线程去即时取走处理,这个时候,就会创建临时线程。(警惕临时线程无限增加的风险)

  • 无界队列:

    队列没有上限的,当没有核心线程空闲的时候,新来的任务可以无止境的向队列中添加,而永远也不会创建临时线程。(警惕任务队列无限堆积的风险)



threadFactory

它是一个接口,用于实现生成线程的方式、定义线程名格式、是否后台执行等等,可以用 Executors.defaultThreadFactory() 默认的实现即可,也可以用 Guava 等三方库提供的方法实现,如果有特殊要求的话可以自己定义。它最重要的地方应该就是定义线程名称的格式,便于排查问题。



handler

当没有空闲的线程处理任务(也无法再创建线程了),并且等待队列已满(当然这只对有界队列有效),再有新任务进来的话,就要做一些取舍了,而这个参数就是指定取舍策略的,有下面四种策略可以选择:

  • ThreadPoolExecutor.AbortPolicy:直接抛出异常,这是默认策略
  • ThreadPoolExecutor.DiscardPolicy:直接丢弃任务,但是不抛出异常
  • ThreadPoolExecutor.DiscardOldestPolicy:丢弃队列最前面的任务,然后将新来的任务加入等待队列
  • ThreadPoolExecutor.CallerRunsPolicy:由线程池所在的线程处理该任务,比如在 main 函数中创建线程池,如果执行此策略,将有 main 线程来执行该任务



二、向线程池提交任务

可以使用两个方法向线程池提交任务:

  • execute()
  • submit()

execute() 方法用于提交不需要返回值的任务,所以无法判断任务是否被线程池执行成功。

通过以下代码可知 execute() 方法输入的任务是一个实现 Runnable 接口的类的实例

在这里插入图片描述

submit() 方法用于提交需要返回值的任务。线程池会返回一个 Future 类型的对象,通过这个 Future 对象可以判断任务是否执行成功,并且可以通过 Future 的 get() 方法来获取返回值,get() 方法会阻塞当前线程直到任务完成,而使用 get(long timeout,TimeUnit unit)方法则会阻塞当前线程一段时间后立即返回,这时候有可能任务没有执行完。



三、关闭线程池

可以通过调用线程池的 shutdown 或 shutdownNow 方法来关闭线程池。它们的原理是遍历线程池中的工作线程,然后逐个调用线程的 interrupt 方法来中断线程,所以无法响应中断的任务可能永远无法终止。但是它们存在一定的区别,shutdownNow 首先将线程池的状态设置成 STOP,然后尝试停止所有的正在执行或暂停任务的线程,并返回等待执行任务的列表,而shutdown 只是将线程池的状态设置成 SHUTDOWN 状态,然后中断所有没有正在执行任务的线程。

只要调用了这两个关闭方法中的任意一个,isShutdown 方法就会返回 true。当所有的任务都已关闭后,才表示线程池关闭成功,这时调用 isTerminaed方法会返回 true。至于应该调用哪一种方法来关闭线程池,应该由提交到线程池的任务特性决定,通常调用 shutdown 方法来关闭线程池,如果任务不一定要执行完,则可以调用 shutdownNow 方法。



使用线程池的几个小建议

  • 区分 CPU 密集型还是 IO 密集型:CPU 密集型任务应配置尽可能少的线程,如配置 CPU核心数 + 1 个线程的线程池。而对于 IO 密集型任务,它的线程并不是一直在执行任务,则应配置尽可能多的线程,如 CPU核心数 * 2
  • 如果对于任务的优先级有要求:那么构建线程池时,BlockingQueue 可以使用 PriorityBlockingQueue ,它是一个阻塞的优先级队列,可以让优先级高的任务先执行
  • 阻塞队列最好使用有界队列



版权声明:本文为u013568373原创文章,遵循 CC 4.0 BY-SA 版权协议,转载请附上原文出处链接和本声明。