性能文章>谨防JDK8重复类定义造成的内存泄漏>

谨防JDK8重复类定义造成的内存泄漏原创

2年前
913305

概述

如今JDK8成了主流,大家都紧锣密鼓地进行着升级,享受着JDK8带来的各种便利,然而有时候升级并没有那么顺利?比如说今天要说的这个问题。我们都知道JDK8在内存模型上最大的改变是,放弃了Perm,迎来了Metaspace的时代。如果你对Metaspace还不熟,之前我写过一篇介绍Metaspace的文章,大家有兴趣的可以看看我前面的那篇文章。

我们之前一般在系统的JVM参数上都加了类似-XX:PermSize=256M -XX:MaxPermSize=256M的参数,升级到JDK8之后,因为Perm已经没了,如果还有这些参数JVM会抛出一些警告信息,于是我们会将参数进行升级,比如直接将PermSize改成MetaspaceSizeMaxPermSize改成MaxMetaspaceSize,但是我们后面会发现一个问题,经常会看到MetaspaceOutOfMemory异常或者GC日志里提示Metaspace导致的Full GC,此时我们不得不将MaxMetaspaceSize以及MetaspaceSize调大到512M或者更大,幸运的话,发现问题解决了,后面没再出现OOM,但是有时候也会很不幸,仍然会出现OOM。此时大家是不是非常疑惑了,代码完全没有变化,但是加载类貌似需要更多的内存?

之前我其实并没有仔细去想这个问题,碰到这类OOM的问题,都觉得主要是Metaspace内存碎片的问题,因为之前帮人解决过类似的问题,他们构建了成千上万个类加载器,确实也是因为Metsapce碎片的问题导致的,因为Metaspace并不会做压缩,解决的方案主要是调大MetaspaceSizeMaxMetaspaceSize,并将它们设置相等。然后这次碰到的问题并不是这样,类加载个数并不多,然而却抛出了Metaspace的OutOfMemory异常,并且Full GC一直持续着,而且从jstat来看,Metaspace的GC前后使用情况基本不变,也就是GC前后基本没有回收什么内存。

通过我们的内存分析工具看到的现象是同一个类加载器居然加载了同一个类多遍,内存里有多份类实例,这个我们可以通过加上-verbose:class的参数也能得到验证,要输出如下日志,那只有在不断定义某个类才会输出,于是想构建出这种场景来,于是简单地写了个demo来验证
image.png

Demo

image.png
代码很简单,就是通过反射直接调用ClassLoader的defineClass方法来对某个类做重复的定义。
其中在JDK7下跑的JVM参数设置的是:
image.png
在JDK8下跑的JVM参数是:
image.png
大家可以通过jstat -gcutil <pid> 1000看看JDK7和JDK8下有什么不一样,结果你会发现JDK7下Perm的使用率随着FGC的进行GC前后不断发生着变化,而Metsapce的使用率到一定阶段之后GC前后却一直没有变化

JDK7下的结果:
image.png
JDK8下的结果:
image.png

重复类定义

重复类定义,从上面的Demo里已经得到了证明,当我们多次调用ClassLoader的defineClass方法的时候哪怕是同一个类加载器加载同一个类文件,在JVM里也会在对应的Perm或者Metaspace里创建多份Klass结构,当然一般情况下我们不会直接这么调用,但是反射提供了这么强大的能力,有些人还是会利用这种写法,其实我想直接这么用的人对类加载的实现机制真的没有全弄明白,包括这次问题发生的场景其实还是吸纳进JDK里的jaxp/jaxws,比如它就存在这样的代码实现com.sun.xml.bind.v2.runtime.reflect.opt.Injector里的inject方法就存在直接调用的情况:
image.png
不过从2.2.2这个版本开始这种实现就改变了
image.png
所以大家如果还是使用jaxb-impl-2.2.2以下版本的请注意啦,升级到JDK8可能会存在本文说的问题。

重复类定义带来的影响

那重复类定义会带来什么危害呢?正常的类加载都会先走一遍缓存查找,看是否已经有了对应的类,如果有了就直接返回,如果没有就进行定义,如果直接调用类定义的方法,在JVM里会创建多份临时的类结构实例,这些相关的结构是存在Perm或者Metaspace里的,也就是说会消耗Perm或Metaspace的内存,但是这些类在定义出来之后,最终会做一次约束检查,如果发现已经定义了,那就直接抛出LinkageError的异常
image.png
这样这些临时创建的结构,只能等待GC的时候去回收掉了,因为它们不可达,所以在GC的时候会被回收,那问题来了,为什么在Perm下能正常回收,但是在Metaspace里不能正常回收呢?

Perm和Metaspace在类卸载上的差异

这里我主要拿我们目前最常用的GC算法CMS GC举例。

在JDK7 CMS下,Perm的结构其实和Old的内存结构是一样的,如果Perm不够的时候我们会做一次Full GC,这个Full GC默认情况下是会对各个分代做压缩的,包括Perm,这样一来根据对象的可达性,任何一个类都只会和一个活着的类加载器绑定,在标记阶段将这些类标记成活的,并将他们进行新地址的计算及移动压缩,而之前因为重复定义生成的类结构等,因为没有将它们和任何一个活着的类加载器关联(有个叫做SystemDictionary的Hashtable结构来记录这种关联),从而在压缩过程中会被回收掉。
image.png
在JDK8下,Metaspace是完全独立分散的内存结构,由非连续的内存组合起来,在Metaspace达到了触发GC的阈值的时候(和MaxMetaspaceSize及MetaspaceSize有关),就会做一次Full GC,但是这次Full GC,并不会对Metaspace做压缩,唯一卸载类的情况是,对应的类加载器必须是死的,如果类加载器都是活的,那肯定不会做卸载的事情了
image.png
从上面贴的代码我们也能看出来,JDK7里会对Perm做压缩,然后JDK8里并不会对Metaspace做压缩,从而只要和那些重复定义的类相关的类加载一直存活,那将一直不会被回收,但是如果类加载死了,那就会被回收,这是因为那些重复类都是在和这个类加载器关联的内存块里分配的,如果这个类加载器死了,那整块内存会被清理并被下次重用。

如何证明压缩能回收Perm里的重复类

在没看GC源码的情况下,有什么办法来证明Perm在FGC下的回收是因为压缩而导致那些重复类被回收呢?大家可以改改上面的测试用例,将最后那个死循环改一下:
image.png
在System.gc那里设置个断点,然后再通过jstat -gcutil <pid> 1000来看Perm的使用率是否发生变化,另外你再加上-XX:+ ExplicitGCInvokesConcurrent再重复上面的动作,你看看输出是怎样的,为什么这个可以证明,大家可以想一想,哈哈

请先登录,再评论

暂无回复,快来写下第一个回复吧~

为你推荐

关于内存溢出,咱再聊点有意思的?
概述 上篇文章讲了JVM在GC上的一个设计缺陷,揪出一个导致GC慢慢变长的JVM设计缺陷,可能有不少人还是没怎么看明白的,今天准备讲的大家应该都很容易看明白 本文其实很犹豫写不写,因为感觉没有
不起眼,但是足以让你收获的JVM内存案例
今天的这个案例我觉得应该会让你涨姿势吧,不管你对JVM有多熟悉,看到这篇文章,应该还是会有点小惊讶的,不过我觉得这个案例我分享出来,是想表达不管多么奇怪的现象请一定要追究下去,会让你慢慢变得强大起来,
字符串字面量长度是有限制的
前言 偶然在一次单元测试中写了一个非常长的字符串字面量。 正文 在一次单元测试中,我写了一个很长的字符串字面量,大概10万个字符左右,编译时,编译器给出了异常告警 `java: constant
多次字符串相加一定要用StringBuilder而不用-吗?
今天在写一个读取Java class File并进行分析的Demo时,偶然发现了下面这个场景(基于oracle jdk 1.8.0_144): ``` package test; public c
如何通过反射获得方法的真实参数名(以及扩展研究)
前段时间,在做一个小的工程时,遇到了需要通过反射获得方法真实参数名的场景,在这里我遇到了一些小小的问题,后来在部门老大的指导下,我解决了这个问题。通过解决这个问题,附带着我了解到了很多新的知识,我觉得
谨防JDK8重复类定义造成的内存泄漏
概述 如今JDK8成了主流,大家都紧锣密鼓地进行着升级,享受着JDK8带来的各种便利,然而有时候升级并没有那么顺利?比如说今天要说的这个问题。我们都知道JDK8在内存模型上最大的改变是,放弃了Perm
JDK13 GA发布:5大特性解读
JDK13 GA版本 5大新特性如下: 350: Dynamic CDS Archives 351: ZGC: Uncommit Unused Memory 353: Reimplement the
译:谁是 JDK8 中最快的 GC
我们都知道 OpenJDK8 有好几个垃圾回收算法,比如 ParallelGC,CMS,还有 G1,那么哪个才是最快的?如果 GC 算法从 Java8 中默认的 ParallelGC 切换到 G1 会