慧销平台ThreadPoolExecutor内存泄漏分析

京东云开发者
• 阅读 351

作者:京东零售 冯晓涛

问题背景

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

最近根据告警发现内存持续升高,每隔2-3天会收到内存超过阈值告警,猜测可能存在内存泄漏的情况,然后进行排查。根据24小时时间段内存监控可以发现,容器的内存在持续上升:

慧销平台ThreadPoolExecutor内存泄漏分析

问题排查

初步估计内存泄漏,查看24小时时间段jvm内存监控,排查jvm内存回收情况:

慧销平台ThreadPoolExecutor内存泄漏分析

YoungGC和FullGC情况:

慧销平台ThreadPoolExecutor内存泄漏分析

通过jvm内存分析和YoungGC与FullGC执行情况,可以判断可能原因如下:

1、 存在YoungGC但是没有出现FullGC,可能是对象进入老年代但是没有到达FullGC阈值,所以没有触发FullGC,对象一直存在老年代无法回收

2、 存在内存泄漏,虽然执行了YoungGC,但是这部分内存无法被回收

通过线程数监控,观察当前线程情况,发现当前线程数7427个,并且还在不断上升,基本判断存在内存泄漏,并且和线程池的不当使用有关:

慧销平台ThreadPoolExecutor内存泄漏分析

通过JStack,获取线程堆栈文件并进行分析,排查为什么会有这么多线程:

慧销平台ThreadPoolExecutor内存泄漏分析

慧销平台ThreadPoolExecutor内存泄漏分析

发现通过线程池创建的线程数达7000+:

慧销平台ThreadPoolExecutor内存泄漏分析

代码分析

分析代码中ThreadPoolExecutor的使用场景,发现在一个worker公共类中定义了一个线程池,worker执行时会使用线程池进行异步执行。

 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(() -> {
             try {
                 task.run();
             } catch (Exception e) {
                 e.printStackTrace();
             }
         });
     }

 }

广告缓存刷新worker使用线程池的代码:

 public class AdActivitySyncJob {

    @Scheduled(cron = "0 0/5 * * * ?")
    public void execute() {
        log.info("AdActivitySyncJob start");
        List<DicDTO> locationList = locationService.selectLocation();
        if (CollectionUtils.isEmpty(locationList)) {
            return;
        }

        //中间省略部分无关代码

        BackgroundWorker.init(40);
        locationCodes.forEach(locationCode -> {
            showChannelMap.forEach((key,value)->{
                BackgroundWorker.submit(new Runnable() {
                    @Override
                    public void run() {
                        log.info("AdActivitySyncJob,locationCode:{},showChannel:{}",locationCode,value);
                        Result<AdActivityDTO> result = notLoginAdActivityOuterService.getAdActivityByLocationInner(locationCode, ImmutableMap.of("showChannel", value));
                        LocalCache.AD_ACTIVITY_CACHE.put(locationCode.concat("_").concat(value), result);
                    }
                });
            });
        });
        log.info("AdActivitySyncJob end");
    }

    @PostConstruct
    public void init() {
        execute();
    }
}

原因分析:猜测是worker每次执行,都会执行init方法,创建新的线程池,但是局部创建的线程池并没有被关闭,导致内存中的线程池越来越多,ThreadPoolExecutor在使用完成后,如果不手动关闭,无法被GC回收。

分析验证

验证局部线程池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];
        // 获取根线程组下的所有线程,返回的actualSize便是最终的线程数
        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);
        for (Thread thread : atualThreads) {
            System.out.println("Thread name : " + thread.getName());
        }
    }

    public static void submit(final Runnable task) {
        if (task == null) {
            return;
        }
        threadPoolExecutor.execute(() -> {
            try {
                task.run();
            } catch (Exception e) {
                e.printStackTrace();
            }
        });
    }
}

输出:

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

Thread name : pool-3-thread-1

Thread name : pool-3-thread-2

Thread name : pool-3-thread-3

Thread name : pool-4-thread-1

Thread name : pool-4-thread-2

Thread name : pool-4-thread-3

Thread name : pool-5-thread-1

Thread name : pool-5-thread-2

Thread name : pool-5-thread-3

Thread name : pool-6-thread-1

Thread name : pool-6-thread-2

Thread name : pool-6-thread-3

…………

执行结果分析,线程数量302个,局部线程池创建的核心线程没有被回收。

修改初始化线程池部分:

//初始化一次线程池
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

解决方案

1、只初始化一次,每次执行worker复用线程池

2、每次执行完成后,关闭线程池

BackgroundWorker的定位是后台执行worker均进行线程池的复用,所以采用方案1,每次在static静态代码块中初始化,使用时无需重新初始化。

解决后监控:

jvm内存监控,内存不再持续上升:

慧销平台ThreadPoolExecutor内存泄漏分析

线程池恢复正常且平稳:

慧销平台ThreadPoolExecutor内存泄漏分析

Jstack文件,观察线程池数量恢复正常:

慧销平台ThreadPoolExecutor内存泄漏分析

Dump文件分析线程池对象数量:

慧销平台ThreadPoolExecutor内存泄漏分析

拓展

1、 如何关闭线程池

线程池提供了两个关闭方法,shutdownNow 和 shutdown 方法。

shutdownNow方法的解释是:线程池拒接收新提交的任务,同时立马关闭线程池,线程池里的任务不再执行。

shutdown方法的解释是:线程池拒接收新提交的任务,同时等待线程池里的任务执行完毕后关闭线程池。

2、 为什么threadPoolExecutor不会被GC回收

threadPoolExecutor =
         new ThreadPoolExecutor(3, 15, 1000, TimeUnit.MINUTES, new LinkedBlockingDeque<>(1000), new ThreadPoolExecutor.CallerRunsPolicy());

局部使用后未手动关闭的线程池对象,会被GC回收吗?获取线上jump文件进行分析:

慧销平台ThreadPoolExecutor内存泄漏分析

发现线程池对象没有被回收,为什么不会被回收?查看ThreadPoolExecutor.execute()方法:

如果当前线程数小于核心线程数,就会进入addWorker方法创建线程:

慧销平台ThreadPoolExecutor内存泄漏分析

慧销平台ThreadPoolExecutor内存泄漏分析

分析runWorker方法,如果存在任务则执行,否则调用getTask()获取任务:

慧销平台ThreadPoolExecutor内存泄漏分析

慧销平台ThreadPoolExecutor内存泄漏分析

发现workQueue.take()会一直阻塞,等待队列中的任务,因为Thread线程一直没有结束, 存在引用关系:ThreadPoolExecutor->Worker->Thread,因为存在GC ROOT的引用,所以无法被回收 。

点赞
收藏
评论区
推荐文章
blmius blmius
3年前
MySQL:[Err] 1292 - Incorrect datetime value: ‘0000-00-00 00:00:00‘ for column ‘CREATE_TIME‘ at row 1
文章目录问题用navicat导入数据时,报错:原因这是因为当前的MySQL不支持datetime为0的情况。解决修改sql\mode:sql\mode:SQLMode定义了MySQL应支持的SQL语法、数据校验等,这样可以更容易地在不同的环境中使用MySQL。全局s
Wesley13 Wesley13
3年前
PPDB:今晚老齐直播
【今晚老齐直播】今晚(本周三晚)20:0021:00小白开始“用”飞桨(https://www.oschina.net/action/visit/ad?id1185)由PPDE(飞桨(https://www.oschina.net/action/visit/ad?id1185)开发者专家计划)成员老齐,为深度学习小白指点迷津。
Wesley13 Wesley13
3年前
VBox 启动虚拟机失败
在Vbox(5.0.8版本)启动Ubuntu的虚拟机时,遇到错误信息:NtCreateFile(\\Device\\VBoxDrvStub)failed:0xc000000034STATUS\_OBJECT\_NAME\_NOT\_FOUND(0retries) (rc101)Makesurethekern
Wesley13 Wesley13
3年前
FLV文件格式
1.        FLV文件对齐方式FLV文件以大端对齐方式存放多字节整型。如存放数字无符号16位的数字300(0x012C),那么在FLV文件中存放的顺序是:|0x01|0x2C|。如果是无符号32位数字300(0x0000012C),那么在FLV文件中的存放顺序是:|0x00|0x00|0x00|0x01|0x2C。2.  
Wesley13 Wesley13
3年前
mysql设置时区
mysql设置时区mysql\_query("SETtime\_zone'8:00'")ordie('时区设置失败,请联系管理员!');中国在东8区所以加8方法二:selectcount(user\_id)asdevice,CONVERT\_TZ(FROM\_UNIXTIME(reg\_time),'08:00','0
Wesley13 Wesley13
3年前
PHP创建多级树型结构
<!lang:php<?php$areaarray(array('id'1,'pid'0,'name''中国'),array('id'5,'pid'0,'name''美国'),array('id'2,'pid'1,'name''吉林'),array('id'4,'pid'2,'n
Easter79 Easter79
3年前
SpringBoot整合Redis乱码原因及解决方案
问题描述:springboot使用springdataredis存储数据时乱码rediskey/value出现\\xAC\\xED\\x00\\x05t\\x00\\x05问题分析:查看RedisTemplate类!(https://oscimg.oschina.net/oscnet/0a85565fa
Wesley13 Wesley13
3年前
Java日期时间API系列36
  十二时辰,古代劳动人民把一昼夜划分成十二个时段,每一个时段叫一个时辰。二十四小时和十二时辰对照表:时辰时间24时制子时深夜11:00凌晨01:0023:0001:00丑时上午01:00上午03:0001:0003:00寅时上午03:00上午0
Stella981 Stella981
3年前
Jenkins 插件开发之旅:两天内从 idea 到发布(上篇)
本文首发于:Jenkins中文社区(https://www.oschina.net/action/GoToLink?urlhttp%3A%2F%2Fjenkinszh.cn)!huashan(https://oscimg.oschina.net/oscnet/f499d5b4f76f20cf0bce2a00af236d10265.jpg)
Wesley13 Wesley13
3年前
MySQL部分从库上面因为大量的临时表tmp_table造成慢查询
背景描述Time:20190124T00:08:14.70572408:00User@Host:@Id:Schema:sentrymetaLast_errno:0Killed:0Query_time:0.315758Lock_