线程池是怎样工作的

我们在工作中或多或少都使用过线程池,但是为什么要使用线程池呢?从他的名字中我们就应该知道,线程池使用了一种池化技术,和很多其他池化技术一样,都是为了更高效的利用资源,例如链接池,内存池等等。

数据库链接是一种很昂贵的资源,创建和销毁都需要付出高昂的代价,为了避免频繁的创建数据库链接,所以产生了链接池技术。优先在池子中创建一批数据库链接,有需要访问数据库时,直接到池子中去获取一个可用的链接,使用完了之后再归还到链接池中去。

同样的,线程也是一种宝贵的资源,并且也是一种有限的资源,创建和销毁线程也同样需要付出不菲的代价。我们所有的代码都是由一个一个的线程支撑起来的,如今的芯片架构也决定了我们必须编写多线程执行的程序,以获取最高的程序性能。

那么怎样高效的管理多线程之间的分工与协作就成了一个关键问题,Doug Lea 大神为我们设计并实现了一款线程池工具,通过该工具就可以实现多线程的能力,并实现任务的高效执行与调度。

为了正确合理的使用线程池工具,我们有必要对线程池的原理进行了解。

本篇文章主要从三个方面来对线程池进行分析:线程池状态、重要属性、工作流程。

线程池状态

首先线程池是有状态的,这些状态标识这线程池内部的一些运行情况,线程池的开启到关闭的过程就是线程池状态的一个流转的过程。

线程池共有五种状态:

thread-pool-executor-status.jpg
状态 含义
RUNNING 运行状态,该状态下线程池可以接受新的任务,也可以处理阻塞队列中的任务<br />执行 shutdown 方法可进入 SHUTDOWN 状态<br />执行 shutdownNow 方法可进入 STOP 状态
SHUTDOWN 待关闭状态,不再接受新的任务,继续处理阻塞队列中的任务<br />当阻塞队列中的任务为空,并且工作线程数为0时,进入 TIDYING 状态
STOP 停止状态,不接收新任务,也不处理阻塞队列中的任务,并且会尝试结束执行中的任务<br />当工作线程数为0时,进入 TIDYING 状态
TIDYING 整理状态,此时任务都已经执行完毕,并且也没有工作线程<br />执行 terminated 方法后进入 TERMINATED 状态
TERMINATED 终止状态,此时线程池完全终止了,并完成了所有资源的释放

重要属性

一个线程池的核心参数有很多,每个参数都有着特殊的作用,各个参数聚合在一起后将完成整个线程池的完整工作。

1、线程状态和工作线程数量

首先线程池是有状态的,不同状态下线程池的行为是不一样的,5种状态已经在上面说过了。

另外线程池肯定是需要线程去执行具体的任务的,所以在线程池中就封装了一个内部类 Worker 作为工作线程,每个 Worker 中都维持着一个 Thread。

线程池的重点之一就是控制线程资源合理高效的使用,所以必须控制工作线程的个数,所以需要保存当前线程池中工作线程的个数。

看到这里,你是否觉得需要用两个变量来保存线程池的状态和线程池中工作线程的个数呢?但是在 ThreadPoolExecutor 中只用了一个 AtomicInteger 型的变量就保存了这两个属性的值,那就是 ctl。

ctl.jpg

ctl 的高3位用来表示线程池的状态(runState),低29位用来表示工作线程的个数(workerCnt),为什么要用3位来表示线程池的状态呢,原因是线程池一共有5种状态,而2位只能表示出4种情况,所以至少需要3位才能表示得了5种状态。

2、核心线程数和最大线程数

现在有了标志工作线程的个数的变量了,那到底该有多少个线程才合适呢?线程多了浪费线程资源,少了又不能发挥线程池的性能。

为了解决这个问题,线程池设计了两个变量来协作,分别是:

  • 核心线程数:corePoolSize 用来表示线程池中的核心线程的数量,也可以称为可闲置的线程数量
  • 最大线程数:maximumPoolSize 用来表示线程池中最多能够创建的线程数量

现在我们有一个疑问,既然已经有了标识工作线程的个数的变量了,为什么还要有核心线程数、最大线程数呢?

其实你这样想就能够理解了,创建线程是有代价的,不能每次要执行一个任务时就创建一个线程,但是也不能在任务非常多的时候,只有少量的线程在执行,这样任务是来不及处理的,而是应该创建合适的足够多的线程来及时的处理任务。随着任务数量的变化,当任务数明显很小时,原本创建的多余的线程就没有必要再存活着了,因为这时使用少量的线程就能够处理的过来了,所以说真正工作的线程的数量,是随着任务的变化而变化的。

那核心线程数和最大线程数与工作线程个数的关系是什么呢?

core-maximum-pool-size.jpg

工作线程的个数可能从0到最大线程数之间变化,当执行一段时间之后可能维持在 corePoolSize,但也不是绝对的,取决于核心线程是否允许被超时回收。

3、创建线程的工厂

既然是线程池,那自然少不了线程,线程该如何来创建呢?这个任务就交给了线程工厂 ThreadFactory 来完成。

4、缓存任务的阻塞队列

上面我们说了核心线程数和最大线程数,并且也介绍了工作线程的个数是在0和最大线程数之间变化的。但是不可能一下子就创建了所有线程,把线程池装满,而是有一个过程,这个过程是这样的:

当线程池接收到一个任务时,如果工作线程数没有达到corePoolSize,那么就会新建一个线程,并绑定该任务,直到工作线程的数量达到 corePoolSize 前都不会重用之前的线程。

当工作线程数达到 corePoolSize 了,这时又接收到新任务时,会将任务存放在一个阻塞队列中等待核心线程去执行。为什么不直接创建更多的线程来执行新任务呢,原因是核心线程中很可能已经有线程执行完自己的任务了,或者有其他线程马上就能处理完当前的任务,并且接下来就能投入到新的任务中去,所以阻塞队列是一种缓冲的机制,给核心线程一个机会让他们充分发挥自己的能力。另外一个值得考虑的原因是,创建线程毕竟是比较昂贵的,不可能一有任务要执行就去创建一个新的线程。

所以我们需要为线程池配备一个阻塞队列,用来临时缓存任务,这些任务将等待工作线程来执行。

work-queue.jpg

5、非核心线程存活时间

上面我们说了当工作线程数达到 corePoolSize 时,线程池会将新接收到的任务存放在阻塞队列中,而阻塞队列又两种情况:一种是有界的队列,一种是无界的队列。

如果是无界队列,那么当核心线程都在忙的时候,所有新提交的任务都会被存放在该无界队列中,这时最大线程数将变得没有意义,因为阻塞队列不会存在被装满的情况。

如果是有界队列,那么当阻塞队列中装满了等待执行的任务,这时再有新任务提交时,线程池就需要创建新的“临时”线程来处理,相当于增派人手来处理任务。

但是创建的“临时”线程是有存活时间的,不可能让他们一直都存活着,当阻塞队列中的任务被执行完毕,并且又没有那么多新任务被提交时,“临时”线程就需要被回收销毁,在被回收销毁之前等待的这段时间,就是非核心线程的存活时间,也就是 keepAliveTime 属性。

那么什么是“非核心线程”呢?是不是先创建的线程就是核心线程,后创建的就是非核心线程呢?

其实核心线程跟创建的先后没有关系,而是跟工作线程的个数有关,如果当前工作线程的个数大于核心线程数,那么所有的线程都可能是“非核心线程”,都有被回收的可能。

一个线程执行完了一个任务后,会去阻塞队列里面取新的任务,在取到任务之前它就是一个闲置的线程。

取任务的方法有两种,一种是通过 take() 方法一直阻塞直到取出任务,另一种是通过 poll(keepAliveTime,timeUnit) 方法在一定时间内取出任务或者超时,如果超时这个线程就会被回收,请注意核心线程一般不会被回收。

那么怎么保证核心线程不会被回收呢?还是跟工作线程的个数有关,每一个线程在取任务的时候,线程池会比较当前的工作线程个数与核心线程数:

  • 如果工作线程数小于当前的核心线程数,则使用第一种方法取任务,也就是没有超时回收,这时所有的工作线程都是“核心线程”,他们不会被回收;
  • 如果大于核心线程数,则使用第二种方法取任务,一旦超时就回收,所以并没有绝对的核心线程,只要这个线程没有在存活时间内取到任务去执行就会被回收。

所以每个线程想要保住自己“核心线程”的身份,必须充分努力,尽可能快的获取到任务去执行,这样才能逃避被回收的命运。

核心线程一般不会被回收,但是也不是绝对的,如果我们设置了允许核心线程超时被回收的话,那么就没有核心线程这种说法了,所有的线程都会通过 poll(keepAliveTime, timeUnit) 来获取任务,一旦超时获取不到任务,就会被回收,一般很少会这样来使用,除非该线程池需要处理的任务非常少,并且频率也不高,不需要将核心线程一直维持着。

6、拒绝策略

虽然我们有了阻塞队列来对任务进行缓存,这从一定程度上为线程池的执行提供了缓冲期,但是如果是有界的阻塞队列,那就存在队列满的情况,也存在工作线程的数据已经达到最大线程数的时候。如果这时候再有新的任务提交时,显然线程池已经心有余而力不足了,因为既没有空余的队列空间来存放该任务,也无法创建新的线程来执行该任务了,所以这时我们就需要有一种拒绝策略,即 handler。

拒绝策略是一个 RejectedExecutionHandler 类型的变量,用户可以自行指定拒绝的策略,如果不指定的话,线程池将使用默认的拒绝策略:抛出异常。

在线程池中还为我们提供了很多其他可以选择的拒绝策略:

  • 直接丢弃该任务
  • 使用调用者线程执行该任务
  • 丢弃任务队列中的最老的一个任务,然后提交该任务

工作流程

了解了线程池中所有的重要属性之后,现在我们需要来了解下线程池的工作流程了。

how-thread-pool-work.jpg

上图是一张线程池工作的精简图,实际的过程比这个要复杂的多,不过这些应该能够完全覆盖到线程池的整个工作流程了。

整个过程可以拆分成以下几个部分:

1、提交任务

当向线程池提交一个新的任务时,线程池有三种处理情况,分别是:创建一个工作线程来执行该任务、将任务加入阻塞队列、拒绝该任务。

提交任务的过程也可以拆分成以下几个部分:

  • 当工作线程数小于核心线程数时,直接创建新的核心工作线程
  • 当工作线程数不小于核心线程数时,就需要尝试将任务添加到阻塞队列中去
  • 如果能够加入成功,说明队列还没有满,那么需要做以下的二次验证来保证添加进去的任务能够成功被执行
    • 验证当前线程池的运行状态,如果是非RUNNING状态,则需要将任务从阻塞队列中移除,然后拒绝该任务
    • 验证当前线程池中的工作线程的个数,如果为0,则需要主动添加一个空工作线程来执行刚刚添加到阻塞队列中的任务
  • 如果加入失败,则说明队列已经满了,那么这时就需要创建新的“临时”工作线程来执行任务
    • 如果创建成功,则直接执行该任务
    • 如果创建失败,则说明工作线程数已经等于最大线程数了,则只能拒绝该任务了

整个过程可以用下面这张图来表示:

execute-runnable.jpg

2、创建工作线程

创建工作线程需要做一系列的判断,需要确保当前线程池可以创建新的线程之后,才能创建。

首先,当线程池的状态是 SHUTDOWN 或者 STOP 时,则不能创建新的线程。

另外,当线程工厂创建线程失败时,也不能创建新的线程。

还有就是当前工作线程的数量与核心线程数、最大线程数进行比较,如果前者大于后者的话,也不允许创建。

除此之外,会尝试通过 CAS 来自增工作线程的个数,如果自增成功了,则会创建新的工作线程,即 Worker 对象。

然后加锁进行二次验证是否能够创建工作线程,最后如果创建成功,则会启动该工作线程。

3、启动工作线程

当工作线程创建成功后,也就是 Worker 对象已经创建好了,这时就需要启动该工作线程,让线程开始干活了,Worker 对象中关联着一个 Thread,所以要启动工作线程的话,只要通过 worker.thread.start() 来启动该线程即可。

启动完了之后,就会执行 Worker 对象的 run 方法,因为 Worker 实现了 Runnable 接口,所以本质上 Worker 也是一个线程。

通过线程 start 开启之后就会调用到 Runnable 的 run 方法,在 worker 对象的 run 方法中,调用了 runWorker(this) 方法,也就是把当前对象传递给了 runWorker 方法,让他来执行。

4、获取任务并执行

在 runWorker 方法被调用之后,就是执行具体的任务了,首先需要拿到一个可以执行的任务,而 Worker 对象中默认绑定了一个任务,如果该任务不为空的话,那么就是直接执行。

执行完了之后,就会去阻塞队列中获取任务来执行,而获取任务的过程,需要考虑当前工作线程的个数。

  • 如果工作线程数大于核心线程数,那么就需要通过 poll 来获取,因为这时需要对闲置的线程进行回收;
  • 如果工作线程数小于等于核心线程数,那么就可以通过 take 来获取了,因此这时所有的线程都是核心线程,不需要进行回收,前提是没有设置 allowCoreThreadTimeOut
逅弈逐码,专注于原创分享,用通俗易懂的图文描述源码及原理
最后编辑于
©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 203,456评论 5 477
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 85,370评论 2 381
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 150,337评论 0 337
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 54,583评论 1 273
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 63,596评论 5 365
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 48,572评论 1 281
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 37,936评论 3 395
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 36,595评论 0 258
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 40,850评论 1 297
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 35,601评论 2 321
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 37,685评论 1 329
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 33,371评论 4 318
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 38,951评论 3 307
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 29,934评论 0 19
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 31,167评论 1 259
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 43,636评论 2 349
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 42,411评论 2 342

推荐阅读更多精彩内容

  • 【JAVA 线程】 线程 进程:是一个正在执行中的程序。每一个进程执行都有一个执行顺序。该顺序是一个执行路径,或者...
    Rtia阅读 2,758评论 2 20
  • 第5章 多线程编程 5.1 线程基础 5.1.1 如何创建线程 在java要创建线程,一般有==两种方式==:1)...
    AndroidMaster阅读 1,786评论 0 11
  • 每两周一次的谈话结束了,这次聊了一个多小时,感觉也受益匪浅。 价值观的问题 首先跟我讲了一个故事,涉及了金钱,社会...
    勿忘巛心安阅读 320评论 1 0
  • Pig 夜是那么黑 但没人懂得你的美 月阴晴圆缺 像人都有缺陷会变 夜深人静了空荡了我的世界 感觉不冷天空怎么会下...
    猪猪女兜阅读 306评论 0 0
  • 9月6日早上,一个同事笑着对我说:“游老师,这个学期你的精神好像很好哦!” 是啊,去年开学的第一周和今年开学的第一...
    游游老师阅读 831评论 4 8