Webアプリケーションクラスローダーのシミュレーション
以前に、止まってくれないWebアプリをテーマに次のような記事を書いた。
で、検証に使用していたWASのトライアル版は(たぶん)ライセンスの期限が切れてるし、上の記事のような内容であれば普通のJavaアプリでいいわけだし、ということで、お手軽検証フレームワークを作ってみた。
要はあるクラスローダーからロードされたクラスのインスタンスがエントリポイントとなってさまざまなオブジェクトを生成し、停止命令を出した後にそのクラスローダーがGCによって回収されるかどうかを見ればよいわけだ。ここで「クラスローダー=Webアプリケーションクラスローダー」、「エントリポイント=Servlet」とみなせばWebアプリと同等になる。
ソースコード
まず、エントリポイントとなるクラスが実装するインタフェース。別にコンストラクタですべての処理をするならばこんなインタフェースはいらないんだけど、あった方がそれっぽいでしょ、ということで。
package core; /** * アプリケーションのエントリポイントとなるクラスが実装するインタフェース。 * * @author hhelibex */ public interface App { /** * アプリケーションの初期化を行う。 */ void initialize(); /** * アプリケーションの停止処理を行う。 */ void destroy(); /** * アプリケーションのパラメータをセットする。 * * @param name パラメータ名 * @param value パラメータ値 */ void setParameter(String name, String value); }
で、そのアプリケーションを起動するためのランチャークラス。
package core; import java.lang.ref.Reference; import java.lang.ref.WeakReference; import java.lang.reflect.Constructor; import java.net.URL; import java.net.URLClassLoader; import java.util.LinkedHashMap; import java.util.Map; /** * アプリケーションのランチャー。 * * @author hhelibex */ public class AppLauncher { /** * アプリケーションクラスローダーへの参照。 */ private Reference<ClassLoader> ref; /** * 起動中のアプリケーション。 */ private App app; /** * アプリケーションコンテキストのクラスパス。 */ private URL[] clsPath; /** * アプリケーションのエントリポイントとなるクラスの名前。 */ private String clsName; /** * アプリケーションに与えるパラメータ。 */ private Map<String, String> params; /** * アプリケーションのランチャーを生成する。 * * @param clsPath アプリケーションコンテキストのクラスパス * @param clsName アプリケーションのエントリポイントとなるクラスの名前 * @param params アプリケーションに与えるパラメータ */ public AppLauncher(URL[] clsPath, String clsName, Map<String, String> params) { this.clsPath = clsPath.clone(); this.clsName = clsName; this.params = new LinkedHashMap<String, String>(params); } /** * アプリケーションクラスローダーへの参照を取得する。 * * @return アプリケーションクラスローダーへの参照 */ public synchronized Reference<ClassLoader> getClassLoaderReference() { return ref; } /** * アプリケーションを起動する。 * * @throws Exception いろいろ(オイ) */ public synchronized void initialize() throws Exception { if (this.app != null) { throw new IllegalStateException("This application has been started."); } /* * アプリケーションクラスローダー。 * GCによって回収、破棄されたのが分かるようにするために、 * finalize() メソッド内でログ出力。 * (finalize() をオーバーライドするとGC時の動作が * ちょっと変わっちゃうんだけどとりあえず気にしない。) */ ClassLoader classLoader = new URLClassLoader(clsPath) { protected void finalize() throws Throwable { System.out.println("[" + Thread.currentThread().getName() + "] " + "### finalize start"); try { super.finalize(); } finally { System.out.println("[" + Thread.currentThread().getName() + "] " + "### finalize end"); } } }; /* * アプリケーションのエントリポイントをインスタンス化。 */ Class<?> c = Class.forName(clsName, true, classLoader); Class<? extends App> cls = c.asSubclass(App.class); Constructor<? extends App> constructor = cls.getConstructor(); App r = constructor.newInstance(); /* * パラメータの設定。 */ for (Map.Entry<String, String> entry : params.entrySet()) { r.setParameter(entry.getKey(), entry.getValue()); } /* * 生成したクラスローダーと、アプリケーションのエントリポイントを * ロードしたクラスローダーを比較。 * (詳しくはログ出力を見てね(謎)) */ System.out.println("===+ " + classLoader); System.out.println("===- " + r.getClass().getClassLoader()); /* * アプリケーション起動の実処理。 */ r.initialize(); /* * アプリケーションクラスローダー等の参照を保持。 */ this.ref = new WeakReference<ClassLoader>(classLoader); this.app = r; } /** * アプリケーションを停止する。 */ public synchronized void destroy() { if (app != null) { app.destroy(); app = null; } } }
さらに、アプリケーションランチャーの使用例。パラメータのエラーチェックは手抜き(というかやってない)。
package core; import java.io.File; import java.lang.ref.Reference; import java.net.URL; import java.util.HashMap; import java.util.Map; public class Main { /** * 使用法: * java core.Main <app-classpath> <app-class-name> <param-name-1> <param-value-1> ‥ * * <app-classpath>: * アプリケーションのエントリポイントとなるクラスが含まれるディレクトリ * <app-class-name>: * アプリケーションのエントリポイントとなるクラスの名前 * <param-name-n>: * パラメータ名 * <param-value-n>: * パラメータの値 */ public static void main(String[] args) throws Exception { URL[] clsPath = new URL[] { new File(args[0]).toURI().toURL(), }; String clsName = args[1]; Map<String, String> params = new HashMap<String, String>(); for (int i = 2; i + 1 < args.length; i += 2) { params.put(args[i], args[i + 1]); } AppLauncher launcher = new AppLauncher(clsPath, clsName, params); launcher.initialize(); launcher.destroy(); Reference<ClassLoader> ref = launcher.getClassLoaderReference(); /* * アプリケーションクラスローダーのインスタンスがGCで回収されるまで、 * 待つ、待つ、待つ‥ */ System.out.println("[" + Thread.currentThread().getName() + "] " + "### sleep start"); for (int i = 0; ref.get() != null; ++i) { System.out.println("[" + Thread.currentThread().getName() + "] " + "### sleep[" + i + "] start"); System.gc(); try { Thread.sleep(1000); } catch (InterruptedException e) { e.printStackTrace(System.out); } System.out.println("[" + Thread.currentThread().getName() + "] " + "### sleep[" + i + "] end"); } System.out.println("[" + Thread.currentThread().getName() + "] " + "### sleep end"); } }
実験例
たとえば、staticなThreadLocalに、アプリケーションクラスローダーからロードされたクラスのインスタンスを保持してしまうという例。
package sample; import core.App; public class AppImpl implements App { private static ThreadLocal<App> tl = new ThreadLocal<App>(); public AppImpl() { tl.set(this); } public void initialize() { } public void destroy() { } public void setParameter(String name, String value) { } }
これを実行する際に注意しなければならないのは、アプリケーションのエントリポイントとなるクラス(AppImpl)とランチャーを同じクラスパスに入れないこと。
そこで、こんな感じのスクリプトを作る。(Windowsでゴメンナサイ(謎))
@ECHO OFF SETLOCAL CD /D %~dp0 SET JAVA_HOME=C:\jdk1.5.0_22 SET PATH=%JAVA_HOME%\bin;%PATH% RMDIR /S /Q core-classes RMDIR /S /Q app-classes MKDIR core-classes MKDIR app-classes javac -encoding UTF-8 -d core-classes src\core\App.java src\core\AppLauncher.java src\core\Main.java javac -encoding UTF-8 -classpath core-classes -d app-classes src\sample\AppImpl.java java -classpath core-classes core.Main app-classes sample.AppImpl ENDLOCAL
で、これを実行する。
C:\work> test.bat ===+ core.AppLauncher$1@9304b1 ===- core.AppLauncher$1@9304b1 [main] ### sleep start [main] ### sleep[0] start [main] ### sleep[0] end [main] ### sleep[1] start [main] ### sleep[1] end [main] ### sleep[2] start [main] ### sleep[2] end [main] ### sleep[3] start [main] ### sleep[3] end [main] ### sleep[4] start [main] ### sleep[4] end [main] ### sleep[5] start [main] ### sleep[5] end [main] ### sleep[6] start [main] ### sleep[6] end [main] ### sleep[7] start [main] ### sleep[7] end [main] ### sleep[8] start [main] ### sleep[8] end [main] ### sleep[9] start [main] ### sleep[9] end [main] ### sleep[10] start [main] ### sleep[10] end [main] ### sleep[11] start [main] ### sleep[11] end [main] ### sleep[12] start [main] ### sleep[12] end [main] ### sleep[13] start [main] ### sleep[13] end [main] ### sleep[14] start [main] ### sleep[14] end [main] ### sleep[15] start [main] ### sleep[15] end [main] ### sleep[16] start [main] ### sleep[16] end [main] ### sleep[17] start [main] ### sleep[17] end [main] ### sleep[18] start [main] ### sleep[18] end [main] ### sleep[19] start [main] ### sleep[19] end [main] ### sleep[20] start [main] ### sleep[20] end [main] ### sleep[21] start バッチ ジョブを終了しますか (Y/N)? y C:\work>
‥とまぁ、いつまでたっても終了しないので強制終了するしかないのだが、AppImpl のコンストラクタの中身を次のようにコメントアウトする:
public AppImpl() { // tl.set(this); }
と、次のようにさくっと終了する。
C:\work> test.bat ===+ core.AppLauncher$1@9304b1 ===- core.AppLauncher$1@9304b1 [main] ### sleep start [main] ### sleep[0] start [Finalizer] ### finalize start [Finalizer] ### finalize end [main] ### sleep[0] end [main] ### sleep end C:\work>
なお、コードは Java SE 5 のものとなっているが、必要なところを書き換えれば Java 2 SE 1.4 でも動作は同じになる(はず)。