当前位置:   article > 正文

记一次线上FullGC问题排查_fullgc后内存居高不下

fullgc后内存居高不下

问题起因

2022-03-13在钉钉工作通知中收到申请其中一台机器的预警。预警信息显示FullGC的次数持续超过指定的阈值。

 

我们知道FullGC非常耗时。当一个GC线程执行FullGC时,会严重影响其他线程的正常工作,造成系统卡顿的现象。通常情况下,它被认为可以停止这个世界。

所以这台机器果断重启,但是没过多久,其他机器就开始陆续报出FullGC警告。实在没办法,于是重启了整个应用,重启后应用运行恢复正常。虽然暂时恢复正常了,但不能保证以后不会再出问题,所以要追根溯源,从根源上解决问题。

问题排查

1. 分析JVM指标

我们知道JVM为了有效实施堆内存回收,通常会将堆划分为新生代和老年代,一般当老年代空间不足时会引发FullGC。

所以先去监控上看了下运行应用的JVM相关指标,着重看了堆内存指标。



 

最近半天JVM相关指标



最近一周JVM相关指标

先看近半天的指标,从下午4点开始,FullGC就开始频繁出现,GC耗时也跟着涨了上去,然后就一直居高不下。再看最近一周JVM的堆内存情况,发现老年代的占用成逐步上升的态势,初步怀疑是哪里存在内存泄漏。从这里也可以看到,如果不去管它,过一段时间又会出现FullGC问题了。

2. 分析堆内存

接下来分析下堆内存,先去dump了JVM堆,然后在内存分析平台上进行分析



 

我们知道JVM一般采用可达性分析算费确定一个对象是否可以被回收,被GC根对象强引用的对象是不会被回收的。

所以我们从GC根对象作为切入点进行分析。可以看到, java.lang.Thread @ 0x6c7baadf0 ConsumeMessageThread_14这个类对象占用的内存非常多,这是一个Thread类型的对象,从ConsumeMessageThread_14这个名字可以猜测这是一个MQ的消费者线程对象。这种对象一般是由线程池管理的,创建后一般不会销毁,这就会导致这类对象一旦存储了大量信息,将很难被回收。那么Thread中哪个字段会有可能存大量信息呢?没错,就是线程本地变量threadLocals java.lang.ThreadLocal$ThreadLocalMap。

我们知道ThreadLocal是被设计用来解决多线程并发时的线程安全问题的,如果用的不好可能会引起内存泄漏。



 

继续往下追,发现有一个java.lang.ThreadLocal$ThreadLocalMap$Entry对象占据的内存特别大,里面存储的value值是com.alibaba.common.lang.diagnostic.Profiler$Entry类型的对象,该对象有个subEntries字段,是个ArrayList类型,竟然存储了13.8W+个元素。

3. 结合代码

既然是com.alibaba.common.lang.diagnostic.Profiler$Entry类对象出了问题,就到源码里找一下相关的代码看看。

  1. import com.alibaba.common.lang.diagnostic.Profiler;
  2. import static com.alibaba.common.lang.diagnostic.Profiler.enter;
  3. import static com.alibaba.common.lang.diagnostic.Profiler.getEntry;
  4. import static com.alibaba.common.lang.diagnostic.Profiler.release;
  5. @Aspect
  6. @Order(2)
  7. @Component
  8. public class ProfilerAspect {
  9. private static final Logger DIGEST_LOGGER = LoggerFactory.getLogger("digest");
  10. @Pointcut("execution(public * com.alibaba.onetouch.tax.order.integration.service..*.*(..)) " +
  11. "|| execution(public * com.alibaba.onetouch.tax.order.service..*.*(..)) " +
  12. "|| execution(public * com.alibaba.onetouch.tax.order.persistence.mapper..*.*(..)) " +
  13. "|| execution(public * com.alibaba.onetouch.tax.order.persistence.demeter.mapper..*.*(..)) "
  14. )
  15. public void pointcut() {}
  16. @Around("pointcut()")
  17. public Object entrance(ProceedingJoinPoint joinPoint) throws Throwable {
  18. MethodSignature methodSignature = (MethodSignature) joinPoint.getSignature();
  19. Method invokedMethod = methodSignature.getMethod();
  20. boolean profiler = getEntry() != null;
  21. String methodName = getString(invokedMethod);
  22. try {
  23. if (profiler) {
  24. enter(methodName);
  25. }else{
  26. Profiler.start(methodName);
  27. }
  28. return joinPoint.proceed();
  29. } finally {
  30. release();
  31. long duration = Profiler.getDuration();
  32. if(duration>1000L){
  33. DIGEST_LOGGER.info("invoike {} {}",methodName,Profiler.getDuration());
  34. }
  35. }
  36. }
  37. }
  1. public final class Profiler {
  2. private static final ThreadLocal entryStack = new ThreadLocal();
  3. public static void start(String message) {
  4. entryStack.set(new Entry(message, null, null));
  5. }
  6. public static void start(Message message) {
  7. entryStack.set(new Entry(message, null, null));
  8. }
  9. public static void enter(String message) {
  10. Entry currentEntry = getCurrentEntry();
  11. if (currentEntry != null) {
  12. currentEntry.enterSubEntry(message);
  13. }
  14. }
  15. public static final class Entry {
  16. private final List subEntries = new ArrayList(4);
  17. private final Object message;
  18. private final Entry parentEntry;
  19. private final Entry firstEntry;
  20. private final long baseTime;
  21. private final long startTime;
  22. private long endTime;
  23. private Entry getUnreleasedEntry() {
  24. Entry subEntry = null;
  25. if (!subEntries.isEmpty()) {
  26. subEntry = (Entry) subEntries.get(subEntries.size() - 1);
  27. if (subEntry.isReleased()) {
  28. subEntry = null;
  29. }
  30. }
  31. return subEntry;
  32. }
  33. }
  34. }

可以看到这里做了一个切面处理,我猜它这里是想统计下每个方法的调用时长,监控那些慢处理方法。通过分析代码发现,这段逻辑每走一次,都会往Profiler$Entry的subEntries里增加一条记录,同时Profiler$Entry又是存放在线程本地变量entryStack里的,这将导致这些内容难以被回收,这和前面分析堆内存时观察到结果是一致的。

我们写一个单元测试验证一下。

  1. @Test
  2. public void test() {
  3. f1();
  4. f3();
  5. }
  6. private void f1() {
  7. entryMethod("f1");
  8. f2();
  9. }
  10. private void f2() {
  11. entryMethod("f2");
  12. }
  13. private void f3() {
  14. entryMethod("f2");
  15. }
  16. private void entryMethod(String name) {
  17. boolean profiler = getEntry() != null;
  18. try {
  19. if (profiler) {
  20. enter(name);
  21. }else{
  22. Profiler.start(name);
  23. }
  24. } finally {
  25. release();
  26. }
  27. Profiler.Entry entry = getEntry();
  28. System.out.println(JSON.toJSONString(entry));
  29. System.out.println(name + ": " + entry.getSubEntries().size());
  30. }

测试方法模拟调用方法f1()和f3(),其中f1()中又会调用方法f2(),每个方法都会调用entryMethod()模拟切面的调用,看下结果:



 

可以看到的确是每调用一次,subEntries中就会被增加一条记录的哈,这样日积月累总有一天内存会不够用的。

解决方案

既然根因找到了,出解决方案就不难了。切面处理完后,可以将subEntries中的记录移除,或者将entry对象指空,以达到回收内存的目的。

声明:本文内容由网友自发贡献,不代表【wpsshop博客】立场,版权归原作者所有,本站不承担相应法律责任。如您发现有侵权的内容,请联系我们。转载请注明出处:https://www.wpsshop.cn/w/我家小花儿/article/detail/823429
推荐阅读
相关标签
  

闽ICP备14008679号