FURYU Tech Blog - フリュー株式会社

フリュー株式会社の開発者が技術情報を発信するブログです。

JavaOne 2016 サンフランシスコに参加しました!(その4) #javaone #j1jp

Hello world! コンテンツ・メディア第1事業部のjyukutyoこと阪田です。

前回はコレクションのセッションについてのレポートでした。今回はクラスローダーのセッションについてレポートします。

Join the War on ClassLoader Leaks

スライドはこちらにあります。

https://static.rainfocus.com/oracle/oow16/sess/1461358415846001E0TZ/ppt/Classloader%20leaks%20public.pptx

内容

java.lang.OutOfMemoryError(OOME)はみな出会ったことがあるだろう。Java 8になってPermGen spaceとは出なくなったが、代わりにMetaspaceが出るようになった。ローカルでの開発時にも、継続的デプロイでもOOMEは出る。このセッションのアジェンダは次のようなものだ。

  • OOMEは何を意味するのか?
  • OOMEはなぜ起こるのか?
  • どこでリークするのか?
  • どのように避けるのか?
  • OSSの例
  • レーニン

OOMEは何を意味するのか?

JVMのメモリはヒープとPermGen/Metaspaceとスタックである。スタックはスレッドごと、ローカル変数やメソッドのパラメータを持つ。ヒープはオブジェクトのインスタンスを持つ。PermGen/Metaspaceはjava.lang.Classインスタンスなどを持つ。PermGenという名前はJava EEとクラスのアンロードができる前に名付けられた。Java 8でMetaspaceへ置き換えられた。OOME PermGen space/Metaspaceは、あまりに多くのクラスをロードしたときに起こる。

OOMEはなぜ起こるのか?

OOME PermGen space/Metaspaceが起こる原因は次の2つが考えられる。

  1. アプリケーションが大きすぎる。Java 7までは-XX:MaxPermSize=256Mなどと指定すればよい。Java 8ではMetaspaceは自動的に増えていく。
  2. java.lang.Classインスタンスが再デプロイ後にガベージコレクトされない

参照の型は次の4つがある。

  • 強い参照:到達可能なときは決してGCされない
  • ソフト参照:OOMEになる前にGCされる
  • 弱い参照(WeakHashMap):強い参照とソフト参照がないときにGCされる
  • ファントム参照:GCは妨げない

GCの到達可能性は以下の図のようなことである。

再デプロイでは、新しいWARファイルをデプロイすれば前のクラスローダはGCされる。

クラスローダーの参照は次の図のようになる。

どのようにリークが起きるのか。GCルートからクラスローダへ到達可能だと、クラスローダをGCすることができない。

どこでリークするのか?

Tomcatなどアプリケーションサーバのバグだ。他にCommons LoggingやLog4Jなどロギングフレームワークのバグ、Bean Validation APIやUnified Expression Languageのバグといったこともある。

GCルートでも起こる。まず、システムクラスローダによってロードされるクラス。JDKのクラスにおけるstaticフィールドで起こる。ライブスレッドではローカル変数、メソッドのパラメータのスタック、java.lang.Threadインスタンスで起こる。

システムクラスでは、java.sql.DriverManagerやBean introspectionのキャッシュ、シャットダウンフック、カスタムでデフォルトにしたAuthenticator、カスタムのセキュリティでのProvider、カスタムMBean、カスタムのThreadGroup、カスタムのプロパティエディタ…最初の呼び出し元でのcontextClassLoaderへの参照などで起こる。

DriverManagerをもう少し詳しく見てみよう。たとえばMySQLJDBCドライバを使うときはこうするだろう。

Class.forName("com.mysql.jdbc.Driver");

com.mysql.jdbc.Driverクラスにはstaticイニシャライザがある。

static {
        try {
            DriverManager.registerDriver(new Driver());
        } catch (SQLException var1) {
            throw new RuntimeException("Can\'t register driver!");
        }
    }

registerDriver()メソッドは以下のようになっている。

public static synchronized void registerDriver(java.sql.Driver driver,
            DriverAction da) throws SQLException {
        /* Register the driver if it has not already been added to our list */
        if(driver != null) {
            registeredDrivers.addIfAbsent(new DriverInfo(driver, da));
...

このregisterDriversという変数は、以下のものだ。

public class DriverManager {
    // List of registered JDBC drivers
    private final static CopyOnWriteArrayList<DriverInfo> registeredDrivers = new CopyOnWriteArrayList<>();

図で表すと以下のようになる。

そのため、コンテキストのシャットダウンで以下のように書かなければ、リークしてしまう。

public class MyCleanupListener implements javax.servlet.ServletContextListener {
  /** Called when application is undeployed */
  public void contextDestroyed(
    ServletContextEvent servletContextEvent) {
    DriverManager.deregisterDriver(…);
  }
}

スレッドもここで止めておかなければならない。次のようにスレッドを実装するのは悪いアイデアだ。

public class MyThread extends Thread {
  public void run() {
    while(true) { // Bad idea!
      // Do something 
    }
  }
}

止められるようには以下のようにする。

public class MyThread extends Thread {
  private boolean running = true;
  public void run() {
    while(running) { // Until stopped
      // Do something 
    }
  }
  public void shutdown() {
    running = false;
  }
}

このことは、次の記事に紹介されている。

Heinz Kabutz / Java Specialists’ – The Law of the Blind Spot

次にThreadLocalについて考えてみよう。次のように実装してはならない。

ThreadLocalのJavaDocには以下のようにある。

Each thread holds an implicit reference to its copy of a thread-local variable as long as the thread is alive and the ThreadLocal instance is accessible; …

各スレッドは、スレッドが生存していてThreadLocalインスタンスがアクセス可能である間は、スレッド・ローカル変数のコピーへの暗黙的な参照を保持します。

プールされたスレッドではスレッドはクラスローダより長生きする。ThreadLocalはThreadGlobalになる!

ThreadLocalMapのJavaDocには以下のようにある。

However, since reference queues are not used, stale entries are guaranteed to be removed only when the table starts running out of space

(私訳)しかしながらリファレンスキューが使われないので、テーブルが容量不足になり始めたときだけ失効したエントリが削除されることが保証されます。

参照を含んだカスタムの値は、staticなThreadLocalであればリークし、そうでなければ予測されていないGCとなる。カスタムのThreadLocalであればリークしない。

ThreadLocalはクリアするようにしよう。

try {
  myThreadLocal.set(foo);
  …
}
finally {
  myThreadLocal.remove();
}

実際には、多くのOSSライブラリが違反している。

どのように避けるのか?

リーク防止のライブラリがある。アプリケーションサーバ非依存、Tomcatよりも多くをカバーしており、ログで警告を出力、ライセンスはApache 2ライセンスだ。実行時の依存は0で、設定は必要ない。Mavenであれば以下のように依存ライブラリを追加する。

<dependency>
    <groupId>se.jiderhamn.classloader-leak-prevention</groupId>
    <artifactId>classloader-leak-prevention-servlet3</artifactId>
    <version>2.0.2</version>
</dependency>

これはServlet 3.0以降用だが、2系のものもある。

OSSの例

Bean Validationの1.0.0.GAでは以下のようになっていた。

private static class DefaultValidationProviderResolver implements ValidationProviderResolver {
        //cache per classloader for an appropriate discovery
        //keep them in a weak hashmap to avoid memory leaks and allow proper hot redeployment
        //TODO use a WeakConcurrentHashMap
        //FIXME The List<VP> does keep a strong reference to the key ClassLoader, use the same model as JPA CachingPersistenceProviderResolver
        private static final Map<ClassLoader, List<ValidationProvider<?>>> providersPerClassloader =
                new WeakHashMap<ClassLoader, List<ValidationProvider<?>>>();

(私訳)Listはキーであるクラスローダに強い参照を持っている。JPAのCachingPersistenceProviderResolverと同じモデルを使うようにして。

jyukutyo調査

Bean Validationの1.1.0.GAではこのクラスは次のようになっています。

private static class DefaultValidationProviderResolver implements ValidationProviderResolver {
        public List<ValidationProvider<?>> getValidationProviders() {
            // class loading and ServiceLoader methods should happen in a PrivilegedAction
            return GetValidationProviderListAction.getValidationProviderList();
        }
    }

    private static class GetValidationProviderListAction implements PrivilegedAction<List<ValidationProvider<?>>> {
        //cache per classloader for an appropriate discovery
        //keep them in a weak hash map to avoid memory leaks and allow proper hot redeployment
        private static final WeakHashMap<ClassLoader, SoftReference<List<ValidationProvider<?>>>> providersPerClassloader =
                new WeakHashMap<ClassLoader, SoftReference<List<ValidationProvider<?>>>>();
...
        private synchronized List<ValidationProvider<?>> getCachedValidationProviders(ClassLoader classLoader) {
            SoftReference<List<ValidationProvider<?>>> ref = providersPerClassloader.get( classLoader );
            return ref != null ? ref.get() : null;
        }

        private synchronized void cacheValidationProviders(ClassLoader classLoader, List<ValidationProvider<?>> providers) {
            providersPerClassloader.put( classLoader, new SoftReference<List<ValidationProvider<?>>>( providers ) );
        }

providersPerClassloader.put( classLoader, new SoftReference<List<ValidationProvider<?>>>( providers ) );とあるので、ソフト参照を使うようになったようです。

JPAのCachingPersistenceProviderResolverクラスも調べてみました。

private static class CachingPersistenceProviderResolver implements PersistenceProviderResolver {
            //this assumes that the class loader keeps the list of classes loaded
            private final List&lt;WeakReference&lt;Class&lt;? extends PersistenceProvider&gt;&gt;&gt; resolverClasses
                    = new ArrayList&lt;WeakReference&lt;Class&lt;? extends PersistenceProvider&gt;&gt;&gt;();

その他のOSS

javax.el.BeanELResolverやorg.apache.cxf.transport.http.CXFAuthenticator、com.sun.star.lib.util.AsynchronousFinalizerも該当する。

レーニン

Eclipse Memory Analyzer(MAT)を使おう。

http://www.eclipse.org/mat/

次のオプションをつけてアプリケーションを実行しよう。

-XX:+HeapDumpOnOutOfMemoryError -XX:HeapDumpPath=/temp

感想

最後に出てきた、MATについてはこのブログで私が記事を書いています。

ヒープダンプをEclipse Memory Analyzerで解析しよう!

セッションとしては非常におもしろかったです。とくにOSSの実例を挙げながらリークの原因を解説する箇所ではとても興奮しました。こういう内容のセッションはJavaOneだと他にもいろいろとあります。なかなか普段の勉強会でこの分野を扱っていることは少ないと思いますので、興味がある人にはJavaOneはとても楽しめるカンファレンスです!