• 填个坑!再谈线程池动态调整那点事。


    你好呀,我是歪歪。

    前几天和一个大佬聊天的时候他说自己最近在做线程池的监控,刚刚把动态调整的功能开发完成。

    想起我之前写过这方面的文章,就找出来看了一下:《如何设置线程池参数?美团给出了一个让面试官虎躯一震的回答。》

    然后给我指出了一个问题,我仔细思考了一下,好像确实是留了一个坑。

    为了更好的描述这个坑,我先给大家回顾一下线程池动态调整的几个关键点。

    首先,为什么需要对线程池的参数进行动态调整呢?

    因为随着业务的发展,有可能出现一个线程池开始够用,但是渐渐的被塞满的情况。

    这样就会导致后续提交过来的任务被拒绝。

    没有一劳永逸的配置方案,相关的参数应该是随着系统的浮动而浮动的。

    所以,我们可以对线程池进行多维度的监控,比如其中的一个维度就是队列使用度的监控。

    当队列使用度超过 80% 的时候就发送预警短信,提醒相应的负责人提高警惕,可以到对应的管理后台页面进行线程池参数的调整,防止出现任务被拒绝的情况。

    以后有人问你线程池的各个参数怎么配置的时候,你先把分为 IO 密集型和 CPU 密集型的这个八股文答案背完之后。

    加上一个:但是,除了这些方案外,我在实际解决问题的时候用的是另外一套方案”。

    然后把上面的话复述一遍。

    那么线程池可以修改的参数有哪些呢?

    正常来说是可以调整核心线程数和最大线程数的。

    线程池也直接提供了其对应的 set 方法:

    但是其实还有一个关键参数也是需要调整的,那就是队列的长度。

    哦,对了,说明一下,本文默认使用的队列是 LinkedBlockingQueue

    其容量是 final 修饰的,也就是说指定之后就不能修改:

    所以队列的长度调整起来稍微要动点脑筋。

    至于怎么绕过 final 这个限制,等下就说,先先给大家上个代码。

    我一般是不会贴大段的代码的,但是这次为什么贴了呢?

    因为我发现我之前的那篇文章就没有贴,之前写的代码也早就不知道去哪里了。

    所以,我又苦哈哈的敲了一遍...

    import cn.hutool.core.thread.NamedThreadFactory;

    import java.util.concurrent.BlockingQueue;
    import java.util.concurrent.ThreadPoolExecutor;
    import java.util.concurrent.TimeUnit;

    public class ThreadChangeDemo {

        public static void main(String[] args) {
            dynamicModifyExecutor();
        }

        private static ThreadPoolExecutor buildThreadPoolExecutor() {
            return new ThreadPoolExecutor(2,
                    5,
                    60,
                    TimeUnit.SECONDS,
                    new ResizeableCapacityLinkedBlockingQueue<>(10),
                    new NamedThreadFactory("why技术"false));
        }

        private static void dynamicModifyExecutor() {
            ThreadPoolExecutor executor = buildThreadPoolExecutor();
            for (int i = 0; i < 15; i++) {
                executor.execute(() -> {
                    threadPoolStatus(executor,"创建任务");
                    try {
                        TimeUnit.SECONDS.sleep(5);
                    } catch (InterruptedException e) {
                        e.printStackTrace();
                    }
                });
            }
            threadPoolStatus(executor,"改变之前");
            executor.setCorePoolSize(10);
            executor.setMaximumPoolSize(10);
            ResizeableCapacityLinkedBlockingQueue<Runnable> queue = (ResizeableCapacityLinkedBlockingQueue)executor.getQueue();
            queue.setCapacity(100);
            threadPoolStatus(executor,"改变之后");
        }

        /**
         * 打印线程池状态
         *
         * @param executor
         * @param name
         */
        private static void threadPoolStatus(ThreadPoolExecutor executor, String name) {
            BlockingQueue<Runnable> queue = executor.getQueue();
            System.out.println(Thread.currentThread().getName() + "-" + name + "-:" +
                    "核心线程数:" + executor.getCorePoolSize() +
                    " 活动线程数:" + executor.getActiveCount() +
                    " 最大线程数:" + executor.getMaximumPoolSize() +
                    " 线程池活跃度:" +
                    divide(executor.getActiveCount(), executor.getMaximumPoolSize()) +
                    " 任务完成数:" + executor.getCompletedTaskCount() +
                    " 队列大小:" + (queue.size() + queue.remainingCapacity()) +
                    " 当前排队线程数:" + queue.size() +
                    " 队列剩余大小:" + queue.remainingCapacity() +
                    " 队列使用度:" + divide(queue.size(), queue.size() + queue.remainingCapacity()));
        }

        private static String divide(int num1, int num2) {
            return String.format("%1.2f%%", Double.parseDouble(num1 + "") / Double.parseDouble(num2 + "") * 100);
        }
    }

    当你把这个代码粘过去之后,你会发现你没有 NamedThreadFactory 这个类。

    没有关系,我用的是 hutool 工具包里面的,你要是没有,可以自定义一个,也可以在构造函数里面不传,这不是重点,问题不大。

    问题大的是 ResizeableCapacityLinkedBlockingQueue 这个玩意。

    它是怎么来的呢?

    在之前的文章里面提到过:

    就是把 LinkedBlockingQueue 粘贴一份出来,修改个名字,然后把 Capacity 参数的 final 修饰符去掉,并提供其对应的 get/set 方法。

    感觉非常的简单,就能实现 capacity 参数的动态变更。

    但是,我当时写的时候就感觉是有坑的。

    毕竟这么简单的话,为什么官方要把它给设计为 final 呢?

    坑在哪里?

    关于 LinkedBlockingQueue 的工作原理就不在这里说了,都是属于必背八股文的内容。

    主要说一下前面提到的场景中,如果我直接把 final 修饰符去掉,并提供其对应的 get/set 方法,这样的做法坑在哪里。

    先说一下,如果没有特殊说明,本文中的源码都是 JDK 8 版本。

    我们看一下这个 put 方法:

    主要看这个被框起来的部分。

    while 条件里面的 capacity 我们知道代表的是当前容量。

    那么 count.get 是个什么玩意呢?

    就是当前队列里面有多少个元素。

    count.get == capacity 就是说队列已经满了,然后执行 notFull.await() 把当前的这个 put 操作挂起来。

    来个简单的例子验证一下:

    申请一个长度为 5 的队列,然后在循环里面调用 put 方法,当队列满了之后,程序就阻塞住了。

    通过 dump 当前线程可以知道主线程确实是阻塞在了我们前面分析的地方:

    所以,你想想。如果我把队列的 capacity 修改为了另外的值,这地方会感知到吗?

    它感知不到啊,它在等着别人唤醒呢。

    现在我们把队列换成我修改后的队列验证一下。

    下面验证程序的思路就是在一个子线程中执行队列的 put 操作,直到容量满了,被阻塞。

    然后主线程把容量修改为 100。

    上面的程序其实我想要达到的效果是当容量扩大之后,子线程不应该继续阻塞。

    但是经过前面的分析,我们知道这里并不会去唤醒子线程。

    所以,输出结果是这样的:

    子线程还是阻塞着,所以并没有达到预期。

    所以这个时候我们应该怎么办呢?

    当然是去主动唤醒一下啦。

    也就是修改一下 setCapacity 的逻辑:

    public void setCapacity(int capacity) {
        final int oldCapacity = this.capacity;
        this.capacity = capacity;
        final int size = count.get();
        if (capacity > size && size >= oldCapacity) {
            signalNotFull();
        }
    }

    核心逻辑就是发现如果容量扩大了,那么就调用一下 signalNotFull 方法:

    唤醒一下被 park 起来的线程。

    如果看到这里你觉得你有点懵,不知道 LinkedBlockingQueue 的这几个玩意是干啥的:

    赶紧去花一小时时间补充一下 LinkedBlockingQueue 相关的知识点。这样玩意,面试也经常考的。

    好了,我们说回来。

    修改完我们自定义的 setCapacity 方法后,再次执行程序,就出现了我们预期的输出:

    除了改 setCapacity 方法之外,我在写文章的时候不经意间还触发了另外一个答案:

    在调用完 setCapacity 方法之后,再次调用 put 方法,也能得到预期的输出:

    我们观察 put 方法就能发现其实道理是一样的:

    当调用完 setCapacity 方法之后,再次调用 put 方法,由于不满足标号为 ① 的代码的条件,所以就不会被阻塞。

    于是可以顺利走到标号为 ② 的地方唤醒被阻塞的线程。

    所以也就变相的达到了改变队列长度,唤醒被阻塞的任务目的。

    而究根结底,就是需要执行一次唤醒的操作。

    那么那一种优雅一点呢?

    那肯定是第一种把逻辑封装在 setCapacity 方法里面操作起来更加优雅。

    第二种方式,大多适用于那种“你也不知道为什么,反正这样写程序就是正常了”的情况。

    现在我们知道在线程池里面动态调整队列长度的坑是什么了。

    那就是队列满了之后,调用 put 方法的线程就会被阻塞住,即使此时另外的线程调用了 setCapacity 方法,改变了队列长度,如果没有线程再次触发 put 操作,被阻塞的线程也不会被唤醒。

    是不是?

    了不了解?

    对不对?

    这是不对的,朋友们。

    看到前面内容,频频点头的朋友,要注意了。

    这地方要开始转弯了。

    开始转弯

    线程池里面往队列里面添加对象的时候,用的是 offer 命令,并没有用 put 命令:

    我们看看 offer 命令在干啥事儿:

    队列满了之后,直接返回 false,不会出现阻塞的情况。

    也就是说,线程池中根本就不会出现我前面说的需要唤醒的情况,因为根本就没有阻塞中的线程。

    在和大佬交流的过程中,他提到了一个 VariableLinkedBlockingQueue 的东西。

    这个类位于 MQ 包里面,我前面提到的 setCapacity 方法的修改方式就是在它这里学来的:

    同时,项目里面也用到了它的 put 方法:

    所以,它是有可能出现我们前面分析的情况,有需要被唤醒的线程。

    但是,你想想,线程池里面并没有使用 put 方法,是不是就刚好避免这样的情况?

    是的,确实是。

    但是,不够严谨,如果知道有问题了的话,为什么要留个坑在这里呢?

    你学 MQ 的 VariableLinkedBlockingQueue 考虑的周全一点,就算 put 方法阻塞的时候也能用,它不香吗?

    写到这里其实好像除了让你熟悉一下 LinkedBlockingQueue 外,似乎是一个没啥卵用的知识点,

    但是,我能让这个没有卵用的知识点起到大作用。

    因为这其实是一个小细节。

    假设我出去面试,在面试的时候提到动态调整方法的时候,在不经意间拿捏一下这个小细节,即使我没有真的落地过动态调整,但是我提到这样的一个小细节,就显得很真实。

    面试官一听:很不错,有整体,有局部,应该是假不了。

    在 VariableLinkedBlockingQueue 里面还有几处细节,拿 put 方法来说:

    判断条件从 count.get() >= capacity 变成了 count.get() = capacity,目的是为了支持 capacity 由大变小的场景。

    这样的地方还有好几处,就不一一列举了。

    魔鬼,都在细节里面。

    同学们得好好的拿捏一下。

    JDK bug

    其实原计划写到前面,就打算收尾了,因为我本来就只是想补充一下我之前没有注意到的细节。

    但是,我手贱,跑到 JDK bug 列表里面去搜索了一下 LinkedBlockingQueue,想看看还有没有什么其他的收获。

    我是万万没想到,确实是有一点意外收获的。

    首先是这一个 bug ,它是在 2019-12-29 被提出来的:

    https://bugs.openjdk.java.net/browse/JDK-8236580

    看标题的意思也是想要给 LinkedBlockingQueue 赋能,可以让它的容量进行修改。

    加上他下面的场景描述,应该也想要和线程池配合,找到队列的抓手,下钻到底层逻辑,联动监控系统,拉通配置页面,打出一套动态适应的组合拳。

    但是官方并没有采纳这个建议。

    回复里面说写 concurrent 包的这些哥们对于在并发类里面加东西是非常谨慎的。他们觉得给 ThreadPoolExecutor 提供可动态修改的特性会带来或者已经带来众多的 bug 了。

    我理解就是简单一句话:建议还是不错的,但是我不敢动。并发这块,牵一发动全身,不知道会出些什么幺蛾子。

    所以要实现这个功能,还是得自己想办法。

    这里也就解释了为什么用 final 去修饰了队列的容量,毕竟把功能缩减一下,出现 bug 的几率也少了很多。

    第二个 bug 就有意思了,和我们动态调整线程池的需求非常匹配:

    https://bugs.openjdk.java.net/browse/JDK-8241094

    这是一个 2020 年 3 月份提出的 bug,描述的是说在更新线程池的核心线程数的时候,会抛出一个拒绝异常。

    在 bug 描述的那部分他贴了很多代码,但是他给的代码写的很复杂,不太好理解。

    好在 Martin 大佬写了一个简化版,一目了然,就好理解的多:

    这段代码是干了个啥事儿呢,简单给大家汇报一下。

    首先 main 方法里面有个循环,循环里面是调用了 test 方法,当 test 方法抛出异常的时候循环结束。

    然后 test 方法里面是每次都搞一个新的线程池,接着往线程池里面提交队列长度加最大线程数个任务,最后关闭这个线程池。

    同时还有另外一个线程把线程池的核心线程数从 1 修改为 5。

    你可以打开前面提到的 bug 链接,把这段代码贴出来跑一下,非常的匪夷所思。

    Martin 大佬他也认为这是一个 BUG.

    说实在的,我跑了一下案例,我觉得这应该算是一个 bug,但是经过 Doug Lea 老爷子的亲自认证,他并不觉得这是一个 Bug。

    主要是这个 bug 确实也有点超出我的认知,而且在链接中并没有明确的说具体原因是什么,导致我定位的时间非常的长,甚至一度想要放弃。

    但是最终定位到问题之后也是长叹一口:害,就这?没啥意思。

    先看一下问题的表现是怎么样的:

    上面的程序运行起来后,会抛出 RejectedExecutionException,也就是线程池拒绝执行该任务。

    但是我们前面分析了,for 循环的次数是线程池刚好能容纳的任务数:

    按理来说不应该有问题啊?

    这也就是提问的哥们纳闷的地方:

    他说:我很费解啊,我提交的任务数量根本就不会超过 queueCapacity+maxThreads,为什么线程池还抛出了一个 RejectedExecutionException?而且这个问题非常的难以调试,因为在任务中添加任何形式的延迟,这个问题都不会复现。

    他的言外之意就是:这个问题非常的莫名其妙,但是我可以稳定复现,只是每次复现出现问题的时机都非常的随机,我搞不定了,我觉得是一个 bug,你们帮忙看看吧。

    我先不说我定位到的 Bug 的主要原因是啥吧。

    先看看老爷子是怎么说的:

    老爷子的观点简单来说就是四个字:

    老爷子说他没有说服自己上面的这段程序应该被正常运行成功。

    意思就是他觉得抛出异常也是正常的事情。但是他没有说为什么。

    一天之后,他又补了一句话:

    我先给大家翻译一下:

    他说当线程池的 submit 方法和 setCorePoolSize 或者 prestartAllCoreThreads 同时存在,且在不同的线程中运行的时候,它们之间会有竞争的关系。

    在新线程处于预启动但还没完全就绪接受队列中的任务的时候,会有一个短暂的窗口。在这个窗口中队列还是处于满的状态。

    解决方案其实也很简单,比如可以在 setCorePoolSize 方法中把预启动线程的逻辑拿掉,但是如果是用 prestartAllCoreThreads 方法,那么还是会出现前面的问题。

    但是,不管是什么情况吧,我还是不确定这是一个需要被修复的问题。

    怎么样,老爷子的话看起来是不是很懵?

    是的,这段话我最开始的时候读了 10 遍,都是懵的,但是当我理解到这个问题出现的原因之后,我还是不得不感叹一句:

    还是老爷子总结到位,没有一句废话。

    到底啥原因?

    首先我们看一下示例代码里面操作线程池的这两个地方:

    修改核心线程数的是一个线程,即 CompletableFuture 的默认线程池 ForkJoinPool 中的一个线程。

    往线程池里面提交任务是另外一个线程,即主线程。

    老爷子的第一句话,说的就是这回事:

    racing,就是开车,就是开快车,就是与...比赛的意思。

    这是一个多线程的场景,主线程和 ForkJoinPool 中的线程正在 race,即可能出现谁先谁后的问题。

    接着我们看看 setCorePoolSize 方法干了啥事:

    标号为 ① 的地方是计算新设置的核心线程数与原核心线程数之间的差值。

    得出的差值,在标号为 ② 的地方进行使用。

    也就是取差值和当前队列中正在排队的任务数中小的那一个。

    比如当前的核心线程数配置就是 2,这个时候我要把它修改为 5。队列里面有 10 个任务在排队。

    那么差值就是 5-2=3,即标号为 ① 处的 delta=3。

    workQueue.size 就是正在排队的那 10 个任务。

    也就是 Math.min(3,10),所以标号为 ② 处的 k=3。

    含义为需要新增 3 个核心线程数,去帮忙把排队的任务给处理一下。

    但是,你想新增 3 个就一定是对的吗?

    会不会在新增的过程中,队列中的任务已经被处理完了,有可能根本就不需要 3 个这么多了?

    所以,循环终止的条件除了老老实实的循环 k 次外,还有什么?

    就是队列为空的时候:

    同时,你去看代码上面的那一大段注释,你就知道,其实它描述的和我是一回事。

    好,我们接着看 addWorker 里面,我想要让你看到地方:

    在这个方法里面经过一系列判断后,会走入到 new Worker() 的逻辑,即工作线程。

    然后把这个线程加入到 workers 里面。

    workers 就是一个存放工作线程的 HashSet 集合:

    你看我框起来的这两局代码,从 workers.add(w)t.start()

    从加入到集合到真正的启动,中间还有一些逻辑。

    执行中间的逻辑的这一小段时间,就是老爷子说的 “window”。

    there's a window while new threads are in the process of being prestarted but not yet taking tasks。

    就是在新线程处于预启动,但尚未接受任务时,会有一个窗口。

    这个窗口会发生啥事儿呢?

    就是下面这句话:

    the queue may remain (transiently) full。

    队列有可能还是满的,但是只是暂时的。

    接下来我们连起来看:

    所以怎么理解上面被划线的这句话呢?

    带入一个实际的场景,也就是前面的示例代码,只是调整一下参数:

    这个线程池核心线程数是 1,最大线程数是 2,队列长度是 5,最多能容纳的任务数是 7。

    另外有一个线程在执行把核心线程池从 1 修改为 2 的操作。

    假设我们记线程池 submit 提交了 6 个任务,正在提交第 7 个任务的时间点为 T1。

    为什么是要强调这个时间点呢?

    因为当提交第 7 个任务的时候,就需要去启用非核心线程数了。

    具体的源码在这里:

    java.util.concurrent.ThreadPoolExecutor#execute

    也就是说此时队列满了, workQueue.offer(command) 返回的是 fasle。因此要走到 addWorker(command, false) 方法中去了。

    代码走到 1378 行这个时间点,是 T1。

    如果 1378 行的 addWorker 方法返回 false,说明添加工作线程失败,抛出拒绝异常。

    前面示例程序抛出拒绝异常就是因为这里返回了 fasle。

    那么问题就变成了:为什么 1378 行中的 addWorker 执行后返回了 false 呢?

    因为当前不满足这个条件了 wc >= (core ? corePoolSize : maximumPoolSize)

    wc 就是当前线程池,正在工作的线程数。

    把我们前面的条件带进去,就是这样的 wc >=(false?2:2)

    即 wc=2。

    为什么会等于 2,不应该是 1 吗?

    多的哪一个是哪里来的呢?

    真相只有一个:恰好此时 setCorePoolSize 方法中的 addWorker 也执行到了 workers.add(w),导致 wc 从 1 变成了 2。

    撞车了,所以抛出拒绝异常。

    那么为什么大多数情况下不会抛出异常呢?

    因为从 workers.add(w)t.start()这个时间窗口,非常的短暂。

    大多数情况下,setCorePoolSize 方法中的 addWorker 执行了后,就会理解从队列里面拿一个任务出来执行。

    而这个情况下,另外的任务通过线程池提交进来后,发现队列还有位子,就放到队列里面去了,根本不会去执行 addWorker 方法。

    道理,就是这样一个道理。

    这个多线程问题确实是比较难复现,我是怎么定位到的呢?

    加日志。

    源码里面怎么加日志呢?

    我不仅搞了一个自定义队列,还把线程池的源码粘出来了一份,这样就可以加日志了:

    另外,其实我这个定位方案也是很不严谨的。

    调试多线程的时候,最好是不要使用 System.out.println,有坑!

    场景

    我们再回头看看老爷子给出的方案:

    其实它给了两个。

    第一个是拿掉 setCorePoolSize 方法中的 addworker 的逻辑。

    第二个是说原程序中,即提问者给的程序中,使用的是 prestartAllCoreThreads 方法,这个里面必须要调用 addWorker 方法,所以还是有一定的几率出现前面的问题。

    但是,老爷子不明白为什么会这样写?

    我想也许他是没有想到什么合适的场景?

    其实前面提到的这个 Bug,其实在动态调整的这个场景下,还是有可能会出现的。

    虽然,出现的概率非常低,条件也非常苛刻。

    但是,还是有几率出现的。

    万一出现了,当同事都在抠脑壳的时候,你就说:这个嘛,我见过,是个 Bug。不一定每次都出现的。

    这又是一个你可以拿捏的小细节。

    但是,如果你在面试的时候遇到这个问题了,这属于一个傻逼问题。

    毫无意义。

    属于,面试官不知道在哪看到了一个感觉很厉害的观点,一定要展现出自己很厉害的样子。

    但是他不知道的是,这个题:

    最后说一句

    好了,看到了这里了,安排一个点赞吧。写文章很累的,需要一点正反馈。

    给各位读者朋友们磕一个了:

    本文已收录自个人博客,欢迎大家来玩:

    https://www.whywhy.vip/

  • 相关阅读:
    ssh根据姓名查询的时候报错java.lang.IndexOutOfBoundsException: Remember that
    JSF>自订转换器 小强斋
    JSF>标准验证器 小强斋
    JSF>自订验证器 小强斋
    JSF>输入、输出、命令、选择、表格、其他标签 小强斋
    JSF>自订转换器 小强斋
    JSF>自订验证器 小强斋
    JSF>错误讯息处理 小强斋
    JSF>自订验证器 小强斋
    JSF>输入、输出、命令、选择、表格、其他标签 小强斋
  • 原文地址:https://www.cnblogs.com/thisiswhy/p/15457810.html
Copyright © 2020-2023  润新知