この記事は 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 ヒープとは別のネイティブメモリとして扱われるメモリ領域です。
このメモリ領域にはクラスのメタデータが格納されます。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
でコンパイルすることでクラスファイルを生成します。
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
という文字列が現れる箇所を探します。以下は実際に開いた画像になりますが、赤枠の箇所が該当します。
バイトコードの値を JVMS の定義に沿って確認してみます。
まず先頭の1バイトはこの定数プールで格納されているデータの種類をタグとして表現しています。ここでは値は 01
となっており、CONSTANT_Utf8
を指します。これは先ほど見た javap
の出力結果 #22 の内容と一致します。
続く2バイト 00 0B
はデータの長さを指します。10進数では11となり、これは HelloWorld0
の文字列の長さと一致しています。なおこの 0B
はバイト配列の213番目の要素です。
以降の11バイトは HelloWorld0
という文字列を格納しています。なお文字列の最後の 0
(バイト表記では 30
)はバイト配列の224番目の要素です。
このことから HelloWorld0
, HelloWorld1
, HelloWorld2
, … とループの回数に応じてクラス名を採番していくにあたって、整合性を保つためにバイトコードで修正すべき箇所が把握できました。
クラス名のみを変えた HelloWorld0.java
と HelloWorld1.java
のバイナリ差分は javap
でいうと #22 のクラス名と #27 のソースファイル名のみとなります。詳細な説明は割愛しますが cmp
コマンドにより他に差分がないことを確認しています。
$ cmp -lb HelloWorld0.class HelloWorld1.class
224 60 0 61 1
284 60 0 61 1
以上の内容をもって実際にサンプルコードを作成してみました。
サンプルコード
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 しています)
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 を発生させることができました。
$ 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 起因以外にもいくつか種類があるので、また機会があればそれらのサンプルコードも作成してみたいなと思います。
記載されている会社名、製品名、サービス名は、各社の商標または登録商標です。