JVM源码分析之谨防JDK8重复类定义造成的内存泄漏

简介: ##概述 如今JDK8成了主流,大家都紧锣密鼓地进行着升级,享受着JDK8带来的各种便利,然而有时候升级并没有那么顺利?比如说今天要说的这个问题。我们都知道JDK8在内存模型上最大的改变是,放弃了Perm,迎来了Metaspace的时代。

概述

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

[Loaded ResponseVO$JaxbAccessorM_getDescription_setDescription_java_lang_String from __JVM_DefineClass__]
[Loaded ResponseVO$JaxbAccessorM_getDescription_setDescription_java_lang_String from __JVM_DefineClass__]
[Loaded ResponseVO$JaxbAccessorM_getDescription_setDescription_java_lang_String from __JVM_DefineClass__]

Demo

import java.io.File;
import java.io.FileInputStream;
import java.io.IOException;
import java.lang.reflect.Method;

/**
 * Created by nijiaben on 2017/3/7.
 */
public class B {
    public static void main(String args[]) throws Throwable {
        Method defineClass = ClassLoader.class.getDeclaredMethod("defineClass",
                new Class[]{String.class, byte[].class, int.class, int.class});
        defineClass.setAccessible(true);
        File file = new File("/Users/nijiaben/BBBB.class");
        byte[] bcs = new byte[(int) file.length()];
        FileInputStream in = null;
        try {
            in = new FileInputStream(file);
            while ((in.read(bcs)) != -1) {
            }
        } catch (Exception e) {

        } finally {
            if (in != null) {
                try {
                    in.close();
                } catch (IOException e) {
                }
            }
        }
        while (true) {
            try {
                defineClass.invoke(B.class.getClassLoader(), new Object[]{"BBBB", bcs, 0, bcs.length});
            } catch (Throwable e) {
            }
        }

    }
}

代码很简单,就是通过反射直接调用ClassLoader的defineClass方法来对某个类做重复的定义。

其中在JDK7下跑的JVM参数设置的是:

-Xmx100M -Xms100M -verbose:class -XX:+PrintGCDetails -XX:MaxPermSize=50M -XX:PermSize=50M -XX:+UseConcMarkSweepGC -XX:+CMSClassUnloadingEnabled 

在JDK8下跑的JVM参数是:

-Xmx100M -Xms100M -verbose:class -XX:+PrintGCDetails -XX:MaxMetaspaceSize=50M -XX:MetaspaceSize=50M -XX:+UseConcMarkSweepGC -XX:+CMSClassUnloadingEnabled 

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

JDK7下的结果:

[Full GC[CMS: 0K->346K(68288K), 0.0267620 secs] 12607K->346K(99008K), [CMS Perm : 51199K->3122K(51200K)], 0.0269490 secs] [Times: user=0.03 sys=0.00, real=0.03 secs] 

JDK8下的结果:

[Full GC (Metadata GC Threshold) [CMS: 5308K->5308K(68288K), 0.0397720 secs] 5844K->5308K(99008K), [Metaspace: 49585K->49585K(1081344K)], 0.0398189 secs] [Times: user=0.04 sys=0.00, real=0.04 secs] 
[Full GC (Last ditch collection) [CMS: 5308K->5308K(68288K), 0.0343949 secs] 5308K->5308K(99008K), [Metaspace: 49585K->49585K(1081344K)], 0.0344473 secs] [Times: user=0.03 sys=0

重复类定义

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

private synchronized Class inject(String className, byte[] image)
  {
    if (!this.loadable) {
      return null;
    }
    Class c = (Class)this.classes.get(className);
    if (c == null)
    {
      try
      {
        c = (Class)defineClass.invoke(this.parent, new Object[] { className.replace('/', '.'), image, Integer.valueOf(0), Integer.valueOf(image.length) });
        resolveClass.invoke(this.parent, new Object[] { c });
      }
      catch (IllegalAccessException e)
      {
        logger.log(Level.FINE, "Unable to inject " + className, e);
        return null;
      }
      catch (InvocationTargetException e)
      {
        logger.log(Level.FINE, "Unable to inject " + className, e);
        return null;
      }
      catch (SecurityException e)
      {
        logger.log(Level.FINE, "Unable to inject " + className, e);
        return null;
      }
      catch (LinkageError e)
      {
        logger.log(Level.FINE, "Unable to inject " + className, e);
        return null;
      }
      this.classes.put(className, c);
    }
    return c;
  }

不过从2.2.2这个版本开始这种实现就改变了

private Class inject(String className, byte[] image)
  {
        ...
          c = (Class)findLoadedClass.invoke(this.parent, new Object[] { className.replace('/', '.') });
        ...
        
        if (c == null)
        {
            c = (Class)defineClass.invoke(this.parent, new Object[] { className.replace('/', '.'), image, Integer.valueOf(0), Integer.valueOf(image.length) });
            resolveClass.invoke(this.parent, new Object[] { c })
            ...
        }
 }     

所以大家如果还是使用jaxb-impl-2.2.2以下版本的请注意啦,升级到JDK8可能会存在本文说的问题。

重复类定义带来的影响

那重复类定义会带来什么危害呢?正常的类加载都会先走一遍缓存查找,看是否已经有了对应的类,如果有了就直接返回,如果没有就进行定义,如果直接调用类定义的方法,在JVM里会创建多份临时的类结构实例,这些相关的结构是存在Perm或者Metaspace里的,也就是说会消耗Perm或Metaspace的内存,但是这些类在定义出来之后,最终会做一次约束检查,如果发现已经定义了,那就直接抛出LinkageError的异常

void SystemDictionary::check_constraints(int d_index, unsigned int d_hash,
                                         instanceKlassHandle k,
                                         Handle class_loader, bool defining,
                                         TRAPS) {
  const char *linkage_error = NULL;
  {
    Symbol*  name  = k->name();
    ClassLoaderData *loader_data = class_loader_data(class_loader);

    MutexLocker mu(SystemDictionary_lock, THREAD);

    Klass* check = find_class(d_index, d_hash, name, loader_data);
    if (check != (Klass*)NULL) {
      // if different InstanceKlass - duplicate class definition,
      // else - ok, class loaded by a different thread in parallel,
      // we should only have found it if it was done loading and ok to use
      // system dictionary only holds instance classes, placeholders
      // also holds array classes

      assert(check->oop_is_instance(), "noninstance in systemdictionary");
      if ((defining == true) || (k() != check)) {
        linkage_error = "loader (instance of  %s): attempted  duplicate class "
          "definition for name: \"%s\"";
      } else {
        return;
      }
    }
    ...
 }

这样这些临时创建的结构,只能等待GC的时候去回收掉了,因为它们不可达,所以在GC的时候会被回收,那问题来了,为什么在Perm下能正常回收,但是在Metaspace里不能正常回收呢?

Perm和Metaspace在类卸载上的差异

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

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

void GenMarkSweep::mark_sweep_phase4() {
  // All pointers are now adjusted, move objects accordingly

  // It is imperative that we traverse perm_gen first in phase4. All
  // classes must be allocated earlier than their instances, and traversing
  // perm_gen first makes sure that all klassOops have moved to their new
  // location before any instance does a dispatch through it's klass!

  // The ValidateMarkSweep live oops tracking expects us to traverse spaces
  // in the same order in phase2, phase3 and phase4. We don't quite do that
  // here (perm_gen first rather than last), so we tell the validate code
  // to use a higher index (saved from phase2) when verifying perm_gen.
  GenCollectedHeap* gch = GenCollectedHeap::heap();
  Generation* pg = gch->perm_gen();

  GCTraceTime tm("phase 4", PrintGC && Verbose, true, _gc_timer);
  trace("4");

  VALIDATE_MARK_SWEEP_ONLY(reset_live_oop_tracking(true));

  pg->compact();

  VALIDATE_MARK_SWEEP_ONLY(reset_live_oop_tracking(false));

  GenCompactClosure blk;
  gch->generation_iterate(&blk, true);

  VALIDATE_MARK_SWEEP_ONLY(compaction_complete());

  pg->post_compact(); // Shared spaces verification.
}

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

void GenMarkSweep::mark_sweep_phase4() {
  // All pointers are now adjusted, move objects accordingly

  // It is imperative that we traverse perm_gen first in phase4. All
  // classes must be allocated earlier than their instances, and traversing
  // perm_gen first makes sure that all Klass*s have moved to their new
  // location before any instance does a dispatch through it's klass!

  // The ValidateMarkSweep live oops tracking expects us to traverse spaces
  // in the same order in phase2, phase3 and phase4. We don't quite do that
  // here (perm_gen first rather than last), so we tell the validate code
  // to use a higher index (saved from phase2) when verifying perm_gen.
  GenCollectedHeap* gch = GenCollectedHeap::heap();

  GCTraceTime tm("phase 4", PrintGC && (Verbose || LogCMSParallelFullGC),
                 true, _gc_timer, _gc_tracer->gc_id());
  trace("4");

  GenCompactClosure blk;
  gch->generation_iterate(&blk, true);
}

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

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

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

        int i = 0;
        while (i++ < 1000) {
            try {
                defineClass.invoke(B.class.getClassLoader(), new Object[]{"BBBB", bcs, 0, bcs.length});
            } catch (Throwable e) {
            }
        }
        System.gc();

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

目录
相关文章
|
27天前
|
缓存 Prometheus 监控
Elasticsearch集群JVM调优设置合适的堆内存大小
Elasticsearch集群JVM调优设置合适的堆内存大小
211 1
|
1月前
|
监控 Oracle Java
JDK 21中的分代ZGC:一场内存管理的革命
JDK 21引入了分代ZGC,为Java应用程序的内存管理带来了革命性的进步。分代ZGC通过将堆内存划分为年轻代和老年代,采用并发处理和染色指针技术,实现了高吞吐量、低延迟和更好的可扩展性。这一特性显著提升了系统的性能和稳定性。
160 51
|
16天前
|
存储 监控 算法
深入探索Java虚拟机(JVM)的内存管理机制
本文旨在为读者提供对Java虚拟机(JVM)内存管理机制的深入理解。通过详细解析JVM的内存结构、垃圾回收算法以及性能优化策略,本文不仅揭示了Java程序高效运行背后的原理,还为开发者提供了优化应用程序性能的实用技巧。不同于常规摘要仅概述文章大意,本文摘要将简要介绍JVM内存管理的关键点,为读者提供一个清晰的学习路线图。
|
25天前
|
Java
JVM内存参数
-Xmx[]:堆空间最大内存 -Xms[]:堆空间最小内存,一般设置成跟堆空间最大内存一样的 -Xmn[]:新生代的最大内存 -xx[use 垃圾回收器名称]:指定垃圾回收器 -xss:设置单个线程栈大小 一般设堆空间为最大可用物理地址的百分之80
|
26天前
|
Java
JVM运行时数据区(内存结构)
1)虚拟机栈:每次调用方法都会在虚拟机栈中产生一个栈帧,每个栈帧中都有方法的参数、局部变量、方法出口等信息,方法执行完毕后释放栈帧 (2)本地方法栈:为native修饰的本地方法提供的空间,在HotSpot中与虚拟机合二为一 (3)程序计数器:保存指令执行的地址,方便线程切回后能继续执行代码
21 3
|
27天前
|
存储 缓存 监控
Elasticsearch集群JVM调优堆外内存
Elasticsearch集群JVM调优堆外内存
45 1
|
1月前
|
Arthas 监控 Java
JVM进阶调优系列(9)大厂面试官:内存溢出几种?能否现场演示一下?| 面试就那点事
本文介绍了JVM内存溢出(OOM)的四种类型:堆内存、栈内存、元数据区和直接内存溢出。每种类型通过示例代码演示了如何触发OOM,并分析了其原因。文章还提供了如何使用JVM命令工具(如jmap、jhat、GCeasy、Arthas等)分析和定位内存溢出问题的方法。最后,强调了合理设置JVM参数和及时回收内存的重要性。
|
2月前
|
缓存 算法 Java
JVM知识体系学习六:JVM垃圾是什么、GC常用垃圾清除算法、堆内存逻辑分区、栈上分配、对象何时进入老年代、有关老年代新生代的两个问题、常见的垃圾回收器、CMS
这篇文章详细介绍了Java虚拟机(JVM)中的垃圾回收机制,包括垃圾的定义、垃圾回收算法、堆内存的逻辑分区、对象的内存分配和回收过程,以及不同垃圾回收器的工作原理和参数设置。
79 4
JVM知识体系学习六:JVM垃圾是什么、GC常用垃圾清除算法、堆内存逻辑分区、栈上分配、对象何时进入老年代、有关老年代新生代的两个问题、常见的垃圾回收器、CMS
|
2月前
|
存储 安全 Java
jdk21的外部函数和内存API(MemorySegment)(官方翻译)
本文介绍了JDK 21中引入的外部函数和内存API(MemorySegment),这些API使得Java程序能够更安全、高效地与JVM外部的代码和数据进行互操作,包括调用外部函数、访问外部内存,以及使用不同的Arena竞技场来分配和管理MemorySegment。
70 1
jdk21的外部函数和内存API(MemorySegment)(官方翻译)
|
2月前
|
存储 算法 Java
Java虚拟机(JVM)的内存管理与性能优化
本文深入探讨了Java虚拟机(JVM)的内存管理机制,包括堆、栈、方法区等关键区域的功能与作用。通过分析垃圾回收算法和调优策略,旨在帮助开发者理解如何有效提升Java应用的性能。文章采用通俗易懂的语言,结合具体实例,使读者能够轻松掌握复杂的内存管理概念,并应用于实际开发中。