某天下午正在噼里啪啦的写代码时,钉钉群疯狂的发FullGC告警,登陆相关机器,jps -lv | grep 找到PID后,执行 jstat -gccause pid 2000 pid显示如下:
CG的原因是per space已满,再执行jmap -heap pid,输出如下:
很明显,Per space已经用完了,当前使用的jdk版本为:Java(TM) SE Runtime Environment (build 1.6.0_29-b11)JVM参数如下:
永久代最大内存为96M,这个区域存储了class字节码以及一些常量信息,要溢出除非是以下几种情况:
1、该区域设置过小,根本无法装载应用的所需的所有class
2、应用里大量动态生成class,如频繁编译jsp或大量使用动态代理生成许多proxy
3、应用自定义classloader,频繁load class
4、应用大量生成字符串,并调用string.intern()
出问题的服务一个class平均大小在10k左右,96M可以加载9800多个class,JVM6在初始化是会加载2000左右类,应用本身只有99个class[linux递归统计文件数:ls -lR | grep "^-" |wc -l],加上引用的类在4000个左右,再加上一些字符串常量,大约在60M左右,因此不可能是第一个原因,该服务是基于Spring MVC的纯后台服务,只有一个jsp管理页面,不管是Spring的AOP还是其他一些动态代理,都是在程序启动就生成好了的,因此也基本可排除第二个原因,剩下3, 4,在应用的代码层面,没有显示调string.intern(),也没有显示自定义classloader,但无法排除引用的代码里是否有,jmap 打印的信息看,per区的确是占用99%,究竟是什么数据消耗内存呢,jvm既然有命令可以看各个区的消耗,应该有命令可以查看永久带的信息,jmap --help,果然找到一个参数 :
-permstat to print permanent generation statistics
执行该命令,首先打印的是字符串占用的空间,20658 intern Strings occupying 2174792bytes,接下输出满屏的groovy/lang/GroovyClassLoader,该GroovyClassLoader只有3个instance,却有4800多条纪录,也就是说这些相同的classloader在不断load class到虚拟机,直到per区内存消耗完。直觉告诉我,肯定是某个代码static引用了GroovyClassLoader,并不断触发该类load class,问题点已经找到,为了不影响线上业务,先重启服务,接下来去代码里搜关键字:groovy,没有结果,正准备通过maven打印依赖关系【mvndependency:tree】查找线索时,突然想到有个工具servlet里使用groovy动态执行一些java代码,groovy是将脚本动态编译后load到虚拟机执行的,自然会产生许多class,问题应该就是这里了,接下来我们梳理下整个事情来龙去脉:
1 代码里创建了一个static GroovyShell ,GroovyShell持有GroovyClassLoader
2 每次传一些java代码过来,调用GroovyShell.eval(xxx)执行
3 GroovyShell动态编译脚本,再调用GroovyClassLoader load到虚拟机执行。
4 当脚本执行完,此前动态生成class就没有什么用了,但字节码还驻留在per区,并且不会被卸载,随着eval调用次数增多, Per区内存就一点点的被消耗完。
为什么这些无用的字节码没被JVM回收呢,这得从class卸载机制说起,JVM的Per区没有单独的Garbage collector,这个区域只是在老年代FullGC时顺带回收的,一个class只有在满足以下条件时,才能被JVM 卸载
1. 该类所有的实例已经被回收
2. 该类的ClassLoder已经被回收
3. 该类对应的Java.lang.Class对象没有任何对方被引用
我们的代码中,因为GroovyClassLoader被GroovyShell引用,而GroovyShell被应用代码static引用,整个应用运行期间,该引用链一直都在,无法满足条件2,所以即使我们脚本已经执行完,但动态生成的字节码还会一直驻留JVM直到内存溢出。因为java并没有提供卸载class的接口,所以我们只能想办法满足上述3个条件,让JVM在必要时卸载class,解决思路就是要打破上述引用链,方案如下:
1 将GroovyShell由static改为局部变量
2 将GroovyShell放到WeakReference里,既能避免重复创建,又支持JVM卸载class
由于问题代码只是一个运维型工具类,时间上不是很敏感,直接采用第一种方案。在本地测试,以下代码执行循环到6000多次时Per区内存溢出,用Jconsole观察,加载的类直线上升,JVM加:XX:+TraceClassLoading -XX:+TraceClassUnloading,控制台只打印load lcass,不见unload class日志,信息如下:
而改成局部变量后,控制台出现unload class日志,程序一直运行直到完成,再无per区溢出。
许多运行在JVM上的脚本语言(如Groovy)为我们带来很多便利,如在不重启服务的情况下,修改服务的运行数据,清空缓存等,但若使用不当,则会带来致命打击,所以使用时请小心谨慎。在java项目中,不管是从工程的可维护性还是运行性能来讲,都不建议大量使用脚本。
Note:Java8去掉Per gen,改为metaspace,并且把stirng常量也挪到heap里,metaspace采用直接内存,会自动调节大小,该区域大小只受限于物理内存,因此不会再有PerGen溢出问题,