1.线程创建的开销
对操作系统来说,创建一个线程的代价是十分昂贵的, 需要给它分配内存、列入调度,同时在线程切换的时候还要执行内存换页,CPU 的缓存被清空,切换回来的时候还要重新从内存中读取信息,破坏了数据的局部性。【分配内存、列入调度、内存换页、清空缓存和重新读取】
关于内存开销
Java线程的线程栈区别于堆,它是不受Java程序控制的,只受系统资源限制。默认一个线程的线程栈大小是1M,别小看这1M的空间,如果每个用户请求都新建线程的话,1024个用户光线程就占用了1个G的内存,如果系统比较大的话,一下子系统资源就不够用了,最后程序就崩溃了。
【创建一个线程默认需要消耗1M的内存,如果每个用户请求都创建一个线程,那么1024个用户就是1G了,并发量一大就扛不住了】
如果不对线程的创建进行有效监控、管理,风险巨大:
- 频繁申请/销毁资源和调度资源,将带来额外的消耗,可能会非常巨大。
- 对资源无限申请缺少抑制手段,将引发系统资源耗尽的风险。
- 系统无法合理管理内部的资源分布,将降低系统的稳定性。
正是因为如此大的开销,所以几乎所有的servlet容器、web框架、rpc框架都会采用线程池化技术去提高资源复用,限制线程的随意创建。
2.线程池的设计思路
首先,我们先自己想想,如果是我们自己来解决线程创建开销大的问题,会去怎么解决?下面的分析思路是比较合理,符合逻辑的:
- 资源复用:尽可能的复用资源,这里的资源当然是线程资源,提前预热全部或部分线程,由一个组件负责线程的分配和调度执行。
- 限制线程数:限制线程数是为了保护系统,避免线程创建过多导致内存不足。
这里有一个问题是,当线程不足分配时怎么办?为此有几个解决思路:
- 由提交者自行执行任务。
- 抛异常。
- 丢弃任务。可以是丢弃最老的任务或最新提交的任务。
实际上,我们上面的分析思路正好是池化管理思想的提现,线程池就是依据以上的思路进行设计的。采用这种思路有几个好处:
- 降低资源开销:由于线程是反复利用的,降低了创建线程分配资源和销毁线程的开销。
- 提高响应速度:由于线程是提前预热的,因此减少了创建线程这段时间的开销。
- 提高系统资源可管理性:使用线程池统一分配、管理和监控。
3.线程池工作原理
线程池包含3个部分:
- 线程:核心线程和工作线程。
- 阻塞队列:用于待执行任务排队。
- 被拒绝时的处理器。
以下是线程池ThreadPoolExecutor组件和执行示意图。
下面是JDK中常见的阻塞队列。
ArrayBlockingQueue
一个用数组实现的有界阻塞队列,此队列按照先进先出(FIFO)的原则对元素进行排序。支持公平锁和非公平锁。
LinkedBlockingQueue
一个由链表结构组成的有界队列,此队列按照先进先出(FIFO)的原则对元素进行排序。此队列的默认长度为Integer.MAX_VALUE,所以默认创建的该队列有容量危险。
PriorityBlockingQueue
一个支持线程优先级排序的无界队列,默认自然序进行排序,也可以自定义实现compareTo()方法来指定元素排序规则,不能保证同优先级元素的顺序。
DelayQueue
一个实现PriorityBlockingQueue实现延迟获取的无界队列,在创建元素时,可以指定多久才能从队列中获取当前元素。只有延时期满后才能从队列中获取元素。
SynchronousQueue
一个不存储元素的阻塞队列,每一个put操作必须等待take操作,否则不能添加元素。支持公平锁和非公平锁。SynchronousQueue的一个使用场景是在线程池里。Executors.newCachedThreadPool()就使用了SynchronousQueue,这个线程池根据需要(新任务到来时)创建新的线程,如果有空闲线程则会重复使用,线程空闲了60秒后会被回收。
LinkedTransferQueue
一个由链表结构组成的无界阻塞队列,相当于其它队列,LinkedTransferQueue队列多了transfer和tryTransfer方法。
LinkedBlockingDeque
一个由链表结构组成的双向阻塞队列。队列头部和尾部都可以添加和移除元素,多线程并发时,可以将锁的竞争最多降到一半。
线程池创建阻塞队列时建议选取有界队列,有界队列能增加系统的稳定性和预警能力,可以根据需要设大一点,经历过几年Java开发的同学们估计都遇到到线程池的队列和线程池满的情况,这时使用有界队列会不断的抛出抛弃任务的异常,如果当时我们设置成无界队列,线程池的队列就会越来越多,有可能会撑满内存,导致整个系统不可用,而不只是后台任务出现问题。
线程池的主要工作流程如下图:
从上图我们可以看出,当提交一个新任务到线程池时,线程池的处理流程如下:
- 首先线程池判断基本线程池是否已满?没满,创建一个工作线程来执行任务。满了,则进入下个流程。
- 其次线程池判断工作队列是否已满?没满,则将新提交的任务存储在工作队列里。满了,则进入下个流程。
- 最后线程池判断整个线程池是否已满?没满,则创建一个新的工作线程来执行任务,满了,则交给饱和策略来处理这个任务。
上面的流程分析让我们很直观的了解了线程池的工作原理,让我们再通过源代码来看看是如何实现的。线程池执行任务的方法如下:
public void execute(Runnable command) {
if (command == null)
throw new NullPointerException();
// 如果线程数小于基本线程数,则创建线程并执行当前任务
if (poolSize >= corePoolSize || !addIfUnderCorePoolSize(command)) {
// 如线程数大于等于基本线程数或线程创建失败,则将当前任务放到工作队列中。
if (runState == RUNNING && workQueue.offer(command)) {
if (runState != RUNNING || poolSize == 0)
ensureQueuedTaskHandled(command);
}
// 如果线程池不处于运行中或任务无法放入队列,并且当前线程数量小于最大允许的线程数量,则创建一个线程执行任务。
else if (!addIfUnderMaximumPoolSize(command))
// 抛出 RejectedExecutionException 异常
reject(command); // is shutdown or saturated
}
}
工作线程。线程池创建线程时,会将线程封装成工作线程 Worker,Worker 在执行完任务后,还会无限循环获取工作队列里的任务来执行。我们可以从 Worker 的 run 方法里看到这点:
public void run() {
try {
Runnable task = firstTask;
firstTask = null;
while (task != null || (task = getTask()) != null) {
runTask(task);
task = null;
}
} finally {
workerDone(this);
}
}
以下是线程池的运行状态。
RUNNING
能接受新提交的任务,并且也能处理阻塞队列中的任务;
SHUTDOWN
关闭状态,不再接受新提交的任务,但却可以继续处理阻塞队列中已保存的任务。
STOP
不能接受新任务,也不处理队列中的任务,会中断正在处理任务的线程。
TIDYING
如果所有的任务都已终止了,workerCount (有效线程数) 为0
TERMINATED
在terminated() 方法执行完后进入该状态
4.一个系统创建多少线程比较合适
要想合理的配置线程池,就必须首先分析任务特性,可以从以下几个角度来进行分析:
- 任务的性质:CPU 密集型任务,IO 密集型任务和混合型任务。
- 任务的优先级:高,中和低。
- 任务的执行时间:长,中和短。
- 任务的依赖性:是否依赖其他系统资源,如数据库连接。
任务性质不同的任务可以用不同规模的线程池分开处理。
- CPU 密集型任务配置尽可能小的线程,如配置 Ncpu+1 个线程的线程池。
- IO 密集型任务则由于线程并不是一直在执行任务,则配置尽可能多的线程,如 2*Ncpu。
- 混合型的任务,如果可以拆分,则将其拆分成一个 CPU 密集型任务和一个 IO 密集型任务,只要这两个任务执行的时间相差不是太大,那么分解后执行的吞吐率要高于串行执行的吞吐率,如果这两个任务执行时间相差太大,则没必要进行分解。
我们可以通过 Runtime.getRuntime().availableProcessors() 方法获得当前设备的 CPU 个数。
优先级不同的任务可以使用优先级队列 PriorityBlockingQueue 来处理。它可以让优先级高的任务先得到执行,需要注意的是如果一直有优先级高的任务提交到队列里,那么优先级低的任务可能永远不能执行。
执行时间不同的任务可以交给不同规模的线程池来处理,或者也可以使用优先级队列,让执行时间短的任务先执行。比如依赖数据库连接池的任务,因为线程提交 SQL 后需要等待数据库返回结果,如果等待的时间越长 CPU 空闲时间就越长,那么线程数应该设置越大,这样才能更好的利用 CPU。
5.线程池的监控
通过线程池提供的参数进行监控。线程池里有一些属性在监控线程池的时候可以使用
- taskCount:线程池需要执行的任务数量。
- completedTaskCount:线程池在运行过程中已完成的任务数量。小于或等于 taskCount。
- largestPoolSize:线程池曾经创建过的最大线程数量。通过这个数据可以知道线程池是否满过。如等于线程池的最大大小,则表示线程池曾经满了。
- getPoolSize: 线程池的线程数量。如果线程池不销毁的话,池里的线程不会自动销毁。
- getActiveCount:获取活动的线程数。
通过线程池提供的一些指标,结合业务扩展线程池就能完成一些核心线程池的运行监控。
线程池负载监控
如果线程池任务过载,则会出现Reject异常,因此Reject异常是用户需要关心的一个事件,线程池支持Reject异常告警,除此之外还可通过活跃度来让运维人员或开发人员在发生Reject异常之前能够感知线程池负载问题,线程池活跃度 = activeCount/maximumPoolSize,当活跃度超过用户配置的告警阈值,就会告警。
运行时状态实时查看
有时候用户想要了解线程池当前的运行状况,比如当前有多少个工作线程,执行了多少个任务,队列中等待的任务数等等,这些都是可以做到的。
任务级精细化监控
按业务进行线程池精细化监控。
6.线程池的扩展
1)定制线程池
Executors工厂类提供的几种原生的线程池产品,不一定贴合特定的业务场景。开发人员为了在特定的业务场景下使线程池管理的并发任务合理地运行,一方面会针对场景特点调整线程池构造参数,另一方面也会根据场景特点定制特殊的线程池产品。
- Netty中的MemoryAwareThreadPoolExecutor和OrderedMemoryAwareThreadPoolExecutor就继承ThreadPoolExecutor,该线程池通过对线程池内存的使用控制可以控制Executor中待处理任务的上限,确保JVM不会因为过多的线程而导致内存溢出错误。
- Hadoop中也有定制的继承自ThreadPoolExecutor的HadoopThreadPoolExecutor类,该产品是拓展了ThreadPoolExecutor的钩子方法,在任务执行的前后都保留了执行情况的日志。
2)拓展新能力
ThreadPoolExecutor 主要提供了管理线程资源的能力,在此基础上可以继续延伸出更多适用于业务的执行任务能力。
- ThreadPoolExecutor的直接子类ScheduledThreadPoolExecutor。ScheduledThreadPoolExecutor是一个可以调度指定延迟时间的任务或者定期执行某任务的线程池。相对于任务调度的Timer来说,其功能更加强大,Time只能使用一个后台线程执行任务,而ScheduledThreadPoolExecutor则可以通过构造函数来制定后台线程的个数。
3)更改执行任务策略
ThreadPoolExecutor 主要是通过阻塞队列去执行任务,是一个生产者消费者模型,任务之间的执行并无关联。JDK还提出了新的策略去执行任务,Java 7中引入了一种新的线程池:ForkJoinPool。同ThreadPoolExecutor一样,也实现了Executor和ExecutorService接口。它使用了一个无限队列来保存需要执行的任务,而线程的数量则是通过构造函数传入,如果没有向构造函数中传入希望的线程数量,那么当前计算机可用的CPU数量会被设置为线程数量作为默认值。
ForkJoinPool主要用来使用分治法(Divide-and-Conquer Algorithm)来解决问题。典型的应用比如快速排序算法。这里的要点在于,ForkJoinPool需要使用相对少的线程来处理大量的任务。比如要对1000万个数据进行排序,那么会将这个任务分割成两个500万的排序任务和一个针对这两组500万数据的合并任务。以此类推,对于500万的数据也会做出同样的分割处理,到最后会设置一个阈值来规定当数据规模到多少时,停止这样的分割处理。比如,当元素的数量小于10时,会停止分割,转而使用插入排序对它们进行排序。使用ForkJoinPool能够使用数量有限的线程来完成非常多的具有父子关系的任务,比如使用4个线程来完成超过200万个任务。但是,使用ThreadPoolExecutor时,是不可能完成的,因为ThreadPoolExecutor中的Thread无法选择优先执行子任务,需要完成200万个具有父子关系的任务时,也需要200万个线程,显然这是不可行的。
4)解决线程争用问题
线程池内部是通过一个工作队列去维护任务的执行的。这将任务和线程分离,给了线程池优化线程数量的空间,解决了资源分配的问题,这些可以使应用程序和服务器应用响应更快。使用线程池是一个看似很不错的解决方案,但是它却有一个根本性的缺陷:连续争用问题。也就是多个线程在申请任务时,为了合理地分配任务要付出锁资源,对比快速的任务执行来说,这部分申请的损耗是巨大的。业界有一些思想去解决这个问题:
- 环形缓冲(Disruptor):LMAX的一个基于环形缓冲区的高性能进程间消息库。这个框架是基于一个叫环形缓冲的数据结构。现阶段看可能是线程间发送消息的最有效方式了。它是队列的一种替代实现方式,这种特殊的数据结构,将会避免申请任务时出现的连续争用状况。
- Actor模型:Actor模型是一个概念模型,通过维护多个Actor去处理并发的任务,它放弃了直接使用线程去获取并发性,而是自己定义了一系列系统组件应该如何动作和交互的通用规则。每个Actor对应一个最基本的计算单元,接收一个消息并且基于消息进行执行计算。Actor模型下提供了一种可靠的任务调度系统,也就是在原生的线程或协程的级别上做了更高层次的封装,这会给编程模式带来巨大的好处:由于抽象了任务调度系统,所以系统的线程调度可控,易于统一处理,稳定性和可维护性更高。另外开发者只需要关心每个Actor的逻辑即可从而避免了锁的滥用。这种方式很大程度解决了传统并发编程模式下大量依赖悲观锁导致的资源竞争情况。
5)协程
协程是一种用户态的轻量级线程,又称微线程。协程拥有自己的寄存器上下文和栈,调度切换时,将寄存器上下文和栈保存到其他地方,在切回来的时候,恢复先前保存的寄存器上下文和栈。因此:协程能保留上一次调用时的状态(即所有局部状态的一个特定组合),每次过程重入时,就相当于进入上一次调用的状态,换种说法:进入上一次离开时所处逻辑流的位置。在瓶颈侧重IO的情况,使用协程获得并发性要优于使用线程。
发布于 2020-07-19 11:01 原文链接