ClassLoaderを使って実行時にクラスを更新する方法(リロード処理)

JavaのClassLoaderの話をちょっとだけ。意外にClassLoaderを知らない人が居るようです。

ざっくりと説明をしますと、
クラスローダーにはBootstrapクラスローダーやシステムクラスローダー、コンテキストクラスローダーがあります。(あと拡張クラスローダーですかね)。そんでもって、クラスローダーは親子関係を持っています。委譲モデルと言って、あるクラスローダーにクラスをロードする依頼があると、親に依頼を委譲します。最初に発見されたクラスが実際にロードされるわけです。

1. Bootstrap (rt.jar)
2. 親クラスローダーA
3. 子クラスローダーB

こんなクラスローダーがあるとしてまだロードされていないクラスXをロードしようとした場合は次のように動作します。

  1. B.loadClass(X)
  2. Aに委譲
  3. Bootstrapに委譲
  4. BoostrapにXは存在しない
  5. AにXは存在しない
  6. BにXが存在するのでロードする。

ちなみにClassLoader#findClassをオーバーライドして自前の処理で上書きしてしまえば、この委譲モデルを破壊する事ができます。まぁそんな事は非推奨ですけど(´・ω・)

JVMプロセス内においては異なるClassLoaderで読み込んだクラスは例え同じバイナリでも異なるクラスとして扱われます。ClassLoaderAによってロードされたクラスXと、ClassLoaderBによってロードされたクラスXは別物です。
※クラスAとBが親子関係を持っていて、AがXをロードしている時にBでロードしても委譲モデルでAが既にロードしてあるXを使うので、こういう場合は同じ定義を指します。
TomcatのWebApps1とWebApps2の関係を想像すると理解しやすいと思います。

この特性を生かすとリロード処理が実現できるわけです。

ClassLoaderの細かい話はDeveloperWorksの記事が丁寧なのでそちらを参照した方が良いです。今回はTomcatにも実装されているリロード処理のサンプルを作ってみたいと思います。TomcatのドキュメントにTomcatのClassLoaderの戦略が書いてあるのでそれを参考にすると分かりやすいと思います。

例えばRPCサーバやスケジューラーを考えます。これらを設計する時には大きく二つに分けることができ、RPCサーバではサーバ本体と各命令、スケジューラーでも本体と各タスクという感じです。Tomcatの場合はServletエンジンとビジネスロジックを書いたServlet。(ビジネスロジックはServletに書くものではありませんけどね。)
RPCの命令、スケジューラーのタスク、ServletコンテナのServletは抽象化する事ができ、インターフェースを切ることが多いです。(HttpServletとかね。)

擬似コードだとこんな感じですね。

class MyServer {
  void run() {
    ITask task = getTask();
    task.execute();
  }
}

このMyServerとITaskを本体側のClassLoaderに、ITaskを実装したクラスを別のClassLoaderに読み込ませます。
例えば本体はCLASSPATHを指定してシステムクラスローダーに読み込ませます。(TomcatはBoostrap用のクラスを挟んでいますけどね。)
タスクは独自のクラスローダーをインスタンス化(このオブジェクトをc1とする)して(URLClassLoaderを直接でも良い)、そのClassLoaderを使ってクラス定義を読み込ませます。C言語だとdlopenみたいなイメージです。

異なるクラスローダーから読み込んだクラスは別のクラスになるので、リロードしたい場合はClassLoaderをもう1回インスタンス化(c2)してあげればいいのです。

文章だけで説明してもアレなので簡単なサンプルを書いてみました。
Thread#setContextClassLoaderを使うともっと簡単に書けると思います。

サンプルの内容は定期的にタスクを実行するだけのものです。Reload処理はJMX経由で行うようにしました。
Eclipse上で作る時はタスククラスは別プロジェクトで作るのが良いです。同じプロジェクトに入れてしまうとシステムクラスローダーによってタスクもロードされてしまうので。色々適当です。Sleepの代わりにSemaphoreを使っている理由はsleepの場合Future#cancelで割り込みが発生しなかったからです。Semaphoreだと割り込みが入ってすぐに終了できます。
tasklist.propertiesに登録したいタスクのクラス名を書く必要があります。まぁこんな事をしなくてもLunarClassLoaderに手を加えて特定のインターフェースを実装しているクラスを検索(列挙)すれば良いだけです。今回はそこまで余力がありませんでした。 タスク用のクラスパスはソースに直書きしています。自分の環境に合わせて修正してください。VMの引数に-Dhogehogeを指定してシステムプロパティとして読み込むのが一般的な方法かと思います。

package at.orz.lunar;

import java.io.File;
import java.lang.management.ManagementFactory;
import java.net.URL;
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;
import java.util.concurrent.Future;

import javax.management.MBeanServer;
import javax.management.ObjectName;

public class LunarBootstrap {

    public static void main(String[] args) throws Exception {

        URL[] urls = new URL[]{
            new File("C:/usr/local/eclipse3.5/workspace/lunar-subsystem/lunar-task.jar").toURI().toURL(),
            new File("C:/usr/local/eclipse3.5/workspace/lunar-subsystem/bin").toURI().toURL()
        };

        final ExecutorService service = Executors.newCachedThreadPool();
        final LunarServer lunarServer = LunarServer.newInstance(urls, Thread.currentThread().getContextClassLoader());
        final Future<?> future = service.submit(lunarServer);
        service.shutdown(); // 新規追加STOP

        MBeanServer mbServer = ManagementFactory.getPlatformMBeanServer();
        mbServer.registerMBean(new ServerOperationMXBean() {
            @Override
            public void stop() {
                lunarServer.stop();
                future.cancel(true);
                if (!service.isShutdown()) {
                    service.shutdown();
                }
            }
            @Override
            public void reload() {
                lunarServer.reload();
            }
        },  new ObjectName("at.orz.lunar.LunarBootstrap:name=LunarServer"));

    }

}

シングルインスタンスモデルにするか、プロトタイプモデルにするか迷ったのでclsMapとかでClass定義を保持しているわけです。今回はシングルインスタンスモデルにしたので実はclsMapは必要ありません。

package at.orz.lunar;

import java.io.IOException;
import java.io.InputStream;
import java.net.URL;
import java.net.URLClassLoader;
import java.util.Enumeration;
import java.util.HashMap;
import java.util.Properties;

public class LunarClassLoader extends URLClassLoader {

    private HashMap<String, Class<? extends LunarTask>> clsMap = new HashMap<String, Class<? extends LunarTask>>();
    private HashMap<String, LunarTask> insMap = new HashMap<String, LunarTask>();

    public LunarClassLoader(URL[] urls, ClassLoader parent) {
        super(urls, parent);
        init();
    }

    @SuppressWarnings("unchecked")
    private void init() {

        try {

            Enumeration<URL> en = getResources("tasklist.properties");
            while (en.hasMoreElements()) {
                URL url =  en.nextElement();

                Properties prop = new Properties();
                InputStream is = url.openStream();
                prop.load(is);
                is.close();

                for (String clsName: prop.stringPropertyNames()) {
                    try {
                        Class<?> c = loadClass(clsName);
                        if (LunarTask.class.isAssignableFrom(c)) {
                            Class<? extends LunarTask> cls = (Class<? extends LunarTask>) c;
                            clsMap.put(clsName, cls);
                            System.out.printf("register class:%s%n", clsName);

                            LunarTask task = cls.newInstance();
                            task.init();
                            insMap.put(clsName, task);
                            System.out.printf("init class:%s%n", clsName);
                        }
                    } catch (ClassNotFoundException e) {
                        e.printStackTrace();
                    } catch (InstantiationException e) {
                        e.printStackTrace();
                    } catch (IllegalAccessException e) {
                        e.printStackTrace();
                    }
                }

            }
        } catch (IOException e) {
            e.printStackTrace();
        }

    }

    void destroy() {
        for (LunarTask task : insMap.values()) {
            try {
                task.destroy();
            } catch (Exception e) {
                e.printStackTrace();
            }
        }
        insMap.clear();
        clsMap.clear();
    }

    public LunarTask getTask(String className) {
        return insMap.get(className);
    }

    public Iterable<LunarTask> taskIterator() {
        return new HashMap<String, LunarTask>(insMap).values();
    }

}
package at.orz.lunar;

import java.net.URL;
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;
import java.util.concurrent.Semaphore;
import java.util.concurrent.TimeUnit;

public class LunarServer implements Runnable, ServerOperationMXBean {

    private LunarClassLoader loader;
    private volatile boolean running;
    private LunarServer() {

    }

    @Override
    public void run() {

        ExecutorService service = Executors.newFixedThreadPool(3);

        running = true;
        while (running) {
            for (final LunarTask task : loader.taskIterator()) {
                service.submit(new Runnable() {
                    @Override
                    public void run() {
                        task.execute();
                    }
                });
            }
            try {
                Semaphore sema = new Semaphore(0);
                sema.tryAcquire(10, TimeUnit.SECONDS);
            } catch (InterruptedException e) {
                e.printStackTrace();
            }
        }
        service.shutdown();

    }

    @Override
    public void reload() {
        LunarClassLoader curLoader = loader;
        LunarClassLoader newLoader = new LunarClassLoader(curLoader.getURLs(), curLoader.getParent());
        this.loader = newLoader;
        curLoader.destroy();
    }

    @Override
    public void stop() {
        running = false;
    }

    public static LunarServer newInstance(URL[] urls, ClassLoader parent) {
        LunarServer server = new LunarServer();
        server.loader = new LunarClassLoader(urls, parent);
        return server;
    }

}
package at.orz.lunar;

public interface LunarTask {
    public void init();
    public void destroy();
    public void execute();
}
package at.orz.lunar;

public interface ServerOperationMXBean {
    public void stop();
    public void reload();
}

ここからタスクです。Eclipse上では別プロジェクトにした方が良いです。今回は2つのクラスを登録します。

package at.orz.lunar.task;

import at.orz.lunar.LunarTask;

public class SampleTask1 implements LunarTask {
    @Override
    public void destroy() {
        System.out.println("SampleTask1.destroy()");
    }
    @Override
    public void execute() {
        System.out.println("☆SampleTask1.execute()");
    }
    @Override
    public void init() {
        System.out.println("SampleTask1.init()");
    }
}
package at.orz.lunar.task;

import at.orz.lunar.LunarTask;

public class SampleTask2 implements LunarTask {
    @Override
    public void destroy() {
        System.out.println("SampleTask2.destroy()");
    }
    @Override
    public void execute() {
        System.out.println("★SampleTask2.execute()");
    }
    @Override
    public void init() {
        System.out.println("SampleTask2.init()");
    }
}

クラスパスのルートにtasklist.propertiesを作成します。

at.orz.lunar.task.SampleTask1
at.orz.lunar.task.SampleTask2

実行した後にSampleTask2の内容を変更(★を★★★★★に変更)した後にjconsoleからreloadを呼び出した結果が↓です。

register class:at.orz.lunar.task.SampleTask2
SampleTask2.init()
init class:at.orz.lunar.task.SampleTask2
register class:at.orz.lunar.task.SampleTask1
SampleTask1.init()
init class:at.orz.lunar.task.SampleTask1
★SampleTask2.execute()
☆SampleTask1.execute()
★SampleTask2.execute()
☆SampleTask1.execute()
☆SampleTask1.execute()
★SampleTask2.execute()
☆SampleTask1.execute()
★SampleTask2.execute()
register class:at.orz.lunar.task.SampleTask2
SampleTask2.init()
init class:at.orz.lunar.task.SampleTask2
register class:at.orz.lunar.task.SampleTask1
SampleTask1.init()
init class:at.orz.lunar.task.SampleTask1
SampleTask1.destroy()
SampleTask2.destroy()
☆SampleTask1.execute()
★★★★★SampleTask2.execute()
☆SampleTask1.execute()
★★★★★SampleTask2.execute()

リロード処理の所にログ仕込むの忘れたのでリロードが発端になっているかは確認できませんが、リロード処理は呼ばれています(; ・`д・´)
そしてリ新しいクラスの内容が読み込まれているのも確認できます。

※途中から書くのが面倒になったのがソースからわかっちゃうと思いますが、サンプルなので気にしない(・ε・)

ただし、このような処理はちょっとミスするとPermGenが解放されずにOutOfMemoryErrorを引き起こす原因になりやすいので気をつけましょー。(IBMのVM使えばいいじゃんとかそういう野暮な事は言わないの。)

 

新しいサイトもよろしくお願いします!