Kengo's blog

Technical articles about original projects, JVM, Static Analysis and TypeScript.

ウェブアプリケーションサーバでよくあるクラスローダのトラブル

これはJ2EE Advent Calendarの25日目の記事です。昨日の記事はnobuokaさんによるJava Persistence API (JPA) 実践入門でした!

本記事の趣旨は、developerWorksクラスローダーとJ2EEパッケージング戦略を理解するに書いてあることをコードで確かめようというものです。昨今はOSGiの登場などによりあまり目立たなくなったのかもしれませんが、未だにクラスローダはJ2EEアプリケーションの実装・運用において重要な役割を担っています。本記事がクラスローダの理解に役立てば幸いです。

はじめに:クラスローダとは?

クラスローダとは、クラス定義をclassファイルから読み込んでくれるものです。 通常のJavaアプリケーションではJVMが用意する複数の基本クラスローダが存在し、このクラスローダがJREのクラスとCLASSPATHにあるクラスを読み込んでくれています。各クラスローダは1つの親クラスローダを持っていて、自分でロードする前に親にロードを委譲したり、自分でクラスを見つけられなかったときに親に問い合せたりします。

エンタープライズウェブアプリケーションの場合は基本クラスローダだけではなく、アプリケーションサーバがEARごと・WARごとにクラスローダを作成しています*1。このため、アプリケーションごとに異なるライブラリを使用することが可能です。またWARのクラスローダはEARのクラスローダを親として持っているため、skinny WARと呼ばれる技法によりEARのファイルサイズを減らすこともできます。

クラスロードポリシー

クラスローダはクラスを読み込んでくれるものだと書きましたが、その探し方には2パターンあります。

1つ目は「親が最初」、つまりまずは親クラスローダに読み込みを依頼するというものです。親クラスローダが対象クラスを見つけた場合はそれを返し、見つけられなかった場合は自分が参照できるJARファイルやclassファイルからの発見を試みます。クラスローダのデフォルトの挙動はこの「親が最初」になっています。

a ClassLoader instance will delegate the search for the class or resource to its parent class loader before attempting to find the class or resource itself. http://docs.oracle.com/javase/7/docs/api/java/lang/ClassLoader.html

2つ目は「親が最後」です。まず自分でクラスを探し、見つからなかったときにだけ親に委譲します。GitHubに「親が最後」クラスローダの簡易実装を置いてあります

クラスのアンロード

クラスローダはロードしたクラスをメモリ上のパーマネント領域に記録します。ロードされたクラスはクラスローダおよびクラスの両方が使われなくなったときにアンロードされ、メモリが解放されます。

例えばアプリケーションサーバを起動したまま(JVMを動作させたまま)ウェブアプリケーションを再起動すると、かつて使っていたクラスローダとクラスはGCとアンロードによってメモリから消去されます。新しくクラスローダが作られ、新規にクラスがロードされていきます。

詳しく学ぶにはNAKAMURA MinoruさんのJava のクラスアンロード (Class Unloading)が参考になります。

事例集

以上を踏まえて、トラブルを見ていきましょう。

warやearにパッケージしたライブラリが使われない問題

クラスローダはデフォルトで「親が最初」ポリシーを利用するため、Java基本クラスローダなどの親クラスローダが同じ名前のクラスを持っていた場合、子クラスローダは自分が参照している場所からクラスを読み込むことがありません。 このため以下のテストケースがpassします。親も子も同じクラスを参照するため、各クラスローダから取ってきたシングルトンインスタンスが等しいことがわかります。

    @Test
    public void parentFirstHasNoMultiSingletonProblem() throws Exception {
        Object singletonInParent = findSingletonInstanceFrom(firstClassLoader);
        Object singletonInChild = findSingletonInstanceFrom(childOfFirstClassLoader);
        assertThat(singletonInChild, is(sameInstance(singletonInParent)));

        Object singletonInAnotherChild = findSingletonInstanceFrom(anotherChildOfFirstClassLoader);
        assertThat(singletonInAnotherChild, is(sameInstance(singletonInParent)));
        assertThat(singletonInAnotherChild, is(sameInstance(singletonInChild)));
    }

このため以下のようなトラブルが発生した場合は、親クラスローダがパッケージされているものと同じライブラリを持っていないか確認する必要があります。

シングルトンがシングルトンじゃない問題

developerWorksにも細かい解説が載っていますが、シングルトンパターンで使用するようなstaticフィールドは「JVM内に1つしかない」のではなく「ロードされたクラスごとに1つずつある」ため、以下のような条件下で期待と異なる動作をします。

  • 「親が最後」ポリシーを使用した場合
  • 兄弟クラスローダがそれぞれクラスをロードした場合

以下のテストケースは上記2パターンでシングルトンインスタンスが複数存在してしまうケースを説明するものです。

    @Test
    public void parentLastMayHaveMultiSingletonProblem() throws Exception {
        Object singletonInParent = findSingletonInstanceFrom(secondClassLoader);
        Object singletonInChild = findSingletonInstanceFrom(childOfSecondClassLoader);
        assertThat(singletonInChild, is(not(sameInstance(singletonInParent))));

        Object singletonInAnotherChild = findSingletonInstanceFrom(anotherChildOfSecondClassLoader);
        assertThat(singletonInAnotherChild, is(not(sameInstance(singletonInParent))));
        assertThat(singletonInAnotherChild, is(not(sameInstance(singletonInChild))));
    }

こうした問題もあるので、今であればJ2EE環境では自前でシングルトンを実装するのではなく、環境が提供する手法(@Singletonアノテーションとか)に頼ったほうが良いでしょう。テスト可能性も向上するはずです。

アプリケーションを再起動したのにメモリが解放されない問題

上述したとおり、クラスがアンロードされるためにはクラスとクラスローダの双方が利用されない必要があります。利用されないというのは、それらのインスタンスやstaticフィールド、Classインスタンスなどへの参照がなくなることです。

例として以下のテストを見てみます。クラスとクラスローダに対する参照は破棄されているもののインスタンスに対する参照(singletonInstance変数)が残っているため、クラスがアンロードされません。

    @Test
    public void classLoaderShouldNotBeFinalizedIfSomeoneRefersIt() throws Exception {
        UnloadEventListener listener = new UnloadEventListener();
        Object singletonInstance = loadSingletonFromNewClassLoader(listener);
        assertThat(listener.unloaded, is(false));

        runFinalization(listener);
        assertThat("class loader should not be finalized, because we still use Singleton instance", listener.unloaded, is(false));
        System.out.printf("Singleton instance is %s%n", singletonInstance.getClass().getClassLoader()); // we should use singletonInstance like this, or optimization removes singletonInstance local variable
    }

本番環境では、Java基本クラスローダやアプリケーションサーバのクラスローダがロードしたクラスからの参照がリークの原因となります。

事例をまとめた資料として、nekopさんのクラスローダーリークパターンがすばらしいです。JCL(Jakarta Commons Logging)あるいはJUL(java.util.logging)を使用されている場合は必見です。

おわりに

以上、駆け足でしたがクラスローダとそれに関連するトラブルを紹介しました。クラスローダはJVMの基本のひとつであり、J2EEではEJBなどでも利用されています。理解を深めて気軽に触れるようになっておきましょう。

サンプルコード

本記事で使用したコードはすべてGitHubから入手いただけます。クラスローダ実装例ないしJUnitの使い方としても使えそうです。

関連記事

クラスローダ自作はバイトコードを生成・改変する際にも行います。このブログでもObjectWeb ASMによるクラスの動的定義で使用しています。

*1:アプリケーションサーバによっては設定で変更することも可能