見出し画像

Javassistとメモリリーク

何かと便利なJavassistですが、使い方によってはメモリリークが発生することをご存じでしょうか。今回はJavassistのメモリリーク回避方法を紹介したいと思います。


下準備

まずはメモリリークの検証コードを実行する下準備をします。
今回はIntelliJで環境を構築しました。

プロジェクトの作成

IntelliJでプロジェクトを新規作成します。

  • 言語:Java

  • ビルドシステム:Gradle

  • JDK:11.0.14

  • Gradle DSL:Groovy

依存ライブラリの指定

build.gradleを編集して、Javassistを利用できるようにします。
Javassistのバージョンは3.29.2-GAを使用しました。

dependencies {
    implementation("org.javassist:javassist:3.29.2-GA")
}

検証に使用するIFとClassを作成

Javassist経由で利用するIFとClassを作成します。

package com.wingarc.note.javassist;

public interface Executable {
    int exec();
}

public class Template implements Executable {
    @Override
    public int exec() {
        return 0;
    }
}

※メモリリークを確認することが目的のため、中身は適当です。

メモリリークの検証

メモリリーク1

下準備で用意したTemplate Classを元に、新しいClassを作成するコードを用意しました。

package com.wingarc.note.javassist;

import javassist.ClassPool;
import javassist.CtClass;

import java.util.concurrent.atomic.AtomicInteger;

public class Main1 {

    private static final String TEMPLATE_CLASS = "com.wingarc.note.javassist.Template";

    public static void main(String[] args) throws Exception {
        ClassPool classPool = ClassPool.getDefault();
        AtomicInteger classNum = new AtomicInteger();

        long start = System.nanoTime();
        for (int i = 0; i < 100000; i++) {
            CtClass ctClass = classPool.getAndRename(TEMPLATE_CLASS,
                    TEMPLATE_CLASS + '_' + classNum.incrementAndGet());
            @SuppressWarnings("unchecked")
            Class<Executable> clazz = (Class<Executable>) ctClass.toClass();
            Executable executable = clazz.getDeclaredConstructor().newInstance();
            System.out.println(i + ": " + executable.exec());
        }
        long end = System.nanoTime();
        System.out.println((end - start) / 1000000 + "ms");
    }
}

処理の内容は以下のようになっています。

  1. Template Classを元に、CtClassを作成

  2. CtClassの書き換え(メモリリークの確認が目的のため省略)

  3. CtClassからClassを作成

  4. 作成したClassのインスタンスを作成

  5. 作成したインスタンスのexecメソッドを実行

  6. 1~5を10万回繰り返す

このコードをヒープ割り当て16MB(-Xmx16m)で実行すると、15000回ほど繰り返したところでOutOfMemoryErrorが発生します。

.
.
.
15072: 0
15073: 0

Exception: java.lang.OutOfMemoryError thrown from the UncaughtExceptionHandler in thread "main"

メモリリーク2

メモリリーク1で紹介したコードは、メモリリークとしてわかりやすいケースです。

原因はCtClassをdetachしていないことにあります。
CtClassをdetachしないと、ClassPoolからの参照が残りメモリリークします。

次のコードではCtClassをdetachしてClassPoolからの参照が残らないようにしました。

package com.wingarc.note.javassist;

import javassist.ClassPool;
import javassist.CtClass;

import java.util.concurrent.atomic.AtomicInteger;

public class Main2 {

    private static final String TEMPLATE_CLASS = "com.wingarc.note.javassist.Template";

    public static void main(String[] args) throws Exception {
        ClassPool classPool = ClassPool.getDefault();
        AtomicInteger classNum = new AtomicInteger();

        long start = System.nanoTime();
        for (int i = 0; i < 100000; i++) {
            CtClass ctClass = classPool.getAndRename(TEMPLATE_CLASS,
                    TEMPLATE_CLASS + '_' + classNum.incrementAndGet());
            try {
                @SuppressWarnings("unchecked")
                Class<Executable> clazz = (Class<Executable>) ctClass.toClass();
                Executable executable = clazz.getDeclaredConstructor().newInstance();
                System.out.println(i + ": " + executable.exec());
            }
            finally {
                ctClass.detach();
            }
        }
        long end = System.nanoTime();
        System.out.println((end - start) / 1000000 + "ms");
    }
}

同じようにヒープ割り当て16MBで実行すると、6万回を過ぎたあたりでOutOfMemoryErrorが発生します。
1.5万回からは増えていますが10万回の完遂はできません。

.
.
.
60080: 0
60081: 0
Exception in thread "main" java.lang.OutOfMemoryError: Java heap space
	at java.base/java.io.BufferedInputStream.<init>(BufferedInputStream.java:209)
	at java.base/java.io.BufferedInputStream.<init>(BufferedInputStream.java:189)
	at javassist.CtClassType.getClassFile3(CtClassType.java:221)
	at javassist.CtClassType.getClassFile2(CtClassType.java:178)
	at javassist.CtClassType.setName(CtClassType.java:382)
	at javassist.ClassPool.getAndRename(ClassPool.java:386)
	at com.wingarc.note.javassist.Main2.main(Main2.java:18)

原因と対策

ClassPoolからの参照が無くなったことで、リークする要素は一見なくなったように見えます。実際、Javassistがメモリリークしているわけではありません。それではどこでメモリリークが発生しているのでしょうか?

メモリリークの原因調査がテーマではないため、調査方法については省略しますが、メモリリークの箇所はClassLoaderになります。

ClassLoaderが解放されないので、ClassLoaderが保持しているClass情報も解放されないのです。

ClassLoaderの対策をしたコードは次のようになります。

package com.wingarc.note.javassist;

import javassist.ClassPool;
import javassist.CtClass;

import java.net.URL;
import java.net.URLClassLoader;
import java.util.concurrent.atomic.AtomicInteger;

public class Main3 {

    private static final String TEMPLATE_CLASS = "com.wingarc.note.javassist.Template";

    public static void main(String[] args) throws Exception {
        ClassPool classPool = ClassPool.getDefault();
        AtomicInteger classNum = new AtomicInteger();
        ClassLoader parent = Main3.class.getClassLoader();

        long start = System.nanoTime();
        for (int i = 0; i < 1000000; i++) {
            CtClass ctClass = classPool.getAndRename(TEMPLATE_CLASS,
                    TEMPLATE_CLASS + '_' + classNum.incrementAndGet());
            try {
                /*
                 * メモリリーク対策で毎回異なるクラスローダーを使用する
                 */
                URLClassLoader loader = new URLClassLoader(new URL[0], parent);
                @SuppressWarnings("unchecked")
                Class<Executable> clazz = (Class<Executable>) ctClass.toClass(loader, null);
                Executable executable = clazz.getDeclaredConstructor().newInstance();
                System.out.println(i + ": " + executable.exec());
            }
            finally {
                ctClass.detach();
            }
        }
        long end = System.nanoTime();
        System.out.println((end - start) / 1000000 + "ms");
    }
}

使い捨てのURLClassLoaderを使用することで、ClassLoader含めて解放されるようになります。
ヒープ割り当て16MBでも100万回の実行を完遂します。

.
.
.
999998: 0
999999: 0
209835ms

さいごに

最後まで目を通していただきありがとうございます。
メモリリークとしては、インスタンスのリークが一般的ですし、開発の現場で直面することも多いと思います。
Classのリークは普通の実装で発生することはなく、Javassistの使い方によって発生する特殊なリークですので、気付いていないだけでリークしていた。ということもあるのではないでしょうか。

この記事が気に入ったらサポートをしてみませんか?