2

写在开篇

线程池的源码从刚开始学Java就在看,刚开始看得很痛苦,纵然师父给我手把手讲过一遍,我依然是半懂半不懂。现在距离刚开始学Java过去一年了,可能一方面是自己对Java语言越来越熟,另一方面是用到了线程池的相关知识,再来看源码,已经没那么吃力了

一点心得:

JDK的源码是一定要看的,只要你学Java。这里的看不只是跟着我或者其他人的博文看过一遍就算看了,是自己要硬生生去亲自啃这块骨头。为什么呢?因为“横看成岭侧成峰”,同一本书,同一段代码在不同的人的眼中,内容是不同的。写博客的人会着重讲自己认为重要的,忽略掉一些不重要的部分,而可能对于初学者的你或者我,这些“不重要”的部分,我们是不懂的。但是也不意味着看别人博文没意思,有些博文确实e......但是用心写的博文,一般加入了作者的思考和经验,这些都是无形的财富。

说这些不是要你一开始就去啃这块难啃的骨头,最近在看《软技能》,对里面的学习法感同身受。学习新知识并不是一开始就要看很深入的东西,而是先了解最简单的,基础,去用,不会再看,再用。比如学习线程池,我们先了解什么是线程池,有什么好处,有什么内容,怎么用。再结合我或者他人的博客去看看源码,再自己去看看源码,当自己能输出(博文,或者讲给其他人听)的时候,就算懂了。

了解ThreadPool,一定要看Doug Lea大神的注释,私以为很多博客写的还不如大神的注释,本节内容基本是注释原文翻译,括号内是本人加入的一些补充。下一节有读源码的个人经验和源码的解读。

1. 线程池功能

线程池解决了2个问题:

  1. 在执行大量异步任务时,通过减少每个任务的调用开销,提高了性能(不用重复创建销毁线程等)。
  2. 它提供了可以管理并限制资源的方法,这些资源包括线程(限制线程池的大小,队列大小等等)。

    每个线程池都维护了一些基础的统计信息,例如完成的任务数。

2. 可调节的参数和钩子

2.1 线程池的core size和max size:

线程池可以通过core sizemax size动态调节池的大小(线程数的多少)。

当一个新任务通过 execute(Runnable)提交时,如果工作线程数 < core size,那么线程池会新建一个线程来处理这个新任务,即使这些工作线程处于空闲的状态(也就是没有处理任务)。

如果工作线程数 > core size,但是 < max size,那么这个任务会被塞入队列,除非队列满了。

如果设置core size=max size,那么实际上,你创建了一个固定大小的线程池

如果设置max size 为无限大,比如Integer.MAX_VALUE,那么这个线程池可以同时处理任意多个任务。

通常情况下,core sizemax size在初始化的时候就设置好了。当然,你可以随时通过setCorePoolSizesetMaximumPoolSize方法更改。

2.2 根据需求进行初始化

默认情况下,当新任务到达时,工作线程才会被创建。但是你可以通过prestartCoreThread或者prestartAllCoreThreads方法预先让core size个线程提前启动。当你初始化的时候,如果传入的队列是非空的(也就是已经有任务“迫不及待”地待执行),这个时候,你需要提前准备好运行的线程(具体查看下一节的源码分析,就知道原因了)。

2.3 新建线程

新线程由ThreadFactory工厂创建,如果你没有提供,线程池将使用Executors#defaultThreadFactory,也就是默认的工厂类来构造。通过默认工厂创建的线程都在一个线程组(thread group)里,他们拥有同样的“NORM_PRIORITY”优先级和“非守护线程”的配置。如果你提供了不同的工厂,你可以修改线程的名字,线程组,优先级,守护状态等等。

当工厂新建线程失败,池会继续运行,但是可能没法处理任何任务。线程应该拥有名为“modifyThread”的运行时权限(RuntimePermission).如果工作线程,或者其他线程使用这个池,但是没有拥有这个权限,服务可能会退化:配置虽然修改了,但是没有及时起效,并且一个关闭(SHUTDOWN)的池可能处于终止但可能未完成的状态。

2.4 Keep-alive 时间

​ 如果现在线程池的线程数量 > core size ,其中某个线程的空闲时间(一直没有拿到任务) > keep-alive time,那么这个线程会终止。这个方式可以在线程池没有太多任务的时候,用来降低线程资源的消耗。 keep-alive time可以通过setKeepAliveTime(TimeUnit)方法动态更改。如果传入Long.Max_VALUE,那么空闲线程将永远不会被终止。默认情况下,只有线程数超过core size, 超时策略才会生效。但allowCoreThreadTimeOut(boolean)方法可以让超时策略在线程数小于 core size时候也生效,只要keep-alive time非0。

2.5 入队

任何的阻塞队列可以传递和接收任务,但是具体策略和线程池的大小有关:

  1. 如果线程数 < core size,线程池会新建线程来处理新任务,而不会塞入队列。
  2. 如果线程数 >= core size,新任务会入队。
  3. 如果新任务入队失败(例如队列满了),并且线程数量 < max size,那么会新建线程处理任务。反之如果线程数= max size,那么任务会被拒绝。

线程池的状态贯穿了线程池的整个生命周期,有以下5个生命周期:

RUNNING: 接收新任务,处理队列的任务。

SHUTDOWN:不接收新任务,继续处理队列的任务。

STOP:不接收新任务,也不处理队列里的任务,并尝试停止正在运行的任务。

TIDYING:所有的任务都终止了,线程数为0之后,线程池状态会过度到TIDYING,然后执行terminated()钩子方法。

TERMINATED:在terminated()方法执行完之后,线程池状态就会变成TERTMINATED。

每个状态对应的数值很重要,用于后续的比较,每个状态的数值递增,但状态的变化并不需要连贯。有以下几种变化形式:

RUNNING -> SHUTDOWN:当调用shutdown()或者finalize()方法时

(RUNNING or SHUTDOWN) -> STOP:调用 shutdownNow()方法时

SHUTDOWN -> TIDYING:当队列和池都空了的时候

STOP -> TIDYING:当池空了

TIDYING -> TERMINATED:当 terminated() 方法结束的时候。

线程池有以下三个入队策略:

a. 直接传递:

一个优秀的默认队列是SynchronousQueue.它会在任务入队后,立刻将任务转给线程处理,而不保留任务。如果没有可用的线程(没法新建更多的线程)来处理新任务,那么会入队失败。这个策略可以避免任务被锁住(线程的饥饿死锁,查看页尾补充说明)

b. 无界队列:

在线程池中使用无界队列(例如没有预设容量的LinkedBlockingQueue),意味着池中如果有core size个线程正在运行,那么新来的任务会全部塞入这个队列。因此,该线程池最多存在core size个工作线程(max size将会失效)。当任务彼此不相关时,这是一个很好的做法。例如,无界队列可以容纳突如其来的顺势爆发的请求,即使请求到来的速度超出服务的处理速度。

c. 有界队列:

有界队列(例如 ArrayBlockingQueue)可以通过设定max size来保护资源,但同时也更难协调和控制。队列的长度和池的大小需要相互协调:

​ 长队列和小(线程池)池的组合减少了CPU的使用,OS 资源和上下文切换带来的损耗,但是可能会人为地降低吞吐量。如果任务经常阻塞(例如I/O密集型任务),系统可以为更多的线程安排时间,可能比你设定的线程数还要多(没有充分利用CPU)。

短队列通常需要和大(线程)池搭配使用,它们能充分利用CPU,但是也可能会带来不可预计的调度开销,因而降低吞吐量。

2.6 拒绝任务

当线程池SHUTDOWN之后,或者在设定了固定的池的最大线程数和队列长度,并都处于饱和的状态下,通过execute(Runnable)方法提交的任务会被拒绝。在上述两种情况下,execute方法会调用RejectedExecutionHandler#rejectedExecution(Runnable,ThreadPoolExecutor)方法,RejectedExecutionHandler是一个接口,每个线程池的RejectedExecutionHandler变量不一样,该接口有四种具体实现:

  1. ThreadPoolExecutor.AbortPolicy(默认):拒绝新任务,并抛出RejectedExecutionException异常。
  2. ThreadPoolExecutor.CallerRunsPolicy : 调用execute方法的线程本身来执行这个任务。这种做法提供了一个简易的反馈控制机制降低新任务的提交频率。
  3. ThreadPoolExecutor.DiscardPolicy:直接丢弃。
  4. ThreadPoolExecutor.DiscardOldestPolicy:线程池正常运行的情况下,放弃最旧的未处理请求,然后重试 execute;如果执行程序已关闭,则会丢弃该任务。

当然,你也可以自定义其他的拒绝策略。这时你需要格外小心,尤其在你的策略应用于特定的池的大小,或者排队策略上时。

2.7 钩子方法

ThreadPoolExecutor类提供 beforeExecute(Thread, Runnable)

afterExecute(Runnable, Throwable)} 两个可被覆盖的钩子函数。它们在任务的开始和结束的时候被调用。可被用于配置运行环境,例如更改ThreadLocals,收集统计信息,或者加日志。此外,terminated()方法也可以被覆盖,在线程池完全终止的时候,你可以通过这个方法做一些特殊的处理。

如果钩子方法抛出异常,内部的工作线程可能会逐个失败直至线程池终止

2.8 队列维护

getQueue()可以获取队列来监控和调试,强烈不建议大家使用这个方法来达到其他目的。当大量的入队任务被取消时,remove(Runnable)purge方法可以帮助来回收空间。

2.9 回收线程池

当一个线程池不再被其他程序引用,并且池中没有线程的时候,就会自动shut down。如果你希望一个不再被引用的线程池可以被自动回收(都说是自动,当然不是手动使用shutdown方法),那么你必须确保空闲线程会自动停止。你可以通过设置keep-alive time,core size设为0,并且要记住调用allowCoreThreadTimeOut方法使keep-alive time在所有线程上都能生效。

3 用例

这是一个使用线程池的例子,我们新增了一个简单的停止/恢复 功能:

class  PausableThreadPoolExecutor extends ThreadPoolExecutor {
    private boolean isPaused;
    private ReentrantLock pauseLock = new ReentrantLock();
    private Condition unpaused = pauseLock.newCondition();
    public PausableThreadPoolExecutor(...) { super(...); }
    
    protected void beforeExecute(Thread t, Runnable r) {
        super.beforeExecute(t, r);
         pauseLock.lock();
        try {
            while (isPaused) 
                unpaused.await();
        }  catch (InterruptedException ie) {
            t.interrupt();
        }  finally {
            pauseLock.unlock();
        }
    }

    public void pause() {
        pauseLock.lock();
        try {
            isPaused = true;
        }  finally {
            pauseLock.unlock();
        }
    }

    public void resume() {
        pauseLock.lock();
        try {
            isPaused = false;
            unpaused.signalAll();
        }  finally {
            pauseLock.unlock();
        }
    }
}

4. 补充说明

1.线程饥饿死锁(《Java并发编程实战》):

在线程池,如果任务依赖于任务,那么可能产生死锁。在单线程的Executor中,如果一个任务将另一个任务提交到同一个Executor,并且等待这个被提交的结果,那么通常会发生死锁。如果正在执行的线程都由于等待其他仍处于工作队列的任务而阻塞,这种现象称为饥饿死锁(Thread Starvation Deadlock)。只要线程池中的的任务,需要无限期等待一些必须由池中其他任务才能提供的资源,或者条件,例如某个任务等待另一个任务的返回值或者执行结果,那么除非这个池够大,否则将发生线程饥饿死锁。


Lavender
196 声望59 粉丝

喜欢开发,就酱紫~