JVM原始碼分析之謹防JDK8重複類定義造成的記憶體洩漏

寒泉子發表於2017-03-21

概述

如今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再重複上面的動作,你看看輸出是怎樣的,為什麼這個可以證明,大家可以想一想,哈哈


相關文章