线程池设置问题导致线程池被打满

1、问题背景

有一个核心数据的查询的接口,进行了项目重构,为保证正确性,在原接口逻辑执行完毕后,使用原接口入参和出参异步提交到线程池执行Diff流程(执行重构后的逻辑,Diff对比两个接口执行的结果)。

2、问题现象

线上灰度极少部分流量执行Diff流程,系统随即抛出RejectedExecutionException异常过多的告警。

问题:Diff流程使用线程池执行任务,但是未对线程池进行异常处理,导致影响的主流程。

3、问题原因

从上一步可以得知,是Diff流程中提交线程池任务被线程池拒绝而导致的问题,因此问题原因就是Diff流程的线程池被打满了。线程池为什么会被打满呢?这时候就需要先探究一下线程池的实现原理了。

3.1 线程池ThreadPoolExecutor

首先,为什么要使用线程池呢?

  • 随意创建线程,不易于管理。
  • 每次任务都新创建一个线程,执行完毕销毁,线程的创建和销毁的成本很高,耗费资源。

ThreadPoolExecutor是jdk提供的线程池实现,创建线程池的时候,共有7个核心的参数。

    public ThreadPoolExecutor(int corePoolSize, // 核心线程数
                              int maximumPoolSize, // 最大线程数
                              long keepAliveTime, // 线程无任务时最大等待时间
                              TimeUnit unit, // 线程无任务时最大等待时间单位
                              BlockingQueue<Runnable> workQueue, // 任务队列
                              ThreadFactory threadFactory, // 线程工厂
                              RejectedExecutionHandler handler) { // 拒绝策略
        if (corePoolSize < 0 ||
            maximumPoolSize <= 0 ||
            maximumPoolSize < corePoolSize ||
            keepAliveTime < 0)
            throw new IllegalArgumentException();
        if (workQueue == null || threadFactory == null || handler == null)
            throw new NullPointerException();
        this.acc = System.getSecurityManager() == null ?
                null :
                AccessController.getContext();
        this.corePoolSize = corePoolSize;
        this.maximumPoolSize = maximumPoolSize;
        this.workQueue = workQueue;
        this.keepAliveTime = unit.toNanos(keepAliveTime);
        this.threadFactory = threadFactory;
        this.handler = handler;
    }

这7个参数的含义都在注释上了,其相关逻辑可以从线程池的execute方法看出来,这里先做一下解释,方便对照代码理解。

提交任务之后

  1. 线程池中的线程数小于核心线程数量(corePoolSize)时,开启新线程执行任务
  2. 线程数大于等于核心线程数时,将任务提交到队列(workQueue)中
  3. 如果队列也满了,就尝试启动非核心线程执行任务
  4. 如果已达到最大线程数量(maximumPoolSize),则根据拒绝策略(handler)进行任务拒绝
  5. 此后,核心线程会一直阻塞并监听队列中的任务,非核心线程也阻塞监听队列中的任务,但是会在等待固定的时间(keepAliveTime+unit)依然没有任务时,结束等待,销毁线程。

下面来一起看一下实现:

3.1.1 execute方法

    public void execute(Runnable command) {
        if (command == null)
            throw new NullPointerException();

        int c = ctl.get(); // 获取线程的运行状态
        if (workerCountOf(c) < corePoolSize) { // 1.线程数量少于核心线程数时,新增一个线程
            if (addWorker(command, true)) // 新增加一个worker(其实就是一个线程),并执行这个任务
                return;
            c = ctl.get(); // 执行这里证明添加任务失败了,可能是线程池状态发生了变化
        }
        // 2.此时线程数已超过核心线程数,判断线程池为运行状态,并将任务添加到队列中
        if (isRunning(c) && workQueue.offer(command)) {
            int recheck = ctl.get(); // 再次检查线程池的状态
            if (! isRunning(recheck) && remove(command)) // 如果线程池非运行状态,移除这个任务
                reject(command); // 移除成功之后,执行拒绝策略
            // 2.1 如果线程池关闭了,但队列还有任务,新建一个线程去执行队列中的任务(因此方法第一个参数为null)
            else if (workerCountOf(recheck) == 0) 
                addWorker(null, false);
        }
        // 3.添加队列失败(即队列已满),则新启动非核心线程去执行这个任务
        else if (!addWorker(command, false))
            reject(command); // 添加非核心线程失败,执行拒绝策略
    }

根据代码注释可以看到,其整个流程与上边描述的基本一致,不过有一些逻辑不在这里,需要关注的有两个方法:addWorkerreject,先说reject方法,比较简单,就是根据创建线程池时传入的RejectedExecutionHandler handler参数,执行对应的拒绝策略。

拒绝策略共有四种

new ThreadPoolExecutor.AbortPolicy() //任务过量,不处理,直接抛异常
new ThreadPoolExecutor.CallerRunsPolicy() //任务过量,哪来的回哪去,谁把任务给线程池的,就谁去执行,这里是main线程扔给线程池的,因此main线程执行,不抛异常
new ThreadPoolExecutor.DiscardPolicy() //过量的任务直接放弃,不抛异常  
new ThreadPoolExecutor.DiscardOldestPolicy() //过量的任务会尝试和最早的竞争,不抛异常

一般会使用AbortPolicy的方式,过量就抛出Rejected异常。

3.1.2 addWorker方法

接下来我们一起看下addWorker方法,这个方法是将提交的任务给到新启动的线程去执行的操作。

先看源码:

private boolean addWorker(Runnable firstTask, boolean core) {
        // 1.第一部分代码,判断线程池的状态和线程数是否超出限制,超出则添加失败,否则先CAS增加线程数量,失败后重试直到成功或者添加失败为止
        retry:
        for (;;) {
            int c = ctl.get(); // 取线程池状态
            int rs = runStateOf(c);

            // Check if queue empty only if necessary.
            if (rs >= SHUTDOWN && //线程池已经关闭,并且不是来处理队列任务的就直接返回false,添加线程失败。
                ! (rs == SHUTDOWN &&
                   firstTask == null &&
                   ! workQueue.isEmpty()))
                return false;

            for (;;) {
                int wc = workerCountOf(c);
                if (wc >= CAPACITY || // 当前线程数超过限制:添加核心线程时超过了corePoolSize,添加非核心线程时超过了maximumPoolSize,就返回添加失败
                    wc >= (core ? corePoolSize : maximumPoolSize))
                    return false;
                if (compareAndIncrementWorkerCount(c)) CAS增加线程数量,失败则继续,否则结束循环
                    break retry;
                c = ctl.get();  // Re-read ctl
                if (runStateOf(c) != rs) // 如果状态改变了,要从头开始判断
                    continue retry;
                // else CAS failed due to workerCount change; retry inner loop
            }
        }

        // 2.第二部分代码,新增线程,并执行任务
        boolean workerStarted = false; // 线程是否已启动
        boolean workerAdded = false; // 线程是否已添加
        Worker w = null;
        try {
            w = new Worker(firstTask); // 2.1创建一个线程任务,并告诉他第一个任务,注意:这里与下一个方法有关联
            final Thread t = w.thread; // 取出创建的线程
            if (t != null) {
                final ReentrantLock mainLock = this.mainLock;
                mainLock.lock(); // 加锁
                try {
                    // 判断线程池的状态,在运行或者已经要停止,但添加的是处理队列的线程时,可以继续
                    int rs = runStateOf(ctl.get());
                    if (rs < SHUTDOWN ||
                        (rs == SHUTDOWN && firstTask == null)) {
                        if (t.isAlive()) // t线程当然不能是个在跑的线程
                            throw new IllegalThreadStateException();
                        workers.add(w); // 2.2储存了所有的worker的一个hashset容器添加这个worker
                        int s = workers.size();
                        if (s > largestPoolSize)
                            largestPoolSize = s; // 更新线程数最大值
                        workerAdded = true; // 标记线程已添加
                    }
                } finally {
                    mainLock.unlock(); // 解锁
                }
                if (workerAdded) { // 2.3如果线程已添加,则启动线程,并标识为已启动
                    t.start();
                    workerStarted = true;
                }
            }
        } finally {
            if (! workerStarted) // 失败处理
                addWorkerFailed(w);
        }
        return workerStarted;
    }

这一部分代码,看起来貌似很长,其实逻辑也比较简单,可以主要关注标注数字的流程。总共分为两部分

第一段代码:判断线程池的状态和线程的数量,可以添加线程,先自旋+CAS增加线程的数量,添加成功后,再创建线程。

第二段代码:创建线程,并执行任务

这里有个比较重要的点,是入参的第一个字段firstTask,这里可以先看下Worker类的源码

Worker类(有一些删减):

    private final class Worker
        extends AbstractQueuedSynchronizer
        implements Runnable
    {

        final Thread thread; 
        
        Runnable firstTask;

        Worker(Runnable firstTask) {
            setState(-1); // inhibit interrupts until runWorker
            this.firstTask = firstTask;
            this.thread = getThreadFactory().newThread(this);
        }

        /** Delegates main run loop to outer runWorker  */
        public void run() {
            runWorker(this);
        }

    }

可以看到,Worker有两个核心属性,threadfirstTask,在构造方法中会设置firstTask和使用工厂新创建一个线程。需要关注的是,run方法中调用了runWorker方法,这里是真正执行任务的地方。

到此为止,我们已经看到了整个线程池运行流程的第1-4步,下面就看下第5步内容,也就是runWorker方法以及任务获取方法getTask。

3.1.3 runWorker方法和getTask方法

先上源码

    final void runWorker(Worker w) {
        Thread wt = Thread.currentThread();
        Runnable task = w.firstTask; // 1.取出传进来的worker中的第一个任务
        w.firstTask = null; // 2.并将其第一个任务设置为空,因为剩下的任务都要从队列取
        w.unlock(); // allow interrupts 这个地方允许被打断
        boolean completedAbruptly = true;
        try {
            // 3.如果有第一个任务,或者getTask从阻塞队列中能取到任务,就执行任务
            while (task != null || (task = getTask()) != null) {
                w.lock();

                if ((runStateAtLeast(ctl.get(), STOP) ||
                     (Thread.interrupted() &&
                      runStateAtLeast(ctl.get(), STOP))) &&
                    !wt.isInterrupted())
                    wt.interrupt();
                try {
                    beforeExecute(wt, task);
                    Throwable thrown = null;
                    try {
                        task.run();
                    } catch (RuntimeException x) {
                        thrown = x; throw x;
                    } catch (Error x) {
                        thrown = x; throw x;
                    } catch (Throwable x) {
                        thrown = x; throw new Error(x);
                    } finally {
                        afterExecute(task, thrown);
                    }
                } finally {
                    task = null;
                    w.completedTasks++;
                    w.unlock();
                }
            }
            completedAbruptly = false;
        } finally {
            processWorkerExit(w, completedAbruptly);
        }
    }

这里能看到,上边赋值的firstTask的含义了

  • 创建Worker时设置了firstTask,就先执行firstTask,之后才去阻塞队列getTask获取任务
  • 创建Worker时firstTask为空时,则是直接去阻塞队列获取任务,就对应于线程池已关闭但是队列中还有任务的场景,需要尽快将队列中的任务都执行完毕

同时,可以看到如果一直能从队列中获取到任务,已经开启的线程是不会关闭的。

下面我们来看getTask方法

    private Runnable getTask() {
        boolean timedOut = false; // Did the last poll() time out?

        for (;;) {
            int c = ctl.get();
            int rs = runStateOf(c);

            // Check if queue empty only if necessary.
            if (rs >= SHUTDOWN && (rs >= STOP || workQueue.isEmpty())) {
                decrementWorkerCount();
                return null;
            }

            int wc = workerCountOf(c);

            // Are workers subject to culling? 
            // 1.主要关注后一个条件,线程数量大于核心线程时,进来的线程会作为非核心线程
            boolean timed = allowCoreThreadTimeOut || wc > corePoolSize;
            // 2.线程数超过总线程数,或者已经等待超时了;并且线程数大于1或者队列为空,线程数减1,并结束循环
            if ((wc > maximumPoolSize || (timed && timedOut))
                && (wc > 1 || workQueue.isEmpty())) {
                if (compareAndDecrementWorkerCount(c))
                    return null;
                continue;
            }

            try {
                // 3.根据上边的判断,核心线程会一直阻塞等待,而非核心线程只等待预先设置的时间,等不到任务,就会标记为超时,结束任务,也会结束runWorker中的循环,最后会尝试销毁线程
                Runnable r = timed ?
                    workQueue.poll(keepAliveTime, TimeUnit.NANOSECONDS) :
                    workQueue.take();
                if (r != null)
                    return r;
                timedOut = true;
            } catch (InterruptedException retry) {
                timedOut = false;
            }
        }
    }

这里也能看到,是一个死循环等待任务的过程,步骤1.如果当前线程数大于核心线程数,就按照非核心线程处理,即获取队列任务时可以超时,否则就一直阻塞等待到任务到来。而超时的线程会在下一次循环中经过步骤2判断,将结束任务。步骤3则为核心线程一直阻塞等待任务,非核心线程等待超时后,结束任务尝试销毁。

至此,五个步骤就清晰了,最后再看下流程:

  1. 线程池中的线程数小于核心线程数量(corePoolSize)时,开启新线程执行任务
  2. 线程数大于等于核心线程数时,将任务提交到队列(workQueue)中
  3. 如果队列也满了,就尝试启动非核心线程执行任务
  4. 如果已达到最大线程数量(maximumPoolSize),则根据拒绝策略(handler)进行任务拒绝
  5. 此后,核心线程会一直阻塞并监听队列中的任务,非核心线程也阻塞监听队列中的任务,但是会在等待固定的时间(keepAliveTime+unit)依然没有任务时,结束等待,销毁线程。

执行流程图

3.2 问题的根因

此时,再来回顾一下线上的问题,其对应的线程池设置为:核心线程数:32,最大线程数:64,队列长度:0(问题点1)队列实现:LinkedBlockingQueue(问题点2),等待超时时间60s(非核心问题点)

从设置中可以看到,为了Diff任务能够快速执行,而不阻塞等待,将队列长度设置为了0,同时使用了LinkedBlockingQueue队列,这就导致了一个问题的产生:

1.在核心线程和非核心线程创建完毕之后,都是监听队列,阻塞等待队列中的任务到来的,核心线程一直阻塞,非核心线程等待超时后销毁。

2.由于流量比较大,任务到来时,32个核心线程首先开满,再来的任务发现添加队列失败,就开启非核心线程,也很快开满64个线程

3.此时所有的线程都在等待队列中的任务,任务既不能添加进队列也不能开启新线程执行,只能抛出拒绝异常,而线程池的吞吐量由于keep-alive的设置,则只有在32个非核心线程均等待1分钟后销毁,才能新创建线程处理任务,即每分钟的吞吐量为32。

4.因此导致了大量的reject异常的出现。

此时看起来好像jdk是有问题,为什么可以设置这样的阻塞队列长度为0呢?其实还有一个问题,因为jdk官方的LinkedBlockingQueue是不允许队列长度为空的,而我们使用的是公司封装后拓展的队列,此队列允许设置为0,才最终导致了问题的出现。

LinkedBlockingQueue:

    public LinkedBlockingQueue(int capacity) {
         // 队列长度不允许为0
        if (capacity <= 0) throw new IllegalArgumentException();
        this.capacity = capacity;
        last = head = new Node<E>(null);
    }

新队列

    public XxxLinkedBlockingQueue(int capacity) {
        // 允许为0
        if (capacity < 0) {
            throw new IllegalArgumentException();
        } else {
            xxxxxx
        }
    }

4.拓展延伸

常见的BlockingQueue
队列类描述
ArrayBlockingQueue

1.基于数组实现,维护了一个定长数组,还有分别指向头尾位置的整型变量

2.在放入和取出数据时,使用的是同一把锁,因此不能并行的存取,这一点与LinkedBlockingQueue不同

LinkedBlockingQueue

1.基于链表的阻塞队列

2.链表缓冲队列容量达到最大值时,会阻塞生产者继续生产,直到消费一部分数据后,才会唤醒生产者;反之队列为0时会阻塞消费者,直到添加了新任务才会唤醒消费者

3.生产和消费采用了独立的锁,提高了并发性能

4.构造LinkedBlockingQueue不传入大小时,默认为无界队列(默认设置为Integer.MAX_VALUE),如果生产者速度大于消费者,可能会导致内存溢出,因此强烈建议设置队列长度

SynchronousQueue

1.一个无缓存的等待队列,生产者直接拿任务给消费者,如果没有对应目标,则两者均等待

2.相对于有缓冲的BlockingQueue,少了缓冲区,降低了吞吐量,但是提高了响应的时间

3.支持公平模式和非公平模式

如果想要提高响应速度,可以使用SynchronousQueue队列

5、参考文献

并发包的线程池第一篇--ThreadPoolExecutor执行逻辑 - DavieTiming - 博客园

  • 0
    点赞
  • 9
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
关于 Java 线程池导致 MySQL 的 Bug,可能是由于线程池中的线程数量过多,导致 MySQL 连接池被耗尽,从而出现连接超时或者连接泄露的情况。这种情况下,可以通过优化线程池的配置,增加 MySQL 连接池的大小,或者使用连接池管理工具进行监控和管理,来避免这种情况的发生。 至于关于线程池问题,可以具体分为以下几个方面: 1. 线程池的大小:线程池的大小需要根据实际的业务场景来进行设置,如果线程池的大小过小,可能会导致任务无法及时处理,而过大则会占用过多的系统资源,影响系统的性能表现。 2. 线程池的类型:线程池的类型包括 FixedThreadPool、CachedThreadPool、ScheduledThreadPool 等,不同类型的线程池适用于不同的场景,需要根据实际的业务需求进行选择。 3. 线程池的拒绝策略:当线程池中的任务数量超过线程池的最大容量时,需要采取一定的拒绝策略,如 AbortPolicy、CallerRunsPolicy、DiscardOldestPolicy 等,需要根据业务场景和系统性能要求进行选择。 4. 线程池的生命周期管理:线程池的生命周期包括创建、启动、运行、停止等多个阶段,在使用线程池时需要对其进行合理的生命周期管理,以确保线程池的稳定运行和性能表现。 总之,线程池是一个非常重要的并发编程工具,需要在实践中不断学习和积累经验,以提高系统的性能和稳定性。

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值