慧销平台ThreadPoolExecutor内存泄漏分析
字数 1469 2025-08-11 08:35:42

ThreadPoolExecutor内存泄漏分析与解决方案

问题背景

京东生旅平台慧销系统作为平台系统对接了多条业务线,主要进行各个业务线广告、召回等活动相关内容与能力管理。系统出现以下异常现象:

  • 内存持续升高,每隔2-3天会收到内存超过阈值告警
  • 容器内存在24小时内持续上升
  • 线程数监控显示当前线程数7427个且不断上升

问题排查步骤

1. JVM内存分析

通过监控YoungGC和FullGC情况发现:

  • 存在YoungGC但没有出现FullGC
  • 可能原因:
    • 对象进入老年代但未达到FullGC阈值,无法回收
    • 存在内存泄漏,YoungGC无法回收部分内存

2. 线程分析

通过JStack获取线程堆栈文件分析:

  • 发现通过线程池创建的线程数达7000+
  • 判断内存泄漏与线程池不当使用有关

问题代码分析

有问题的线程池实现

public class BackgroundWorker {
    private static ThreadPoolExecutor threadPoolExecutor;
    
    static {
        init(15);
    }
    
    public static void init() {
        init(15);
    }
    
    public static void init(int poolSize) {
        threadPoolExecutor = new ThreadPoolExecutor(3, poolSize, 1000, 
            TimeUnit.MINUTES, 
            new LinkedBlockingDeque<>(1000), 
            new ThreadPoolExecutor.CallerRunsPolicy());
    }
    
    public static void shutdown() {
        if (threadPoolExecutor != null && !threadPoolExecutor.isShutdown()) {
            threadPoolExecutor.shutdownNow();
        }
    }
    
    public static void submit(final Runnable task) {
        if (task == null) {
            return;
        }
        threadPoolExecutor.execute(task);
    }
}

错误使用方式

public class AdActivitySyncJob {
    @Scheduled(cron = "0 0/5 * * * ?")
    public void execute() {
        // ...
        BackgroundWorker.init(40); // 每次执行都重新初始化线程池
        locationCodes.forEach(locationCode -> {
            showChannelMap.forEach((key,value)->{
                BackgroundWorker.submit(new Runnable() {
                    @Override
                    public void run() {
                        // 业务逻辑
                    }
                });
            });
        });
    }
    
    @PostConstruct
    public void init() {
        execute();
    }
}

原因分析

  1. 线程池重复创建:每次执行AdActivitySyncJob都会调用BackgroundWorker.init(40),创建新的线程池
  2. 线程池未关闭:局部创建的线程池没有被关闭,导致内存中的线程池越来越多
  3. 线程无法回收ThreadPoolExecutor在使用完成后如果不手动关闭,无法被GC回收

验证测试

public class Test {
    private static ThreadPoolExecutor threadPoolExecutor;
    
    public static void main(String[] args) {
        for (int i=1;i<100;i++){
            // 每次均初始化线程池
            threadPoolExecutor = new ThreadPoolExecutor(3, 15, 1000, 
                TimeUnit.MINUTES, 
                new LinkedBlockingDeque<>(1000), 
                new ThreadPoolExecutor.CallerRunsPolicy());
            
            // 使用线程池执行任务
            for(int j=0;j<10;j++){
                submit(new Runnable() {
                    @Override
                    public void run() {
                        // 任务逻辑
                    }
                });
            }
            
            // 获取当前所有线程
            ThreadGroup group = Thread.currentThread().getThreadGroup();
            ThreadGroup topGroup = group;
            while (group != null) {
                topGroup = group;
                group = group.getParent();
            }
            int slackSize = topGroup.activeCount() * 2;
            Thread[] slackThreads = new Thread[slackSize];
            int actualSize = topGroup.enumerate(slackThreads);
            Thread[] atualThreads = new Thread[actualSize];
            System.arraycopy(slackThreads, 0, atualThreads, 0, actualSize);
            System.out.println("Threads size is " + atualThreads.length);
        }
    }
}

输出结果

Threads size is 302
Thread name : Reference Handler
Thread name : Finalizer
Thread name : Signal Dispatcher
Thread name : main
Thread name : Monitor Ctrl-Break
Thread name : pool-1-thread-1
Thread name : pool-1-thread-2
Thread name : pool-1-thread-3
Thread name : pool-2-thread-1
Thread name : pool-2-thread-2
Thread name : pool-2-thread-3
...

解决方案

方案1:只初始化一次,复用线程池

// 初始化一次线程池
threadPoolExecutor = new ThreadPoolExecutor(3, 15, 1000, 
    TimeUnit.MINUTES, 
    new LinkedBlockingDeque<>(1000), 
    new ThreadPoolExecutor.CallerRunsPolicy());

for (int i=1;i<100;i++){
    // 使用线程池执行任务
    for(int j=0;j<10;j++){
        submit(new Runnable() {
            @Override
            public void run() {
                // 任务逻辑
            }
        });
    }
}

输出结果

Threads size is 8
Thread name : Reference Handler
Thread name : Finalizer
Thread name : Signal Dispatcher
Thread name : main
Thread name : Monitor Ctrl-Break
Thread name : pool-1-thread-1
Thread name : pool-1-thread-2
Thread name : pool-1-thread-3

方案2:每次执行完成后关闭线程池

对于需要频繁创建线程池的场景,应在使用后调用shutdown()方法

最终采用方案

由于BackgroundWorker定位是后台执行worker,应采用方案1,在static静态代码块中初始化,使用时无需重新初始化。

解决效果

  • JVM内存监控显示内存不再持续上升
  • 线程池数量恢复正常且平稳
  • Jstack分析显示线程池数量恢复正常
  • Dump文件分析线程池对象数量正常

技术原理深入

为什么ThreadPoolExecutor不会被GC回收

  1. 线程池工作原理

    • execute()方法中,如果当前线程数小于核心线程数,会进入addWorker方法创建线程
    • runWorker方法中,如果存在任务则执行,否则调用getTask()获取任务
    • workQueue.take()会一直阻塞,等待队列中的任务
  2. 引用链分析

    • Thread线程一直没有结束
    • 存在引用关系:ThreadPoolExecutor -> Worker -> Thread
    • 由于存在GC ROOT的引用,所以无法被回收

线程池关闭方法

  1. shutdownNow()

    • 线程池拒接收新提交的任务
    • 立即关闭线程池,线程池里的任务不再执行
  2. shutdown()

    • 线程池拒接收新提交的任务
    • 等待线程池里的任务执行完毕后关闭线程池

最佳实践建议

  1. 线程池使用原则

    • 尽量复用线程池,避免频繁创建
    • 对于长期存在的服务,使用单例线程池
    • 对于短期任务,使用后必须关闭线程池
  2. 线程池配置建议

    • 合理设置核心线程数、最大线程数
    • 根据任务特性选择合适的阻塞队列
    • 设置合理的线程存活时间
    • 定义恰当的拒绝策略
  3. 监控建议

    • 定期监控线程池状态
    • 监控线程数量变化
    • 监控任务队列长度
    • 设置合理的告警阈值

总结

本案例展示了由于线程池不当使用导致的内存泄漏问题,通过分析我们可以得出以下关键点:

  1. 线程池应当作为长期资源管理,避免重复创建
  2. 必须正确处理线程池的生命周期,适时关闭
  3. 理解线程池内部工作原理有助于排查相关问题
  4. 合理的监控体系能及早发现问题
  5. 遵循线程池使用最佳实践可避免类似问题
ThreadPoolExecutor内存泄漏分析与解决方案 问题背景 京东生旅平台慧销系统作为平台系统对接了多条业务线,主要进行各个业务线广告、召回等活动相关内容与能力管理。系统出现以下异常现象: 内存持续升高,每隔2-3天会收到内存超过阈值告警 容器内存在24小时内持续上升 线程数监控显示当前线程数7427个且不断上升 问题排查步骤 1. JVM内存分析 通过监控YoungGC和FullGC情况发现: 存在YoungGC但没有出现FullGC 可能原因: 对象进入老年代但未达到FullGC阈值,无法回收 存在内存泄漏,YoungGC无法回收部分内存 2. 线程分析 通过JStack获取线程堆栈文件分析: 发现通过线程池创建的线程数达7000+ 判断内存泄漏与线程池不当使用有关 问题代码分析 有问题的线程池实现 错误使用方式 原因分析 线程池重复创建 :每次执行 AdActivitySyncJob 都会调用 BackgroundWorker.init(40) ,创建新的线程池 线程池未关闭 :局部创建的线程池没有被关闭,导致内存中的线程池越来越多 线程无法回收 : ThreadPoolExecutor 在使用完成后如果不手动关闭,无法被GC回收 验证测试 输出结果 : 解决方案 方案1:只初始化一次,复用线程池 输出结果 : 方案2:每次执行完成后关闭线程池 对于需要频繁创建线程池的场景,应在使用后调用 shutdown() 方法 最终采用方案 由于 BackgroundWorker 定位是后台执行worker,应采用 方案1 ,在static静态代码块中初始化,使用时无需重新初始化。 解决效果 JVM内存监控显示内存不再持续上升 线程池数量恢复正常且平稳 Jstack分析显示线程池数量恢复正常 Dump文件分析线程池对象数量正常 技术原理深入 为什么ThreadPoolExecutor不会被GC回收 线程池工作原理 : execute() 方法中,如果当前线程数小于核心线程数,会进入 addWorker 方法创建线程 runWorker 方法中,如果存在任务则执行,否则调用 getTask() 获取任务 workQueue.take() 会一直阻塞,等待队列中的任务 引用链分析 : Thread线程一直没有结束 存在引用关系: ThreadPoolExecutor -> Worker -> Thread 由于存在GC ROOT的引用,所以无法被回收 线程池关闭方法 shutdownNow() : 线程池拒接收新提交的任务 立即关闭线程池,线程池里的任务不再执行 shutdown() : 线程池拒接收新提交的任务 等待线程池里的任务执行完毕后关闭线程池 最佳实践建议 线程池使用原则 : 尽量复用线程池,避免频繁创建 对于长期存在的服务,使用单例线程池 对于短期任务,使用后必须关闭线程池 线程池配置建议 : 合理设置核心线程数、最大线程数 根据任务特性选择合适的阻塞队列 设置合理的线程存活时间 定义恰当的拒绝策略 监控建议 : 定期监控线程池状态 监控线程数量变化 监控任务队列长度 设置合理的告警阈值 总结 本案例展示了由于线程池不当使用导致的内存泄漏问题,通过分析我们可以得出以下关键点: 线程池应当作为长期资源管理,避免重复创建 必须正确处理线程池的生命周期,适时关闭 理解线程池内部工作原理有助于排查相关问题 合理的监控体系能及早发现问题 遵循线程池使用最佳实践可避免类似问题