码字不易,欢迎大家转载,烦请注明出处;谢谢配合

场景描述

JDK版本信息

java version "1.8.0_144"
Java(TM) SE Runtime Environment (build 1.8.0_144-b01)
Java HotSpot(TM) 64-Bit Server VM (build 25.144-b01, mixed mode)

JVM启动参数

#未明确指定,启动命令类似于以下形式
nohup java -XX:+HeapDumpOnOutOfMemoryError 
-XX:HeapDumpPath=/opt/dump/  
-Xloggc:gc.log -XX:+PrintGCDetails
-jar xxx.jar --spring.profiles.active=prod

问题描述

项目启动时正常,没有频繁Full GC 情况发生,
项目运行一段时间后(大约半个月左右),出现频繁的Full GC(3-5秒一次),
严重影响服务的吞吐量以及稳定性。

案发取证

获取java应用进程id:jps

# 示例
jps
17802 Application.jar

观察GC情况:jstat -gcutil <PID> milliseconds

# 示例:每3000毫秒(3秒)输出一次GC情况
  jstat -gcutil 17802 3000
  S0     S1     E      O      M     CCS    YGC     YGCT    FGC    FGCT     GCT   
  0.00  69.30  51.27  86.99  95.50  91.70   1057    8.450     5    1.374    9.824
  0.00  69.30  60.54  86.99  95.50  91.70   1057    8.450     5    1.374    9.824
  0.00  69.30  67.57  86.99  95.50  91.70   1057    8.450     5    1.374    9.824

堆使用情况:jmap -heap <PID>
image-1655631270252

服务器有备份的情况下,可以从集群中摘除,对当前节点生成堆dump文件:jmap -dump:format=b,file=xxx.hprof <PID>

# 示例
jmap -dump:format=b,file=plugin.hprof  17802

栈快照保存:jatack <PID> >jstack.log

# 示例:如果伴随CPU较高可以生产栈快照
jstack 17802 > jstack.log

所以一般建议打开HeapDumpOnOutOfMemoryError,HeapDumpPath等设置。

案情梳理

  • 首先使用JDK1.8,未明确指定JVM启动参数,未指定垃圾收集器,默认会采用:Parallel Scanvage + Parallel Old 来进行垃圾收集

  • 使用 JProfiler进行dump分析,也可以使用MAT等工具,如下图:

image-1655631404135

也可以利用jmap命令获取前10个数量的对象统计

jmap -histo pid| head -n 23

发现char数组、ConcurrentHashMap$Node实例数量比较多有200W+,同时char数组size达到605MB。

结合上图1-1.堆使用情况分析最大堆 948MB老年代 capacity 632MB;怀疑问题可能出在char数组产生了内存泄露,导致老年代过大,进而导致频繁Full GC

  • 选中当前char[]进行引用分析

image-1655631680201

这里有很多引用分析的方法,例如:Incoming referencesOutgoning referencesMerged incoming referencesMerged outgoning referencesMerged dominating references;以当前char[]为例,其中Incoming referencesOutgoning references更倾向于当前数组中的每个个体的信息,Merged incoming referencesMerged outgoning referencesMerged dominating references更倾向于数组整体的信息,incoming表示指向当前数组的引用关系,outgoning表示当前数组的对其他对象的引用关系

1-3.引用分析图中含义表示 99%是String实例,其中91%是AnnotationAwareAspectJAutoProxyCreator

  • 我们再选取当前引用进行分析,如下图

1-4.具体引用
1-4.具体引用

1-5.引用内容
1-5.引用内容

我们发现91%的引用竟然都是 redirect https://xxxxx?variable=变量值,占用了堆中将近567MB的内存空间

  • 那么redirect 为何会占着茅坑不拉屎呢,别着急,我们以其中一个为例,来看看它的GC Roots

image-1655631798240

我们选取其中一个GC Root 进行分析,我们发现其存在GC Root引用链,所以无法被回收,而这部分是应该被回收的,所以验证了我们的猜测,确实发生了 内存泄露

原因分析

我们找到了问题,如何梳理整个流程呢?RequestMappingHandlerAdapter->AnnotationAwareAspectJAutoProxyCreator->redirect:https://XXX,我们模拟问题代码,探寻流程,示例如下:

@Controller
public class TestController {
    /**用UUID模拟变量**/
    @RequestMapping("/test1")
    public String test() {
        return "redirect:index.html?openId=" + UUID.randomUUID();
    }
}

熟悉Spring MVC的同学应该知道,RequestMappingHandlerAdapterHandlerAdapter的实现类,这里我们不做过多的描述,不熟悉的同学,可以查看笔者MVC的专题。如图由下到上是到RequestMappingHandlerAdapter的调用关系

调用关系

而在 DispatcherServlet执行完handle之后,会进行视图的渲染,我们一起来看render方法。

render

调用ViewResolverresolveViewName
resolveViewName

ViewResolver的抽象实现类AbstractCachingViewResolver,具体过程可以参考示例代码来debug,这里调用了创建视图,并进行了缓存
resolveViewName实现

注意: viewAccessCache,viewCreationCache 都是有大小限制的这里不会造成内存泄露,限制大小为1024
Cache

调用子类UrlBasedViewResolver 执行createView方法,创建视图的过程
createView

调用子类UrlBasedViewResolver 执行applyLifecycleMethods方法,初始化Bean
applyLifecycleMethods

此处的initializeBean 最终会调用到AbstractAutowireCapableBeanFactory,对应initializeBean的三个阶段,初始化前,初始化,初始化后,这里的beanName就是之前的字符串redirect:https://xxx

initializeBean

applyBeanPostProcessorsAfterInitialization

看到这里,你可能会有疑问,redirect跟哪个BeanPostProcessor有关系呢?还记得GC Root 引用链中的AnnotationAwareAspectJAutoProxyCreator么?如下是它的继承实现关系

AnnotationAwareAspectJAutoProxyCreator

AnnotationAwareAspectJAutoProxyCreator的抽象父类AbstractAutoProxyCreator实现了BeanPostProcessor的子接口SmartInstantiationAwareBeanPostProcessor,它的postProcessAfterInitialization实现如下:

postProcessAfterInitialization

wrapIfNecessary

最终放入到adviseBeans中,其实类型则是ConcurrentHashMap,而其大小则是无限制的。

问题解决

经过以上悉心的分析,我们找到了问题的原因,那么该如何解决呢?常见的有以下几种解决方法。

方法一:使用RedirectView

    @RequestMapping("/test4")
    public RedirectView test4() {
        RedirectView redirectView = new RedirectView("index.html");
        Map<String, String> map = new HashMap<>();
        map.put("openId", UUID.randomUUID().toString());
        redirectView.setAttributesMap(map);
        return redirectView;
    }

为什么使用Redirect可以避免cache呢?原因在于,渲染render方法中利用mv.isReference()是否是引用。
image-1655632069101

image-1655632097998

所以直接使用RedirectView可以解决

方法二:直接利用response.sendRedirect方法来重定向

    @RequestMapping("/test3")
    public void test3(HttpServletResponse response) {
        try {
            response.sendRedirect("index.html?openId=" + UUID.randomUUID());
        } catch (IOException e) {
            e.printStackTrace();
        }
    }

以上代码经过HandlerAdapter.handle返回的ModelAndViewnull,所以不会出现内存泄露

方法三:重定向的参数信息通过RedirectAttributes传递

    @RequestMapping("/test2")
    public String test2(RedirectAttributes attributes) {
        attributes.addAttribute("openId", UUID.randomUUID());
        return "redirect:index.html";
    }

此方法经过AbstractCachingViewResolver缓存时 viewNameredirecr:index.html不带变量参数,即使通过BeanPostProcessor也只会缓存一次。
第一次

第二次调用时,AbstractCachingViewResolver可以从cache中取出
第二次

总结

以上便是整个问题定位,以及解决的全部流程,希望大家也可以从本文中有所收获。