Java线程池详解

213 阅读10分钟

我们为什么要使用线程池

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

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

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 的原理

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

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

//这个是计数位 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 方法

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 方法。接着看

//方法入参就能看出两个关键,一个是线程任务,一个是是否是核心线程的标识,会看上面的代码
//当任务入队之后,如果工作线程数为 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 是什么结构,我们看类的信息就足够了

private final class Worker
    extends AbstractQueuedSynchronizer
    implements Runnable

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

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 的,所以并不是非核心线程执行完就销毁,而是同核心线程一样,不断的取队列的任务执行,干的活和核心线程一样,以此达到最大的并发效率。

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

接下来我们来看一下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,非常好用,下一篇文章将会实操使用它。