CrabNote螃蟹笔记

Java 线程池详解

池化技术想必大家已经屡见不鲜了,线程池、数据库连接池、HTTP 连接池等等都是对这个思想的应用。池化技术的思想主要是为了减少每次获取资源的消耗,提高对资源的利用率。 这篇文章我会详细介绍一下线程池的

池化技术想必大家已经屡见不鲜了,线程池、数据库连接池、HTTP 连接池等等都是对这个思想的应用。池化技术的思想主要是为了减少每次获取资源的消耗,提高对资源的利用率。 这篇文章我会详细介绍一下线程池的基本概念以及核心原理。 线程池介绍 池化技术想必大家已经屡见不鲜了,线程池、数据库连接池、HTTP 连接池等等都是对这个思想的应用。池化技术的思想主要是为了减少每次获取资源的消耗,提高对资源的利用率。 线程池提供了一种限制和管理资源(包括执行一个任务)的方式。 每个线程池还维护一些基本统计信息,例如已完成任务的数量。使用线程池主要带来以下几个好处: 1. 降低资源消耗 :线程池里的线程是可以重复利用的。一旦线程完成了某个任务,它不会立即销毁,而是回到池子里等待下一个任务。这就避免了频繁创建和销毁线程带来的开销。 2. 提高响应速度 :因为线程池里通常会维护一定数量的核心线程(或者说“常驻工人”),任务来了之后,可以直接交给这些已经存在的、空闲的线程去执行,省去了创建线程的时间,任务能够更快地得到处理。 3. 提高线程的可管理性 :线程池允许我们统一管理池中的线程。我们可以配置线程池的大小(核心线程数、最大线程数)、任务队列的类型和大小、拒绝策略等。这样就能控制并发线程的总量,防止资源耗尽,保证系统的稳定性。同时,线程池通常也提供了监控接口,方便我们了解线程池的运行状态(比如有多少活跃线程、多少任务在排队等),便于调优。 Executor 框架介绍 框架是 Java5 之后引进的,在 Java 5 之后,通过 来启动线程比使用 的 方法更好,除了更易管理,效率更好(用线程池实现,节约开销)外,还有关键的一点:有助于避免 this 逃逸问题。 this 逃逸是指在构造函数返回之前其他线程就持有该对象的引用,调用尚未构造完全的对象的方法可能引发令人疑惑的错误。 框架不仅包括了线程池的管理,还提供了线程工厂、队列以及拒绝策略等, 框架让并发编程变得更加简单。 框架结构主要由三大部分组成: 1、任务( / ) 执行任务需要实现的 接口 或 接口 。 接口 或 接口 实现类都可以被 或 执行。 2、任务的执行( ) 如下图所示,包括任务执行机制的核心接口 ,以及继承自 接口的 接口。 和 这两个关键类实现了 接口。 这里提了很多底层的类关系,但是,实际上我们需要更多关注的是 这个类,这个类在我们实际使用线程池的过程中,使用频率还是非常高的。 注意: 通过查看 源代码我们发现 实际上是继承了 并实现了 ,而 又实现了 ,正如我们上面给出的类关系图显示的一样。 类描述: 类描述: 3、异步计算的结果( ) 接口以及 接口的实现类 类都可以代表异步计算的结果。 当我们把 接口 或 接口 的实现类提交给 或 执行。(调用 方法时会返回一个 对象) 框架的使用示意图 : 1. 主线程首先要创建实现 或者 接口的任务对象。 2. 把创建完成的实现 / 接口的 对象直接交给 执行: )或者也可以把 对象或 对象提交给 执行( 或 )。 3. 如果执行 , 将返回一个实现 接口的对象(我们刚刚也提到过了执行 方法和 方法的区别, 会返回一个 实现了 ,我们也可以创建 ,然后直接交给 执行。 4. 最后,主线程可以执行 方法来等待任务执行完成。主线程也可以执行 来取消此任务的执行。 ThreadPoolExecutor 类介绍(重要) 线程池实现类 是 框架最核心的类。 线程池参数分析 类中提供的四个构造方法。我们来看最长的那个,其余三个都是在这个构造方法的基础上产生(其他几个构造方法说白点都是给定某些默认参数的构造方法比如默认制定拒绝策略是什么)。 下面这些参数非常重要,在后面使用线程池的过程中你一定会用到!所以,务必拿着小本本记清楚。 3 个最重要的参数: : 任务队列未达到队列容量时,最大可以同时运行的线程数量。 : 任务队列中存放的任务达到队列容量的时候,当前可以同时运行的线程数量变为最大线程数。 : 新任务来的时候会先判断当前运行的线程数量是否达到核心线程数,如果达到的话,新任务就会被存放在队列中。 其他常见参数 : :线程池中的线程数量大于 的时候,如果这时没有新的任务提交,核心线程外的线程不会立即销毁,而是会等待,直到等待的时间超过了 才会被回收销毁。 : 参数的时间单位。 :executor 创建新线程的时候会用到。 :拒绝策略(后面会单独详细介绍一下)。 下面这张图可以加深你对线程池中各个参数的相互关系的理解(图片来源:《Java 性能调优实战》): 线程池生命周期状态 使用 变量( 类型)同时管理线程池的运行状态和工作线程数量。线程池共有 5 种状态: 运行中( ) :接受新任务,并处理队列中的任务。线程池创建后的初始状态。 关闭( ) :不再接受新任务,但会继续处理队列中已有的任务。调用 后进入。 停止( ) :不接受新任务,不处理队列中的任务,并尝试中断正在执行的任务。调用 后进入。 整理中( ) :所有任务已终止,工作线程数为 0,即将执行 钩子方法。 已终止( ) : 方法执行完毕,线程池彻底终结。 状态只能单向流转:运行中( )→ 关闭( )→ 整理中( )→ 已终止( ),或者运行中( )→ 停止( )→ 整理中( )→ 已终止( )。在关闭( )状态下再调用 也会转为停止( )。 是“温和关闭”——中断空闲线程,但队列中的任务仍会执行完毕。 是“强制关闭”——尝试中断所有正在运行的线程,并将队列中未执行的任务以 返回。 是一个空的钩子方法,可以通过继承 来重写它,用于在线程池终止后做清理工作。 Worker 工作线程机制 将每个工作线程封装为内部类 。 继承了 AQS 并实现了 接口。 为什么 要继承 AQS? 实现了一个 不可重入的独占锁 ,用于配合 区分线程是空闲还是正在工作——正在执行任务的 Worker 持有锁, 对每个 Worker 尝试 ,失败则说明该线程正在工作,不会被中断。 Worker 的生命周期: 1. 创建 : 判断需要新建线程时,调用 创建 实例,内部通过 创建线程。 2. 运行 :线程启动后进入 的 循环,通过 不断从队列取任务执行。核心线程用 (阻塞等待),非核心线程用 (超时等待)。 3. 退出 : 返回 时 Worker 退出循环并清理。返回 的情况包括:线程池处于停止( )状态、线程池处于关闭( )状态且队列为空、非核心线程等待超时、或运行时缩小了 。如果退出后工作线程数低于核心数,会自动补充一个新线程。 拒绝策略定义: 如果当前同时运行的线程数量达到最大线程数量并且队列也已经被放满了任务时, 定义一些策略: :抛出 来拒绝新任务的处理。 :调用执行自己的线程运行任务,也就是直接在调用 方法的线程中运行( )被拒绝的任务,如果执行程序已关闭,则会丢弃该任务。因此这种策略会降低对于新任务提交速度,影响程序的整体性能。如果您的应用程序可以承受此延迟并且你要求任何一个任务请求都要被执行的话,你可以选择这个策略。 :不处理新任务,直接丢弃掉。 :此策略将丢弃最早的未处理的任务请求。 举个例子: 举个例子:Spring 通过 或者我们直接通过 的构造函数创建线程池的时候,当我们不指定 拒绝策略来配置线程池的时候,默认使用的是 。在这种拒绝策略下,如果队列满了, 将抛出 异常来拒绝新来的任务,这代表你将丢失对这个任务的处理。如果不想丢弃任务的话,可以使用 。 和其他的几个策略不同,它既不会抛弃任务,也不会抛出异常,而是将任务回退给调用者,使用调用者的线程来执行任务 4 种拒绝策略的实际应用场景 上面介绍了 4 种内置拒绝策略的基本行为,下面结合实际生产经验,说明它们各自适合什么场景: :适用于对任务丢失零容忍的核心业务(如支付、转账)。任务被拒绝时调用方会收到 ,必须在业务代码中捕获并做补偿(如重试或持久化到数据库后补偿执行)。《阿里巴巴 Java 开发手册》指出,如果不做任何配置,队列满时会直接抛异常,开发者必须显式处理。 :适用于不允许丢弃任务、且允许降低提交速度的场景。由于任务在调用者线程中执行,调用者在此期间无法提交新任务,形成了一种天然的 反压(back pressure) 机制。美团技术团队在《Java 线程池实现原理及其在美团业务中的实践》中提到,这是他们线上业务中较常使用的拒绝策略。但需要注意:如果提交任务的线程是 Web 容器的请求处理线程(如 Tomcat 的 Worker 线程),会导致该请求响应时间显著增加,在延迟敏感的场景中需谨慎。 :适用于任务允许丢失的非关键路径,如日志异步写入、监控指标上报。该策略完全静默(空实现),被拒绝的任务不会留下任何痕迹,排查问题时可能难以发现任务丢失。 :适用于只关心最新数据、旧任务可被覆盖的场景,如实时行情推送、传感器数据采集。需要注意:如果使用了 , 弹出的是优先级最高的任务而非最旧的任务,可能导致重要任务被误丢。 生产环境中的常见做法 :以上 4 种内置策略往往不能完全满足需求。Dubbo 框架自定义了 策略,在抛异常之外还会将被拒绝的任务信息 dump 到本地文件,方便事后排查。美团技术团队建议对线程池的拒绝次数进行监控和告警。常见的自定义策略思路包括:将被拒绝的任务写入数据库或消息队列后续补偿消费、递增监控计数器上报 Prometheus、或者调用 阻塞等待队列有空位(Netty 中有类似实现)。 线程池创建的两种方式 在 Java 中,创建线程池主要有两种方式: 方式一:通过 构造函数直接创建(推荐) 这是最推荐的方式,因为它允许开发者明确指定线程池的核心参数,对线程池的运行行为有更精细的控制,从而避免资源耗尽的风险。 方式二:通过 工具类创建(不推荐用于生产环境) 工具类提供的创建线程池的方法如下图所示: 可以看出,通过 工具类可以创建多种类型的线程池,包括: :固定线程数量的线程池。该线程池中的线程数量始终不变。当有一个新的任务提交时,线程池中若有空闲线程,则立即执行。若没有,则新的任务会被暂存在一个任务队列中,待有线程空闲时,便处理在任务队列中的任务。 : 只有一个线程的线程池。若多余一个任务被提交到该线程池,任务会被保存在一个任务队列中,待线程空闲,按先入先出的顺序执行队列中的任务。 : 可根据实际情况调整线程数量的线程池。线程池的线程数量不确定,但若有空闲线程可以复用,则会优先使用可复用的线程。若所有线程均在工作,又有新的任务提交,则会创建新的线程处理任务。所有线程在当前任务执行完毕后,将返回线程池进行复用。 :给定的延迟后运行任务或者定期执行任务的线程池。 《阿里巴巴 Java 开发手册》强制线程池不允许使用 去创建,而是通过 构造函数的方式,这样的处理方式让写的同学更加明确线程池的运行规则,规避资源耗尽的风险 返回线程池对象的弊端如下(后文会详细介绍到): 和 :使用的是阻塞队列 ,任务队列最大长度为 ,可以看作是无界的,可能堆积大量的请求,从而导致 OOM。 :使用的是同步队列 , 允许创建的线程数量为 ,如果任务数量过多且执行速度较慢,可能会创建大量的线程,从而导致 OOM。 和 :使用的无界的延迟阻塞队列 ,任务队列最大长度为 ,可能堆积大量的请求,从而导致 OOM。 线程池常用的阻塞队列总结 新任务来的时候会先判断当前运行的线程数量是否达到核心线程数,如果达到的话,新任务就会被存放在队列中。 不同的线程池会选用不同的阻塞队列,我们可以结合内置线程池来分析。 容量为 的 (无界队列): 和 。 最多只能创建核心线程数的线程(核心线程数和最大线程数相等), 只能创建一个线程(核心线程数和最大线程数都是 1),二者的任务队列永远不会被放满。 (同步队列): 。 没有容量,不存储元素,目的是保证对于提交的任务,如果有空闲线程,则使用空闲线程来处理;否则新建一个线程来处理任务。也就是说, 的最大线程数是 ,可以理解为线程数是可以无限扩展的,可能会创建大量线程,从而导致 OOM。 (延迟阻塞队列): 和 。 的内部元素并不是按照放入的时间排序,而是会按照延迟的时间长短对任务进行排序,内部采用的是“堆”的数据结构,可以保证每次出队的任务都是当前队列中执行时间最靠前的。 添加元素满了之后会自动扩容原来容量的 1/2,即永远不会阻塞,最大扩容可达 ,所以最多只能创建核心线程数的线程。 线程池原理分析(重要) 我们上面讲解了 框架以及 类,下面让我们实战一下,来通过写一个 的小 Demo 来回顾上面的内容。 线程池示例代码 首先创建一个 接口的实现类(当然也可以是 接口,我们后面会介绍两者的区别。) 编写测试程序,我们这里以阿里巴巴推荐的使用 构造函数自定义参数的方式来创建线程池。 可以看到我们上面的代码指定了: : 核心线程数为 5。 :最大线程数 10 : 等待时间为 1L。 : 等待时间的单位为 TimeUnit.SECONDS。 :任务队列为 ,并且容量为 100; :拒绝策略为 。 输出结构 : 线程池原理分析 我们通过前面的代码输出结果可以看出: 线程池首先会先执行 5 个任务,然后这些任务有任务被执行完的话,就会去拿新的任务执行。 大家可以先通过上面讲解的内容,分析一下到底是咋回事?(自己独立思考一会) 现在,我们就分析上面的输出内容来简单分析一下线程池原理。 为了搞懂线程池的原理,我们需要首先分析一下 方法。 在示例代码中,我们使用 来提交一个任务到线程池中去。 这个方法非常重要,下面我们来看看它的源码: 这里简单分析一下整个流程(对整个逻辑进行了简化,方便理解): 1. 如果当前工作线程总数小于核心线程数,那么就会新建一个线程来执行任务。 2. 如果当前工作线程总数已经达到核心线程数,先尝试把任务放入任务队列中等待执行。 3. 如果向任务队列投放任务失败(任务队列已经满了),并且当前工作线程总数小于最大线程数,就新建一个非核心线程来执行任务。 4. 如果当前工作线程总数已经等同于最大线程数,任务队列也无法继续接收任务,那么当前任务会被拒绝,拒绝策略会调用 方法。 补充说明 :很多人误以为非核心线程只在任务队列满的时候才会被创建,之后就“闲着”等销毁。实际上,非核心线程执行完初始任务后,并不会立刻销毁,而是会 主动从任务队列中拉取任务执行 (通过 方法)。具体来说,核心线程使用 阻塞等待任务,而非核心线程使用 ——如果在存活时间内从队列中取到了任务,就会继续执行;只有超时没有取到任务,非核心线程才会被回收。这意味着,即使新任务被放入了队列,空闲的非核心线程也会抢先从队列中取走任务来执行,而不是等队列满了才被动响应。 在 方法中,多次调用 方法。 这个方法主要用来创建新的工作线程,如果返回 true 说明创建和启动工作线程成功,否则的话返回的就是 false。 更多关于线程池源码分析的内容推荐这篇文章:硬核干货:4W 字从源码上分析 JUC 线程池 ThreadPoolExecutor 的实现原理。 现在,让我们在回到示例代码, 现在应该是不是很容易就可以搞懂它的原理了呢? 没搞懂的话,也没关系,可以看看我的分析: 我们在代码中模拟了 10 个任务,我们配置的核心线程数为 5、等待队列容量为 100,所以每次只可能存在 5 个任务同时执行,剩下的 5 个任务会被放到等待队列中去。当前的 5 个任务中如果有任务被执行完了,线程池就会去拿新的任务执行。 几个常见的对比 vs 自 Java 1.0 以来一直存在,但 仅在 Java 1.5 中引入,目的就是为了来处理 不支持的用例。 接口不会返回结果或抛出检查异常,但是 接口可以。所以,如果任务不需要返回结果或抛出异常推荐使用 接口,这样代码看起来会更加简洁。 工具类 可以实现将 对象转换成 对象。( 或 )。 vs 和 是两种提交任务到线程池的方法,有一些区别: 返回值 : 方法用于提交不需要返回值的任务。通常用于执行 任务,无法判断任务是否被线程池成功执行。 方法用于提交需要返回值的任务。可以提交 或 任务。 方法返回一个 对象,通过这个 对象可以判断任务是否执行成功,并获取任务的返回值( 方法会阻塞当前线程直到任务完成, 多了一个超时时间,如果在 时间内任务还没有执行完,就会抛出 )。 异常处理 :在使用 方法时,可以通过 对象处理任务执行过程中抛出的异常;而在使用 方法时,异常处理需要通过自定义的 (在线程工厂创建线程的时候设置 对象来 处理异常)或 的 方法来处理 示例 1:使用 方法获取返回值。 输出: 示例 2:使用 方法获取返回值。 输出: VS :关闭线程池,线程池的状态变为 。线程池不再接受新任务了,但是队列里的任务得执行完毕。 :关闭线程池,线程池的状态变为 。线程池会终止当前正在运行的任务,并停止处理排队的任务并返回正在等待执行的 List。 VS 当调用 方法后返回为 true。 当调用 方法后,并且所有提交的任务完成后返回为 true 几种常见的内置线程池 FixedThreadPool 介绍 被称为可重用固定线程数的线程池。通过 类中的相关源代码来看一下相关实现: 另外还有一个 的实现方法,和上面的类似,所以这里不多做阐述: 从上面源代码可以看出新创建的 的 和 都被设置为 ,这个 参数是我们使用的时候自己传递的。 即使 的值比 大,也至多只会创建 个线程。这是因为 使用的是容量为 的 (无界队列),队列永远不会被放满。 执行任务过程介绍 的 方法运行示意图(该图片来源:《Java 并发编程的艺术》): 上图说明: 1. 如果当前工作线程总数小于 ,如果再来新任务的话,就创建新的线程来执行任务; 2. 当前工作线程总数达到 后,如果再来新任务的话,会将任务加入 ; 3. 线程池中的线程执行完 手头的任务后,会在循环中反复从 中获取任务来执行; 为什么不推荐使用 ? 使用无界队列 (队列的容量为 Integer.MAX\ VALUE)作为线程池的工作队列会对线程池带来如下影响: 1. 当线程池中的线程数达到 后,新任务将在无界队列中等待,因此线程池中的线程数不会超过 ; 2. 由于使用无界队列时 将是一个无效参数,因为不可能存在任务队列满的情况。所以,通过创建 的源码可以看出创建的 的 和 被设置为同一个值。 3. 由于 1 和 2,使用无界队列时 将是一个无效参数; 4. 运行中的 (未执行 或 )不会拒绝任务,在任务比较多的时候会导致 OOM(内存溢出)。 SingleThreadExecutor 介绍 是只有一个线程的线程池。下面看看 SingleThreadExecutor 的实现: 从上面源代码可以看出新创建的 的 和 都被设置为 1,其他参数和 相同。 执行任务过程介绍 的运行示意图(该图片来源:《Java 并发编程的艺术》): 上图说明 : 1. 如果当前运行的线程数少于 ,则创建一个新的线程执行任务; 2. 当前线程池中有一个运行的线程后,将任务加入 3. 线程执行完当前的任务后,会在循环中反复从 中获取任务来执行; 为什么不推荐使用 ? 和 一样,使用的都是容量为 的 (无界队列)。 使用无界队列作为线程池的工作队列会对线程池带来的影响与 相同。说简单点,就是可能会导致 OOM。 CachedThreadPool 介绍 是一个会根据需要创建新线程的线程池。下面通过源码来看看 的实现: 的 被设置为空(0), 被设置为 ,即它是无界的,这也就意味着如果主线程提交任务的速度高于 中线程处理任务的速度时, 会不断创建新的线程。极端情况下,这样会导致耗尽 cpu 和内存资源。 执行任务过程介绍 的 方法的执行示意图(该图片来源:《Java 并发编程的艺术》): 上图说明: 1. 首先执行 提交任务到任务队列。如果当前 中有闲线程正在执行 ,那么主线程执行 offer 操作与空闲线程执行的 操作配对成功,主线程把任务交给空闲线程执行, 方法执行完成,否则执行下面的步骤 2; 2. 当初始 为空,或者 中没有空闲线程时,将没有线程执行 。这种情况下,步骤 1 将失败,此时 会创建新线程执行任务,execute 方法执行完成; 为什么不推荐使用 ? 使用的是同步队列 , 允许创建的线程数量为 ,可能会创建大量线程,从而导致 OOM。 ScheduledThreadPool 介绍 用来在给定的延迟后运行任务或者定期执行任务。这个在实际项目中基本不会被用到,也不推荐使用,大家只需要简单了解一下即可。 是通过 创建的,使用的 (延迟阻塞队列)作为线程池的任务队列。 的内部元素并不是按照放入的时间排序,而是会按照延迟的时间长短对任务进行排序,内部采用的是“堆”的数据结构,可以保证每次出队的任务都是当前队列中执行时间最靠前的。 添加元素满了之后会自动扩容原来容量的 1/2,即永远不会阻塞,最大扩容可达 ,所以最多只能创建核心线程数的线程。 继承了 ,所以创建 本质也是创建一个 线程池,只是传入的参数不相同。 ScheduledThreadPoolExecutor 和 Timer 对比 对系统时钟的变化敏感, 不是; 只有一个执行线程,因此长时间运行的任务可以延迟其他任务。 可以配置任意数量的线程。 此外,如果你想(通过提供 ),你可以完全控制创建的线程; 在 中抛出的运行时异常会杀死一个线程,从而导致 死机即计划任务将不再运行。 不仅捕获运行时异常,还允许您在需要时处理它们(通过重写 方法 )。抛出异常的任务将被取消,但其他任务将继续运行。 关于定时任务的详细介绍,可以看这篇文章:Java 定时任务详解。 线程池最佳实践 Java 线程池最佳实践这篇文章总结了一些使用线程池的时候应该注意的东西,实际项目使用线程池之前可以看看。 参考 《Java 并发编程的艺术》 Java Scheduler ScheduledExecutorService ScheduledThreadPoolExecutor Example java.util.concurrent.ScheduledThreadPoolExecutor Example ThreadPoolExecutor – Java Thread Pool Example 写在最后 如果内容对你有帮助的话,欢迎顺手给 JavaGuide 点一个免费的 Star 支持一下:GitHub | Gitee。 JavaGuide 已持续维护近七年,累计 6100+ 次提交,来自 620+ 位贡献者共同完善。你的 Star、反馈和 PR,都是这个项目继续更新的动力。 如果你正在准备后端/AI 应用开发面试,也可以了解一下我的知识星球,里面包括后端和 AI 实战项目、简历优化、一对一提问和高频考点资料,已经持续维护六年。