🔥线程池用完不Shutdown,CPU和内存都快哭了

2 阅读11分钟

前言🚨

        大家平常使用SpringBoot进行Web项目开发,线程池会被配置成为全局可复用的工具,生命周期随服务启动开始,到服务停止即结束。这种"线程池托管"模式,让多少兄弟产生美丽的错觉💔:"原来线程池会自己管理自己啊!"

        但是,当需求经理对你露出神秘的微笑:"这个批量导出需求,今晚就要...",你不得不撸起袖子,写下了罪恶的代码创建临时线程池,这时兄弟们,如果对线程池使用不当,很容易给服务埋下隐患💣。

1 问题初现

1.1 示例代码

public void test() {
    ExecutorService threadPoolExecutor = 
        threadPoolFactory.newThreadPool("test11-no-shutdown-pool",
        100, 100, 60, TimeUnit.SECONDS,
        new LinkedBlockingDeque(1),
        new ThreadPoolExecutor.CallerRunsPolicy());
    
    //假装这里有10w个任务要执行
    for (int i = 0; i < 100000; i++) {
        CompletableFuture.runAsync(() -> {
            //假装这里有任务在执行
        }, threadPoolExecutor);
    }
}

1.2 问题描述

面试官:不考虑任务内部的复杂度,这个线程池的使用会有问题吗?

菜鸟:方法执行完弹栈后,局部变量,都会被GC回收,谁写的代码,稳得很啊!

老鸟:(一口咖啡喷屏幕上)老弟,你线程池用完不用shutdown呀?

===========

再问:反正任务执行完,内存都会被GC回收,非得Shutdown一下不多余吗?

菜鸟:......

老鸟:(邪魅一笑)倒也不是必须Shutdown,但是不建议犯险尝试,请看VCR演示(代码演示)

2 走进科学实验现场(验证)

说明:本文讨论的线程池对象及相关源码,都围绕常用的java.util.concurrent.ThreadPoolExecutor类展开,下文不再额外说明

2.2 装备说明(代码)

        下面将出现大量"案发现场"监控截图以及源码解析,可能引起轻微不适。😉

说明:以下代码都是在一个类中,queue、phantomRef这两个对象是作为全局对象,专门捕捉那个"肉身已死但阴魂不散"的线程池对象。

2.2.1 幽灵探测仪(判断线程池对象是否被回收)

        因为验证的代码是在一个成熟的SpringBoot项目中跑的,线程池对象太多了,从内存分析工具上监测这单个线程池对象是否被GC回收不够直观,这里借助虚引用来判断线程池对象是否被回收。

ReferenceQueue<ExecutorService> queue;
PhantomReference<ExecutorService> phantomRef = null;

/**
 * 使用虚引用,捕捉阴魂不散的线程池对象
 */
@PostMapping(value = "/thread/pool/judgeGc")
public String judgeGc() {
    Reference<? extends ExecutorService> poll = queue.poll();
    return "虚引用队列是否为空: "+ Objects.isNull(poll);
}

2.2.2 实验1:core 0,max 100 任务完成后,不进行shutdown

/**
 * 测试core:0,max:100 可变线程数量线程池
 * 任务执行完后,不进行shutdown
 */
@PostMapping(value = "/thread/pool/test11")
public void test11() {
    ExecutorService threadPoolExecutor = 
        threadPoolFactory.newThreadPool("test11-no-shutdown-pool",
        0, 100, 60, TimeUnit.SECONDS,
        new LinkedBlockingDeque(1),
        new ThreadPoolExecutor.CallerRunsPolicy());
    
    queue = new ReferenceQueue<>();
    phantomRef = new PhantomReference<>(threadPoolExecutor, queue);
    
    //模拟任务执行
    this.testExecute(threadPoolExecutor,"test11");
}

2.2.3 实验2:core 0,max 100 任务完成后,进行shutdown

/**
 * 测试core:0,max:100 ,可变线程数量线程池
 * 任务执行完后,进行shutdown
 */
@PostMapping(value = "/thread/pool/test12")
public void test12() {
    ExecutorService threadPoolExecutor = 
        threadPoolFactory.newThreadPool("test12-shutdown-pool",
        0, 100, 60, TimeUnit.SECONDS,
        new LinkedBlockingDeque(1),
        new ThreadPoolExecutor.CallerRunsPolicy());
    
    queue = new ReferenceQueue<>();
    phantomRef = new PhantomReference<>(threadPoolExecutor, queue);
    
    //模拟任务执行
    this.testExecute(threadPoolExecutor,"test12");
    threadPoolExecutor.shutdown();
}

2.2.4 实验3:core 100,max 100 任务完成后,不进行shutdown

/**
 * 测试core:100,max:100 ,固定线程数量线程池
 * 任务执行完后,不进行shutdown
 */
@PostMapping(value = "/thread/pool/test21")
public void test21() {
    ExecutorService threadPoolExecutor = 
        threadPoolFactory.newThreadPool("test21-no-shutdown-pool",
        100, 100, 60, TimeUnit.SECONDS,
        new LinkedBlockingDeque(1),
        new ThreadPoolExecutor.CallerRunsPolicy());
    
    queue = new ReferenceQueue<>();
    phantomRef = new PhantomReference<>(threadPoolExecutor, queue);
    
    //模拟任务执行
    this.testExecute(threadPoolExecutor,"test21");
}

2.2.5 实验4:core 100,max 100 任务完成后,进行shutdown

/**
 * 测试core:100,max:100 ,固定线程数量线程池
 * 任务执行完后,进行shutdown
 */
@PostMapping(value = "/thread/pool/test22")
public void test22() {
    ExecutorService threadPoolExecutor = 
        threadPoolFactory.newThreadPool("test22-shutdown-pool",
        100, 100, 60, TimeUnit.SECONDS,
        new LinkedBlockingDeque(1),
        new ThreadPoolExecutor.CallerRunsPolicy());
    
    queue = new ReferenceQueue<>();
    phantomRef = new PhantomReference<>(threadPoolExecutor, queue);
    
    //模拟任务执行
    this.testExecute(threadPoolExecutor,"test22");
    threadPoolExecutor.shutdown();
}

2.2.6 任务执行代码示例

解释说明: 任务数量是110,主要是为了保证有足够多的任务让线程池所有线程能够打满;任务里面要sleep 500ms,也是同样的道理

//模拟任务执行
private void testExecute(ExecutorService threadPool,String testName) {
    System.out.println(testName+" start=======================================");
    for (int i = 0; i < 110; i++) {
        CompletableFuture.runAsync(() -> {
            System.out.println(Thread.currentThread().getName()+" exe=======================================");
            try {
                Thread.sleep(500);
            } catch (InterruptedException e) {
                throw new RuntimeException(e);
            }
        }, threadPool);
    }
    System.out.println(testName+" end=======================================");
}

2.3 实验执行结果

2.3.1 实验执行步骤

        项目启动后,依次执行上述4个实验。需要注意的是时间间隔要保证前一个实验的任务全部执行完,最好再留一些空挡,这样监控更清晰

        每个实验执行完后,分别执行一次2.2.1 判断线程池对象是否回收

2.3.2 结果分析

JVM初始状态

1. 实验1执行监控

core 0,max 100 任务完成后,不进行shutdown

image.png

2. 实验2执行监控

core 0,max 100 任务完成后,进行shutdown

image.png

3. 实验3执行监控

core 100,max 100 任务完成后,不进行shutdown

image.png

image.png

4. 实验4执行监控

core 100,max 100 任务完成后,进行shutdown

image.png

3 原因解剖室

接下来都是对实验3的异常现象进行的分析

3.1 shutdown的五步拆解法

        首先看下ThreadPoolExecutor#shutdown方法

        截图中,可以看到shutdown方法里面,主要做了5个动作:

  1. 根据方法名称可以看出是一个检查动作,这里不用细看
  2. 把线程池状态置为SHUTDOWN状态 重要
  3. 根据方法名称可以看出是将线程池中的空闲线程进行中断 重要
  4. 根据注释来看,是给特定场景对象使用,这里不用细看
  5. 尝试终止线程池(中断线程、关闭线程池)

        每一步的细节处理,这里就不带大家看了,有兴趣可以点开源码一步步研究下。总结下来就是,线程池执行shutdown方法后:

  1. 线程池对象置为SHUTDOWN状态——挂上"暂停营业"牌子
  2. 将线程池中空闲线程置为中断状态,最终从线程池中剔除(会被GC回收)——给闲逛的线程发《解聘通知书》
  3. 线程池中的线程对象会置为中断状态,最终terminated

3.2 为什么线程池不进行shutdown,在方法弹栈后不会立即被GC回收?

首先我们梳理一下当前执行实验的线程方法栈、线程池、线程池中的线程之间的引用关系图如下:

        实验3中,当主方法执行完弹栈后,短时间内,线程池中的线程对象仍处于空闲/活跃状态。但是线程池对象已经不被主线程对象中的方法栈持有,也就是图中关系1断开,按照JVM的垃圾回收机制,这时,ThreadPoolExecutor对象已经不被GCRoot引用,是要被GC回收的,但是从2.3.2中的实验3执行监控来看,ThreadPoolExecutor对象并没有被GC回收。

疑问:难道还有什么对象持有这个线程池对象的引用?

        首先,由于JVM的运行机制,每一个java线程都关联一个OS线程,线程对象在terminated之前(线程任务执行完之前),都不会被GC回收。

        上面的引用关系实际上应该是下面这样的,线程池中的每个线程对象都有自己的执行方法栈对象

image.png

        根据2.3.2中的几个执行结果监控,就能看出,线程池对象的回收与线程池中的工作线程是否被全部回收是有关系的,所以先预测线程池中的线程对象是持有线程池对象的引用的,然后基于这个预测,去源码中找理论支撑。

预测存在黄色箭头依赖关系,如下图:

3.3 线程对象为何会持有线程池对象的引用?

        其实,上面的引用关系图中,所有的正向依赖关系我们不难理解。需要验证的是反向的依赖关系r 2.x和r 3.x,这些反向的依赖关系都是什么时候建立的?可以从下面几个问题入手去排查:

  1. 线程池(ThreadPoolExecutor)类结构中是否有相关对象的属性字段?
  2. 线程池对象中Worker集合中Worker对象创建时机?
  3. Worker对象创建时是如何建立相应的依赖关系的?

        从线程池提交任务开始,从源码中可以看到worker类结构中本身定义有Thread变量属性,在Worker对象创建时,就为Thread属性显式赋值:

Worker类定义如下:

Worker对象创建时机如下:

        从CompletableFuture工具任务执行方法中一步步进入源码,可以看到如下关系

        上面两段源码截图中,验证了依赖关系图中2.x,3.xr 3.x的依赖关系,还剩下r 2.x依赖关系没有得到验证。

        首先,从Worker类结构上,没有找到Worker类中有定义对ThreadPoolExecutor类的显式引用,并且从2.3.2的实验执行结果图中可以看到,即使多次触发GC,依旧没有将ThreadPoolExecutor对象回收掉,所以,Worker->ThreadPoolExecutor肯定是一种强引用(4种引用关系:强引用、软引用、弱引用、虚引用)。

        那么,哪些行为会让对象之间建立强引用关系呢?我们先问下AI助手,让它罗列出会建立引用关系的代码行为。总结归纳如下:

  1. 对象中的属性字段显式赋值引用
  2. 数组、集合对象中添加其他对象的引用
  3. 子类通过面向对象的继承多态特性引用父类中的属性字段
  4. 还有一种平常关注较少的,相对隐式的引用关系——内部类对象引用外部类对象

        从Worker的类结构来看,是没有显示的对ThreadPoolExecutor的属性引用的,也没有相关的数据、集合,所以1,2不成立。Worker对象与ThreadPoolExecutor也没有直接或者间接的继承/实现关系,所以3也不成立。

        最后再看Worker类定义,确实是在ThreadPoolExecutor类的内部(内部类对象持有外部类对象的验证很简单,不在这里赘述)。这样Worker->ThreadPoolExecutor的引用关系就能说得通了。至此,引用依赖关系图中预测的r 2.x和r 3.x关系都成立了。

3.4 原因逻辑梳理

        再次把依赖关系图贴出来,一起再梳理一下

  1. 当前方法main-Thread执行完,main-Thread的方法栈都弹栈
  2. 依赖关系1断开,线程池不再被活跃线程引用,不会再有任务进来
  3. 当线程池任务都完成,此时线程池中的线程都处于休眠(wait)状态,等待任务队列中任务进来
  4. 由于线程池中的线程都处于存活状态,不会被回收
  5. 存在线程池线程_n -> worker_n -> 线程池的逆向引用关系
  6. 所以导致实验3中的现象——线程池和线程池中线程无法被GC回收

4 线程池未shutdown的影响

        这里再把实验3会导致系统的出现的问题总结一下。通过2.3.2中的实验3监控图以及源码,我们可以看到,线程池使用完,未及时进行shutdown,就最差的情况来说,会导致的问题:

1. 线程池对象无法被GC回收——内存泄露💣

2. 线程对象无法被GC回收——内存泄漏💣

3. 从线程获取任务的源码来看,即使任务队列中是空的,只要线程池状态仍处于Running,线程会定时从wait状态苏醒,重新获取任务——占用CPU执行分片🤑

血泪教训总结

  1. 线程池用完一定要养成shutdown的习惯:因为有的没关好,真的会漏水(内存泄露)
  2. 线程池创建线程命名,一定要有业务相关标识:建议采用"业务场景+线程计数"法:"订单导出-xx"、"邮件发送-xx",出问题时秒锁嫌疑人:"就是订单导出线程在摸鱼!"
  3. 内部类使用要谨慎:谈恋爱可以,玩失踪不行,别让人家(GC)找不到你!

🥚文末彩蛋

        以后,你在代码里看到随手创建的线程池,最终没有.shutdown(),请像看到有人上厕所不冲水一样,大喊:"同!学!你!线程池!忘!关!了!"

【🌟悄悄告诉你】
扫码关注,解锁“不写bug”魔法咒语

6ac75c40b91f29d5e0fad2ce2d517c33.jpg