池化技术想必大家已经屡见不鲜了,线程池、数据库连接池、HTTP 连接池等等都是对这个思想的应用。池化技术的思想主要是为了减少每次获取资源的消耗,提高对资源的利用率。
这篇文章我会详细介绍一下线程池的基本概念以及核心原理。
顾名思义,线程池就是管理一系列线程的资源池,其提供了一种限制和管理线程资源的方式。每个线程池还维护一些基本统计信息,例如已完成任务的数量。
这里借用《Java 并发编程的艺术》书中的部分内容来总结一下使用线程池的好处:
- 降低资源消耗。通过重复利用已创建的线程降低线程创建和销毁造成的消耗。
- 提高响应速度。当任务到达时,任务可以不需要等到线程创建就能立即执行。
- 提高线程的可管理性。线程是稀缺资源,如果无限制的创建,不仅会消耗系统资源,还会降低系统的稳定性,使用线程池可以进行统一的分配,调优和监控。
线程池一般用于执行多个不相关联的耗时任务,没有多线程的情况下,任务顺序执行,使用了线程池的话可让多个不相关联的任务同时执行。
框架是 Java5 之后引进的,在 Java 5 之后,通过 来启动线程比使用 的 方法更好,除了更易管理,效率更好(用线程池实现,节约开销)外,还有关键的一点:有助于避免 this 逃逸问题。
this 逃逸是指在构造函数返回之前其他线程就持有该对象的引用,调用尚未构造完全的对象的方法可能引发令人疑惑的错误。
框架不仅包括了线程池的管理,还提供了线程工厂、队列以及拒绝策略等, 框架让并发编程变得更加简单。
框架结构主要由三大部分组成:
1、任务( /)
执行任务需要实现的 接口 或 接口。 接口或 接口 实现类都可以被 或 执行。
2、任务的执行()
如下图所示,包括任务执行机制的核心接口 ,以及继承自 接口的 接口。 和 这两个关键类实现了 接口。
这里提了很多底层的类关系,但是,实际上我们需要更多关注的是 这个类,这个类在我们实际使用线程池的过程中,使用频率还是非常高的。
注意: 通过查看 源代码我们发现 实际上是继承了 并实现了 ,而 又实现了 ,正如我们上面给出的类关系图显示的一样。
类描述:
类描述:
3、异步计算的结果()
接口以及 接口的实现类 类都可以代表异步计算的结果。
当我们把 接口 或 接口 的实现类提交给 或 执行。(调用 方法时会返回一个 对象)
框架的使用示意图:
- 主线程首先要创建实现 或者 接口的任务对象。
- 把创建完成的实现 /接口的 对象直接交给 执行: )或者也可以把 对象或 对象提交给 执行(或 )。
- 如果执行 , 将返回一个实现接口的对象(我们刚刚也提到过了执行 方法和 方法的区别,会返回一个 实现了 ,我们也可以创建 ,然后直接交给 执行。
- 最后,主线程可以执行 方法来等待任务执行完成。主线程也可以执行 来取消此任务的执行。
线程池实现类 是 框架最核心的类。
类中提供的四个构造方法。我们来看最长的那个,其余三个都是在这个构造方法的基础上产生(其他几个构造方法说白点都是给定某些默认参数的构造方法比如默认制定拒绝策略是什么)。
下面这些参数非常重要,在后面使用线程池的过程中你一定会用到!所以,务必拿着小本本记清楚。
3 个最重要的参数:
- : 任务队列未达到队列容量时,最大可以同时运行的线程数量。
- : 任务队列中存放的任务达到队列容量的时候,当前可以同时运行的线程数量变为最大线程数。
- : 新任务来的时候会先判断当前运行的线程数量是否达到核心线程数,如果达到的话,新任务就会被存放在队列中。
其他常见参数 :
- :线程池中的线程数量大于 的时候,如果这时没有新的任务提交,核心线程外的线程不会立即销毁,而是会等待,直到等待的时间超过了 才会被回收销毁。
- : 参数的时间单位。
- :executor 创建新线程的时候会用到。
- :拒绝策略(后面会单独详细介绍一下)。
下面这张图可以加深你对线程池中各个参数的相互关系的理解(图片来源:《Java 性能调优实战》):
拒绝策略定义:
如果当前同时运行的线程数量达到最大线程数量并且队列也已经被放满了任务时, 定义一些策略:
- :抛出 来拒绝新任务的处理。
- :调用执行自己的线程运行任务,也就是直接在调用方法的线程中运行()被拒绝的任务,如果执行程序已关闭,则会丢弃该任务。因此这种策略会降低对于新任务提交速度,影响程序的整体性能。如果您的应用程序可以承受此延迟并且你要求任何一个任务请求都要被执行的话,你可以选择这个策略。
- :不处理新任务,直接丢弃掉。
- :此策略将丢弃最早的未处理的任务请求。
举个例子:
举个例子:Spring 通过 或者我们直接通过 的构造函数创建线程池的时候,当我们不指定 拒绝策略来配置线程池的时候,默认使用的是 。在这种拒绝策略下,如果队列满了, 将抛出 异常来拒绝新来的任务 ,这代表你将丢失对这个任务的处理。如果不想丢弃任务的话,可以使用。 和其他的几个策略不同,它既不会抛弃任务,也不会抛出异常,而是将任务回退给调用者,使用调用者的线程来执行任务
方式一:通过构造函数来创建(推荐)。
方式二:通过 框架的工具类 来创建。
工具类提供的创建线程池的方法如下图所示:
可以看出,通过工具类可以创建多种类型的线程池,包括:
- :固定线程数量的线程池。该线程池中的线程数量始终不变。当有一个新的任务提交时,线程池中若有空闲线程,则立即执行。若没有,则新的任务会被暂存在一个任务队列中,待有线程空闲时,便处理在任务队列中的任务。
- : 只有一个线程的线程池。若多余一个任务被提交到该线程池,任务会被保存在一个任务队列中,待线程空闲,按先入先出的顺序执行队列中的任务。
- : 可根据实际情况调整线程数量的线程池。线程池的线程数量不确定,但若有空闲线程可以复用,则会优先使用可复用的线程。若所有线程均在工作,又有新的任务提交,则会创建新的线程处理任务。所有线程在当前任务执行完毕后,将返回线程池进行复用。
- :给定的延迟后运行任务或者定期执行任务的线程池。
《阿里巴巴 Java 开发手册》强制线程池不允许使用 去创建,而是通过 构造函数的方式,这样的处理方式让写的同学更加明确线程池的运行规则,规避资源耗尽的风险
返回线程池对象的弊端如下(后文会详细介绍到):
- 和 :使用的是无界的 ,任务队列最大长度为 ,可能堆积大量的请求,从而导致 OOM。
- :使用的是同步队列 , 允许创建的线程数量为 ,如果任务数量过多且执行速度较慢,可能会创建大量的线程,从而导致 OOM。
- 和 :使用的无界的延迟阻塞队列,任务队列最大长度为 ,可能堆积大量的请求,从而导致 OOM。
新任务来的时候会先判断当前运行的线程数量是否达到核心线程数,如果达到的话,新任务就会被存放在队列中。
不同的线程池会选用不同的阻塞队列,我们可以结合内置线程池来分析。
- 容量为 的 (无界队列): 和 。最多只能创建核心线程数的线程(核心线程数和最大线程数相等),只能创建一个线程(核心线程数和最大线程数都是 1),二者的任务队列永远不会被放满。
- (同步队列): 。 没有容量,不存储元素,目的是保证对于提交的任务,如果有空闲线程,则使用空闲线程来处理;否则新建一个线程来处理任务。也就是说, 的最大线程数是 ,可以理解为线程数是可以无限扩展的,可能会创建大量线程,从而导致 OOM。
- (延迟阻塞队列): 和 。 的内部元素并不是按照放入的时间排序,而是会按照延迟的时间长短对任务进行排序,内部采用的是“堆”的数据结构,可以保证每次出队的任务都是当前队列中执行时间最靠前的。 添加元素满了之后会自动扩容原来容量的 1/2,即永远不会阻塞,最大扩容可达 ,所以最多只能创建核心线程数的线程。
我们上面讲解了 框架以及 类,下面让我们实战一下,来通过写一个 的小 Demo 来回顾上面的内容。
首先创建一个 接口的实现类(当然也可以是 接口,我们后面会介绍两者的区别。)
编写测试程序,我们这里以阿里巴巴推荐的使用 构造函数自定义参数的方式来创建线程池。
可以看到我们上面的代码指定了:
- : 核心线程数为 5。
- :最大线程数 10
- : 等待时间为 1L。
- : 等待时间的单位为 TimeUnit.SECONDS。
- :任务队列为 ,并且容量为 100;
- :拒绝策略为 。
输出结构:
我们通过前面的代码输出结果可以看出:线程池首先会先执行 5 个任务,然后这些任务有任务被执行完的话,就会去拿新的任务执行。 大家可以先通过上面讲解的内容,分析一下到底是咋回事?(自己独立思考一会)
现在,我们就分析上面的输出内容来简单分析一下线程池原理。
为了搞懂线程池的原理,我们需要首先分析一下 方法。 在示例代码中,我们使用 来提交一个任务到线程池中去。
这个方法非常重要,下面我们来看看它的源码:
这里简单分析一下整个流程(对整个逻辑进行了简化,方便理解):
- 如果当前运行的线程数小于核心线程数,那么就会新建一个线程来执行任务。
- 如果当前运行的线程数等于或大于核心线程数,但是小于最大线程数,那么就把该任务放入到任务队列里等待执行。
- 如果向任务队列投放任务失败(任务队列已经满了),但是当前运行的线程数是小于最大线程数的,就新建一个线程来执行任务。
- 如果当前运行的线程数已经等同于最大线程数了,新建线程将会使当前运行的线程超出最大线程数,那么当前任务会被拒绝,拒绝策略会调用方法。
在 方法中,多次调用 方法。 这个方法主要用来创建新的工作线程,如果返回 true 说明创建和启动工作线程成功,否则的话返回的就是 false。
更多关于线程池源码分析的内容推荐这篇文章:硬核干货:4W 字从源码上分析 JUC 线程池 ThreadPoolExecutor 的实现原理
现在,让我们在回到示例代码, 现在应该是不是很容易就可以搞懂它的原理了呢?
没搞懂的话,也没关系,可以看看我的分析:
我们在代码中模拟了 10 个任务,我们配置的核心线程数为 5、等待队列容量为 100 ,所以每次只可能存在 5 个任务同时执行,剩下的 5 个任务会被放到等待队列中去。当前的 5 个任务中如果有任务被执行完了,线程池就会去拿新的任务执行。
自 Java 1.0 以来一直存在,但仅在 Java 1.5 中引入,目的就是为了来处理不支持的用例。 接口不会返回结果或抛出检查异常,但是 接口可以。所以,如果任务不需要返回结果或抛出异常推荐使用 接口,这样代码看起来会更加简洁。
工具类 可以实现将 对象转换成 对象。( 或 )。
和 是两种提交任务到线程池的方法,有一些区别:
- 返回值: 方法用于提交不需要返回值的任务。通常用于执行 任务,无法判断任务是否被线程池成功执行。 方法用于提交需要返回值的任务。可以提交 或 任务。 方法返回一个 对象,通过这个 对象可以判断任务是否执行成功,并获取任务的返回值(方法会阻塞当前线程直到任务完成, 多了一个超时时间,如果在 时间内任务还没有执行完,就会抛出 )。
- 异常处理:在使用 方法时,可以通过 对象处理任务执行过程中抛出的异常;而在使用 方法时,异常处理需要通过自定义的 (在线程工厂创建线程的时候设置对象来 处理异常)或 的 方法来处理
示例 1:使用 方法获取返回值。
输出:
示例 2:使用 方法获取返回值。
输出:
- :关闭线程池,线程池的状态变为 。线程池不再接受新任务了,但是队列里的任务得执行完毕。
- :关闭线程池,线程池的状态变为 。线程池会终止当前正在运行的任务,并停止处理排队的任务并返回正在等待执行的 List。
- 当调用 方法后返回为 true。
- 当调用 方法后,并且所有提交的任务完成后返回为 true
被称为可重用固定线程数的线程池。通过 类中的相关源代码来看一下相关实现:
另外还有一个 的实现方法,和上面的类似,所以这里不多做阐述:
从上面源代码可以看出新创建的 的 和 都被设置为 ,这个 参数是我们使用的时候自己传递的。
即使 的值比 大,也至多只会创建 个线程。这是因为 使用的是容量为 的 (无界队列),队列永远不会被放满。
的 方法运行示意图(该图片来源:《Java 并发编程的艺术》):
上图说明:
- 如果当前运行的线程数小于 , 如果再来新任务的话,就创建新的线程来执行任务;
- 当前运行的线程数等于 后, 如果再来新任务的话,会将任务加入 ;
- 线程池中的线程执行完 手头的任务后,会在循环中反复从 中获取任务来执行;
使用无界队列 (队列的容量为 Integer.MAX_VALUE)作为线程池的工作队列会对线程池带来如下影响:
- 当线程池中的线程数达到 后,新任务将在无界队列中等待,因此线程池中的线程数不会超过 ;
- 由于使用无界队列时 将是一个无效参数,因为不可能存在任务队列满的情况。所以,通过创建 的源码可以看出创建的 的 和 被设置为同一个值。
- 由于 1 和 2,使用无界队列时 将是一个无效参数;
- 运行中的 (未执行 或 )不会拒绝任务,在任务比较多的时候会导致 OOM(内存溢出)。
是只有一个线程的线程池。下面看看SingleThreadExecutor 的实现:
从上面源代码可以看出新创建的 的 和 都被设置为 1,其他参数和 相同。
的运行示意图(该图片来源:《Java 并发编程的艺术》):
上图说明 :
- 如果当前运行的线程数少于 ,则创建一个新的线程执行任务;
- 当前线程池中有一个运行的线程后,将任务加入
- 线程执行完当前的任务后,会在循环中反复从 中获取任务来执行;
和 一样,使用的都是容量为 的 (无界队列)作为线程池的工作队列。 使用无界队列作为线程池的工作队列会对线程池带来的影响与 相同。说简单点,就是可能会导致 OOM。
是一个会根据需要创建新线程的线程池。下面通过源码来看看 的实现:
的 被设置为空(0),被设置为 ,即它是无界的,这也就意味着如果主线程提交任务的速度高于 中线程处理任务的速度时, 会不断创建新的线程。极端情况下,这样会导致耗尽 cpu 和内存资源。
的 方法的执行示意图(该图片来源:《Java 并发编程的艺术》):
上图说明:
- 首先执行 提交任务到任务队列。如果当前 中有闲线程正在执行 ,那么主线程执行 offer 操作与空闲线程执行的 操作配对成功,主线程把任务交给空闲线程执行,方法执行完成,否则执行下面的步骤 2;
- 当初始 为空,或者 中没有空闲线程时,将没有线程执行 。这种情况下,步骤 1 将失败,此时 会创建新线程执行任务,execute 方法执行完成;
使用的是同步队列 , 允许创建的线程数量为 ,可能会创建大量线程,从而导致 OOM。
用来在给定的延迟后运行任务或者定期执行任务。这个在实际项目中基本不会被用到,也不推荐使用,大家只需要简单了解一下即可。
是通过 创建的,使用的(延迟阻塞队列)作为线程池的任务队列。
的内部元素并不是按照放入的时间排序,而是会按照延迟的时间长短对任务进行排序,内部采用的是“堆”的数据结构,可以保证每次出队的任务都是当前队列中执行时间最靠前的。 添加元素满了之后会自动扩容原来容量的 1/2,即永远不会阻塞,最大扩容可达 ,所以最多只能创建核心线程数的线程。
继承了 ,所以创建 本质也是创建一个 线程池,只是传入的参数不相同。
- 对系统时钟的变化敏感,不是;
- 只有一个执行线程,因此长时间运行的任务可以延迟其他任务。 可以配置任意数量的线程。 此外,如果你想(通过提供 ),你可以完全控制创建的线程;
- 在 中抛出的运行时异常会杀死一个线程,从而导致 死机即计划任务将不再运行。 不仅捕获运行时异常,还允许您在需要时处理它们(通过重写 方法)。抛出异常的任务将被取消,但其他任务将继续运行。
关于定时任务的详细介绍,可以看这篇文章:Java 定时任务详解 。
Java 线程池最佳实践这篇文章总结了一些使用线程池的时候应该注意的东西,实际项目使用线程池之前可以看看。
- 《Java 并发编程的艺术》
- Java Scheduler ScheduledExecutorService ScheduledThreadPoolExecutor Example
- java.util.concurrent.ScheduledThreadPoolExecutor Example
- ThreadPoolExecutor – Java Thread Pool Example
简单总结一下我了解的使用线程池的时候应该注意的东西,网上似乎还没有专门写这方面的文章。
线程池必须手动通过 的构造函数来声明,避免使用 类创建线程池,会有 OOM 风险。
返回线程池对象的弊端如下(后文会详细介绍到):
- 和 :使用的是有界阻塞队列 ,任务队列的默认长度和最大长度为 ,可能堆积大量的请求,从而导致 OOM。
- :使用的是同步队列 ,允许创建的线程数量为 ,可能会创建大量线程,从而导致 OOM。
- 和 : 使用的无界的延迟阻塞队列,任务队列最大长度为 ,可能堆积大量的请求,从而导致 OOM。
说白了就是:使用有界队列,控制线程创建数量。
除了避免 OOM 的原因之外,不推荐使用 提供的两种快捷的线程池的原因还有:
- 实际使用中需要根据自己机器的性能、业务场景来手动配置线程池的参数比如核心线程数、使用的任务队列、饱和策略等等。
- 我们应该显示地给我们的线程池命名,这样有助于我们定位问题。
你可以通过一些手段来检测线程池的运行状态比如 SpringBoot 中的 Actuator 组件。
除此之外,我们还可以利用 的相关 API 做一个简陋的监控。从下图可以看出, 提供了获取线程池当前的线程数和活跃线程数、已经执行完成的任务数、正在排队中的任务数等等。
下面是一个简单的 Demo。会每隔一秒打印出线程池的线程数、活跃线程数、完成的任务数、以及队列中的任务数。
很多人在实际项目中都会有类似这样的问题:我的项目中多个业务需要用到线程池,是为每个线程池都定义一个还是说定义一个公共的线程池呢?
一般建议是不同的业务使用不同的线程池,配置线程池的时候根据当前业务的情况对当前线程池进行配置,因为不同的业务的并发以及对资源的使用情况都不同,重心优化系统性能瓶颈相关的业务。
我们再来看一个真实的事故案例! (本案例来源自:《线程池运用不当的一次线上事故》 ,很精彩的一个案例)
上面的代码可能会存在死锁的情况,为什么呢?画个图给大家捋一捋。
试想这样一种极端情况:假如我们线程池的核心线程数为 n,父任务(扣费任务)数量为 n,父任务下面有两个子任务(扣费任务下的子任务),其中一个已经执行完成,另外一个被放在了任务队列中。由于父任务把线程池核心线程资源用完,所以子任务因为无法获取到线程资源无法正常执行,一直被阻塞在队列中。父任务等待子任务执行完成,而子任务等待父任务释放线程池资源,这也就造成了 "死锁" 。
解决方法也很简单,就是新增加一个用于执行子任务的线程池专门为其服务。
初始化线程池的时候需要显示命名(设置线程池名称前缀),有利于定位问题。
默认情况下创建的线程名字类似 这样的,没有业务含义,不利于我们定位问题。
给线程池里的线程命名通常有下面两种方式:
1、利用 guava 的
2、自己实现 。
说到如何给线程池配置参数,美团的骚操作至今让我难忘(后面会提到)!
我们先来看一下各种书籍和博客上一般推荐的配置线程池参数的方式,可以作为参考。
很多人甚至可能都会觉得把线程池配置过大一点比较好!我觉得这明显是有问题的。就拿我们生活中非常常见的一例子来说:并不是人多就能把事情做好,增加了沟通交流成本。你本来一件事情只需要 3 个人做,你硬是拉来了 6 个人,会提升做事效率嘛?我想并不会。 线程数量过多的影响也是和我们分配多少人做事情一样,对于多线程这个场景来说主要是增加了上下文切换 成本。不清楚什么是上下文切换的话,可以看我下面的介绍。
上下文切换:
多线程编程中一般线程的个数都大于 CPU 核心的个数,而一个 CPU 核心在任意时刻只能被一个线程使用,为了让这些线程都能得到有效执行,CPU 采取的策略是为每个线程分配时间片并轮转的形式。当一个线程的时间片用完的时候就会重新处于就绪状态让给其他线程使用,这个过程就属于一次上下文切换。概括来说就是:当前任务在执行完 CPU 时间片切换到另一个任务之前会先保存自己的状态,以便下次再切换回这个任务时,可以再加载这个任务的状态。任务从保存到再加载的过程就是一次上下文切换。
上下文切换通常是计算密集型的。也就是说,它需要相当可观的处理器时间,在每秒几十上百次的切换中,每次切换都需要纳秒量级的时间。所以,上下文切换对系统来说意味着消耗大量的 CPU 时间,事实上,可能是操作系统中时间消耗最大的操作。
Linux 相比与其他操作系统(包括其他类 Unix 系统)有很多的优点,其中有一项就是,其上下文切换和模式切换的时间消耗非常少。
类比于实现世界中的人类通过合作做某件事情,我们可以肯定的一点是线程池大小设置过大或者过小都会有问题,合适的才是最好。
- 如果我们设置的线程池数量太小的话,如果同一时间有大量任务/请求需要处理,可能会导致大量的请求/任务在任务队列中排队等待执行,甚至会出现任务队列满了之后任务/请求无法处理的情况,或者大量任务堆积在任务队列导致 OOM。这样很明显是有问题的,CPU 根本没有得到充分利用。
- 如果我们设置线程数量太大,大量线程可能会同时在争取 CPU 资源,这样会导致大量的上下文切换,从而增加线程的执行时间,影响了整体执行效率。
有一个简单并且适用面比较广的公式:
- CPU 密集型任务 (N): 这种任务消耗的主要是 CPU 资源,线程数应设置为 N(CPU 核心数)。由于任务主要瓶颈在于 CPU 计算能力,与核心数相等的线程数能够最大化 CPU 利用率,过多线程反而会导致竞争和上下文切换开销。
- I/O 密集型任务(M * N): 这类任务大部分时间处理 I/O 交互,线程在等待 I/O 时不占用 CPU。 为了充分利用 CPU 资源,线程数可以设置为 M * N,其中 N 是 CPU 核心数,M 是一个大于 1 的倍数,建议默认设置为 2 ,具体取值取决于 I/O 等待时间和任务特点,需要通过测试和监控找到最佳平衡点。
CPU 密集型任务不再推荐 N+1,原因如下:
- "N+1" 的初衷是希望预留线程处理突发暂停,但实际上,处理缺页中断等情况仍然需要占用 CPU 核心。
- CPU 密集场景下,CPU 始终是瓶颈,预留线程并不能凭空增加 CPU 处理能力,反而可能加剧竞争。
如何判断是 CPU 密集任务还是 IO 密集任务?
CPU 密集型简单理解就是利用 CPU 计算能力的任务比如你在内存中对大量数据进行排序。但凡涉及到网络读取,文件读取这类都是 IO 密集型,这类任务的特点是 CPU 计算耗费时间相比于等待 IO 操作完成的时间来说很少,大部分时间都花在了等待 IO 操作完成上。
🌈 拓展一下(参见:issue#1737):
线程数更严谨的计算的方法应该是:,其中 。
线程等待时间所占比例越高,需要越多线程。线程计算时间所占比例越高,需要越少线程。
我们可以通过 JDK 自带的工具 VisualVM 来查看 比例。
CPU 密集型任务的 接近或者等于 0,因此, 线程数可以设置为 N(CPU 核心数)∗(1+0)= N,和我们上面说的 N(CPU 核心数)+1 差不多。
IO 密集型任务下,几乎全是线程等待时间,从理论上来说,你就可以将线程数设置为 2N(按道理来说,WT/ST 的结果应该比较大,这里选择 2N 的原因应该是为了避免创建过多线程吧)。
注意:上面提到的公示也只是参考,实际项目不太可能直接按照公式来设置线程池参数,毕竟不同的业务场景对应的需求不同,具体还是要根据项目实际线上运行情况来动态调整。接下来介绍的美团的线程池参数动态配置这种方案就非常不错,很实用!
美团技术团队在《Java 线程池实现原理及其在美团业务中的实践》这篇文章中介绍到对线程池参数实现可自定义配置的思路和方法。
美团技术团队的思路是主要对线程池的核心参数实现自定义可配置。这三个核心参数是:
- : 核心线程数定义了最小可以同时运行的线程数量。
- : 当队列中存放的任务达到队列容量的时候,当前可以同时运行的线程数量变为最大线程数。
- : 当新任务来的时候会先判断当前运行的线程数量是否达到核心线程数,如果达到的话,新任务就会被存放在队列中。
为什么是这三个参数?
我在这篇《新手也能看懂的线程池学习总结》 中就说过这三个参数是 最重要的参数,它们基本决定了线程池对于任务的处理策略。
如何支持参数动态配置? 且看 提供的下面这些方法。
格外需要注意的是, 程序运行期间的时候,我们调用 这个方法的话,线程池会首先判断当前工作线程数是否大于,如果大于的话就会回收工作线程。
另外,你也看到了上面并没有动态指定队列长度的方法,美团的方式是自定义了一个叫做 的队列(主要就是把的 capacity 字段的 final 关键字修饰给去掉了,让它变为可变的)。
最终实现的可动态修改线程池参数效果如下。👏👏👏
如果我们的项目也想要实现这种效果的话,可以借助现成的开源项目:
- Hippo4j:异步线程池框架,支持线程池动态变更&监控&报警,无需修改代码轻松引入。支持多种使用模式,轻松引入,致力于提高系统运行保障能力。
- Dynamic TP:轻量级动态线程池,内置监控告警功能,集成三方中间件线程池管理,基于主流配置中心(已支持 Nacos、Apollo,Zookeeper、Consul、Etcd,可通过 SPI 自定义实现)。
当线程池不再需要使用时,应该显式地关闭线程池,释放线程资源。
线程池提供了两个关闭方法:
- :关闭线程池,线程池的状态变为 。线程池不再接受新任务了,但是队列里的任务得执行完毕。
- :关闭线程池,线程池的状态变为 。线程池会终止当前正在运行的任务,停止处理排队的任务并返回正在等待执行的 List。
调用完 和 方法后,并不代表线程池已经完成关闭操作,它只是异步的通知线程池进行关闭处理。如果要同步等待线程池彻底关闭后才继续往下执行,需要调用方法进行同步等待。
在调用 方法时,应该设置合理的超时时间,以避免程序长时间阻塞而导致性能问题。另外。由于线程池中的任务可能会被取消或抛出异常,因此在使用 方法时还需要进行异常处理。 方法会抛出 异常,需要捕获并处理该异常,以避免程序崩溃或者无法正常退出。
线程池本身的目的是为了提高任务执行效率,避免因频繁创建和销毁线程而带来的性能开销。如果将耗时任务提交到线程池中执行,可能会导致线程池中的线程被长时间占用,无法及时响应其他任务,甚至会导致线程池崩溃或者程序假死。
因此,在使用线程池时,我们应该尽量避免将耗时任务提交到线程池中执行。对于一些比较耗时的操作,如网络请求、文件读写等,可以采用 等其他异步操作的方式来处理,以避免阻塞线程池中的线程。
线程池是可以复用的,一定不要频繁创建线程池比如一个用户请求到了就单独创建一个线程池。
出现这种问题的原因还是对于线程池认识不够,需要加强线程池的基础知识。
使用 Spring 内部线程池时,一定要手动自定义线程池,配置合理的参数,不然会出现生产问题(一个请求创建一个线程)。
线程池和 共用,可能会导致线程从获取到的是旧值/脏数据。这是因为线程池会复用线程对象,与线程对象绑定的类的静态属性 变量也会被重用,这就导致一个线程可能获取到其他线程的 值。
不要以为代码中没有显示使用线程池就不存在线程池了,像常用的 Web 服务器 Tomcat 处理任务为了提高并发量,就使用到了线程池,并且使用的是基于原生 Java 线程池改进完善得到的自定义线程池。
当然了,你可以将 Tomcat 设置为单线程处理任务。不过,这并不合适,会严重影响其处理任务的速度。
解决上述问题比较建议的办法是使用阿里巴巴开源的 ()。类继承并加强了 JDK 内置的类,在使用线程池等会池化复用线程的执行组件情况下,提供值的传递功能,解决异步执行时上下文传递的问题。
项目地址:https://github.com/alibaba/transmittable-thread-local 。
JDK 提供的这些容器大部分在 包中。
- : 线程安全的
- : 线程安全的 ,在读多写少的场合性能非常好,远远好于 。
- : 高效的并发队列,使用链表实现。可以看做一个线程安全的 ,这是一个非阻塞队列。
- : 这是一个接口,JDK 内部通过链表、数组等方式实现了这个接口。表示阻塞队列,非常适合用于作为数据共享的通道。
- : 跳表的实现。这是一个 Map,使用跳表的数据结构进行快速查找。
我们知道 不是线程安全的,在并发场景下如果要保证一种可行的方式是使用 方法来包装我们的 。但这是通过使用一个全局的锁来同步不同线程间的并发访问,因此会带来不可忽视的性能问题。
所以就有了 的线程安全版本—— 的诞生。
在 JDK1.7 的时候, 对整个桶数组进行了分割分段(,分段锁),每一把锁只锁容器其中一部分数据(下面有示意图),多线程访问容器里不同数据段的数据,就不会存在锁竞争,提高并发访问率。
到了 JDK1.8 的时候, 已经摒弃了 的概念,而是直接用 数组+链表+红黑树的数据结构来实现,并发控制使用 和 CAS 来操作。(JDK1.6 以后 锁做了很多优化) 整个看起来就像是优化过且线程安全的 ,虽然在 JDK1.8 中还能看到 的数据结构,但是已经简化了属性,只是为了兼容旧版本。
关于 的详细介绍,请看我写的这篇文章:。
在 JDK1.5 之前,如果想要使用并发安全的 只能选择 。而 是一种老旧的集合,已经被淘汰。 对于增删改查等方法基本都加了 ,这种方式虽然能够保证同步,但这相当于对整个 加上了一把大锁,使得每个方法执行的时候都要去获得锁,导致性能非常低下。
JDK1.5 引入了 (JUC)包,其中提供了很多线程安全且并发性能良好的容器,其中唯一的线程安全 实现就是 。
对于大部分业务场景来说,读取操作往往是远大于写入操作的。由于读取操作不会对原有数据进行修改,因此,对于每次读取都进行加锁其实是一种资源浪费。相比之下,我们应该允许多个线程同时访问 的内部数据,毕竟对于读取操作来说是安全的。
这种思路与 读写锁的设计思想非常类似,即读读不互斥、读写互斥、写写互斥(只有读读不互斥)。 更进一步地实现了这一思想。为了将读操作性能发挥到极致, 中的读取操作是完全无需加锁的。更加厉害的是,写入操作也不会阻塞读取操作,只有写写才会互斥。这样一来,读操作的性能就可以大幅度提升。
线程安全的核心在于其采用了 写时复制(Copy-On-Write) 的策略,从 的名字就能看出了。
当需要修改( ,、 等操作) 的内容时,不会直接修改原数组,而是会先创建底层数组的副本,对副本数组进行修改,修改完之后再将修改后的数组赋值回去,这样就可以保证写操作不会影响读操作了。
关于 的详细介绍,请看我写的这篇文章:。
Java 提供的线程安全的 可以分为阻塞队列和非阻塞队列,其中阻塞队列的典型例子是 ,非阻塞队列的典型例子是 ,在实际应用中要根据实际需要选用阻塞队列或者非阻塞队列。 阻塞队列可以通过加锁来实现,非阻塞队列可以通过 CAS 操作实现。
从名字可以看出,这个队列使用链表作为其数据结构. 应该算是在高并发环境中性能最好的队列了。它之所有能有很好的性能,是因为其内部复杂的实现。
内部代码我们就不分析了,大家知道 主要使用 CAS 非阻塞算法来实现线程安全就好了。
适合在对性能要求相对较高,同时对队列的读写存在多个线程同时进行的场景,即如果对队列加锁的成本较高则适合使用无锁的 来替代。
上面我们己经提到了 作为高性能的非阻塞队列。下面我们要讲到的是阻塞队列——。阻塞队列()被广泛使用在“生产者-消费者”问题中,其原因是 提供了可阻塞的插入和移除的方法。当队列容器已满,生产者线程会被阻塞,直到队列未满;当队列容器为空时,消费者线程会被阻塞,直至队列非空时为止。
是一个接口,继承自 ,所以其实现类也可以作为 的实现来使用,而 又继承自 接口。下面是 的相关实现类:
下面主要介绍一下 3 个常见的 的实现类:、、 。
是 接口的有界队列实现类,底层采用数组来实现。
一旦创建,容量不能改变。其并发控制采用可重入锁 ,不管是插入操作还是读取操作,都需要获取到锁才能进行操作。当队列容量满时,尝试将元素放入队列将导致操作阻塞;尝试从一个空队列中取一个元素也会同样阻塞。
默认情况下不能保证线程访问队列的公平性,所谓公平性是指严格按照线程等待的绝对时间顺序,即最先等待的线程能够最先访问到 。而非公平性则是指访问 的顺序不是遵守严格的时间顺序,有可能存在,当 可以被访问时,长时间阻塞的线程依然无法访问到 。如果保证公平性,通常会降低吞吐量。如果需要获得公平性的 ,可采用如下代码:
底层基于单向链表实现的阻塞队列,可以当做无界队列也可以当做有界队列来使用,同样满足 FIFO 的特性,与 相比起来具有更高的吞吐量,为了防止 容量迅速增,损耗大量内存。通常在创建 对象时,会指定其大小,如果未指定,容量等于 。
相关构造方法:
是一个支持优先级的无界阻塞队列。默认情况下元素采用自然顺序进行排序,也可以通过自定义类实现 方法来指定元素排序规则,或者初始化时通过构造器参数 来指定排序规则。
并发控制采用的是可重入锁 ,队列为无界队列( 是有界队列, 也可以通过在构造函数中传入 指定队列最大的容量,但是 只能指定初始的队列大小,后面插入元素的时候,如果空间不够的话会自动扩容)。
简单地说,它就是 的线程安全版本。不可以插入 null 值,同时,插入队列的对象必须是可比较大小的(comparable),否则报 异常。它的插入操作 put 方法不会 block,因为它是无界队列(take 方法在队列为空的时候会阻塞)。
推荐文章: 《解读 Java 并发队列 BlockingQueue》
下面这部分内容参考了极客时间专栏《数据结构与算法之美》以及《实战 Java 高并发程序设计》。
为了引出 ,先带着大家简单理解一下跳表。
对于一个单链表,即使链表是有序的,如果我们想要在其中查找某个数据,也只能从头到尾遍历链表,这样效率自然就会很低,跳表就不一样了。跳表是一种可以用来快速查找的数据结构,有点类似于平衡树。它们都可以对元素进行快速的查找。但一个重要的区别是:对平衡树的插入和删除往往很可能导致平衡树进行一次全局的调整。而对跳表的插入和删除只需要对整个数据结构的局部进行操作即可。这样带来的好处是:在高并发的情况下,你会需要一个全局锁来保证整个平衡树的线程安全。而对于跳表,你只需要部分锁即可。这样,在高并发环境下,你就可以拥有更好的性能。而就查询的性能而言,跳表的时间复杂度也是 O(logn) 所以在并发数据结构中,JDK 使用跳表来实现一个 Map。
跳表的本质是同时维护了多个链表,并且链表是分层的,
最低层的链表维护了跳表内所有的元素,每上面一层链表都是下面一层的子集。
跳表内的所有链表的元素都是排序的。查找时,可以从顶级链表开始找。一旦发现被查找的元素大于当前链表中的取值,就会转入下一层链表继续找。这也就是说在查找过程中,搜索是跳跃式的。如上图所示,在跳表中查找元素 18。
查找 18 的时候原来需要遍历 18 次,现在只需要 7 次即可。针对链表长度比较大的时候,构建索引查找效率的提升就会非常明显。
从上面很容易看出,跳表是一种利用空间换时间的算法。
使用跳表实现 和使用哈希算法实现 的另外一个不同之处是:哈希并不会保存元素的顺序,而跳表内所有的元素都是排序的。因此在对跳表进行遍历时,你会得到一个有序的结果。所以,如果你的应用需要有序性,那么跳表就是你不二的选择。JDK 中实现这一数据结构的类是 。
- 《实战 Java 高并发程序设计》
- https://javadoop.com/post/java-concurrent-queue
- https://juejin.im/post/5aeebd0f19c546
AQS 的全称为 ,翻译过来的意思就是抽象队列同步器。这个类在 包下面。
AQS 就是一个抽象类,主要用来构建锁和同步器。
AQS 为构建锁和同步器提供了一些通用功能的实现,因此,使用 AQS 能简单且高效地构造出应用广泛的大量的同步器,比如我们提到的 ,,其他的诸如 ,等等皆是基于 AQS 的。
在面试中被问到并发知识的时候,大多都会被问到“请你说一下自己对于 AQS 原理的理解”。下面给大家一个示例供大家参考,面试不是背题,大家一定要加入自己的思想,即使加入不了自己的思想也要保证自己能够通俗的讲出来而不是背出来。
AQS 核心思想是,如果被请求的共享资源空闲,则将当前请求资源的线程设置为有效的工作线程,并且将共享资源设置为锁定状态。如果被请求的共享资源被占用,那么就需要一套线程阻塞等待以及被唤醒时锁分配的机制,这个机制 AQS 是基于 CLH 锁 (Craig, Landin, and Hagersten locks) 实现的。
CLH 锁是对自旋锁的一种改进,是一个虚拟的双向队列(虚拟的双向队列即不存在队列实例,仅存在结点之间的关联关系),暂时获取不到锁的线程将被加入到该队列中。AQS 将每条请求共享资源的线程封装成一个 CLH 队列锁的一个结点(Node)来实现锁的分配。在 CLH 队列锁中,一个节点表示一个线程,它保存着线程的引用(thread)、 当前节点在队列中的状态(waitStatus)、前驱节点(prev)、后继节点(next)。
CLH 队列结构如下图所示:
关于 AQS 核心数据结构-CLH 锁的详细解读,强烈推荐阅读 Java AQS 核心数据结构-CLH 锁 - Qunar 技术沙龙 这篇文章。
AQS()的核心原理图:
AQS 使用 int 成员变量 表示同步状态,通过内置的 FIFO 线程等待/等待队列 来完成获取资源线程的排队工作。
变量由 修饰,用于展示当前临界资源的获锁情况。
另外,状态信息 可以通过 类型的、和 进行操作。并且,这几个方法都是 修饰的,在子类中无法被重写。
以可重入的互斥锁 为例,它的内部维护了一个 变量,用来表示锁的占用状态。 的初始值为 0,表示锁处于未锁定状态。当线程 A 调用 方法时,会尝试通过 方法独占该锁,并让 的值加 1。如果成功了,那么线程 A 就获取到了锁。如果失败了,那么线程 A 就会被加入到一个等待队列(CLH 队列)中,直到其他线程释放该锁。假设线程 A 获取锁成功了,释放锁之前,A 线程自己是可以重复获取此锁的( 会累加)。这就是可重入性的体现:一个线程可以多次获取同一个锁而不会被阻塞。但是,这也意味着,一个线程必须释放与获取的次数相同的锁,才能让 的值回到 0,也就是让锁恢复到未锁定状态。只有这样,其他等待的线程才能有机会获取该锁。
线程 A 尝试获取锁的过程如下图所示(图源):
再以倒计时器 以例,任务分为 N 个子线程去执行, 也初始化为 N(注意 N 要与线程个数一致)。这 N 个子线程开始执行任务,每执行完一个子线程,就调用一次 方法。该方法会尝试使用 CAS(Compare and Swap) 操作,让 的值减少 1。当所有的子线程都执行完毕后(即 的值变为 0), 会调用 方法,唤醒主线程。这时,主线程就可以从 方法( 中的 方法而非 AQS 中的)返回,继续执行后续的操作。
AQS 定义两种资源共享方式:(独占,只有一个线程能执行,如)和(共享,多个线程可同时执行,如/)。
一般来说,自定义同步器的共享方式要么是独占,要么是共享,他们也只需实现、中的一种即可。但 AQS 也支持自定义同步器同时实现独占和共享两种方式,如。
同步器的设计是基于模板方法模式的,如果需要自定义同步器一般的方式是这样(模板方法模式很经典的一个应用):
- 使用者继承 并重写指定的方法。
- 将 AQS 组合在自定义同步组件的实现中,并调用其模板方法,而这些模板方法会调用使用者重写的方法。
这和我们以往通过实现接口的方式有很大区别,这是模板方法模式很经典的一个运用。
AQS 使用了模板方法模式,自定义同步器时需要重写下面几个 AQS 提供的钩子方法:
什么是钩子方法呢? 钩子方法是一种被声明在抽象类中的方法,一般使用 关键字修饰,它可以是空方法(由子类实现),也可以是默认实现的方法。模板设计模式通过钩子方法控制固定步骤的实现。
篇幅问题,这里就不详细介绍模板方法模式了,不太了解的小伙伴可以看看这篇文章:用 Java8 改造后的模板方法模式真的是 yyds!。
除了上面提到的钩子方法之外,AQS 类中的其他方法都是 ,所以无法被其他类重写。
下面介绍几个基于 AQS 的常见同步工具类。
和 都是一次只允许一个线程访问某个资源,而(信号量)可以用来控制同时访问特定资源的线程数量。
的使用简单,我们这里假设有 个线程来获取 中的共享资源,下面的代码表示同一时刻 N 个线程中只有 5 个线程能获取到共享资源,其他线程都会阻塞,只有获取到共享资源的线程才能执行。等到有线程释放了共享资源,其他阻塞的线程才能获取到。
当初始的资源个数为 1 的时候, 退化为排他锁。
有两种模式:。
- 公平模式: 调用 方法的顺序就是获取许可证的顺序,遵循 FIFO;
- 非公平模式: 抢占式的。
对应的两个构造方法如下:
这两个构造方法,都必须提供许可的数量,第二个构造方法可以指定是公平模式还是非公平模式,默认非公平模式。
通常用于那些资源有明确访问数量限制的场景比如限流(仅限于单机模式,实际项目中推荐使用 Redis +Lua 来做限流)。
是共享锁的一种实现,它默认构造 AQS 的 值为 ,你可以将 的值理解为许可证的数量,只有拿到许可证的线程才能执行。
以无参 方法为例,调用 ,线程尝试获取许可证,如果 的话,则表示可以获取成功,如果 的话,则表示许可证数量不足,获取失败。
如果可以获取成功的话( ),会尝试使用 CAS 操作去修改 的值 。如果获取失败则会创建一个 Node 节点加入等待队列,挂起当前线程。
方法是 中的默认实现。
这里再以非公平模式()的为例,看看 方法的实现。
以无参 方法为例,调用 ,线程尝试释放许可证,并使用 CAS 操作去修改 的值 。释放许可证成功之后,同时会唤醒等待队列中的一个线程。被唤醒的线程会重新尝试去修改 的值 ,如果 则获取令牌成功,否则重新进入等待队列,挂起线程。
方法是 中的默认实现。
方法是 的内部类 重写的一个方法, 中的默认实现仅仅抛出 异常。
可以看到,上面提到的几个方法底层基本都是通过同步器 实现的。 是 的内部类 , 继承了 ,重写了其中的某些方法。并且,Sync 对应的还有两个子类 (对应非公平模式) 和 (对应公平模式)。
执行 方法阻塞,直到有一个许可证可以获得然后拿走一个许可证;每个 方法增加一个许可证,这可能会释放一个阻塞的 方法。然而,其实并没有实际的许可证这个对象, 只是维持了一个可获得许可证的数量。 经常用于限制获取某种资源的线程数量。
当然一次也可以一次拿取和释放多个许可,不过一般没有必要这样做:
除了 方法之外,另一个比较常用的与之对应的方法是 方法,该方法如果获取不到许可就立即返回 false。
issue645 补充内容:
允许 个线程阻塞在一个地方,直至所有线程的任务都执行完毕。
是一次性的,计数器的值只能在构造方法中初始化一次,之后没有任何机制再次对其设置值,当 使用完毕后,它不能再次被使用。
是共享锁的一种实现,它默认构造 AQS 的 值为 。这个我们通过 的构造方法即可看出。
当线程调用 时,其实使用了方法以 CAS 的操作来减少 ,直至 为 0 。当 为 0 时,表示所有的线程都调用了 方法,那么在 上等待的线程就会被唤醒并继续执行。
方法是 中的默认实现。
方法是 的内部类 重写的一个方法, 中的默认实现仅仅抛出 异常。
以无参 方法为例,当调用 的时候,如果 不为 0,那就证明任务还没有执行完毕, 就会一直阻塞,也就是说 之后的语句不会被执行( 线程被加入到等待队列也就是 CLH 队列中了)。然后, 会自旋 CAS 判断 ,如果 的话,就会释放所有等待的线程, 方法之后的语句得到执行。
方法是 中的默认实现。
方法是 的内部类 重写的一个方法,其作用就是判断 的值是否为 0,是的话就返回 1,否则返回 -1。
CountDownLatch 的两种典型用法:
- 某一线程在开始运行前等待 n 个线程执行完毕 : 将 的计数器初始化为 n (),每当一个任务线程执行完毕,就将计数器减 1 (),当计数器的值变为 0 时,在 的线程就会被唤醒。一个典型应用场景就是启动一个服务时,主线程需要等待多个组件加载完毕,之后再继续执行。
- 实现多个线程开始执行任务的最大并行性:注意是并行性,不是并发,强调的是多个线程在某一时刻同时开始执行。类似于赛跑,将多个线程放到起点,等待发令枪响,然后同时开跑。做法是初始化一个共享的 对象,将其计数器初始化为 1 (),多个线程在开始执行任务前首先 ,当主线程调用 时,计数器变为 0,多个线程同时被唤醒。
CountDownLatch 代码示例:
上面的代码中,我们定义了请求的数量为 550,当这 550 个请求被处理完成之后,才会执行。
与 的第一次交互是主线程等待其他线程。主线程必须在启动其他线程后立即调用 方法。这样主线程的操作就会在这个方法上阻塞,直到其他线程完成各自的任务。
其他 N 个线程必须引用闭锁对象,因为他们需要通知 对象,他们已经完成了各自的任务。这种通知机制是通过 方法来完成的;每调用一次这个方法,在构造函数中初始化的 count 值就减 1。所以当 N 个线程都调 用了这个方法,count 的值等于 0,然后主线程就能通过 方法,恢复执行自己的任务。
再插一嘴: 的 方法使用不当很容易产生死锁,比如我们上面代码中的 for 循环改为:
这样就导致 的值没办法等于 0,然后就会导致一直等待。
和 非常类似,它也可以实现线程间的技术等待,但是它的功能比 更加复杂和强大。主要应用场景和 类似。
的实现是基于 AQS 的,而 是基于 ( 也属于 AQS 同步器)和 的。
的字面意思是可循环使用(Cyclic)的屏障(Barrier)。它要做的事情是:让一组线程到达一个屏障(也可以叫同步点)时被阻塞,直到最后一个线程到达屏障时,屏障才会开门,所有被屏障拦截的线程才会继续干活。
内部通过一个 变量作为计数器, 的初始值为 属性的初始化值,每当一个线程到了栅栏这里了,那么就将计数器减 1。如果 count 值为 0 了,表示这是这一代最后一个线程到达栅栏,就尝试执行我们构造方法中输入的任务。
下面我们结合源码来简单看看。
1、 默认的构造方法是 ,其参数表示屏障拦截的线程数量,每个线程调用 方法告诉 我已经到达了屏障,然后当前线程被阻塞。
其中, 就代表了有拦截的线程的数量,当拦截的线程数量达到这个值的时候就打开栅栏,让所有线程通过。
2、当调用 对象调用 方法时,实际上调用的是 方法。 方法就像树立起一个栅栏的行为一样,将线程挡住了,当拦住的线程数量达到 的值时,栅栏才会打开,线程才得以通过执行。
方法源码分析如下:
示例 1:
运行结果,如下:
可以看到当线程数量也就是请求数量达到我们定义的 5 个的时候, 方法之后的方法才被执行。
另外, 还提供一个更高级的构造函数 ,用于在线程到达屏障时,优先执行 ,方便处理更复杂的业务场景。
示例 2:
运行结果,如下:
- Java 并发之 AQS 详解:https://www.cnblogs.com/waterystone/p/4920797.html
- 从 ReentrantLock 的实现看 AQS 的原理及应用:https://tech.meituan.com/2019/12/05/aqs-theory-and-apply.html
翻译成中文是“原子”的意思。在化学上,原子是构成物质的最小单位,在化学反应中不可分割。在编程中, 指的是一个操作具有原子性,即该操作不可分割、不可中断。即使在多个线程同时执行时,该操作要么全部执行完成,要么不执行,不会被其他线程看到部分完成的状态。
原子类简单来说就是具有原子性操作特征的类。
包中的 原子类提供了一种线程安全的方式来操作单个变量。
类依赖于 CAS(Compare-And-Swap,比较并交换)乐观锁来保证其方法的原子性,而不需要使用传统的锁机制(如 块或 )。
这篇文章我们只介绍 Atomic 原子类的概念,具体实现原理可以阅读笔者写的这篇文章:。
根据操作的数据类型,可以将 JUC 包中的原子类分为 4 类:
1、基本类型
使用原子的方式更新基本类型
- :整型原子类
- :长整型原子类
- :布尔型原子类
2、数组类型
使用原子的方式更新数组里的某个元素
- :整型数组原子类
- :长整型数组原子类
- :引用类型数组原子类
3、引用类型
- :引用类型原子类
- :原子更新带有标记的引用类型。该类将 boolean 标记与引用关联起来,
也可以解决使用 CAS 进行原子更新时可能出现的 ABA 问题。 - :原子更新带有版本号的引用类型。该类将整数值与引用关联起来,可用于解决原子的更新数据和数据的版本号,可以解决使用 CAS 进行原子更新时可能出现的 ABA 问题。
🐛 修正(参见:issue#626) : 不能解决 ABA 问题。
4、对象的属性修改类型
- :原子更新整型字段的更新器
- :原子更新长整型字段的更新器
- :原子更新引用类型里的字段
使用原子的方式更新基本类型
- :整型原子类
- :长整型原子类
- :布尔型原子类
上面三个类提供的方法几乎相同,所以我们这里以 为例子来介绍。
类常用方法 :
类使用示例 :
输出:
使用原子的方式更新数组里的某个元素
- :整形数组原子类
- :长整形数组原子类
- :引用类型数组原子类
上面三个类提供的方法几乎相同,所以我们这里以 为例子来介绍。
类常用方法:
类使用示例 :
输出:
基本类型原子类只能更新一个变量,如果需要原子更新多个变量,需要使用 引用类型原子类。
- :引用类型原子类
- :原子更新带有版本号的引用类型。该类将整数值与引用关联起来,可用于解决原子的更新数据和数据的版本号,可以解决使用 CAS 进行原子更新时可能出现的 ABA 问题。
- :原子更新带有标记的引用类型。该类将 boolean 标记与引用关联起来,
也可以解决使用 CAS 进行原子更新时可能出现的 ABA 问题。
上面三个类提供的方法几乎相同,所以我们这里以 为例子来介绍。
类使用示例 :
输出:
类使用示例 :
输出结果如下:
类使用示例 :
输出结果如下:
如果需要原子更新某个类里的某个字段时,需要用到对象的属性修改类型原子类。
- :原子更新整形字段的更新器
- :原子更新长整形字段的更新器
- :原子更新引用类型里的字段的更新器
要想原子地更新对象的属性需要两步。第一步,因为对象的属性修改类型原子类都是抽象类,所以每次使用都必须使用静态方法 newUpdater()创建一个更新器,并且需要设置想要更新的类和属性。第二步,更新的对象属性必须使用 public volatile 修饰符。
上面三个类提供的方法几乎相同,所以我们这里以 为例子来介绍。
类使用示例 :
输出结果:
- 《Java 并发编程的艺术》
本文来自一枝花算不算浪漫投稿, 原文地址:https://juejin.cn/post/。
全文共 10000+字,31 张图,这篇文章同样耗费了不少的时间和精力才创作完成,原创不易,请大家点点关注+在看,感谢。
对于,大家的第一反应可能是很简单呀,线程的变量副本,每个线程隔离。那这里有几个问题大家可以思考一下:
- 的 key 是弱引用,那么在 的时候,发生GC之后,key 是否为null?
- 中的数据结构?
- 的Hash 算法?
- 中Hash 冲突如何解决?
- 的扩容机制?
- 中过期 key 的清理机制?探测式清理和启发式清理流程?
- 方法实现原理?
- 方法实现原理?
- 项目中使用情况?遇到的坑?
- ……
上述的一些问题你是否都已经掌握的很清楚了呢?本文将围绕这些问题使用图文方式来剖析的点点滴滴。
注明: 本文源码基于
我们先看下使用示例:
打印结果:
对象可以提供线程局部变量,每个线程拥有一份自己的副本变量,多个线程互不干扰。
类有一个类型为的实例变量,也就是说每个线程有一个自己的。
有自己的独立实现,可以简单地将它的视作,为代码中放入的值(实际上并不是本身,而是它的一个弱引用)。
每个线程在往里放值的时候,都会往自己的里存,读也是以作为引用,在自己的里找对应的,从而实现了线程隔离。
有点类似的结构,只是是由数组+链表实现的,而中并没有链表结构。
我们还要注意, 它的是 ,继承自, 也就是我们常说的弱引用类型。
回应开头的那个问题, 的是弱引用,那么在的时候,发生之后,是否是?
为了搞清楚这个问题,我们需要搞清楚的四种引用类型:
- 强引用:我们常常 new 出来的对象就是强引用类型,只要强引用存在,垃圾回收器将永远不会回收被引用的对象,哪怕内存不足的时候
- 软引用:使用 SoftReference 修饰的对象被称为软引用,软引用指向的对象在内存要溢出的时候被回收
- 弱引用:使用 WeakReference 修饰的对象被称为弱引用,只要发生垃圾回收,若这个对象只被弱引用指向,那么就会被回收
- 虚引用:虚引用是最弱的引用,在 Java 中使用 PhantomReference 进行定义。虚引用中唯一的作用就是用队列接收对象即将死亡的通知
接着再来看下代码,我们使用反射的方式来看看后中的数据情况:(下面代码来源自:https://blog.csdn.net/thewindkee/article/details/ 本地运行演示 GC 回收场景)
结果如下:
如图所示,因为这里创建的并没有指向任何值,也就是没有任何引用:
所以这里在之后,就会被回收,我们看到上面中的, 如果改动一下代码:
这个问题刚开始看,如果没有过多思考,弱引用,还有垃圾回收,那么肯定会觉得是。
其实是不对的,因为题目说的是在做 操作,证明其实还是有强引用存在的,所以 并不为 ,如下图所示,的强引用仍然是存在的。
如果我们的强引用不存在的话,那么 就会被回收,也就是会出现我们 没被回收, 被回收,导致 永远存在,出现内存泄漏。
中的方法原理如上图所示,很简单,主要是判断是否存在,然后使用中的方法进行数据处理。
代码如下:
主要的核心逻辑还是在中的,一步步往下看,后面还有更详细的剖析。
既然是结构,那么当然也要实现自己的算法来解决散列表数组冲突问题。
中算法很简单,这里就是当前 key 在散列表中对应的数组下标位置。
这里最关键的就是值的计算,中有一个属性为
每当创建一个对象,这个 这个值就会增长 。
这个值很特殊,它是斐波那契数 也叫 黄金分割数。增量为 这个数字,带来的好处就是 分布非常均匀。
我们自己可以尝试下:
可以看到产生的哈希码分布很均匀,这里不去细纠斐波那契具体算法,感兴趣的可以自行查阅相关资料。
注明: 下面所有示例图中,绿色块代表正常数据,灰色块代表的值为,已被垃圾回收。白色块表示为。
虽然中使用了黄金分割数来作为计算因子,大大减少了冲突的概率,但是仍然会存在冲突。
中解决冲突的方法是在数组上构造一个链表结构,冲突的数据挂载到链表上,如果链表长度超过一定数量则会转化成红黑树。
而 中并没有链表结构,所以这里不能使用 解决冲突的方式了。
如上图所示,如果我们插入一个的数据,通过 计算后应该落入槽位 4 中,而槽位 4 已经有了 数据。
此时就会线性向后查找,一直找到 为 的槽位才会停止查找,将当前元素放入此槽位中。当然迭代过程中还有其他的情况,比如遇到了 不为 且 值相等的情况,还有 中的 值为 的情况等等都会有不同的处理,后面会一一详细讲解。
这里还画了一个中的为的数据(Entry=2 的灰色块数据),因为值是弱引用类型,所以会有这种数据存在。在过程中,如果遇到了过期的数据,实际上是会进行一轮探测式清理操作的,具体操作方式后面会讲到。
看完了 hash 算法后,我们再来看是如何实现的。
往中数据(新增或者更新数据)分为好几种情况,针对不同的情况我们画图来说明。
第一种情况: 通过计算后的槽位对应的数据为空:
这里直接将数据放到该槽位即可。
第二种情况: 槽位数据不为空,值与当前通过计算获取的值一致:
这里直接更新该槽位的数据。
第三种情况: 槽位数据不为空,往后遍历过程中,在找到为的槽位之前,没有遇到过期的:
遍历散列数组,线性往后查找,如果找到为的槽位,则将数据放入该槽位中,或者往后遍历过程中,遇到了key 值相等的数据,直接更新即可。
第四种情况: 槽位数据不为空,往后遍历过程中,在找到为的槽位之前,遇到过期的,如下图,往后遍历过程中,遇到了的槽位数据的:
散列数组下标为 7 位置对应的数据为,表明此数据值已经被垃圾回收掉了,此时就会执行方法,该方法含义是替换过期数据的逻辑,以index=7位起点开始遍历,进行探测式数据清理工作。
初始化探测式清理过期数据扫描的开始位置:
以当前开始 向前迭代查找,找其他过期的数据,然后更新过期数据起始扫描下标。循环迭代,直到碰到为结束。
如果找到了过期的数据,继续向前迭代,直到遇到的槽位才停止迭代,如下图所示,slotToExpunge 被更新为 0:
以当前节点()向前迭代,检测是否有过期的数据,如果有则更新值。碰到则结束探测。以上图为例被更新为 0。
上面向前迭代的操作是为了更新探测清理过期数据的起始下标的值,这个值在后面会讲解,它是用来判断当前过期槽位之前是否还有过期元素。
接着开始以位置()向后迭代,如果找到了相同 key 值的 Entry 数据:
从当前节点向后查找值相等的元素,找到后更新的值并交换元素的位置(位置为过期元素),更新数据,然后开始进行过期的清理工作,如下图所示:
向后遍历过程中,如果没有找到相同 key 值的 Entry 数据:
从当前节点向后查找值相等的元素,直到为则停止寻找。通过上图可知,此时中没有值相同的。
创建新的,替换位置:
替换完成后也是进行过期元素清理工作,清理工作主要是有两个方法:和,具体细节后面会讲到,请继续往后看。
上面已经用图的方式解析了实现的原理,其实已经很清晰了,我们接着再看下源码:
.:
这里会通过来计算在散列表中的对应位置,然后以当前对应的桶的位置向后查找,找到可以使用的桶。
什么情况下桶才是可以使用的呢?
- 说明是替换操作,可以使用
- 碰到一个过期的桶,执行替换逻辑,占用过期桶
- 查找过程中,碰到桶中的情况,直接使用
接着就是执行循环遍历,向后查找,我们先看下、方法实现:
接着看剩下循环中的逻辑:
- 遍历当前值对应的桶中数据为空,这说明散列数组这里没有数据冲突,跳出循环,直接数据到对应的桶中
- 如果值对应的桶中数据不为空
2.1 如果,说明当前操作是一个替换操作,做替换逻辑,直接返回
2.2 如果,说明当前桶位置的是过期数据,执行方法(核心方法),然后返回 - 循环执行完毕,继续往下执行说明向后迭代的过程中遇到了为的情况
3.1 在为的桶中创建一个新的对象
3.2 执行操作 - 调用做一次启发式清理工作,清理散列数组中的过期的数据
4.1 如果清理工作完成后,未清理到任何数据,且超过了阈值(数组长度的 2/3),进行操作
4.2 中会先进行一轮探测式清理,清理过期,清理完成后如果size >= threshold - threshold / 4,就会执行真正的扩容逻辑(扩容逻辑往后看)
接着重点看下方法,方法提供替换过期数据的功能,我们可以对应上面第四种情况的原理图来再回顾下,具体代码如下:
:
表示开始探测式清理过期数据的开始下标,默认从当前的开始。以当前的开始,向前迭代查找,找到没有过期的数据,循环一直碰到为才会结束。如果向前找到了过期数据,更新探测清理过期数据的开始下标为 i,即
接着开始从向后查找,也是碰到为的桶结束。
如果迭代过程中,碰到 k == key,这说明这里是替换逻辑,替换新数据并且交换当前位置。如果,这说明一开始向前查找过期数据时并未找到过期的数据,接着向后查找过程中也未发现过期数据,修改开始探测式清理过期数据的下标为当前循环的 index,即。最后调用进行启发式过期数据清理。
和方法后面都会细讲,这两个是和清理相关的方法,一个是过期相关的启发式清理(),另一个是过期相关的探测式清理。
如果 k != key则会接着往下走,说明当前遍历的是一个过期数据,说明,一开始的向前查找数据并未找到过期的。如果条件成立,则更新 为当前位置,这个前提是前驱节点扫描时未发现过期数据。
往后迭代的过程中如果没有找到的数据,且碰到为的数据,则结束当前的迭代操作。此时说明这里是一个添加的逻辑,将新的数据添加到 对应的中。
最后判断除了以外,还发现了其他过期的数据,就要开启清理数据的逻辑:
上面我们有提及的两种过期数据清理方式:探测式清理和启发式清理。
我们先讲下探测式清理,也就是方法,遍历散列数组,从开始位置向后探测清理过期数据,将过期数据的设置为,沿途中碰到未过期的数据则将此数据后重新在数组中定位,如果定位的位置已经有了数据,则会将未过期的数据放到最靠近此位置的的桶中,使后的数据距离正确的桶的位置更近一些。操作逻辑如下:
如上图, 经过 hash 计算后应该落到的桶中,由于桶已经有了数据,所以往后迭代最终数据放入到的桶中,放入后一段时间后中的数据变为了
如果再有其他数据到中,就会触发探测式清理操作。
如上图,执行探测式清理后,的数据被清理掉,继续往后迭代,到的元素时,经过后发现该元素正确的,而此位置已经有了数据,往后查找离最近的的节点(刚被探测式清理掉的数据:),找到后移动的数据到中,此时桶的位置离正确的位置更近了。
经过一轮探测式清理后,过期的数据会被清理掉,没过期的数据经过重定位后所处的桶位置理论上更接近的位置。这种优化会提高整个散列表查询性能。
接着看下具体流程,我们还是以先原理图后源码讲解的方式来一步步梳理:
我们假设 来调用此方法,如上图所示,我们可以看到中的数据情况,接着执行清理操作:
第一步是清空当前位置的数据,位置的变成了。然后接着往后探测:
执行完第二步后,index=4 的元素挪到 index=3 的槽位中。
继续往后迭代检查,碰到正常数据,计算该数据位置是否偏移,如果被偏移,则重新计算位置,目的是让正常数据尽可能存放在正确位置或离正确位置更近的位置
在往后迭代的过程中碰到空的槽位,终止探测,这样一轮探测式清理工作就完成了,接着我们继续看看具体实现源代码:
如果没有过期,重新计算当前的下标位置是不是当前槽位下标位置,如果不是,那么说明产生了冲突,此时以新计算出来正确的槽位位置往后迭代,找到最近一个可以存放的位置。
这里是处理正常的产生冲突的数据,经过迭代后,有过冲突数据的位置会更靠近正确位置,这样的话,查询的时候 效率才会更高。
在方法的最后,如果执行完启发式清理工作后,未清理到任何数据,且当前散列数组中的数量已经达到了列表的扩容阈值,就开始执行逻辑:
接着看下具体实现:
这里首先是会进行探测式清理工作,从的起始位置往后清理,上面有分析清理的详细流程。清理完成之后,中可能有一些为的数据被清理掉,所以此时通过判断 也就是 来决定是否扩容。
我们还记得上面进行的阈值是,所以当面试官套路我们扩容机制的时候 我们一定要说清楚这两个步骤:
接着看看具体的方法,为了方便演示,我们以来举例:
扩容后的的大小为,然后遍历老的散列表,重新计算位置,然后放到新的数组中,如果出现冲突则往后寻找最近的为的槽位,遍历完成之后,中所有的数据都已经放入到新的中了。重新计算下次扩容的阈值,具体代码如下:
上面已经看完了方法的源码,其中包括数据、清理数据、优化数据桶的位置等操作,接着看看操作的原理。
第一种情况: 通过查找值计算出散列表中位置,然后该位置中的和查找的一致,则直接返回:
第二种情况: 位置中的和要查找的不一致:
我们以为例,通过计算后,正确的位置应该是 4,而的槽位已经有了数据,且值不等于,所以需要继续往后迭代查找。
迭代到的数据时,此时,触发一次探测式数据回收操作,执行方法,执行完后,的数据都会被回收,而的数据都会前移。前移之后,继续从 往后迭代,于是就在 找到了值相等的数据,如下图所示:
:
上面多次提及到过期 key 的两种清理方式:探测式清理(expungeStaleEntry())、启发式清理(cleanSomeSlots())
探测式清理是以当前 往后清理,遇到值为则结束清理,属于线性探测清理。
而启发式清理被作者定义为:Heuristically scan some cells looking for stale entries.
具体代码如下:
我们使用的时候,在异步场景下是无法给子线程共享父线程中创建的线程副本数据的。
为了解决这个问题,JDK 中还有一个类,我们来看一个例子:
打印结果:
实现原理是子线程是通过在父线程中通过调用方法来创建子线程,方法在的构造方法中被调用。在方法中拷贝父线程数据到子线程中:
但仍然有缺陷,一般我们做异步化处理都是使用的线程池,而是在中的方法给赋值的,而线程池是线程复用的逻辑,所以这里会存在问题。
当然,有问题出现就会有解决问题的方案,阿里巴巴开源了一个组件就可以解决这个问题,这里就不再延伸,感兴趣的可自行查阅资料。
我们现在项目中日志记录用的是,最后在中进行展示和检索。
现在都是分布式系统统一对外提供服务,项目间调用的关系可以通过 来关联,但是不同项目之间如何传递 呢?
这里我们使用 来实现此功能,内部就是通过 来实现的,具体实现如下:
当前端发送请求到服务 A时,服务 A会生成一个类似的字符串,将此字符串放入当前线程的中,在调用服务 B的时候,将写入到请求的中,服务 B在接收请求时会先判断请求的中是否有,如果存在则写入自己线程的中。
图中的即为我们各个系统链路关联的,系统间互相调用,通过这个即可找到对应链路,这里还有会有一些其他场景:
针对于这些场景,我们都可以有相应的解决方案,如下所示
服务发送请求:
服务接收请求:
因为是基于去实现的,异步过程中,子线程并没有办法获取到父线程存储的数据,所以这里可以自定义线程池执行器,修改其中的方法:
在 MQ 发送的消息体中自定义属性,接收方消费消息后,自己解析使用即可。
实际项目中,一个接口可能需要同时获取多种不同的数据,然后再汇总返回,这种场景还是挺常见的。举个例子:用户请求获取订单信息,可能需要同时获取用户信息、商品详情、物流信息、商品推荐等数据。
如果是串行(按顺序依次执行每个任务)执行的话,接口的响应速度会非常慢。考虑到这些任务之间有大部分都是 无前后顺序关联 的,可以 并行执行 ,就比如说调用获取商品详情的时候,可以同时调用获取物流信息。通过并行执行多个任务的方式,接口的响应速度会得到大幅优化。
对于存在前后调用顺序关系的任务,可以进行任务编排。
- 获取用户信息之后,才能调用商品详情和物流信息接口。
- 成功获取商品详情和物流信息之后,才能调用商品推荐接口。
可能会用到多线程异步任务编排的场景(这里只是举例,数据不一定是一次返回,可能会对接口进行拆分):
- 首页:例如技术社区的首页可能需要同时获取文章推荐列表、广告栏、文章排行榜、热门话题等信息。
- 详情页:例如技术社区的文章详情页可能需要同时获取作者信息、文章详情、文章评论等信息。
- 统计模块:例如技术社区的后台统计模块可能需要同时获取粉丝数汇总、文章数据(阅读量、评论量、收藏量)汇总等信息。
对于 Java 程序来说,Java 8 才被引入的 可以帮助我们来做多个任务的编排,功能非常强大。
这篇文章是 的简单入门,带大家看看 常用的 API。
类是异步思想的典型运用,主要用在一些需要执行耗时任务的场景,避免程序一直原地等待耗时任务执行完成,执行效率太低。具体来说是这样的:当我们执行某一耗时的任务时,可以将这个耗时任务交给一个子线程去异步执行,同时我们可以干点其他事情,不用傻傻等待耗时任务执行完成。等我们的事情干完后,我们再通过 类获取到耗时任务的执行结果。这样一来,程序的执行效率就明显提高了。
这其实就是多线程中经典的 Future 模式,你可以将其看作是一种设计模式,核心思想是异步调用,主要用在多线程领域,并非 Java 语言独有。
在 Java 中, 类只是一个泛型接口,位于 包下,其中定义了 5 个方法,主要包括下面这 4 个功能:
- 取消任务;
- 判断任务是否被取消;
- 判断任务是否已经执行完成;
- 获取任务执行结果。
简单理解就是:我有一个任务,提交给了 来处理。任务执行期间我自己可以去做任何想做的事情。并且,在这期间我还可以取消任务以及获取任务的执行状态。一段时间之后,我就可以 那里直接取出任务执行结果。
在实际使用过程中存在一些局限性比如不支持异步任务的编排组合、获取计算结果的 方法为阻塞调用。
Java 8 才被引入 类可以解决 的这些缺陷。 除了提供了更为好用和强大的 特性之外,还提供了函数式编程、异步任务编排组合(可以将多个异步任务串联起来,组成一个完整的链式调用)等能力。
下面我们来简单看看 类的定义。
可以看到, 同时实现了 和 接口。
接口描述了一个异步计算的阶段。很多计算可以分成多个阶段或步骤,此时可以通过它将所有步骤组合起来,形成异步计算的流水线。
除了提供了更为好用和强大的 特性之外,还提供了函数式编程的能力。
接口有 5 个方法:
- :尝试取消执行任务。
- :判断任务是否被取消。
- :判断任务是否已经被执行完成。
- :等待任务执行完成并获取运算结果。
- :多了一个超时时间。
接口描述了一个异步计算的阶段。很多计算可以分成多个阶段或步骤,此时可以通过它将所有步骤组合起来,形成异步计算的流水线。
接口中的方法比较多, 的函数式能力就是这个接口赋予的。从这个接口的方法参数你就可以发现其大量使用了 Java8 引入的函数式编程。
由于方法众多,所以这里不能一一讲解,下文中我会介绍大部分常见方法的使用。
常见的创建 对象的方法如下:
- 通过 new 关键字。
- 基于 自带的静态工厂方法:、 。
通过 new 关键字创建 对象这种使用方式可以看作是将 当做 来使用。
我在我的开源项目 guide-rpc-framework 中就是这种方式创建的 对象。
下面咱们来看一个简单的案例。
我们通过创建了一个结果值类型为 的 ,你可以把 看作是异步运算结果的载体。
假设在未来的某个时刻,我们得到了最终的结果。这时,我们可以调用 方法为其传入结果,这表示 已经被完成了。
你可以通过 方法来检查是否已经完成。
获取异步计算的结果也非常简单,直接调用 方法即可。调用 方法的线程会阻塞直到 完成运算。
如果你已经知道计算的结果的话,可以使用静态方法 来创建 。
方法底层调用的是带参数的 new 方法,只不过,这个方法不对外暴露。
这两个方法可以帮助我们封装计算逻辑。
方法接受的参数是 ,这是一个函数式接口,不允许返回值。当你需要异步操作且不关心返回结果的时候可以使用 方法。
方法接受的参数是 ,这也是一个函数式接口, 是返回结果值的类型。
当你需要异步操作且关心返回结果的时候,可以使用 方法。
当我们获取到异步计算的结果之后,还可以对其进行进一步的处理,比较常用的方法有下面几个:
方法接受一个 实例,用它来处理结果。
方法使用示例如下:
你还可以进行 流式调用:
如果你不需要从回调函数中获取返回结果,可以使用 或者 。这两个方法的区别在于 不能访问异步计算的结果。
方法的参数是 。
顾名思义, 属于消费型接口,它可以接收 1 个输入对象然后进行“消费”。
的方法是的参数是 。
和 使用示例如下:
的方法的参数是 。
相对于 , 可以接收 2 个输入对象然后进行“消费”。
使用示例如下:
你可以通过 方法来处理任务执行过程中可能出现的抛出异常的情况。
示例代码如下:
你还可以通过 方法来处理异常情况。
如果你想让 的结果就是异常的话,可以使用 方法为其赋值。
你可以使用 按顺序链接两个 对象,实现异步的任务链。它的作用是将前一个任务的返回结果作为下一个任务的输入参数,从而形成一个依赖关系。
方法会使用示例如下:
在实际开发中,这个方法还是非常有用的。比如说,task1 和 task2 都是异步执行的,但 task1 必须执行完成后才能开始执行 task2(task2 依赖 task1 的执行结果)。
和 方法类似的还有 方法, 它同样可以组合两个 对象。
那 和 有什么区别呢?
- 可以链接两个 对象,并将前一个任务的返回结果作为下一个任务的参数,它们之间存在着先后顺序。
- 会在两个任务都执行完成后,把两个任务的结果合并。两个任务是并行执行的,它们之间并没有先后依赖顺序。
除了 和 之外, 还有一些其他的组合 的方法用于实现不同的效果,满足不同的业务需求。
例如,如果我们想要实现 task1 和 task2 中的任意一个任务执行完后就执行 task3 的话,可以使用 。
简单举一个例子:
输出:
任务组合操作会在异步任务 1 和异步任务 2 中的任意一个完成时触发执行任务 3,但是需要注意,这个触发时机是不确定的。如果任务 1 和任务 2 都还未完成,那么任务 3 就不能被执行。
你可以通过 的 这个静态方法来并行运行多个 。
实际项目中,我们经常需要并行运行多个互不相关的任务,这些任务之间没有依赖关系,可以互相独立地运行。
比说我们要读取处理 6 个文件,这 6 个任务都是没有执行顺序依赖的任务,但是我们需要返回给用户的时候将这几个文件的处理的结果进行统计整理。像这种情况我们就可以使用并行运行多个 来处理。
示例代码如下:
经常和 方法拿来对比的是 方法。
方法会等到所有的 都运行完成之后再返回
调用 可以让程序等 和 都运行完了之后再继续执行。
输出:
方法不会等待所有的 都运行完成之后再返回,只要有一个执行完成即可!
输出结果可能是:
也可能是:
我们上面的代码示例中,为了方便,都没有选择自定义线程池。实际项目中,这是不可取的。
默认使用全局共享的 作为执行器,所有未指定执行器的异步任务都会使用该线程池。这意味着应用程序、多个库或框架(如 Spring、第三方库)若都依赖 ,默认情况下它们都会共享同一个线程池。
虽然 效率很高,但当同时提交大量任务时,可能会导致资源竞争和线程饥饿,进而影响系统性能。
为避免这些问题,建议为 提供自定义线程池,带来以下优势:
- 隔离性:为不同任务分配独立的线程池,避免全局线程池资源争夺。
- 资源控制:根据任务特性调整线程池大小和队列类型,优化性能表现。
- 异常处理:通过自定义 更好地处理线程中的异常情况。
的方法是阻塞的,尽量避免使用。如果必须要使用的话,需要添加超时时间,否则可能会导致主线程一直等待,无法执行其他任务。
上面这段代码在调用 时抛出了 异常。这样我们就可以在异常处理中进行相应的操作,比如取消任务、重试任务、记录日志等。
使用 的时候一定要以正确的方式进行异常处理,避免异常丢失或者出现不可控问题。
下面是一些建议:
- 使用 方法可以在任务完成时触发回调函数,并正确地处理异常,而不是让异常被吞噬或丢失。
- 使用 方法可以处理异常并重新抛出,以便异常能够传播到后续阶段,而不是让异常被忽略或终止。
- 使用 方法可以处理正常的返回结果和异常,并返回一个新的结果,而不是让异常影响正常的业务逻辑。
- 使用 方法可以组合多个 ,并统一处理所有任务的异常,而不是让异常处理过于冗长或重复。
- ……
正确使用 、 、、、等方法来组合多个异步任务,以满足实际业务的需求,提高程序执行效率。
实际使用中,我们还可以利用或者参考现成的异步任务编排框架,比如京东的 asyncTool 。
这篇文章只是简单介绍了 的核心概念和比较常用的一些 API 。如果想要深入学习的话,还可以多找一些书籍和博客看,比如下面几篇文章就挺不错:
- CompletableFuture 原理与实践-外卖商家端 API 的异步化 - 美团技术团队:这篇文章详细介绍了 在实际项目中的运用。参考这篇文章,可以对项目中类似的场景进行优化,也算是一个小亮点了。这种性能优化方式比较简单且效果还不错!
- 读 RocketMQ 源码,学习并发编程三大神器 - 勇哥 java 实战分享:这篇文章介绍了 RocketMQ 对的应用。具体来说,从 RocketMQ 4.7 开始,RocketMQ 引入了 来实现异步消息处理 。
另外,建议 G 友们可以看看京东的 asyncTool 这个并发框架,里面大量使用到了 。
本文部分内容来自 Lorin 的PR。
虚拟线程在 Java 21 正式发布,这是一项重量级的更新。
虚拟线程(Virtual Thread)是 JDK 而不是 OS 实现的轻量级线程(Lightweight Process,LWP),由 JVM 调度。许多虚拟线程共享同一个操作系统线程,虚拟线程的数量可以远大于操作系统线程的数量。
在引入虚拟线程之前, 包已经支持所谓的平台线程(Platform Thread),也就是没有虚拟线程之前,我们一直使用的线程。JVM 调度程序通过平台线程(载体线程)来管理虚拟线程,一个平台线程可以在不同的时间执行不同的虚拟线程(多个虚拟线程挂载在一个平台线程上),当虚拟线程被阻塞或等待时,平台线程可以切换到执行另一个虚拟线程。
虚拟线程、平台线程和系统内核线程的关系图如下所示(图源:How to Use Java 19 Virtual Threads):
关于平台线程和系统内核线程的对应关系多提一点:在 Windows 和 Linux 等主流操作系统中,Java 线程采用的是一对一的线程模型,也就是一个平台线程对应一个系统内核线程。Solaris 系统是一个特例,HotSpot VM 在 Solaris 上支持多对多和一对一。具体可以参考 R 大的回答: JVM 中的线程模型是用户级的么?。
- 非常轻量级:可以在单个线程中创建成百上千个虚拟线程而不会导致过多的线程创建和上下文切换。
- 简化异步编程: 虚拟线程可以简化异步编程,使代码更易于理解和维护。它可以将异步代码编写得更像同步代码,避免了回调地狱(Callback Hell)。
- 减少资源开销: 由于虚拟线程是由 JVM 实现的,它能够更高效地利用底层资源,例如 CPU 和内存。虚拟线程的上下文切换比平台线程更轻量,因此能够更好地支持高并发场景。
- 不适用于计算密集型任务: 虚拟线程适用于 I/O 密集型任务,但不适用于计算密集型任务,因为密集型计算始终需要 CPU 资源作为支持。
- 与某些第三方库不兼容: 虽然虚拟线程设计时考虑了与现有代码的兼容性,但某些依赖平台线程特性的第三方库可能不完全兼容虚拟线程。
官方提供了以下四种方式创建虚拟线程:
- 使用 创建
- 使用 创建
- 使用 创建
- 使用 创建
1、使用 创建
2、使用 创建
3、使用 创建
4、使用创建
通过多线程和虚拟线程的方式处理相同的任务,对比创建的系统线程数和处理耗时。
说明:统计创建的系统线程中部分为后台线程(比如 GC 线程),两种场景下都一样,所以并不影响对比。
测试代码:
请求数 10000 单请求耗时 1s:
请求数 10000 单请求耗时 0.5s:
- 可以看到在密集 IO 的场景下,需要创建大量的平台线程异步处理才能达到虚拟线程的处理速度。
- 因此,在密集 IO 的场景,虚拟线程可以大幅提高线程的执行效率,减少线程资源的创建以及上下文切换。
注意:有段时间 JDK 一直致力于 Reactor 响应式编程来提高 Java 性能,但响应式编程难以理解、调试、使用,最终又回到了同步编程,最终虚拟线程诞生。
如果你想要详细了解虚拟线程实现原理,推荐一篇文章:虚拟线程 - VirtualThread 源码透视。
面试一般是不会问到这个问题的,仅供学有余力的同学进一步研究学习。
版权声明:
本文来源网络,所有图片文章版权属于原作者,如有侵权,联系删除。
本文网址:https://www.mushiming.com/mjsbk/979.html