14
5

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

NTTコムウェアAdvent Calendar 2024

Day 4

Metaspace の OutOfMemoryError を発生させてみた

Last updated at Posted at 2024-12-03

この記事は NTTコムウェア AdventCalendar 2024 4日目の記事です。

はじめに

NTT コムウェアの坂本翔平です。普段は OpenJDK に関する技術支援や調査に従事しています。また Java 関連の技術発表も行っており、直近では 2024年10月27(日) 開催の JJUG CCC 2024 Fallに参加しNative Memory Tracking を使用した Java プロセスのメモリ消費内訳の紹介 という内容で発表させていただきました。リンク先に資料も掲載していますので興味がある方はぜひご覧ください。

さて本題に入りたいと思います。Java アプリケーションを Java VM 上で動作させた際に、以下の出力に悩まされたことがある方も少なくないのではないでしょうか。

Exception in thread "main" java.lang.OutOfMemoryError: Java heap space

OutOfMemoryError でよく見かけるのは上記の Java ヒープ起因のものですが、実は OutOfMemoryError にはそれ以外に起因するものもあります。その中の1つに Metaspace があります。今回はこの Metaspace 起因の OutOfMemoryError について気になったので、意図的に発生させるようなサンプルコードを作成したいと思います。
かなりニッチな内容となりますがご容赦ください。

OutOfMemoryError の詳細については Oracle のドキュメント3 Troubleshoot Memory Leaks も参照してください。

本記事は記事公開時点の情報であり、使用する Java バージョンによっては動作が異なる可能性がある点にご注意ください。本記事で確認に使用した Java バージョンは以下の通りです。

$ java -version
openjdk version "21.0.5" 2024-10-15
OpenJDK Runtime Environment (build 21.0.5+11-Ubuntu-1ubuntu122.04)
OpenJDK 64-Bit Server VM (build 21.0.5+11-Ubuntu-1ubuntu122.04, mixed mode, sharing)

Metaspace とは

Metaspace は Java 8 で導入された、Java ヒープとは別のネイティブメモリとして扱われるメモリ領域です。

image.png

このメモリ領域にはクラスのメタデータが格納されます。Metaspace 領域は Java オプション -XX:MaxMetaspaceSize でその容量(Capacity)を制御できます。
また 64bit 版の Java では Java ヒープが 32GB 未満の場合、デフォルトで 圧縮 OOP-XX:UseCompressedOops)が有効化されます。これにより Metaspace に含まれる領域として Compressed Class Space と呼ばれるメモリ領域が作成されます。この Compressed Class Space 領域は Java オプション -XX:CompressedClassSpaceSize でその容量を制御できます。

なおこれらの Java オプションのデフォルト値は以下の通り、-XX:MaxMetaspaceSize は無制限、-XX:CompressedClassSpaceSize は 1GB となります。

$ java -XX:+PrintFlagsFinal -version | grep -e MaxMetaspaceSize -e CompressedClassSpaceSize
   size_t CompressedClassSpaceSize                 = 1073741824                                {product} {default}
   size_t MaxMetaspaceSize                         = 18446744073709551615                      {product} {default}

Java プロセス起動時に -XX:MaxMetaspaceSize および -XX:CompressedClassSpaceSize はどちらも指定することを推奨します。まず Metaspace の青天井を防止できます。また例えば -XX:MaxMetaspaceSize=512M のみを指定すると以下のように Metaspace の大半の領域が CompressedClassSpace となってしまい、Metaspace の OutOfMemoryError を引き起こす原因に繋がります。

$ java -XX:MaxMetaspaceSize=512M -XX:+PrintFlagsFinal -version | grep -e MaxMetaspaceSize -e CompressedClassSpaceSize
   size_t CompressedClassSpaceSize                 = 436207616                                 {product} {ergonomic}
   size_t MaxMetaspaceSize                         = 536870912                                 {product} {command line}

Metaspace 起因の OutOfMemoryError をどのように発生させるか

では実際に Metaspace 起因の OutOfMemoryError の実現方法について検討していきます。Metaspace の OutOfMemoryError を引き起こすには、クラスのメタデータを大量生成して Metaspace 領域を圧迫すればよいのではと考えました。
そこで Java のクラスファイルのバイトコードレベルで異なるクラスを大量生成することにしました。以下のようなクラスとメソッドを定義し、getClass() の処理で異なるクラスを定義し続ければメタデータを大量生成できるはずです。

public static class MyClassLoader extends ClassLoader {
    public MyClassLoader(ClassLoader parent) {
        super(parent);
    }
    public Class<?> getClass(String name, byte[] code) {
        return defineClass(name, code, 0, code.length);
    }
}

getClass() の内部で呼ばれている defineClass() には引数にクラス名とそのバイトコードが必要になってきます。異なるメタデータを持つクラスを生成するためにはこの引数が呼び出しの都度異なるものにならなければなりません。

defineClass() において、引数のクラス名とバイトコードのクラス名情報が異なると java.lang.NoClassDefFoundError がスローされ、また同一のクラスローダから重複したクラスを定義しようとすると java.lang.LinkageError がスローされます。

これを実現する方法は色々と考えられますが、今回は Java のクラスファイルを直接編集して試すことにしました。すごい力技です。

まずベースとなるクラスファイルを用意します。以下の HelloWorld0.java を作成し、それを javac でコンパイルすることでクラスファイルを生成します。

HelloWorld0.java
public class HelloWorld0 {
    public void hello() {
        System.out.println("Hello, World!");
    }
}

今回は HelloWorld0, HelloWorld1, HelloWorld2, … とループの回数に応じてクラス名を採番したものを defineClass() に読み込ませようと思います。defineClass() にはクラス名の他にバイトコードも渡す必要があるため、Java クラスファイルのバイトコードでクラス名を指す箇所も特定する必要があります。

Java クラスファイルのフォーマットは JVMS で定義されています。これによるとクラス名の情報は定数プール(Constant Pool)に含まれます。またクラスファイルは OpenJDK 同梱のツール javap や外部ツールのバイナリエディタ等で内容を参照できます。
まずは実際に javap でクラスファイルを見てみましょう。出力結果を以下に示します。

$ javap -v HelloWorld0.class
Classfile /path/to/HelloWorld0.class
  Last modified 2024/11/25; size 405 bytes
  SHA-256 checksum 2b27302edad87d8ff1981f7ea9f1ca44e6cd3e8217cdb925d67d0261ef1f2892
  Compiled from "HelloWorld0.java"
public class HelloWorld0
  minor version: 0
  major version: 65
  flags: (0x0021) ACC_PUBLIC, ACC_SUPER
  this_class: #21                         // HelloWorld0
  super_class: #2                         // java/lang/Object
  interfaces: 0, fields: 0, methods: 2, attributes: 1
Constant pool:
   #1 = Methodref          #2.#3          // java/lang/Object."<init>":()V
   #2 = Class              #4             // java/lang/Object
   #3 = NameAndType        #5:#6          // "<init>":()V
   #4 = Utf8               java/lang/Object
   #5 = Utf8               <init>
   #6 = Utf8               ()V
   #7 = Fieldref           #8.#9          // java/lang/System.out:Ljava/io/PrintStream;
   #8 = Class              #10            // java/lang/System
   #9 = NameAndType        #11:#12        // out:Ljava/io/PrintStream;
  #10 = Utf8               java/lang/System
  #11 = Utf8               out
  #12 = Utf8               Ljava/io/PrintStream;
  #13 = String             #14            // Hello, World!
  #14 = Utf8               Hello, World!
  #15 = Methodref          #16.#17        // java/io/PrintStream.println:(Ljava/lang/String;)V
  #16 = Class              #18            // java/io/PrintStream
  #17 = NameAndType        #19:#20        // println:(Ljava/lang/String;)V
  #18 = Utf8               java/io/PrintStream
  #19 = Utf8               println
  #20 = Utf8               (Ljava/lang/String;)V
  #21 = Class              #22            // HelloWorld0
  #22 = Utf8               HelloWorld0
  #23 = Utf8               Code
  #24 = Utf8               LineNumberTable
  #25 = Utf8               hello
  #26 = Utf8               SourceFile
  #27 = Utf8               HelloWorld0.java
(以下略)

定数プールの箇所を見ると #21, #22 に HelloWorld0 という文字列があり、クラス名を指しているようにみえます。これは実際には #22 が HelloWorld0 という文字列のデータを保持しており、#21 は #22 を参照するようになっています。また出力から #22 のデータの種類は Utf8 であることが分かります。

次に生成したクラスファイルをバイナリエディタで開いてみます。
今回使用したバイナリエディタではバイナリの可読部分は右側に表示されます。定数プールで HelloWorld0 という文字列が現れる箇所を探します。以下は実際に開いた画像になりますが、赤枠の箇所が該当します。

スクリーンショット 2024-11-26 095552.png

バイトコードの値を JVMS の定義に沿って確認してみます。
まず先頭の1バイトはこの定数プールで格納されているデータの種類をタグとして表現しています。ここでは値は 01 となっており、CONSTANT_Utf8 を指します。これは先ほど見た javap の出力結果 #22 の内容と一致します。
続く2バイト 00 0B はデータの長さを指します。10進数では11となり、これは HelloWorld0 の文字列の長さと一致しています。なおこの 0B はバイト配列の213番目の要素です。
以降の11バイトは HelloWorld0 という文字列を格納しています。なお文字列の最後の 0(バイト表記では 30)はバイト配列の224番目の要素です。

このことから HelloWorld0, HelloWorld1, HelloWorld2, … とループの回数に応じてクラス名を採番していくにあたって、整合性を保つためにバイトコードで修正すべき箇所が把握できました。

クラス名のみを変えた HelloWorld0.javaHelloWorld1.java のバイナリ差分は javap でいうと #22 のクラス名と #27 のソースファイル名のみとなります。詳細な説明は割愛しますが cmp コマンドにより他に差分がないことを確認しています。

$ cmp -lb HelloWorld0.class HelloWorld1.class
224  60 0     61 1
284  60 0     61 1

以上の内容をもって実際にサンプルコードを作成してみました。

サンプルコード

HelloWorld0.java

大量生成するクラスのベースとなるコードです。

HelloWorld0.java
public class HelloWorld0 {
    public void hello() {
        System.out.println("Hello, World!");
    }
}

Test.java

Metaspace の OutOfMemoryError を発生させるサンプルコードです。
実行には事前に HelloWorld0.javajavac でコンパイルしてください。
CLASSNAME_INDEXCLASSDATA_INDEX の値は、事前に HelloWorld0.class をバイナリエディタ等で開きそれぞれのインデックスの位置を確認したものを設定しています。(配列のインデックスなのでバイナリエディタで確認した位置からそれぞれ -1 しています)

Test.java
import java.util.*;
import java.nio.file.*;

public class Test {
    public static void main(String[] args) throws Exception {
        // バイト配列におけるクラス名の長さと文字列を指すインデックスを定数として定義する
        final int CLASSNAME_INDEX = 212;
        final int CLASSDATA_INDEX = 223;

        // HelloWorld0.class を読み込む
        byte[] helloWorldClass = Files.readAllBytes(Paths.get("HelloWorld0.class"));
        int classNameLength = Integer.valueOf(helloWorldClass[CLASSNAME_INDEX]);

        MyClassLoader myClassLoader = new MyClassLoader(Thread.currentThread().getContextClassLoader());

        // クラスメタデータを大量生成する
        for (int i = 0; ; ++i) {
            // クラス名に用いるループ回数の文字列長をバイトコードで取得する
            byte[] bytes = String.valueOf(i).getBytes();
            // 生成するクラスのバイトコードを格納する配列を定義する
            byte[] bytecode = new byte[helloWorldClass.length + bytes.length - 1];
            // helloWorldClass のバイト配列を CLASSDATA_INDEX の前までコピーする
            System.arraycopy(helloWorldClass, 0, bytecode, 0, CLASSDATA_INDEX);
            // 生成するクラスのバイト配列にループ回数の文字列長を加える
            bytecode[CLASSNAME_INDEX] = (byte) (classNameLength + bytes.length - 1);
            // 生成するクラスのバイト配列にクラス名の文字列部分のバイトコードを加える
            System.arraycopy(bytes, 0, bytecode, CLASSDATA_INDEX, bytes.length);
            // 残りのバイト配列をコピーする
            System.arraycopy(helloWorldClass, CLASSDATA_INDEX + 1, bytecode, CLASSDATA_INDEX + bytes.length, helloWorldClass.length - (CLASSDATA_INDEX + 1) );

            // クラス名を作成し getClass() によりクラスメタデータを生成する
            String classname = "HelloWorld" + i;
            Class c = myClassLoader.getClass(classname, bytecode);
        }
    }

    public static class MyClassLoader extends ClassLoader {
        public MyClassLoader(ClassLoader parent) {
            super(parent);
        }
        public Class<?> getClass(String name, byte[] code) {
            return defineClass(name, code, 0, code.length);
        }
    }
}

HelloWorld0.class のバイトコードをコピーしつつ、必要な箇所は手動で修正するという力技なコードになっていますがご容赦ください。

動作確認

動作確認例を以下に示します。
HelloWorld0.javaTest.java を同一ディレクトリ配下でコンパイルし、Test を実行することで Metaspace 起因の OutOfMemoryError を発生させることができました。

$ javac HelloWorld0.java
$ javac Test.java
$ java -XX:MaxMetaspaceSize=512M Test
Exception in thread "main" java.lang.OutOfMemoryError: Metaspace
        at java.base/java.lang.ClassLoader.defineClass1(Native Method)
        at java.base/java.lang.ClassLoader.defineClass(ClassLoader.java:1027)
        at java.base/java.lang.ClassLoader.defineClass(ClassLoader.java:889)
        at Test$MyClassLoader.getClass(Test.java:43)
        at Test.main(Test.java:33)

また Compressed Class Space 領域のサイズを調整すれば、Compressed Class Space 起因の OutOfMemoryError を発生させることもできます。以下に動作確認例を示します。

$ java -XX:MaxMetaspaceSize=512M -XX:CompressedClassSpaceSize=128M Test
Exception in thread "main" java.lang.OutOfMemoryError: Compressed class space
        at java.base/java.lang.ClassLoader.defineClass1(Native Method)
        at java.base/java.lang.ClassLoader.defineClass(ClassLoader.java:1027)
        at java.base/java.lang.ClassLoader.defineClass(ClassLoader.java:889)
        at Test$MyClassLoader.getClass(Test.java:43)
        at Test.main(Test.java:33)

おわりに

本記事では Metaspace の OutOfMemoryError を発生させるサンプルコードを作成、紹介しました。OutOfMemoryError には Java ヒープや Metaspace 起因以外にもいくつか種類があるので、また機会があればそれらのサンプルコードも作成してみたいなと思います。

記載されている会社名、製品名、サービス名は、各社の商標または登録商標です。

14
5
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
14
5

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?