池化技术的思想主要是为了减少资源的消耗,提高资源的利用率。线程池一般用于执行多个不相关的耗时任务,不使用线程池时任务是顺序执行,使用线程池后可以通过处理多个任务。
Executor
Executor 框架是java5之后引进的,使用Executor 启动线程比直接使用Thread的start要好,除了易于管理,节省资源外,最主要的是还有助于避免this逃逸。
Executor
框架主要由三部分组成:
- 任务(Runnable/Callable)
执行任务需要实现Runnable或者Callable接口,两个接口的实现类都可以被 ThreadPoolExecutor 或 ScheduledThreadPoolExecutor 执行。
- 任务的执行(Executor)
如下图所示,包括任务执行机制的核心接口 Executor ,以及继承自 Executor 接口的 ExecutorService 接口。ThreadPoolExecutor 和 ScheduledThreadPoolExecutor 这两个关键类实现了 ExecutorService 接口。
- 异步计算的结果(Future)
Future接口和其实现类FutureTask都可以代表异步计算的结果。
当我们把 Runnable接口 或 Callable 接口 的实现类提交给 ThreadPoolExecutor 或 ScheduledThreadPoolExecutor 执行。(调用 submit() 方法时会返回一个 FutureTask 对象)
Executor框架的使用
- 主线程创建实现Runnable或Callable接口的任务对象。
- 把创建完成的实现Runnable或Callable接口的对象直接交给ExecutorService执行:ExecutorService.execute(Runnable runnable),或者也可以把 Runnable 对象或Callable 对象提交给 ExecutorService 执行(ExecutorService.submit(Runnable task)或 ExecutorService.submit(Callable <T> task))。
- 如果执行 ExecutorService.submit(…),ExecutorService 将返回一个实现Future接口的对象(刚刚提到过了执行 execute()方法和 submit()方法的区别,submit()会返回一个 FutureTask 对象)。由于 FutureTask 实现了 Runnable,我们也可以创建 FutureTask,然后直接交给 ExecutorService 执行。
- 主线程可以执行FutureTask.get()来等待任务的结果,也可以执行 FutureTask.cancel(boolean mayInterruptIfRunning)来取消此任务的执行
ThreadPoolExecutor
线程池实现类 ThreadPoolExecutor 是 Executor 框架最核心的类。
ThreadPoolExecutor类提供了四个构造方法,我们直接看最后一个。
参数介绍
- corePoolSize:任务队列未达到队列容量时,最大可以同时运行的线程数量(也叫核心线程数,要保留在池中的线程数,即使它们是空闲的)。
- maxmumPoolSize:任务队列中存放的任务达到队列容量的时候,当前可以同时运行的线程数量变为最大线程数。
- keepAliveTime:当线程数大于核心线程数时,这是多余线程在终止前等待新任务的最长时间。
- unit:时间单位
- workQueue:在执行任务之前容纳任务的队列,此队列仅容纳execute方法提交的Runnable任务,新任务来的时候会先判断当前运行的线程数量是否达到核心线程数,如果达到才会放进队列中进行等待。
- threadFactory:执行器创建新线程时使用的工厂
- handler:饱和策略,当由于达到线程边界和队列容量而阻止执行时使用的处理程序
ThreadPoolExecutor
饱和策略
如果当前运行的线程数达到最大线程数并且队列也已经满了,ThreadPoolTaskExecutor 定义一些策略用来解决这个问题:
- ThreadPoolExecutor.AbortPolicy:抛出 RejectedExecutionException 来拒绝接收新任务。
- ThreadPoolExecutor.CallerRunsPolicy:调用执行自己的线程运行任务,也就是直接在调用execute方法的线程中运行(run)被拒绝的任务,如果执行程序已关闭,则会丢弃该任务。因此这种策略会降低对于新任务提交速度,影响程序的整体性能。如果您的应用程序可以承受此延迟并且你要求任何一个任务请求都要被执行的话,你可以选择这个策略。
- ThreadPoolExecutor.DiscardPolicy:不处理新任务,直接丢弃掉。
- ThreadPoolExecutor.DiscardOldestPolicy:此策略将丢弃最早未处理的任务。
线程池的创建
- 通过ThreadPoolExecutor构造函数来进行创建
- 通过Executor的工具类Executors来进行创建,使用Executors可以创建多种线程池。
FixedThreadPool
:该方法返回一个固定线程数量的线程池。该线程池中的线程数量始终不变。当有一个新的任务提交时,线程池中若有空闲线程,则立即执行。若没有,则新的任务会被暂存在一个任务队列中,待有线程空闲时,便处理在任务队列中的任务。
SingleThreadExecutor:
该方法返回一个只有一个线程的线程池。若多余一个任务被提交到该线程池,任务会被保存在一个任务队列中,待线程空闲,按先入先出的顺序执行队列中的任务。
CachedThreadPool:该方法返回一个可根据实际情况调整线程数量的线程池。线程池的线程数量不确定,但若有空闲线程可以复用,则会优先使用可复用的线程。若所有线程均在工作,又有新的任务提交,则会创建新的线程处理任务。所有线程在当前任务执行完毕后,将返回线程池进行复用。
ScheduledThreadPool:返回一个用来在给定的延迟后运行任务或者定期执行任务的线程池。
阿里巴巴规范上面强制不使用Executor来创建线程池,这样的处理方式让写的同学更加明确线程池的运行规则,规避资源耗尽的风险。
Executors返回线程池的弊端:
- FixedThreadPool 和 SingleThreadExecutor:使用的无界的 LinkedBlockingQueue ,任务队列的最大长度为Integer.MAX_VALUE,可能堆积大量任务,导致OOM。
-
CachedPoolThread:使用的是同步队列SychronousQueue,允许创建的线程数量为Integer.MAX_VALUE,可能会创建大量线程,导致OOM。
- ScheduledThreadPool 和 SingleThreadScheduledExecutor:使用的无界的延迟阻塞队列DelayedWorkQueue,任务队列最大长度为 Integer.MAX_VALUE,可能堆积大量的请求,从而导致 OOM。
线程池原理
ThreadPoolExecute示例
定义运行类
public class MyRunnable implements Runnable{
private String command;
public MyRunnable(String command) {
this.command = command;
}
@Override
public void run() {
System.out.println("Start Time:" + System.currentTimeMillis());
try {
Thread.sleep(5000);
} catch (InterruptedException e) {
e.printStackTrace();
}
System.out.println("End Time:" + System.currentTimeMillis());
}
@Override
public String toString() {
return "MyRunnable{" +
"command='" + command + '\'' +
'}';
}
}
public class ThreadPool {
private static final int CORE_POOL_SIZE = 5;
private static final int MAX_POOL_SIZE = 10;
private static final int QUEUE_CAPACITY = 100;
private static final Long KEEP_ALIVE_TIME = 1L;
public static void main(String[] args) {
// 阿里巴巴推荐线程池创建方式
ThreadPoolExecutor executor = new ThreadPoolExecutor(
CORE_POOL_SIZE,
MAX_POOL_SIZE,
KEEP_ALIVE_TIME,
TimeUnit.SECONDS,
new ArrayBlockingQueue<>(QUEUE_CAPACITY),
new ThreadPoolExecutor.CallerRunsPolicy()
);
for (int i = 0; i < 11; i++) {
MyRunnable runnable = new MyRunnable(" " + i);
executor.execute(runnable);
}
//终止线程池
executor.shutdown();
while (!executor.isTerminated()) {
}
System.out.println("Finished all threads");
}
}
一开始运行了五个线程,因为设定了核心线程为5,在队列满了的情况下,才会另外开启新线程
原理分析
为了搞懂线程池的原理,我们需要首先分析一下 execute方法。 在示例代码中,我们使用 executor.execute(runnable)来提交一个任务到线程池中去。
// 存放线程池的运行状态 (runState) 和线程池内有效线程的数量 (workerCount)
private final AtomicInteger ctl = new AtomicInteger(ctlOf(RUNNING, 0));
private static int workerCountOf(int c) {
return c & CAPACITY;
}
//任务队列
private final BlockingQueue<Runnable> workQueue;
public void execute(Runnable command) {
// 如果任务为null,则抛出异常。
if (command == null)
throw new NullPointerException();
// ctl 中保存的线程池当前的一些状态信息
int c = ctl.get();
// 下面会涉及到 3 步 操作
// 1.首先判断当前线程池中执行的任务数量是否小于 corePoolSize
// 如果小于的话,通过addWorker(command, true)新建一个线程,并将任务(command)添加到该线程中;然后,启动该线程从而执行任务。
if (workerCountOf(c) < corePoolSize) {
if (addWorker(command, true))
return;
c = ctl.get();
}
// 2.如果当前执行的任务数量大于等于 corePoolSize 的时候就会走到这里,表明创建新的线程失败。
// 通过 isRunning 方法判断线程池状态,线程池处于 RUNNING 状态并且队列可以加入任务,该任务才会被加入进去
if (isRunning(c) && workQueue.offer(command)) {
int recheck = ctl.get();
// 再次获取线程池状态,如果线程池状态不是 RUNNING 状态就需要从任务队列中移除任务,并尝试判断线程是否全部执行完毕。同时执行拒绝策略。
if (!isRunning(recheck) && remove(command))
reject(command);
// 如果当前工作线程数量为0,新创建一个线程并执行。
else if (workerCountOf(recheck) == 0)
addWorker(null, false);
}
//3. 通过addWorker(command, false)新建一个线程,并将任务(command)添加到该线程中;然后,启动该线程从而执行任务。
// 传入 false 代表增加线程时判断当前线程数是否少于 maxPoolSize
//如果addWorker(command, false)执行失败,则通过reject()执行相应的拒绝策略的内容。
else if (!addWorker(command, false))
reject(command);
}
大概流程就是:
- 判断当前工作线程是否大于核心线程数,如果没有大于,则直接添加新线程执行任务。
- 如果工作线程等于大于核心线程数,并且队列没有满,则将任务添加进队列。
- 如果向任务队列投放任务失败(任务队列已经满了),但是当前运行的线程数是小于最大线程数的,就新建一个线程来执行任务。
- 如果当前运行的线程数已经等同于最大线程数了,新建线程将会使当前运行的线程超出最大线程数,那么当前任务会被拒绝,饱和策略会调用RejectedExecutionHandler.rejectedExecution()方法。
再来看看一些属性,状态控制主要围绕原子整型成员变量ctl:
COUNT_BITS:表示工作线程上限数量位的长度,它的值是Integer.SIZE - 3,也就是正整数29;
我们知道,整型包装类型Integer实例的大小是4 byte,一共32 bit,也就是一共有32个位用于存放0或者1。
在ThreadPoolExecutor实现中,使用32位的整型包装类型存放工作线程数和线程池状态。
其中,低29位用于存放工作线程数,而高3位用于存放线程池状态,所以线程池的状态最多只能有2^3种。
工作线程上限数量为2^29 - 1,超过5亿,这个数量在短时间内不用考虑会超限。
CAPACITY:它的值是(1 < COUNT_BITS) - 1,也就是1左移29位,再减去1,如果补全32位,它的位视图如下:
ctl:控制变量ctl的组成就是通过线程池运行状态rs和工作线程数wc通过或运算得到的
剩下的五个就是用来表示线程池的状态:
状态名称 | 位图 | 十进制值 | 描述 |
---|---|---|---|
RUNNING | 111-00000000000000000000000000000 | -536870912 | 运行中状态,可以接收新的任务和执行任务队列中的任务 |
SHUTDOWN | 000-00000000000000000000000000000 | 0 | shutdown状态,不再接收新的任务,但是会执行任务队列中的任务 |
STOP | 001-00000000000000000000000000000 | 536870912 | 停止状态,不再接收新的任务,也不会执行任务队列中的任务,中断所有执行中的任务 |
TIDYING | 010-00000000000000000000000000000 | 1073741824 | 整理中状态,所有任务已经终结,工作线程数为0,过渡到此状态的工作线程会调用钩子方法terminated() |
TERMINATED | 011-00000000000000000000000000000 | 1610612736 | 终结状态,钩子方法terminated()执行完毕 |
线程池状态跃迁图:
addWorker方法源码分析:
// 添加工作线程,如果返回false说明没有新创建工作线程,如果返回true说明创建和启动工作线程成功
// 方法的第一的参数可以用于直接传入任务实例,第二个参数用于标识将要创建的工作线程是否核心线程
private boolean addWorker(Runnable firstTask, boolean core) {
retry:
// 注意这是一个死循环 - 最外层循环
for (int c = ctl.get();;) {
// 这个是十分复杂的条件,这里先拆分多个与(&&)条件:
// 1. 线程池状态至少为SHUTDOWN状态,也就是rs >= SHUTDOWN(0)
// 2. 线程池状态至少为STOP状态,也就是rs >= STOP(1),或者传入的任务实例firstTask不为null,或者任务队列为空
// 其实这个判断的边界是线程池状态为shutdown状态下,不会再接受新的任务,在此前提下如果状态已经到了STOP、或者传入任务不为空、或者任务队列为空(已经没有积压任务)都不需要添加新的线程
if (runStateAtLeast(c, SHUTDOWN)
&& (runStateAtLeast(c, STOP)
|| firstTask != null
|| workQueue.isEmpty()))
return false;
// 注意这也是一个死循环 - 二层循环
for (;;) {
// 这里每一轮循环都会重新获取工作线程数wc
// 1. 如果传入的core为true,表示将要创建核心线程,通过wc和corePoolSize判断,如果wc >= corePoolSize,则返回false表示创建核心线程失败
// 1. 如果传入的core为false,表示将要创非建核心线程,通过wc和maximumPoolSize判断,如果wc >= maximumPoolSize,则返回false表示创建非核心线程失败
if (workerCountOf(c)
>= ((core ? corePoolSize : maximumPoolSize) & COUNT_MASK))
return false;
// 成功通过CAS更新工作线程数wc,则break到最外层的循环
if (compareAndIncrementWorkerCount(c))
break retry;
// 走到这里说明了通过CAS更新工作线程数wc失败,这个时候需要重新判断线程池的状态是否由RUNNING已经变为SHUTDOWN
c = ctl.get(); // Re-read ctl
// 如果线程池状态已经由RUNNING已经变为SHUTDOWN,则重新跳出到外层循环继续执行
if (runStateAtLeast(c, SHUTDOWN))
continue retry;
// 如果线程池状态依然是RUNNING,CAS更新工作线程数wc失败说明有可能是并发更新导致的失败,则在内层循环重试即可
// else CAS failed due to workerCount change; retry inner loop
}
}
// 标记工作线程是否启动成功
boolean workerStarted = false;
// 标记工作线程是否创建成功
boolean workerAdded = false;
Worker w = null;
try {
// 传入任务实例firstTask创建Worker实例,Worker构造里面会通过线程工厂创建新的Thread对象,所以下面可以直接操作Thread t = w.thread
// 这一步Worker实例已经创建,但是没有加入工作线程集合或者启动它持有的线程Thread实例
w = new Worker(firstTask);
final Thread t = w.thread;
if (t != null) {
// 这里需要全局加锁,因为会改变一些指标值和非线程安全的集合
final ReentrantLock mainLock = this.mainLock;
mainLock.lock();
try {
// Recheck while holding lock.
// Back out on ThreadFactory failure or if
// shut down before lock acquired.
int c = ctl.get();
// 这里主要在加锁的前提下判断ThreadFactory创建的线程是否存活或者判断获取锁成功之后线程池状态是否已经更变为SHUTDOWN
// 1. 如果线程池状态依然为RUNNING,则只需要判断线程实例是否存活,需要添加到工作线程集合和启动新的Worker
// 2. 如果线程池状态小于STOP,也就是RUNNING或者SHUTDOWN状态下,同时传入的任务实例firstTask为null,则需要添加到工作线程集合和启动新的Worker
// 对于2,换言之,如果线程池处于SHUTDOWN状态下,同时传入的任务实例firstTask不为null,则不会添加到工作线程集合和启动新的Worker
// 这一步其实有可能创建了新的Worker实例但是并不启动(临时对象,没有任何强引用),这种Worker有可能成功下一轮GC被收集的垃圾对象
if (isRunning(c) ||
(runStateLessThan(c, STOP) && firstTask == null)) {
if (t.isAlive()) // precheck that t is startable
throw new IllegalThreadStateException();
// 把创建的工作线程实例添加到工作线程集合
workers.add(w);
int s = workers.size();
// 尝试更新历史峰值工作线程数,也就是线程池峰值容量
if (s > largestPoolSize)
largestPoolSize = s;
// 这里更新工作线程是否启动成功标识为true,后面才会调用Thread#start()方法启动真实的线程实例
workerAdded = true;
}
} finally {
mainLock.unlock();
}
// 如果成功添加工作线程,则调用Worker内部的线程实例t的Thread#start()方法启动真实的线程实例
if (workerAdded) {
t.start();
// 标记线程启动成功
workerStarted = true;
}
}
} finally {
// 线程启动失败,需要从工作线程集合移除对应的Worker
if (! workerStarted)
addWorkerFailed(w);
}
return workerStarted;
}
// 添加Worker失败
private void addWorkerFailed(Worker w) {
final ReentrantLock mainLock = this.mainLock;
mainLock.lock();
try {
// 从工作线程集合移除之
if (w != null)
workers.remove(w);
// wc数量减1
decrementWorkerCount();
// 基于状态判断尝试终结线程池
tryTerminate();
} finally {
mainLock.unlock();
}
}
也可以看出线程池的线程是懒加载的
Runnable与Callable的对比
Runnable从java1.0就一直存在,Callable在java1.5进行引入,Callable解决了一些Runnable做不到的事情,比如抛出异常和返回运行结果,平常无特殊需求,使用Runnable即可,代码更加简洁。
Executes可以实现将Runnable对象转换成Callable对象 (Executors.callable(Runnable task) 或 Executors.callable(Runnable task, Object result))
execute()
与 submit()
execute()
方法用于提交不需要返回值的任务,所以无法判断任务是否被线程池执行成功与否;submit()
方法用于提交需要返回值的任务。线程池会返回一个Future
类型的对象,通过这个Future
对象可以判断任务是否执行成功,并且可以通过Future
的get()
方法来获取返回值,get()
方法会阻塞当前线程直到任务完成,而使用get(long timeout,TimeUnit unit)
方法的话,如果在timeout
时间内任务还没有执行完,就会抛出java.util.concurrent.TimeoutException。
线程池的配置
相信很多人都见过一个线程数设置的理论:
- CPU 密集型的程序 - 核心数 + 1
- I/O 密集型的程序 - 核心数 * 2
这个公式其实是不太适合实际开发场景的
真实程序中的线程数
直接说结论,没有固定答案,先设定预期(哈哈哈,说了和没说一样)。比如期望的CPU利用率在多少,负载在多少,在通过测试来进行线程数的设置。
比如一个普通的,SpringBoot 为基础的业务系统,默认Tomcat容器+HikariCP连接池+G1回收器,如果此时项目中也需要一个业务场景的多线程(或者线程池)来异步/并行执行业务流程。
如果直接按照公式来规划线程数的话,误差肯定会很大,因为在运行项目时,服务器已经在运行很多其他线程了,比如Tomcat的线程,JVM的一些编译的线程,G1的后台线程。这些线程也是运行在当前服务器上,也会占用CPU资源。
所以说,在实际运行场景下,线程数是很难进行设置的,直接套用这个公式误差会很大。
在<<java并发编程实战>>这本书里介绍了一个线程数计算公式:
如果希望程序跑到CPU的目标利用率,需要的线程数公式为:
把公式变形一下,还可以通过线程数计算CPU利用率:
虽然这个公式计算结果已经很标准了,但是在实际生产中,获得准确的等待时间和计算时间,因为程序很复杂,不只是“计算”。一段代码中会有很多的内存读写,计算,I/O 等复合操作,精确的获取这两个指标很难,所以光靠公式计算线程数过于理想化。
所以受环境干扰下,单靠公式很难准确的规划线程数,一定要通过测试来验证。
流程一般如下:
- 分析当前主机上,是否有其他干扰进程。
- 分析当前JVM上,有没有其他运行中或者可能运行的线程。
- 设定目标:a.目标CPU利用率 b.目标GC频率/暂停频率(多线程执行后,GC频率会很高,最大能容忍到上什么频率,每次暂停时间为多少) c.执行效率(比如批处理时,我单位时间内要开多少线程才能及时处理完毕)
- 梳理链路关键点,是否有卡脖子的点,因为如果线程数过多,链路上某些节点资源有限可能会导致大量的线程在等待资源(比如三方接口限流,连接池数量有限,中间件压力过大无法支撑等)
- 不断的增加/减少线程数来测试,按最高的要求去测试,最终获得一个“满足要求”的线程数
不同场景下线程数的理念也不一样:
- Tomcat中的maxThreads,在Blocking I/O和No-Blocking I/O下就不一样
- Dubbo 默认还是单连接呢,也有I/O线程(池)和业务线程(池)的区分,I/O线程一般不是瓶颈,所以不必太多,但业务线程很容易称为瓶颈
- Redis 6.0以后也是多线程了,不过它只是I/O 多线程,“业务”处理还是单线程
所以,不要太纠结这个问题,如果并不太在意性能,并且系统没啥压力,直接使用CPU核心数就可以了。