找回密码
立即注册
搜索
热搜: Java Python Linux Go
发回帖 发新帖

1552

积分

0

好友

223

主题
发表于 11 小时前 | 查看: 2| 回复: 0

分享一个由线上监控发现的线程池使用问题,排查过程涉及线程池工作原理、垃圾回收机制,最终定位到因未正确关闭线程池导致的内存泄漏隐患。

在一次日常的SkyWalking监控巡检中,发现某个应用内的线程数异常增高,超过900条,接近1000条。但应用的CPU和内存使用率均处于正常水平。这种“线程数高但资源消耗低”的现象,通常意味着大量线程处于空闲等待状态,是不健康的应用表现。

立即通过jstack命令获取了线程转储文件进行分析。首先观察线程分组概览:

线程分组概览

从分组信息可见,以“pool”开头的线程组占用了616条线程,且全部处于WAITING状态。这强烈暗示了问题与某个线程池有关。接下来,需要排查为何该线程池会创建并保留如此多处于等待状态且无法释放的线程。

查看其中几条线程的具体堆栈信息:

线程堆栈信息

堆栈显示线程正在线程池中循环尝试获取任务(getTask),因队列为空而进入等待状态。这符合线程池空闲线程的行为。但关键在于,为何会有如此多同名线程池的线程?一个合理的猜测是:代码中在不断地创建同名线程池,且这些线程池实例都未被垃圾回收。

由此,需要分析两个核心问题:

  1. 代码中对应的是哪个线程池?
  2. 它是如何被创建的?为何无法释放?

首先尝试在IDE中全局搜索 new ThreadPoolExecutor(),但结果寥寥,未发现大量创建的痕迹。

搜索ThreadPoolExecutor结果

正在困惑时,通过同事提示意识到另一种创建方式:使用Executors工具类。转而搜索 newFixedThreadPool,果然找到了问题根源——正是通过Executors.newFixedThreadPool()创建的线程池。这也解释了为何线程名称是默认的“pool”前缀(这再次提醒我们,建议直接使用ThreadPoolExecutor构造函数,以便自定义线程工厂和更清晰的命名)。

定位到相关代码后,发现该接口是两年前由我本人编写,用于批量统计用户钱包的月度流水。当时为了提升处理速度,在方法内部创建了一个线程池进行批处理,但未正确管理其生命周期,埋下了隐患。

剔除业务逻辑后,还原的问题代码如下:

private static void threadDontGcDemo(){
    ExecutorService executorService = Executors.newFixedThreadPool(10);
    executorService.submit(() -> {
        System.out.println("111");
    });
}

线程池与线程为何无法释放?

最直接的疑问是:是否因为没有调用 shutdown() 方法?编写时可能认为方法执行结束后,局部变量 executorService 会随栈帧出栈而销毁,线程池应被自动回收。

为了验证,编写一个简单的Demo,循环创建线程池但不调用shutdown

循环创建线程池代码

通过 jvisualvm 工具观察线程数变化:

jvisualvm监控线程增长

可以看到线程数和线程池对象数持续增加,且未被回收,完美复现了线上问题。

那么,如果在方法结束前调用 shutdown() 方法呢?

调用shutdown的Demo代码

再次通过 jvisualvm 观察:

jvisualvm监控线程被回收

结果显示,调用了 shutdown 的线程池,其线程和线程池对象最终都被成功回收了。

对象能否被垃圾回收,取决于从GC Roots出发是否存在可达路径。线程池未能被回收,说明存在一条从GC Root到线程池的引用链。这里涉及一个关键知识:线程对象本身可以作为GC Root。具体的引用链是:Thread -> Worker(内部类) -> ThreadPoolExecutor

因此,线程池对象能否被回收,依赖于其内部的Worker线程对象能否先被回收。那么,线程对象在何时会被回收?

一个基本共识是:正在运行中的线程(JVM视其为GC Root)不会被回收。这里的“运行中”不仅指RUNNABLE状态,通常也包括WAITINGTIMED_WAITING等状态。换句话说,一个已经终止(TERMINATED)的线程才不再是GC Root,从而可以被回收。

现在问题清晰了:关键在于如何让线程池中的工作线程结束运行。shutdown()方法正是触发这一过程的关键。它如何做到的呢?让我们深入源码。

源码解析:shutdown() 如何触发回收

首先查看 ThreadPoolExecutor.shutdown() 方法:

public void shutdown() {
    final ReentrantLock mainLock = this.mainLock;
    mainLock.lock();
    try {
        checkShutdownAccess();
        advanceRunState(SHUTDOWN);
        interruptIdleWorkers(); // 关键步骤:中断空闲Worker
        onShutdown(); // 钩子方法,用于子类如ScheduledThreadPoolExecutor
    } finally {
        mainLock.unlock();
    }
    tryTerminate();
}

关键在 interruptIdleWorkers() 方法,它遍历所有Worker并调用其线程的 interrupt() 方法。

private void interruptIdleWorkers(boolean onlyOne) {
    final ReentrantLock mainLock = this.mainLock;
    mainLock.lock();
    try {
        for (Worker w : workers) {
            Thread t = w.thread;
            if (!t.isInterrupted() && w.tryLock()) {
                try {
                    t.interrupt(); // 发送中断信号
                } catch (SecurityException ignore) {
                } finally {
                    w.unlock();
                }
            }
            if (onlyOne)
                break;
        }
    } finally {
        mainLock.unlock();
    }
}

那么,Worker线程如何响应这个中断?需要查看 Worker.runWorker() 方法,这是线程池中工作线程的执行核心。

final void runWorker(Worker w) {
    Thread wt = Thread.currentThread();
    Runnable task = w.firstTask;
    w.firstTask = null;
    w.unlock(); // 允许中断
    boolean completedAbruptly = true;
    try {
        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); // Worker退出处理
    }
}

线程通过 getTask() 方法从工作队列中获取任务。当队列为空时,根据配置(核心线程是否允许超时),线程会调用 workQueue.take()workQueue.poll(keepAliveTime, ...) 进入等待状态(WAITINGTIMED_WAITING)。

重点在于:当一个处于等待状态的线程被中断 (interrupt()) 时,take()poll() 方法会抛出 InterruptedException

getTask() 方法中捕获到此异常后,会返回 nullrunWorker() 方法中收到 null 任务,便会跳出 while 循环,进入 finally 块中的 processWorkerExit(w, completedAbruptly) 方法。

processWorkerExit() 方法是资源清理的关键:

private void processWorkerExit(Worker w, boolean completedAbruptly) {
    if (completedAbruptly) // 如果因异常退出,调整worker计数
        decrementWorkerCount();
    final ReentrantLock mainLock = this.mainLock;
    mainLock.lock();
    try {
        completedTaskCount += w.completedTasks;
        workers.remove(w); // 关键:从workers集合中移除该Worker的引用
    } finally {
        mainLock.unlock();
    }
    tryTerminate();
    // ... 后续可能尝试添加新Worker的逻辑(略)
}

workers.remove(w) 这一行至关重要。它将Worker对象从线程池持有的集合中移除,切断了线程池到该Worker的强引用

此时,Worker对象失去了来自GC Root(线程池的workers集合)的可达路径,变成了可回收对象。当Worker被回收后,其内部持有的Thread线程对象也结束了生命周期(不再是RUNNABLE状态),从而也不再是GC Root。

最终,当所有Worker都被回收,且持有该线程池引用的外部对象(如上例中的方法局部变量)也失效后,ThreadPoolExecutor 线程池对象本身也就变成了垃圾,等待被GC回收。

总结与最佳实践

  1. 根本原因:在局部方法中创建线程池(非Spring Bean管理),且未调用 shutdown()shutdownNow() 方法时,空闲的工作线程会持续等待,因其作为GC Root,会阻止对应的Worker对象及线程池对象被垃圾回收,从而导致线程和内存泄漏。
  2. 回收机制:调用 shutdown() 会中断空闲线程,使其抛出 InterruptedException 并退出运行循环。线程池随后将退出的Worker从内部集合移除,打破引用链,使Worker和线程池得以被GC回收。
  3. 最佳实践
    • 对于局部使用的线程池,务必使用 try-finallytry-with-resources(如果实现了AutoCloseable)来确保 shutdown() 被调用。
    • 推荐直接使用 new ThreadPoolExecutor(...) 构造函数创建线程池,而非 Executors 工厂方法,以便更好地控制参数(如线程命名、拒绝策略等),便于监控和排查问题。
    • 考虑使用已有的、生命周期由框架(如Spring)管理的线程池Bean,避免重复创建和销毁的开销与风险。

通过这个案例,我们不仅解决了一个具体的线上隐患,更深入理解了Java线程池的内部工作机制、线程与垃圾回收的关系,以及正确进行资源管理的重要性。




上一篇:RK3568开发板USB不识别排查指南:供电电路、信号完整性与设备树配置
下一篇:CORDIC算法解析:如何为ARM Cortex-M0+内核MCU实现高性能三角函数计算
您需要登录后才可以回帖 登录 | 立即注册

手机版|小黑屋|网站地图|云栈社区 ( 苏ICP备2022046150号-2 )

GMT+8, 2025-12-24 17:09 , Processed in 0.156569 second(s), 40 queries , Gzip On.

Powered by Discuz! X3.5

© 2025-2025 云栈社区.

快速回复 返回顶部 返回列表