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

45次阅读

共计 2845 个字符,预计需要花费 8 分钟才能阅读完成。

本文来自: PerfMa 技术社区

PerfMa(笨马网络)官网

概述

如今 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 来验证

Demo


代码很简单,就是通过反射直接调用 ClassLoader 的 defineClass 方法来对某个类做重复的定义。
其中在 JDK7 下跑的 JVM 参数设置的是:

在 JDK8 下跑的 JVM 参数是:

大家可以通过 jstat -gcutil <pid> 1000 看看 JDK7 和 JDK8 下有什么不一样,结果你会发现 JDK7 下 Perm 的使用率随着 FGC 的进行 GC 前后不断发生着变化,而 Metsapce 的使用率到一定阶段之后 GC 前后却一直没有变化

JDK7 下的结果:

JDK8 下的结果:

重复类定义

重复类定义,从上面的 Demo 里已经得到了证明,当我们多次调用 ClassLoader 的 defineClass 方法的时候哪怕是同一个类加载器加载同一个类文件,在 JVM 里也会在对应的 Perm 或者 Metaspace 里创建多份 Klass 结构,当然一般情况下我们不会直接这么调用,但是反射提供了这么强大的能力,有些人还是会利用这种写法,其实我想直接这么用的人对类加载的实现机制真的没有全弄明白,包括这次问题发生的场景其实还是吸纳进 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 算法 CMS GC 举例。

在 JDK7 CMS 下,Perm 的结构其实和 Old 的内存结构是一样的,如果 Perm 不够的时候我们会做一次 Full GC,这个 Full GC 默认情况下是会对各个分代做压缩的,包括 Perm,这样一来根据对象的可达性,任何一个类都只会和一个活着的类加载器绑定,在标记阶段将这些类标记成活的,并将他们进行新地址的计算及移动压缩,而之前因为重复定义生成的类结构等,因为没有将它们和任何一个活着的类加载器关联 (有个叫做 SystemDictionary 的 Hashtable 结构来记录这种关联),从而在压缩过程中会被回收掉。

在 JDK8 下,Metaspace 是完全独立分散的内存结构,由非连续的内存组合起来,在 Metaspace 达到了触发 GC 的阈值的时候 (和 MaxMetaspaceSize 及 MetaspaceSize 有关),就会做一次 Full GC,但是这次 Full GC,并不会对 Metaspace 做压缩,唯一卸载类的情况是,对应的类加载器必须是死的,如果类加载器都是活的,那肯定不会做卸载的事情了

从上面贴的代码我们也能看出来,JDK7 里会对 Perm 做压缩,然后 JDK8 里并不会对 Metaspace 做压缩,从而只要和那些重复定义的类相关的类加载一直存活,那将一直不会被回收,但是如果类加载死了,那就会被回收,这是因为那些重复类都是在和这个类加载器关联的内存块里分配的,如果这个类加载器死了,那整块内存会被清理并被下次重用。

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

在没看 GC 源码的情况下,有什么办法来证明 Perm 在 FGC 下的回收是因为压缩而导致那些重复类被回收呢?大家可以改改上面的测试用例,将最后那个死循环改一下:

在 System.gc 那里设置个断点,然后再通过 jstat -gcutil <pid> 1000 来看 Perm 的使用率是否发生变化,另外你再加上 -XX:+ ExplicitGCInvokesConcurrent 再重复上面的动作,你看看输出是怎样的,为什么这个可以证明,大家可以想一想,哈哈

一起来学习吧

PerfMa KO 系列课之 JVM 参数【Memory 篇】

记一次 Java 服务性能优化

正文完
 0