Java程序中,当调用Groovy脚本语言来完成某些功能时。常用GroovyClassLoader脚本执行组件。 Groovy 的 GroovyClassLoader ,它会动态地加载一个脚本并执行它。GroovyClassLoader是一个Groovy定制的类装载器,负责解析加载Java类中用到的Groovy类,然而,目前该执行器会造成JDK本身的内存泄露。
现象:
由于我们的Java程序通过调度的方式来周期的方式进行运行,而程序中包含使用Groovy脚本的执行。当调度运行一段时间后,发现系统的后台服务会变得特别缓慢,甚至造成服务宕机,无法访问。经过排查发现,我们的Web服务发生了内存泄露,经过使用JDK自带的分析工具jvisualvm,分析程序运行呈现如下现象:
首先,堆大小一致增加手动执行gc,然而老年代内存依然坚挺,没有下降趋势。正常应用发生oldgc,老年代内存理论上应该被释放掉绝大部分。其次,类视图中已加载的类的数量一致增加,这个也是不正常的现象。
由此我们可以判断出程序肯定有某处发生了内存泄漏。
通过加载堆dump发现,AppClassLoader里主要ConcurrentHashMap占用内存最大,而ConcurrentHashMap里主要存放的几乎都是Groovy动态生成的类名
这个ConcurrentHashMap存放了近600万个Entry
原因:
AppClassLoader是java内置类加载器,用来加载用户应用程序的类。里面有一个parallelLockMap,主要用来存储类锁,避免JVM加载同名的类,和提高类加载的并发度。
GroovyClassLoader如果加载的是无类名的Script,最终会生成一个随机的类名,每次都不一样。导致parallelLockMap不断膨胀, 所以现象在执行脚本频率不算很高的时候一段时间内很难发现内存吃紧。8G的LVS甚至几个月都不会有问题。如果像调度程序这种运行方式,很快就会发现内存泄露。
这应该是一个JDK的BUG,只要加载过多的不同类,parallelLockMap就会不断的膨胀,导致memory leak,最终机器就会宕机。这个这个BUG早已经提给官方,但是JDK7、JDK8都未修复,而且明确指出不修复。
结论:
理论上是不合理的,因为这个Map只进不出。既然官方指出不修复,所以无论是Groovy还是通过别的方式动态加载类,尽量使用固定类名。如果类名是随机的,就要控制加载数量了。
解决办法:
既然无法修改jdk的类加载方式,那就只能从上层规范使用GroovyClassLoader的使用方式。具体为,原来生成script时,都是直接传入脚本表达式,无论表达式是否重复,都新创建了class,造成了内存泄露。我们可以通过某种业务方式将脚本在内存中存储。通过Key-Value的方式,将脚本生成的class或Script对象通过Map方式缓存起来,这样生成的class和script应该是有限的,无论程序运行多少次,都不会存在parallelLockMap无限增加的情况,从而也不会内存泄露。