当前位置: 首页 > 科技观察

李家鹏:谨防JDK8中类定义重复导致内存泄露

时间:2023-03-19 14:21:55 科技观察

概述现在JDK8已经成为主流,大家也都在紧锣密鼓地升级,享受着JDK8带来的各种便利,但有时候升级并不是那么顺利?比如我今天要讲的问题。我们都知道JDK8在内存模型上最大的变化就是抛弃了Perm,迎来了Metaspace时代。如果你对Metaspace不熟悉,我之前写过一篇介绍Metaspace的文章。有兴趣的可以看看我之前的文章。之前我们通常会在系统的JVM参数中加入-XX:PermSize=256M-XX:MaxPermSize=256M这样的参数。升级到JDK8后,因为Perm没有了,如果还有这些参数信息,JVM会抛出一些警告,所以我们会升级参数,比如直接把PermSize改成MetaspaceSize,MaxPermSize改成MaxMetaspaceSize,但是后面会发现一个问题,我们经常会在GC日志中看到Metaspace的OutOfMemory异常或者Metaspace引起的FullGC,这时候我们不得不将MaxMetaspaceSize和MetaspaceSize增加到512M或者更大。运气好的话,发现问题解决了,后面就没有出现OOM了,但不幸的是,有时候还是会出现OOM。这个时候,你是不是很迷茫?代码完全没变,但是加载类好像需要更多的内存?我之前其实并没有仔细思考过这个问题。遇到这种OOM问题,我认为主要是Metaspace内存碎片。这个问题,因为他们之前帮人解决过类似的问题,他们建了上千个类加载器,确实是Metsapce碎片化的问题,因为Metaspace没有做压缩,解决方法主要是加大MetaspaceSize和MaxMetaspaceSize的大小,并设置它们相等。那么这次遇到的问题不是这样的。类加载次数不多,但是抛出了Metaspace的OutOfMemory异常,并且一直在进行FullGC,而且从jstat来看,GC前后Metaspace的使用情况基本一致。变化,也就是GC前后基本没有回收内存。通过我们的内存分析工具可以看到的现象是同一个类加载器实际上多次加载同一个类,内存中存在多个类实例。我们还可以通过添加-verbose:class参数来验证这一点。输出如下日志,只有在连续定义某个类的时候才会输出,所以想搭建这种场景,所以干脆写了个demo验证一下,demo代码很简单,就是直接调用通过反射调用ClassLoader的defineClass方法。类的重复定义。其中JDK7下运行的JVM参数设置为:JDK8下运行的JVM参数为:可以使用jstat-gcutil1000看看JDK7和JDK8有什么区别,你会发现Perm在JDK7下使用率随着GC前后的FGC而变化,但是Metsapce的使用率在经过一定阶段后GC前后并没有变化。JDK7下的结果:JDK8下的结果:DuplicateclassdefinitionDuplicateclassdefinition,从上面的demo中已经证明,当我们多次调用ClassLoader的defineClass方法时,即使同一个类加载器加载同一个class文件,多个Klass结构会在JVM中对应的Perm或Metaspace中创建。当然,一般情况下,我们不会直接这样调用,但是反射提供了这么强大的能力,有些人还是会用这种写法,其实我觉得直接这么用的人真的不会没有完全理解类加载的实现机制,包括这个出现问题的场景其实是吸进了JDK中的jaxp/jaxws。比如com.sun.xml.bind.v2.runtime.reflect.opt.Injector中有这么一段代码实现了inject方法,直接调用了情况:但是这个实现从2.2.2版本开始就变了,所以如果你还在使用jaxb-impl-2.2.2或以下,请注意,升级到JDK8可能会出现本文提到的问题。类定义重复的影响类定义重复会带来什么危害?正常的类加载会先通过缓存查找是否有对应的类。如果有,则直接返回。如果没有,它将被定义,如果直接调用类定义的方法,会在JVM中创建多个临时类结构实例。这些相关的结构存储在Perm或者Metaspace中,也就是说会消耗Perm或者Metaspace的内存,但是这些类定义出来之后,最后会做一个约束检查。如果发现已经定义,则直接抛出LinkageError异常。这些临时创建的结构只有在等待GC的时候才能被回收。因为它们是不可达的,所以在GC中那么问题来了,为什么在Perm下可以正常回收,而在Metaspace中却不行呢?Perm和Metaspace在类卸载上的区别。这里我主要以我们最常用的GC算法CMSGC为例。在JDK7CMS下,Perm的结构其实和Old的内存结构是一样的。如果Perm不够,我们将进行FullGC。默认情况下,这个FullGC会压缩每一代,包括Perm,这样一来,根据对象的可访问性,任何类只会绑定到一个活的类加载器。在标记阶段,这些类将被标记为存活的,它们的新地址将被计算并移动和压缩。重复定义生成的类结构等,因为它们不与任何活着的类加载器相关联(有一个名为SystemDictionary的Hashtable结构来记录这种关联),所以它们会在压缩过程中被回收。在JDK8下,Metaspace是由不连续内存组成的完全独立、分散的内存结构。当Metaspace达到触发GC的阈值(与MaxMetaspaceSize和MetaspaceSize有关)时,就会进行一次FullGC,但这次FullGC不会对Metaspace进行压缩。卸载类的唯一情况是相应的类加载器必须死掉。如果类加载器是活着的,它肯定不会做卸载。从上面贴出的代码我们也可以看出,在JDK7中Perm会被压缩,而Metaspace在JDK8中不会被压缩,所以只要那些重复定义类相关的类加载存活下来,就永远不会被回收,但是如果如果类加载器死了,它将被回收。这是因为那些重复的类被分配在与这个类加载器关联的内存块中。如果类加载器死了,整个内存块将被清理和删除。下次再用。如何证明压缩可以回收Perm中的重复类在不看GC源码的情况下,有没有办法证明FGC下Perm的回收是由于压缩而回收了那些重复类?你可以改变上面的测试用例,改变***的无限循环:在System.gc中设置一个断点,然后使用jstat-gcutil1000来查看Perm的使用率是否发生变化,你添加-XX:+ExplicitGCInvokesConcurrent再次重复上面的动作,可以看到输出的是什么,为什么这个可以证明,大家可以好好想想。【本文为专栏作家李嘉鹏原创文章,转载请微信公众号(你个假笨蛋,id:lovestblog)联系作者授权转载】点此查看本作者更多好文