记一次 OOM:GC overhead limit exceeded排查

1.背景

  • 产品大大对程序员小亮抱怨某个计算收益类型的任务每次都要2+小时才能跑完,希望该任务能快速优化下。小亮嘴上嘟嚷道:可以做,要先提需求单(内心写照如下图)。
    记一次 OOM:GC overhead limit exceeded排查_第1张图片

2.分析

2.1 流程

  • 1.程序员小亮对着收益类型的任务一顿分析,心想这还不简单(单线程改成多线程不就完事了)。遇事不要慌,开局先来一副流程图:
    记一次 OOM:GC overhead limit exceeded排查_第2张图片

2.2 TTL多线程

- 多线程使用这还不简单,直接ThreadPoolExecutor走起,但是想到组内建议使用阿里的ThreadLocal Transmittable ThreadLocal(TTL,下同),TTL主要解决在使用线程池等会池化复用线程的执行组件情况下,提供ThreadLocal值的传递功能,解决异步执行时上下文传递的问题。
- 那么TTL优化了哪些地方呢?针对JDK的InheritableThreadLocal类可以完成父线程到子线程的值传递。但对于使用线程池等会池化复用线程的执行组件的情况,线程由线程池创建好,并且线程是池化起来反复使用的;这时父子线程关系的ThreadLocal值传递已经没有意义,应用需要的实际上是把==任务提交给线程池时的ThreadLocal值传递到 任务执行时==(请注意高亮这段话,这也是本次OOM的根源所在,后面详解)。

2.3 上代码

  • 1.初始化线程池
private ExecutorService executor;

@PostConstruct
private void initExecutor() {
    executor = TtlExecutors.getTtlExecutorService(
            new ThreadPoolExecutor(6, 12, 5, TimeUnit.MINUTES, newLinkedBlockingQueue<>(),
                                   new ThreadFactoryBuilder()
                                          .setNameFormat("xxx-Thread-Pool-%d").uild(),
                                   newThreadPoolExecutor.CallerRunsPolicy());
}
  • 2.多线程执行任务,考虑到我们需求是汇总所有计算结果,再做其他事。所以我们主线程需要等待多线程计算完后,然后执行其他结果(针对多线程同步,我们可以使用CountDownLatch、Barrier、(Future配合sumbit)等方式来完成同步操作(具体使用方法可以自行百度哈),这里示例我们使用CountDownLatch)。
public void process(){
    // 其他代码
    
    CountDownLatch countDownLatch = new CountDownLatch(fileLineNum);
    while(lineIterator.hasNext()){
        String line=lineIterator.next();
        exector.submit(()->{
            // 具体执行计算逻辑
            calculateLine(line);
            countDownLatch.countDown();
        });
    }
    
    // 主线线程等待多线程执行完毕
    countDownLatch.await();
    // do other things
}
  • 3.OOM异常
  • 小亮开心的把多线程换上了,数据也飞快的跑起来了,多线程牛逼啊。

记一次 OOM:GC overhead limit exceeded排查_第3张图片

  • 可没过一会就处理速度就骤降了,也多了很多full gc的log

记一次 OOM:GC overhead limit exceeded排查_第4张图片

  • 啪,然后就OOM,超出了GC开销限制。(程序猿的快乐没了记一次 OOM:GC overhead limit exceeded排查_第5张图片)。
  • 4.保留现场
  • 小亮哪里见过这异常啊,赶紧咨询组里的大牛。大牛让小亮赶紧登陆机器看看原因(这里也推荐使用阿里自带得诊断工具Arthas):
    • 1.查看下java线程CPU和内存运行情况
    // 查看机器CPU运行情况,其中PID=52950为当前java运行线程PID
    $ top -n 5 
    
    记一次 OOM:GC overhead limit exceeded排查_第6张图片
    // 或查看java线程内存使用情况
    $ jstat -gcutil 52950 1000 ##其中 52950为PID,1000为刷新间隔(1s)
    
    记一次 OOM:GC overhead limit exceeded排查_第7张图片
    • 从上图可知,full gc的次数多达300+次,而且中的full gc时间也曾高达200+s,所以肯定是发生了内存泄漏,导致full gc超限,引发的OOM。
    • 2.dump日志(线上生产可千万不能直接jdump日志)。当然你也可以在jvm参数中配置了如下参数(或线上找PE帮忙dump下),来自动保存OOM的日志:
    -XX:+PrintGCDetails
    -XX:+PrintGCTimeStamps 
    -Xloggc:./gc.log 
    -XX:+HeapDumpOnOutOfMemoryError 
    -XX:HeapDumpPath=自定义的路径
    
    • dump日志命令:
    // dump日志后,会得到java_pid52950.hrof文件
    $ jdump -F jump:format=b,file=heapDump 52950
    
    • 3.dump文件分析,本打算使用jhat命令来分析一番,结果半天没啥反应。于是只能使用java自带的分析神器VisualVM(当然也可以使用MAT),打开命令如下:
    $ jvisualvm
    
    记一次 OOM:GC overhead limit exceeded排查_第8张图片
    • 4.大内存对象
    • 可以看到String和char []两个对象占据内存的Top2.
      记一次 OOM:GC overhead limit exceeded排查_第9张图片
    • 于是点开String和char[]对象,来看看里面都是些啥?看到langue、en-US、IP是不是很熟悉,有点类似我们的http请求头的数据结构。
      记一次 OOM:GC overhead limit exceeded排查_第10张图片
    • ps:如果此时还不能定位到具体代码,可以使用OOL查询具体引用如下图可参考此文章排查具体代码行
      记一次 OOM:GC overhead limit exceeded排查_第11张图片
    • 5.怀疑
    • 因为我们使用的是TTL线程,而且整个请求是会带上链路trace信息的,trace里面的字段正好和大内存对象里面的结构神似,所以怀疑就是TTL把父线程的trace信息拷贝了子线程。
    • 6.源码
    • 代码不会骗人,直接看TTL源码。看看TTL到底对我们的多线程做了什么。先看下TTL时序图,可知在4.1.2的时候进行copy。
      记一次 OOM:GC overhead limit exceeded排查_第12张图片
    • copy流程代码(代码1),注释的意思是在任务在创建时从源线程执行拷贝,也就意味着当我们对task进行sumit时,就已经从主线程的threadlocal进行了相关拷贝(拷贝如代码2)。
    // 代码1
     /**
     * Computes the value for this transmittable thread-local variable
     * as a function of the source thread's value at the time the task
     * Object is created.
     * 

    * This method is called from {@link TtlRunnable} or * {@link TtlCallable} when it create, before the task is started. *

    * This method merely returns reference of its source thread value(the shadow copy), * and should be overridden if a different behavior is desired. * * @since 1.0.0 */ public T copy(T parentValue) { return parentValue; } // 代码2 private static HashMap<ThreadLocal<Object>, Object>captureThreadLocalValues() { final HashMap<ThreadLocal<Object>, Object> threadLocal2Value= new HashMap<ThreadLocal<Object>, Object>(); for (Map.Entry<ThreadLocal<Object>, TtlCopier<Object>> entry: threadLocalHolder.entrySet()) { final ThreadLocal<Object> threadLocal = entry.getKey(); final TtlCopier<Object> copier = entry.getValue(); // 进行拷贝操作 threadLocal2Value.put(threadLocal,copier.copy(threadLocal.get())); } return threadLocal2Value; }

3.解决方案

  • 原因:既然知道是因为TTL在主线程提交任务时,对threadlocal进行了拷贝。结合本次收益文件大约有50W行,也就意味着在2.3.2中会提交约50W个任务,当然拷贝的对象也就多了,必然OOM了。
  • 解决方案:
    • 1.针对业务需要,是否需要传递主线程threadlocal信息至多线程,若不需要则可采用new ThreadPoolExecutor(xx,xx)来创建原生得线程池、或合理调整Jvm内存参数Xmx、Xms。
    • 2.对于任务可以分批处理,即让每个线程处理多条数据,代码如下:
       for(int i=0;i<fileLineNum;i+=batchSize){
           // 分批处理,减少task任务数量,从而减少threadlocal的拷贝次数
           executor.submit(()->{processLines(lines)});
       }
    
    • 3.【强制】设置合理得阻塞队列长度和拒绝策略,本次小亮使用的时无界阻塞队列并没有初始化长度,那么CallerRunsPolicy拒绝策略相当于无效。

4.总结

  • 1.多线程的阻塞队列一定要设置合理得capacity和rejectPolicy。

  • 2.遇到频繁OOM问题,在生产环境一定要谨慎处理(节点下线、dump日志本地分析(或使用Arthas))。最好,平时多了解常用jvm排查命令(jstat、jstack、jmap、jinfo等)。

  • 参考资料:

  • 一次频繁FULL GC的排查过程

  • alibaba TTL

  • 记一次Java服务频繁Full GC的排查过程

  • 一次频繁Full GC问题排查过程分享

你可能感兴趣的:(Java基础学习,java,jvm)