Java线程池详解

我们为什么要使用线程池

这就需要对比着常用的多线程来说了,每次执行任务都会创建新线程去执行任务,而且没办法做统一的管理,所以池化技术的演变就是为了解决这些问题,特点如下:

  • 节省资源: 通过连接复用技术,使得不再每次就创建线程,很大程度的节省了系统的资源。
  • 提高时效: 因为连接复用,不再创建连接,所以节省创建连接的过程。
  • 统一管理: 通过一个空间完成对线程的管理,使得任务进行统一的分配,监控。

1、核心组件ThreadPoolExecutor详解

在这里我就不会磨磨唧唧的说一些类关系的事了,直接进入主题,如果涉及到我会在后文进行简单的阐述。

1.1 ThreadPoolExecutor中的核心参数

  • 核心线程数corePoolSize: 任务队列未达到容量的时候的最大可以同时运行的线程数,同时可以指定核心线程是否回收,默认不回收。
  • 最大线程数maximumPoolSize: 当任务队列达到最大值,线程池可以同时运行的最大线程数。
  • 任务队列workQueue: 当运行线程数到达核心线程数后,那么新进来的任务将会放到队列中。
  • 非核心线程存活时间keepAliveTime: 当线程没有执行任务的时候,不会立即销毁,而是等待这个时长时候销毁。
  • 存活时长的单位unit: 存活时长的单位。
  • 线程工厂threadFactory: 使用 Executor 创建线程池的时候使用。
  • 饱和策略handle: 当线程池无法接纳新任务的时候执行的饱和策略。

1.2 为什么阿里巴巴规定不能使用 Executors 去创建线程池?

我们先介绍一下使用 Executors 创建的线程池常见的有哪几种类型。

  • FixedThreadPool: 这个线程池的特点是核心线程数和最大线程数是同样的数目,然后任务队列是Integer.MAX_VALUE,会导致这个线程池中存在大量的任务堆积,导致 OOM
  • SingleThreadPoolExecutor: 这个线程池的特点是核心线程数和最大线程数都是 1,然后队列是Integer.MAX_VALUE,一样会导致任务堆积,导致 OOM
  • CachedThreadPool: 这个线程池的特点是没有核心线程,全是非核心线程,而且允许创建的非核心线程数是Integer.MAX_VALUE直接 CPU 给你干没了。

1.3 从源码角度解析 ThreadPoolExecutor 的原理

在看执行的源码之前先看一些其他的方法和属性。

下面的和线程池状态相关的,也就代表着线程池能否正常工作

java 复制代码
//这个是计数位 SIZE 是 32,也就是 Integer 占的位数,4 字节,COUNT_BITS=29
private static final int COUNT_BITS = Integer.SIZE - 3;
//这个值等于 1 向左移动 29 位再-1,那么就是 28 个 1
private static final int COUNT_MASK = (1 << COUNT_BITS) - 1;

// runState is stored in the high-order bits
//运行中状态--高三位 101
private static final int RUNNING    = -1 << COUNT_BITS;
//调用shutdown 方法之后变为这个状态,高三位为 000
private static final int SHUTDOWN   =  0 << COUNT_BITS;
//调用shutdownNow 方法之后变为 STOP,高三位为 001
private static final int STOP       =  1 << COUNT_BITS;
//当状态为SHUTDOWN/STOP时候要清空任务队列,清空完之后为这个状态,高三位 010
private static final int TIDYING    =  2 << COUNT_BITS;
//最后就是这个结束态了,线程池完全关闭,高三位 011
private static final int TERMINATED =  3 << COUNT_BITS;
//所以能看出只有高三位为 101 的时候线程池才能接受任务,然后看下面这个
//有一个 ctlOf 方法,执行逻辑就是return rs | wc;就是保留 1,看看是什么状态
private final AtomicInteger ctl = new AtomicInteger(ctlOf(RUNNING, 0));

接着看 execute 方法

java 复制代码
public void execute(Runnable command) {
    if (command == null)
        throw new NullPointerException();
    //先取出来线程池的窗台
    int c = ctl.get();
    //这个workerCountOf就是获取工作线程数,也是用 c 去计算
    //逻辑就不粘贴了,c 就是两个作用,高三位存储线程池状态,然后剩下 29 位存储工作线程数
    //如果工作线程数小于核心线程数,那么就 addWorker,一会说这个
    if (workerCountOf(c) < corePoolSize) {
        if (addWorker(command, true))
            return;
        c = ctl.get();
    }
    //如果 addWorker 没成功,往下走
    //如果线程池还是运行状态,并且能进入任务队列的话
    if (isRunning(c) && workQueue.offer(command)) {
        //双重检查一下
        int recheck = ctl.get();
        //如果线程池不是运行状态  那就从任务队列移除,然后执行饱和策略
        if (! isRunning(recheck) && remove(command))
            reject(command);
        //如果工作线程数为 0,那么就 addWorker 一个 null
        else if (workerCountOf(recheck) == 0)
            addWorker(null, false);
    }
    //线面就是入队失败,那么就添加非核心线程
    //如果没 addWorker 成功,那么就执行饱和策略
    else if (!addWorker(command, false))
        reject(command);
}

从上面就看出一个关键,就是用 ctl 去计算状态和工作线程数,然后使用 addWorker 方法。接着看

java 复制代码
//方法入参就能看出两个关键,一个是线程任务,一个是是否是核心线程的标识,会看上面的代码
//当任务入队之后,如果工作线程数为 0,那么就传入 null,和 false,那就是非核心线程干活了
//你的任务都是在队列里面,所以不需要传入了。
private boolean addWorker(Runnable firstTask, boolean core) {
    //这个关键字类似于 goto 语句,一般和循环混合使用,比如 continue retry,就代表着循环
    //从指定位置开始,对多重循环来说比较方便
    retry:
    //直接来一个死循环,还是看我们上面的状态的那个描述,高三位我都标识出来了
    for (int c = ctl.get();;) {
        // runStateAtLeast这个方法就是判断 第一个参数是否大于等于第二个参数
        //这个判断的作用是什么?我们可以看到除了 RUNNING,剩下的都是大于 0的
        //而且代表着线程池不可用,所以下面的判断就是线程池的状态是不是不可用的
        //如果不可用的话,那么就直接 return false
        if (runStateAtLeast(c, SHUTDOWN)
            && (runStateAtLeast(c, STOP)
                || firstTask != null
                || workQueue.isEmpty()))
            return false;
        //否则继续死循环
        for (;;) {
            //判定一下线程数,如果指定是核心线程任务,那么就判断是否大于等于核心线程数
            //否则就判断是否大于等于最大线程数,如果判定是 true,那么就说明这个任务
            //不能正确执行,要么是核心线程没有空闲了,要么是线程超了
            if (workerCountOf(c)
                >= ((core ? corePoolSize : maximumPoolSize) & COUNT_MASK))
                return false;
            //如果能正确执行,怕么就比较并增长线程数,然后跳出循环往下走
            if (compareAndIncrementWorkerCount(c))
                break retry;
            // 如果没有增长成功线程数,说明这个任务线程分配失败了,就一直比较增长
            //如果线程池状态不对了,那么就从最顶层循环开始
            c = ctl.get();  // Re-read ctl
            if (runStateAtLeast(c, SHUTDOWN))
                continue retry;
            // else CAS failed due to workerCount change; retry inner loop
        }
    }
    //所以上面就是干了一件事,就是不断的查线程池状态,保证可用,然后增加线程数目,这时候
    //没创建线程,就是增长数目,相当于占座了,只有成功才能往下走。
    //把他想象成举手提问,然后最后一个人回答问题就好理解了。
    boolean workerStarted = false;
    boolean workerAdded = false;
    Worker w = null;
    try {
        //将线程任务包装成 worker,里面包含了线程任务和一个新线程
        w = new Worker(firstTask);
        final Thread t = w.thread;
        //如果线程部位 null
        if (t != null) {
            //这里加锁 ,然后将任务放入到 workers 里面,并维护最大线程数,】
            //这里需要判断线程池状态等
            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();
                //这里就是如果线程池正常的情况下那么就进入 workers 等待调用
                if (isRunning(c) ||
                    (runStateLessThan(c, STOP) && firstTask == null)) {
                    if (t.getState() != Thread.State.NEW)
                        throw new IllegalThreadStateException();
                    workers.add(w);
                    workerAdded = true;
                    int s = workers.size();
                    if (s > largestPoolSize)
                        largestPoolSize = s;
                }
            } finally {
                mainLock.unlock();
            }
            //如果 worker 成功添加,那么就异步线程执行了
            if (workerAdded) {
                //线程任务执行
                t.start();
                workerStarted = true;
            }
        }
    } finally {
        if (! workerStarted)
            addWorkerFailed(w);
    }
    return workerStarted;
}

上面的 addWorker 主要就是包装线程任务成为一个 Worker,那么 Worker 是什么结构,我们看类的信息就足够了

java 复制代码
private final class Worker
    extends AbstractQueuedSynchronizer
    implements Runnable

它是一个实现了 Runnable 的类,同时继承了 AQS,所以里面一定是有加锁,释放锁的逻辑的,同时里面的 run 方法就是执行线程任务的逻辑,它调用了一个 this.runWorker,也就是交给 ThreadPoolExecutor 来执行,接着看。

java 复制代码
final void runWorker(Worker w) {
    Thread wt = Thread.currentThread();
    Runnable task = w.firstTask;
    w.firstTask = null;
    w.unlock(); // allow interrupts
    boolean completedAbruptly = true;
    try {
        //这个 getTask 就是不断的从 workQueue 中拿任务,然后执行,
        while (task != null || (task = getTask()) != null) {
            w.lock();
            // If pool is stopping, ensure thread is interrupted;
            // if not, ensure thread is not interrupted.  This
            // requires a recheck in second case to deal with
            // shutdownNow race while clearing interrupt
            if ((runStateAtLeast(ctl.get(), STOP) ||
                 (Thread.interrupted() &&
                  runStateAtLeast(ctl.get(), STOP))) &&
                !wt.isInterrupted())
                wt.interrupt();
            try {
                beforeExecute(wt, task);
                try {
                    task.run();
                    afterExecute(task, null);
                } catch (Throwable ex) {
                    afterExecute(task, ex);
                    throw ex;
                }
            } finally {
                task = null;
                w.completedTasks++;
                w.unlock();
            }
        }
        completedAbruptly = false;
    } finally {
        processWorkerExit(w, completedAbruptly);
    }
}

从这里再梳理一下,ThreadPoolExecutor 放任务的时候判断线程数是否大于核心线程,如果不大于,就包装成 Worker 然后 run,否则就进阻塞队列,然后这些任务就一直执行,一直 getTask,如果 get 不到阻塞呗,拿到了就执行,所以这就体现出了线程复用,小于核心线程的 Worker 不断的执行,执行完就去队列中拿。

还有一个非常关键的一点,就是非核心线程的事,我们大多数听到的执行情况都是先使用核心线程执行任务,然后当核心线程最大值之后就放入阻塞队列中,当阻塞队列满的时候,创建非核心线程执行任务。这个说法体现在了哪里呢?

这个核心线程和非核心线程其实都是理论上的知识,而在实际的源码中都是线程,只不过创建的时机是不同的,我们看上面的源码 addWorker 方法的 core 参数其实就是一个比较线程数和核心线程还是最大线程的标识,最后都是要 addWorker 的,所以并不是非核心线程执行完就销毁,而是同核心线程一样,不断的取队列的任务执行,干的活和核心线程一样,以此达到最大的并发效率。

这个只是简略的过程图,实际上有很多判定条件,比如线程池的状态,线程的数目等等,在源码的注释上面我都写了。

接下来我们来看一下ThreadPoolExecutor 中定义的几个饱和策略。

1.4 ThreadPoolExecutor 中的饱和策略

1. AbortPolicy: 抛出RejectedExecutionException异常,拒绝新任务执行。

2. CallerRunsPolicy: 调用主线程(执行该任务的线程,不是线程池中的了)运行任务。

3. DiscardPolicy: 直接丢弃新任务

4. DiscardOldestPolicy: 丢弃最早未处理的任务请求

2、怎么选择合适的线程数量?

这个其实没有非常完美的公式进行推导计算,在现在的应用场景中大致分为两种

  • CPU 密集型任务: 线程任务包含大量的计算逻辑。
  • IO 密集型任务: 网络 IO,文件读写,数据库读写操作等。

对于 CPU 密集型任务,线程数设置过多会加重CPU 上下文切换的代价,所以推荐是核心数+1;

对于IO 密集型任务,线程数可以设置稍微多一些,毕竟对 CPU 的使用相较于前者还是很轻松的,推荐是核心数*2+1。

但是这只是理论上面的推荐,时间生产过程中没,是需要根据情况进行压测计算的,根据实际的结果来得到一个理想的线程数。而在实际的过程中我们可能还需要动态的调整线程池参数,所以给大家推荐一个开源框架,美团团队开源的 Dynamic-tp,非常好用,下一篇文章将会实操使用它。

相关推荐
Andy01_2 分钟前
Java八股汇总【MySQL】
java·开发语言·mysql
唐 城9 分钟前
Solon v3.0.5 发布!(Spring 可以退休了吗?)
java·spring·log4j
V+zmm1013418 分钟前
社区二手物品交易小程序ssm+论文源码调试讲解
java·微信小程序·小程序·毕业设计·ssm
坊钰21 分钟前
【Java 数据结构】合并两个有序链表
java·开发语言·数据结构·学习·链表
秋天下着雨27 分钟前
apifox调用jar程序
java·python·jar
m0_7482510832 分钟前
docker安装nginx,docker部署vue前端,以及docker部署java的jar部署
java·前端·docker
A227434 分钟前
Redis——缓存雪崩
java·redis·缓存
Mr.朱鹏34 分钟前
操作002:HelloWorld
java·后端·spring·rabbitmq·maven·intellij-idea·java-rabbitmq
顽疲1 小时前
从零用java实现 小红书 springboot vue uniapp (6)用户登录鉴权及发布笔记
java·vue.js·spring boot·uni-app
oscar9991 小时前
Maven项目中不修改 pom.xml 状况下直接运行OpenRewrite的配方
java·maven·openrewrite