当前位置: 首页 > 后端技术 > Java

刺激,线程池的一个BUG直接把CPU干到100%

时间:2023-04-01 17:35:42 Java

大家好,我是伟伟。给大家分享一个关于ScheduledExecutorService线程池的bug。这个bug可以直接把CPU开到100%。我希望你永远不要踩到它。不过u1s1,一般来说,也是很难踩的。到底怎么回事,小编给大家详细盘点一下。demo老规矩,按照惯例,先创建一个demo来玩:项目使用了ScheduledThreadPoolExecutor线程池,线程池对应的核心线程数放在配置文件中,通过读取配置文件@Value注解。然后通过接口触发这个线程池中的任务。具体来说,在上面的示例代码中,调用testScheduledPool接口后,程序会在60秒后输出“executebusinesslogic”。这段代码的逻辑还是很简单明了的,但是上面的代码有个问题,不知道大家看得出来吗?看不到也没关系,我在这里以鼓励的方式进行教学,不要打击学生的积极性。所以,不用着急,我先给你跑一下,你马上就可以一目了然:为什么coreSize是0,而我们的配置文件里明明写成2?因为setCoreSize方法是静态的,所以@Value注释失败了。如果去掉static,则可以正确读取配置文件中的配置:虽然里面有很多知识,但这不是本文的重点。这只是一个介绍,为了引出为什么会出现如下几种coreSize等于0的奇怪代码:ScheduledExecutorServiceexecutor=Executors.newScheduledThreadPool(0);如果我直接给出上面的代码,有人说只有小(大)柯(傻)爱(逼)才会这样写。但是铺好背景之后,就容易接受多了。你可以一直相信我的写结构,老司机很稳定,你可以放心。嗯,经过前面的铺垫,其实我们的demo可以直接简化为:publicstaticvoidmain(String[]args){ScheduledExecutorServicee=Executors.newScheduledThreadPool(0);e.schedule(()->{System.out.println("业务逻辑");},60,TimeUnit.SECONDS);e.shutdown();}这段代码可以正常运行,粘贴后直接运行,60秒后正常输出。如果你觉得60秒太长,那你可以改成3秒,看看程序是否正常运行结束。但是这段看似问题不大的代码,却会导致CPU跑到100%。真的,我的儿子。这是怎么回事?这是怎么回事?这其实是JDK的BUG造成的。给大家演示一下:https://bugs.openjdk.org/brow...首先可以看到FixVersion是9,也就是说这个BUG是在JDK9中修复的。它在JDK8中是可重现的。其次,这个标题实际上包含了很多信息。它说对于ScheduledExecutorService,在getTask方法中有频繁的循环。那么问题来了:频繁的循环,比如for(;;),while(true)的代码,如果长时间不能跳出循环,会造成什么现象呢?那不就是导致CPU飙升吗。注意,我这里说的是“久久不能跳出循环”,不是死循环。两者的区别还是很大的。我代码中的例子是提出BUG的哥们给出的例子:他说这个例子中,如果你在只有单核的服务器上运行,然后使用TOP命令,你会看到CPU为60seconds使用率为100%。为什么?答案就隐藏在上面提到的getTask方法中:java.util.concurrent.ThreadPoolExecutor#getTask这个方法确实有类似死循环的代码,但是为什么一直执行呢?现在赶紧想想线程池的基本运行原理。没有任务处理的时候,核心线程在干什么?是不是就卡在这个地方,等待任务过来处理,这个可以理解:那我再问你一个问题,这行代码的作用是什么:workQueue.poll(keepAliveTime,TimeUnit.NANOSECONDS)是not如果在指定时间内没有从队列中拉取任务,将抛出InterruptedException。那么什么时候触发呢?当timed参数为真时。timed参数什么时候为真?当allowCoreThreadTimeOut为真或当前工作线程大于核心线程数时。而allowCoreThreadTimeOut默认为false:此时满足当前工作线程大于核心线程数的条件:wc>corePoolSize通过Debug知道wc为1,corePoolSize为0:所以timed变为true.嗯,在这里要小心,我的朋友。经过前面的分析,我们已经知道在当前情况下,会触发for(;;)的逻辑:workQueue.poll(keepAliveTime,TimeUnit.NANOSECONDS)那么keepAliveTime是什么?来来来,喊出数字:0,一个意想不到的,刁钻的0。所以,这个地方的r每次都会返回一个null,重新开始循环。对于一个普通的线程池来说,触发了这个逻辑,也就意味着没有任务执行,可以回收对应的线程。回收,对应这部分代码会返回一个null:然后在外面的runWorker方法中,因为getTask返回null,执行finally代码中的逻辑,即从当前线程池中移除线程的逻辑:但是,朋友们,我要说不。在我们的例子中可以看到if判断的条件:.jpg)wherewc>1||workQueue.isEmpty())为false,所以这个ifcondition不为真,那么又去轮询:workQueue.poll(keepAliveTime,TimeUnit.NANOSECONDS)由于这里的keepAliveTime为0,下一个循环会不停的开始.那么这个循环什么时候结束呢?就是从队列中获取任务的时候。那么队列中什么时候会有任务呢?在我们的例子中,60秒后。因此,在这60秒的时间里,这部分代码相当于一个“死循环”,导致CPU持续飙升至100%。这就是BUG,这就是根本原因。但是看到这里你有没有觉得差不多有意思了?当我说100%时,我的意思是100%吗?你必须拿出石锤。所以,为了拿出一个真正的锤子,眼见为实,我把核心流程拿出来,然后稍微改了一下代码:publicstaticvoidmain(String[]args){>(100);//绑定5号CPU执行try(AffinityLockaffinityLock=AffinityLock.acquireLock(5)){for(;;){try{Runnabler=workQueue.poll(0,TimeUnit.NANOSECONDS);如果(r!=null)中断;}catch(InterruptedExceptionretry){}}}}AffinityLock这个类在之前的文章中出现过:《面试官:Java如何绑定线程到指定CPU上执行?》就是把线程绑定到指定的CPU上执行,减少CPU抖动带来的损失,具体我就不介绍了,我有兴趣阅读我以前的文章。运行这个程序后,打开资源监视器,可以看到5号CPU马上就100%了,停止运行后,马上就宕机了:眼见为实,这真是JDK的BUG,我真的没有骗你。如何修复JDK9中的这个BUG如何修复?在上面提到的BUG链接中,有这么一个链接,就是JDK9版本对上面BUG的修复:http://hg.openjdk.java.net/jd...点击这个链接之后,你可以找到这个地方:先比较标①和②的地方,默认值由0纳秒改为DEFAULT_KEEPALIVE_MILLIS毫秒。③处DEFAULT_KEEPALIVE_MILLIS的值为10L。也就是说,默认值从0纳秒更改为10毫秒。而这个改动是为了防止coreSize为0。我们重点关注DEFAULT_KEEPALIVE_MILLIS上面那一堆注释。我给你翻译一下吧,大致是这样的:这个值一般不用,因为ScheduledThreadPoolExecutor线程池里面的线程都是核心线程。但是,如果用户在创建线程池时不顾劝阻,将corePoolSize设置为0,会发生什么情况呢?因为keepAlive参数设置为0,会导致线程在getTask方法中循环非常频繁,导致CPU飙升。那我们该怎么办呢?很简单,设置一个不为零的小值,这个小值是相对于JVM的运行时间而言的。所以这10毫秒就是这样产生的。另外一个我在研究前面提到的编号为8065320的BUG时,也有意外的收获,这个编号为8051859的BUG,他们挨着坐着,排成一排。很有趣也很简单,分享一波:https://bugs.openjdk.org/brow...这个BUG是什么意思:看截图。这个BUG在JDK9版本之后也被修复了。这个BUG的标题是指ScheduledExecutorService线程池的scheduleWithFixedDelay方法,遇到大延时会执行失败。你到底什么意思?先拿demo来说吧://第一个任务executor.scheduleWithFixedDelay(newRunnable(){@Overridepublicvoidrun(){System.out.println("runningscheduledtaskwithdelay:"+newDate());}},0,Long.MAX_VALUE,TimeUnit.MICROSECONDS);//第二个任务执行者。submit(newRunnable(){@Overridepublicvoidrun(){System.out.println("运行即时任务:"+newDate());}});线程.睡眠(5000);执行器.shutdownNow();}}粘贴这段代码后,你会发现输出是这样的:只执行了第一个任务,第二个任务没有任何输出。一般情况下,第一个任务的延迟时间,即initialDelay参数为0,所以在第一次执行时立即执行:比如我改成这样,改周期执行的时间单位从微秒到纳秒,这很正常:这是奇迹吗?你说这不是BUG这是什么?提出bug的哥们在描述中介绍了bug的原因,主要提到了一个字段和两个方法:一个字段是指period,两个方法是TimeUnit.toNanos(-delay)和ScheduledFutureTask.setNextRunTime()。首先,ScheduledThreadPoolExecutor中的period字段有3个取值范围:正数,代表以固定速率(scheduleAtFixedRate)执行。负数表示按照固定延迟(scheduleWithFixedDelay)执行。0,代表非重复性任务。比如我们的示例代码中调用了scheduleWithFixedDelay方法,调用TimeUnit.toNanos方法时会取反,使得period字段为负数:OK,现在开始调试我们的demo,我们先来一个正常的。例如,让我们以每30毫秒执行一次的周期性任务为例。请仔细看:在执行TimeUnit.toNanos(-delay)这行代码时,将30微秒转换为-30000纳秒,即周期设置为-30000。然后我们来到setNextRunTime方法,在计算下一次任务触发时间的时候,我们又把period改成了正数,也没有什么不妥:但是,当我们把30改成Long.MAX_VALUE的时候,就有点意思了happened:delay=9223372036854775807-delay=-9223372036854775807unit.toNanos(-delay)=-9223372036854775808直接溢出,变成了Long.MIN_VALUE:当你来到setNextRunTime方法的时候,你会发现因为我们的p已经是Long.MIN_VAL了。那么什么是-p?给你运行一下:Long.MIN_VALUE的绝对值还是Long.MIN_VALUE。给你一个神奇的小知识点,不客气。所以-p还是Long.MIN_VALUE:算一算,1秒等于10亿纳秒:那么下一次触发时间就变成了这样:292年前。这在BUG描述中提到:这导致triggerTime返回一个遥远过去的时间。遥远的过去是很久很久以前,也就是292年前。那是1731年,雍正九年。那个时候,皇上还是大boss胤禛突围而出,在九子期间肆意杀戮。确实是很久很久以前的事了。那么如何修复这个错误呢?其实很简单:把unit.toNanos(-delay)改成-unit.toNanos(delay),就大功告成了。我帮你查一下:这样就不会溢出了,时间就变成了292年后。那么问题来了,谁会设置一个292年执行一次的Java定时任务呢?好了,看到这里,本文到此结束,我问你一个问题:知道这两个BUG后,你有收获吗?不,是的,除了浪费几分钟的时间之外,没有任何收获。那么恭喜你,又从我这里学到了两个没用的知识点。摘要为什么将此部分称为摘要?因为发现这里出现了一堆BUG,除了本文提到的2个之外,我又写了3个,所以这里总结一下,填词补空:8054446:RepeatedofferandremoveonConcurrentLinkedQueue导致OutOfMemoryError《我的程序跑了60多小时,就是为了让你看一眼JDK的BUG导致的内存泄漏。》这篇文章开头就是ConcurrentLinkedQueue队列的一个BUG。jetty框架中的线程池使用了这个队列,导致内存泄漏。同时通过jconsole、VisualVM、jmc这三个可视化监控工具可以看到“内存泄漏”的发生。8062841:ConcurrentHashMap.computeIfAbsentstuckinanendlessloop《震惊!ConcurrentHashMap里面也有死循环,作者留下的“彩蛋”了解一下?》这个bug在Dubbo和Seata中都有提到,在Seata官方博客中也有引用:https://seata.io/zh-cn/blog/s。..8073704:FutureTask.isDone在task还没有完成时返回true《Doug Lea在J.U.C包里面写的BUG又被网友发现了。》这个bug在JDK9版本中也被修复了,逻辑一波三折,但是理解之后,FutureTask的状态流很容易就可以有一个更深刻的理解。如果你有兴趣,你可以看看。