Metaspace の OutOfMemoryError を発生させてみた

この記事は [NTTコムウェア AdventCalendar 2024](https://qiita.com/advent-calendar/2024/nttcomware) 4日目の記事です。

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

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

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

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

:::note info
OutOfMemoryError の詳細については Oracle のドキュメント[3 Troubleshoot Memory Leaks](https://docs.oracle.com/en/java/javase/17/troubleshoot/troubleshooting-memory-leaks.html#GUID-8090B138-6E0C-4926-9659-BE739062AB75) も参照してください。
:::

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

```bash 
$ 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](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3622317/23cc4561-c5ae-983d-9b6b-4941997570ee.png)

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

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

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

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

```bash 
$ 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()` の処理で異なるクラスを定義し続ければメタデータを大量生成できるはずです。

```java
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()` には引数にクラス名とそのバイトコードが必要になってきます。異なるメタデータを持つクラスを生成するためにはこの引数が呼び出しの都度異なるものにならなければなりません。

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

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

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

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

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

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

```bash
$ 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](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3622317/8a27f394-7b51-13a2-1c79-7a37ce01bffd.png)

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

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

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

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

:::

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

## サンプルコード

### HelloWorld0.java

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

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

### Test.java

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

```java: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.java` と `Test.java` を同一ディレクトリ配下でコンパイルし、`Test` を実行することで Metaspace 起因の OutOfMemoryError を発生させることができました。

```bash
$ 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 を発生させることもできます。以下に動作確認例を示します。

```bash
$ 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 起因以外にもいくつか種類があるので、また機会があればそれらのサンプルコードも作成してみたいなと思います。

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