HHeLiBeXの日記 正道編

日々の記憶の記録とメモ‥

Webアプリケーション開発の際の注意事項(3)

これまでの記事。

ただ、これまでとは違って、コードの書き方の問題ではなく使用しているライブラリに関連する問題。

以前に、Commons FileUploadに関連する問題として、File.deleteOnExit()の呼び出しに関連する問題について書いた。

ここで、Commons FileUpload v1.1以降を使用すると問題が解決すると書いたが、これには別の問題が潜んでいた。
Commons FileUpload v1.1以降ではCommons IOのFileCleanerクラスを使用してファイルの削除処理を行なっている。これがくせもの。
Commons IO v1.2に含まれるFileCleanerクラスのソースコードを見てみると、次のようなコードが見つかる。(説明のために行番号を付与している)(コードのカラーリングのために行番号が見づらいかも。ご容赦を)

 48:    /**
 49:     * The thread that will clean up registered files.
 50:     */
 51:    private static Thread reaper = new Thread("File Reaper") {
 52:
 53:        /**
 54:         * Run the reaper thread that will delete files as their associated
 55:         * marker objects are reclaimed by the garbage collector.
 56:         */
 57:        public void run() {
 58:            for (;;) {
 59:                Tracker tracker = null;
 60:                try {
 61:                    // Wait for a tracker to remove.
 62:                    tracker = (Tracker) q.remove();
 63:                } catch (Exception e) {
 64:                    continue;
 65:                }
 66:
 67:                tracker.delete();
 68:                tracker.clear();
 69:                trackers.remove(tracker);
 70:            }
 71:        }
 72:    };
 73:
 74:    /**
 75:     * The static initializer that starts the reaper thread.
 76:     */
 77:    static {
 78:        reaper.setPriority(Thread.MAX_PRIORITY);
 79:        reaper.setDaemon(true);
 80:        reaper.start();
 81:    }

なんと無限ループ。しかも、このreaperという変数は上記以外に参照しているコードがない。唯一InterruptedExceptionが発生する可能性があるのが62行目だが、仮に発生したとしてもその対処は「continue;」なのでループを抜けることはありえない。
これは、Webアプリケーションを停止した場合にクラスローダーオブジェクトが正常に破棄されない条件(「スレッドの生成」)に合致する。

実際に検証してみる。検証には、前に作ったシミュレーターを使用。
Webアプリケーションクラスローダーのシミュレーション - HHeLiBeXの日記 正道編
まず、以下の目的を達成できるようにcore.Mainクラスをちょっと書き換える。

  • アプリを「起動-停止-起動-停止」と繰り返す処理を書きやすいようにする。
  • アプリのクラスパスエントリを複数指定できるようにする。
package core;

import java.io.File;
import java.lang.ref.Reference;
import java.net.MalformedURLException;
import java.net.URL;
import java.util.ArrayList;
import java.util.HashMap;
import java.util.List;
import java.util.Map;

public class Main {

    /**
     * 使用法:
     *   java core.Main <app-classpath><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;
        int idx = 0;
        {
            List<URL> list = new ArrayList<URL>();
            for (; idx < args.length; ++idx) {
                if (args[idx].equals("--")) {
                    break;
                }
                list.add(new File(args[idx]).toURI().toURL());
            }
            clsPath = list.toArray(new URL[list.size()]);
        }
        String clsName = args[++idx];
        Map<String, String> params = new HashMap<String, String>();
        for (++idx; idx + 1 < args.length; idx += 2) {
            params.put(args[idx], args[idx + 1]);
        }

        test(clsPath, clsName, params);
        test(clsPath, clsName, params);
    }

    private static void test(URL[] clsPath, String clsName, Map<String, String> params) throws MalformedURLException, Exception {
        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");
    }

}

で、作成するアプリ実装(core.Appの実装)は次のとおり。

package test;

import java.io.File;
import java.io.IOException;

import org.apache.commons.io.FileCleaner;

import core.App;

public class AppImpl implements App {

    public void initialize() {
        try {
            File file = File.createTempFile("test", ".txt");
            System.out.println(file.getCanonicalFile());
            Object marker = new Object() {
                protected void finalize() throws Throwable {
                    System.out.println("[" + Thread.currentThread().getName() + "] " + "### marker finalize start");
                    try {
                        super.finalize();
                    } finally {
                        System.out.println("[" + Thread.currentThread().getName() + "] " + "### marker finalize end");
                    }
                }
            };
            FileCleaner.track(file, marker);
            Thread.sleep(5000);
            marker = null;
            System.gc();
            System.gc();
            System.gc();
        } catch (IOException e) {
            e.printStackTrace();
        } catch (InterruptedException e) {
            e.printStackTrace();
        } finally {
        }
    }

    public void destroy() {
    }

    public void setParameter(String name, String value) {
    }

}

さらに、こんな感じのAntビルドファイルを作る。Commons IO v1.2のJARファイルを配置することを忘れずに。

<?xml version='1.0' encoding='UTF-8'?>

<project name="WebApps simulator" default="run" basedir=".">
	<property name="io.version" value="1.2" />
	<target name="run">
		<delete dir="${basedir}/build" />
		<mkdir dir="${basedir}/build/classes" />
		<javac destdir="${basedir}/build/classes" encoding="utf-8" debug="true" optimize="false">
			<src path="${basedir}/src" />
			<include name="**/*.java" />
			<classpath>
				<pathelement path="${basedir}/core-classes" />
				<pathelement location="${basedir}/lib/commons-io-${io.version}.jar" />
			</classpath>
		</javac>
		<java classname="core.Main" fork="true" timeout="30000">
			<classpath>
				<pathelement path="${basedir}/core-classes" />
			</classpath>
			<arg value="${basedir}/build/classes" />
			<arg value="${basedir}/lib/commons-io-${io.version}.jar" />
			<arg value="--" />
			<arg value="test.AppImpl" />
		</java>
	</target>
</project>

で、実行してみる。

Buildfile: C:\workspace\testProject\build.xml
run:
   [delete] Deleting directory C:\workspace\testProject\build
    [mkdir] Created dir: C:\workspace\testProject\build\classes
    [javac] Compiling 1 source file to C:\workspace\testProject\build\classes
     [java] ===+ core.AppLauncher$1@a90653
     [java] ===- core.AppLauncher$1@a90653
     [java] C:\Documents and Settings\hhelibex\Local Settings\Temp\test723462518477310157.txt
     [java] [Finalizer] ### marker finalize start
     [java] [Finalizer] ### marker finalize end
     [java] [main] ### sleep start
     [java] [main] ### sleep[0] start
     [java] [main] ### sleep[0] end
     [java] [main] ### sleep[1] start
     [java] [main] ### sleep[1] end
     [java] [main] ### sleep[2] start
(中略)
     [java] [main] ### sleep[23] start
     [java] [main] ### sleep[23] end
     [java] [main] ### sleep[24] start
     [java] Timeout: killed the sub-process
     [java] 	at org.apache.tools.ant.taskdefs.Java.fork(Java.java:770)
     [java] 	at org.apache.tools.ant.taskdefs.Java.executeJava(Java.java:194)
     [java] 	at org.apache.tools.ant.taskdefs.Java.execute(Java.java:104)
     [java] 	at org.apache.tools.ant.UnknownElement.execute(UnknownElement.java:288)
     [java] 	at sun.reflect.NativeMethodAccessorImpl.invoke0(Native Method)
     [java] 	at sun.reflect.NativeMethodAccessorImpl.invoke(NativeMethodAccessorImpl.java:39)
     [java] 	at sun.reflect.DelegatingMethodAccessorImpl.invoke(DelegatingMethodAccessorImpl.java:25)
     [java] 	at java.lang.reflect.Method.invoke(Method.java:592)
     [java] 	at org.apache.tools.ant.dispatch.DispatchUtils.execute(DispatchUtils.java:105)
     [java] 	at org.apache.tools.ant.Task.perform(Task.java:348)
     [java] 	at org.apache.tools.ant.Target.execute(Target.java:357)
     [java] 	at org.apache.tools.ant.Target.performTasks(Target.java:385)
     [java] 	at org.apache.tools.ant.Project.executeSortedTargets(Project.java:1329)
     [java] 	at org.apache.tools.ant.Project.executeTarget(Project.java:1298)
     [java] 	at org.apache.tools.ant.helper.DefaultExecutor.executeTargets(DefaultExecutor.java:41)
     [java] 	at org.eclipse.ant.internal.ui.antsupport.EclipseDefaultExecutor.executeTargets(EclipseDefaultExecutor.java:32)
     [java] 	at org.apache.tools.ant.Project.executeTargets(Project.java:1181)
     [java] 	at org.eclipse.ant.internal.ui.antsupport.InternalAntRunner.run(InternalAntRunner.java:423)
     [java] 	at org.eclipse.ant.internal.ui.antsupport.InternalAntRunner.main(InternalAntRunner.java:137)
BUILD SUCCESSFUL
Total time: 31 seconds

タイムアウトによってkillされたということは、クラスローダーが破棄されなかったということ。
で、Commons IO v1.2ではこの問題に対応する手立てがないのだが、v1.3では対処がされているということらしい。
そこで、Commons IO v1.3に置き換えてやってみる‥が、「結果は同じ」。
なぜかというと、Commons IO v1.2を使用したコードを変更しない限りにおいては、JARファイルだけ入れ替えてもループが終了しないことに変わりはない。
で、Commons IO v1.3のJavaDocを見てみると、次のようなことが書いてある。


In an environment with multiple class loaders (a servlet container, for example), you should consider stopping the background thread if it is no longer needed. This is done by invoking the method exitWhenFinished, typically in javax.servlet.ServletContextListener#contextDestroyed or similar.
さらに、core.Mainクラスを書き換えた理由でもあるこんな記述。

This method allows the thread to be terminated. Simply call this method in the resource cleanup code, such as javax.servlet.ServletContextListener#contextDestroyed. One called, no new objects can be tracked by the file cleaner.
要するに、FileCleaner.exitWhenFinished()メソッドを呼び出した後にtrack()メソッドを呼び出すと、受け付けてくれず、例外が投げられるので、アプリを再起動した時にちゃんと初期化されることを確認したい。
で、test.AppImplクラスのdestroy()メソッドを次のように書き換える。

    public void destroy() {
        FileCleaner.exitWhenFinished();
    }

Commons IO v1.3のJARファイルを配置し、Antビルドファイルのプロパティ設定(io.version)を"1.3"に書き換えて実行する。

Buildfile: C:\workspace\testProject\build.xml
run:
   [delete] Deleting directory C:\workspace\testProject\build
    [mkdir] Created dir: C:\workspace\testProject\build\classes
    [javac] Compiling 1 source file to C:\workspace\testProject\build\classes
     [java] ===+ core.AppLauncher$1@a90653
     [java] ===- core.AppLauncher$1@a90653
     [java] C:\Documents and Settings\hhelibex\Local Settings\Temp\test7612513785273903068.txt
     [java] [Finalizer] ### marker finalize start
     [java] [Finalizer] ### marker finalize end
     [java] [main] ### sleep start
     [java] [main] ### sleep[0] start
     [java] [main] ### sleep[0] end
     [java] [main] ### sleep[1] start
     [java] [Finalizer] ### finalize start
     [java] [Finalizer] ### finalize end
     [java] [main] ### sleep[1] end
     [java] [main] ### sleep end
     [java] ===+ core.AppLauncher$1@a59698
     [java] ===- core.AppLauncher$1@a59698
     [java] C:\Documents and Settings\hhelibex\Local Settings\Temp\test8399714257004039282.txt
     [java] [Finalizer] ### marker finalize start
     [java] [main] ### sleep start
     [java] [Finalizer] ### marker finalize end
     [java] [main] ### sleep[0] start
     [java] [main] ### sleep[0] end
     [java] [main] ### sleep[1] start
     [java] [Finalizer] ### finalize start
     [java] [Finalizer] ### finalize end
     [java] [main] ### sleep[1] end
     [java] [main] ### sleep end
BUILD SUCCESSFUL
Total time: 15 seconds

今度は成功。起動と停止がちゃんと2回行なわれた。


このように、自分では使っている意識がなくても、あるライブラリが依存しているために使用されているライブラリというのはほぼ必ずあるもの。そう考えるとすごく憂鬱になる‥ # ←何かあったらしい(謎)