本エントリーはJava Advent Calendar 2025の最終日です! 昨日は@zoosm3さんのDBFluteの複数DBをSpring Bootで実装する 開発のヒント でした。
24日に会社ブログでレコードクラスについて書きました。けっこうまじめに書いたので、ぜひ読んでみてください!
[Java] 今から始めるレコードクラス
https://bsblog.casareal.co.jp/archives/13817
レコードクラスの書き方や使い方については書きましたけど、じゃあレコードクラスって実際にはどういうバイトコードになっているの??というのが本エントリーです。
レコードクラスは簡単な記述で済んでいるのですが、コンパイルするとアクセッサーメソッドやequalsメソッドが自動生成されますとよく書かれていますが(会社ブログでもそう書きました)、実際のところどうなっているのというのを紹介していきます。
とりあえず、逆コンパイル
では、レコードクラスのバイトコードがどうなっているのか、さっそく調べてみましょう。題材にするのは、よく出てくる座標を表すPointレコードクラスです。
public record Point(double x, double y) {}
このクラスをコンパイルした後に、javapで逆コンパイルします。
> javac -g Point.java
> javap -p -v Point
Classfile /C:/temp/Point.class
Last modified 2025/12/21; size 1348 bytes
SHA-256 checksum 4bcbbc4b22a7b6c6fb72067dba973f9f08f80def796ecca1173c7c3b6596bda4
Compiled from "Point.java"
public final class Point extends java.lang.Record
... 以下、略
javapのオプションの-pはprivateも逆コンパイルするというオプションで、-vは詳細情報を出力するオプションです。
この後にコンスタントプールが続くのですが、とりあえず重要なところで
public final class Point extends java.lang.Record
レコードクラスは継承ができないのですが、この行が理由です。
まず、java.lang.Recordクラスのサブクラスになるということ。そして、finalクラスだということです。
Recordクラスは以下の3種類の抽象メソッドを定義しています。
- equals
- hashCode
- toString
もちろん、この3つのメソッドはObjectクラスで具象メソッドとして定義されているのですが、Recordクラスではそれらを抽象クラスとしてオーバーライドしています。
つまり、Ojbectクラスの実装は使わないということですね。
しかし、recordキーワードで定義されるレコードクラスは、これらのメソッドを定義する必要がありません。これらのメソッドがコンパイル時に自動生成されるというのが、このことからも分かります。
レコードコンポーネント
javapでは、クラス定義の後にコンスタントプールを出力します。コンスタントプールは必要に応じて参照すればよいので、その後に続く部分を見てみましょう。
通常のクラスであればフィールドの定義が続きます。レコードクラスではどうでしょう?
private final double x;
descriptor: D
flags: (0x0012) ACC_PRIVATE, ACC_FINAL
private final double y;
descriptor: D
flags: (0x0012) ACC_PRIVATE, ACC_FINAL
名前だけはレコードコンポーネントになりましたが、実質的にはインスタンスフィールドと同じでした。まぁ、想像通りですね。
イミュータブルなので、finalで宣言されているところが注目すべき点です。
コンストラクター
レコードクラスではカノニカルコンストラクターが自動的に生成されます。と言われても、カノニカルって何?という感じですよね。
カノニカル(Canonical)は「正規の」とか「標準的な」などの意味の単語です。このため、カノニカルコンストラクターを標準コンストラクターと記述しているドキュメントもあります。でも、標準と書かれるとStandardの方をイメージしてしまうんですよね。
ネイティブの人たちはCanonicalとStandardのニュアンスの違いを分かっているのでしょうが、私には理解できないのです...
それはそれとして、カノニカルコンストラクターはすべてのレコードコンポーネントを初期化するためのコンストラクターです。
では、Pointレコードクラスのカノニカルコンストラクターを見てみましょう。
public Point(double, double);
descriptor: (DD)V
flags: (0x0001) ACC_PUBLIC
Code:
stack=3, locals=5, args_size=3
0: aload_0
1: invokespecial #1 // Method java/lang/Record."<init>":()V
4: aload_0
5: dload_1
6: putfield #7 // Field x:D
9: aload_0
10: dload_3
11: putfield #13 // Field y:D
14: return
LineNumberTable:
line 1: 0
LocalVariableTable:
Start Length Slot Name Signature
0 15 0 this LPoint;
0 15 1 x D
0 15 3 y D
MethodParameters:
Name Flags
x
y
カノニカルコンストラクターの引数は、レコードクラスの定義におけるレコードコンポーネントの並びに対応しています。
Pointレコードクラスの場合、両方ともdoubleなので区別しにくいですが、第1引数がx、第2引数がyです。
一般的にコンストラクターのバイトコードでは、まずスーパークラスのデフォルトコンストラクターをコールします。それが0行目と1行目のinvokespecialです(行と書いていますが、実際にバイト数のことです。あしからず)。
4行目のaloadからがレコードコンポーネントの初期化になります。6行目のputfieldで引数のxの値をフィールドにセットしています。
同様に、11行目のputfieldで引数のyの値をフィールドにセットしています。
単純に引数の値をフィールド(レコードコンポーネント)に代入しているだけですね。
カノニカルコンストラクターは独自に定義することもできるのですが、それについては後述します。
アクセッサーメソッド
レコードクラスではレコードコンポーネントと同名のメソッドが生成され、レコードコンポーネントの値を取得できます。
まぁ、名前は違うもののgetterメソッドと同じですね。Pointレコードクラスのx()メソッドのバイトコードは以下のようになっていました。
public double x();
descriptor: ()D
flags: (0x0001) ACC_PUBLIC
Code:
stack=2, locals=1, args_size=1
0: aload_0
1: getfield #7 // Field x:D
4: dreturn
LineNumberTable:
line 1: 0
LocalVariableTable:
Start Length Slot Name Signature
0 5 0 this LPoint;
getfieldでフィールド(レコードコンポーネント)の値をスタックに積んで、dreturn (doubleのretuen)で返り値にしています。
y()メソッドも同様です。
他の自動生成されたメソッド
ここまでは、まぁ予想通りのバイトコードでした。
レコードクラスではカノニカルコンストラクターとアクセッサーメソッド以外に、次のメソッドを自動生成します。
- equals
- hashCode
- toString
ここではequals()メソッドのバイトコードを見てみましょう。
equals()メソッドの書き方としては、Effective Javaが詳しいですね。Effective Javaの書き方と比べてどうなっているでしょう?
public final boolean equals(java.lang.Object);
descriptor: (Ljava/lang/Object;)Z
flags: (0x0011) ACC_PUBLIC, ACC_FINAL
Code:
stack=2, locals=2, args_size=2
0: aload_0
1: aload_1
2: invokedynamic #24, 0 // InvokeDynamic #0:equals:(LPoint;Ljava/lang/Object;)Z
7: ireturn
LineNumberTable:
line 1: 0
LocalVariableTable:
Start Length Slot Name Signature
0 8 0 this LPoint;
0 8 1 o Ljava/lang/Object;
なんか全然違う!!
おもしろいのは、invokedynamic (indy)を使っているところです。つまり、equals()メソッドは初回実行時に動的に作られるということですね。
このindyの初回実行時にコールされるのが、ブートストラップと呼ばれるメソッドです。ブートストラップメソッドはクラスファイルの最後の方にあるBootstrapMethods:の箇所に記述されています。
BootstrapMethods:
0: #49 REF_invokeStatic java/lang/runtime/ObjectMethods.bootstrap:(Ljava/lang/invoke/MethodHandles$Lookup;Ljava/lang/String;Ljava/lang/invoke/TypeDescriptor;Ljava/lang/Class;Ljava/lang/String;[Ljava/lang/invoke/MethodHandle;)Ljava/lang/Object;
Method arguments:
#8 Point
#45 x;y
#47 REF_getField Point.x:D
#48 REF_getField Point.y:D
これを見ると、ブートストラップとして使われるのは、java.lang.runtime.ObjectMethodsクラスのbootstrap()メソッドだということです。実をいうと、equals()メソッドだけでなく、hashCode()メソッドもtoString()メソッドも同じブートストラップメソッドが使われています。
このObjectMethodsクラスはpublicなクラスなので、APIドキュメントが公開されています。Java 24であれば、以下のリンクから参照できます。
ちょっとおもしろいのが、bootstrap()メソッドの最後の引数にアクセッサーメソッドのMethodHandleが使われているところですね。
では、ObjectMethodsクラスのbootstrap()メソッドを調べてみましょう。ソースは以下のリンクにあります。
以下にObjectMethodsクラスのbootstrap()メソッドのソースを示します。
public static Object bootstrap(MethodHandles.Lookup lookup, String methodName, TypeDescriptor type,
Class<?> recordClass,
String names,
MethodHandle... getters) throws Throwable {
<<省略>>
List<MethodHandle> getterList = List.of(getters);
MethodHandle handle = switch (methodName) {
case "equals" -> {
if (methodType != null && !methodType.equals(MethodType.methodType(boolean.class, recordClass, Object.class)))
throw new IllegalArgumentException("Bad method type: " + methodType);
yield makeEquals(lookup, recordClass, getterList);
}
<<省略>>
};
return methodType != null ? new ConstantCallSite(handle) : handle;
}
メソッド名が"equals"であれば、makeEquals()メソッドをコールしています。
makeEquals()メソッドはClassfile APIでバイトコードを操作しています。ASMの頃に比べると、Classfile APIになって格段に読みやすくなりましたね。
ちょっと長いのですが、makeEquals()メソッドを以下に示しておきます。
private static MethodHandle makeEquals(MethodHandles.Lookup lookup, Class<?> receiverClass,
List<MethodHandle> getters) throws Throwable {
MethodType rr = MethodType.methodType(boolean.class, receiverClass, receiverClass);
MethodType ro = MethodType.methodType(boolean.class, receiverClass, Object.class);
MethodHandle instanceFalse = MethodHandles.dropArguments(FALSE, 0, receiverClass, Object.class); // (RO)Z
MethodHandle instanceTrue = MethodHandles.dropArguments(TRUE, 0, receiverClass, Object.class); // (RO)Z
MethodHandle isSameObject = OBJECT_EQ.asType(ro); // (RO)Z
MethodHandle isInstance = MethodHandles.dropArguments(CLASS_IS_INSTANCE.bindTo(receiverClass), 0, receiverClass); // (RO)Z
MethodHandle accumulator = MethodHandles.dropArguments(TRUE, 0, receiverClass, receiverClass); // (RR)Z
int size = getters.size();
MethodHandle[] equalators = new MethodHandle[size];
boolean hasPolymorphism = false;
for (int i = 0; i < size; i++) {
var getter = getters.get(i);
var type = getter.type().returnType();
if (isMonomorphic(type)) {
equalators[i] = equalator(lookup, type);
} else {
hasPolymorphism = true;
}
}
// Currently, hotspot does not support polymorphic inlining.
// As a result, if we have a MethodHandle to Object.equals,
// it does not enjoy separate profiles like individual invokevirtuals,
// and we must spin bytecode to accomplish separate profiling.
if (hasPolymorphism) {
String[] names = new String[size];
var classFileContext = ClassFile.of(ClassFile.ClassHierarchyResolverOption.of(ClassHierarchyResolver.ofClassLoading(lookup)));
var bytes = classFileContext.build(ClassDesc.of(specializerClassName(lookup.lookupClass(), "Equalator")), clb -> {
for (int i = 0; i < size; i++) {
if (equalators[i] == null) {
var name = "equalator".concat(Integer.toString(i));
names[i] = name;
var type = getters.get(i).type().returnType();
boolean isInterface = type.isInterface();
var typeDesc = type.describeConstable().orElseThrow();
clb.withMethodBody(name, MethodTypeDesc.of(CD_boolean, typeDesc, typeDesc), ACC_STATIC, cob -> {
var nonNullPath = cob.newLabel();
var fail = cob.newLabel();
cob.aload(0)
.ifnonnull(nonNullPath)
.aload(1)
.ifnonnull(fail)
.iconst_1() // arg0 null, arg1 null
.ireturn()
.labelBinding(fail)
.iconst_0() // arg0 null, arg1 non-null
.ireturn()
.labelBinding(nonNullPath)
.aload(0) // arg0.equals(arg1) - bytecode subject to customized profiling
.aload(1)
.invoke(isInterface ? Opcode.INVOKEINTERFACE : Opcode.INVOKEVIRTUAL, typeDesc, "equals", MTD_OBJECT_BOOLEAN, isInterface)
.ireturn();
});
}
}
});
var specializerLookup = lookup.defineHiddenClass(bytes, true, MethodHandles.Lookup.ClassOption.STRONG);
for (int i = 0; i < size; i++) {
if (equalators[i] == null) {
var type = getters.get(i).type().returnType();
equalators[i] = specializerLookup.findStatic(specializerLookup.lookupClass(), names[i], MethodType.methodType(boolean.class, type, type));
}
}
}
for (int i = 0; i < size; i++) {
var getter = getters.get(i);
MethodHandle equalator = equalators[i]; // (TT)Z
MethodHandle thisFieldEqual = MethodHandles.filterArguments(equalator, 0, getter, getter); // (RR)Z
accumulator = MethodHandles.guardWithTest(thisFieldEqual, accumulator, instanceFalse.asType(rr));
}
return MethodHandles.guardWithTest(isSameObject,
instanceTrue,
MethodHandles.guardWithTest(isInstance, accumulator.asType(ro), instanceFalse));
}
簡単にいうと、まずレコードコンポーネントごとにその型に応じたequals()メソッドを探します。次に、レコードコンポーネントごとにequals()メソッドをコールするメソッドを持つクラスを動的に作成して、そのメソッドのMethodHandleを作っています。
なかなかおもしろいですね。
Classfile APIのビルド系のメソッドはバイトコードと対応したメソッド名になっているので、バイトコードを読めればだいたい分かるはずです。
というわけで、equals()メソッド、hashCode()メソッド、toString()メソッドは中身が動的に作成されるのでした。
カノニカルコンストラクター再び
最後に、もう一度カノニカルコンストラクターについて。
前述したカノニカルコンストラクターは自動生成されたものでしたが、カノニカルコンストラクターは自分で書くこともできます。
たとえば、範囲を示すRangeレコードクラスで下限が上限を超える場合IllegalArgumentException例外をスローするようなカノニカルコンストラクターを記述してみます。
public record Range(double min, double max) {
public Range {
if (min > max) {
throw new IllegalArgumentException();
}
}
}
カノニカルコンストラクターは引数がレコードコンポーネントの宣言部分と同じなので省略した書き方になります。
そして、コンパイルした後のバイトコードが以下になります。
public Range(double, double);
descriptor: (DD)V
flags: (0x0001) ACC_PUBLIC
Code:
stack=4, locals=5, args_size=3
0: aload_0
1: invokespecial #1 // Method java/lang/Record."<init>":()V
4: dload_1
5: dload_3
6: dcmpl
7: ifle 18
10: new #7 // class java/lang/IllegalArgumentException
13: dup
14: invokespecial #9 // Method java/lang/IllegalArgumentException."<init>":()V
17: athrow
18: aload_0
19: dload_1
20: putfield #10 // Field min:D
23: aload_0
24: dload_3
25: putfield #16 // Field max:D
28: return
LineNumberTable:
line 2: 0
line 3: 4
line 4: 10
line 2: 18
line 6: 28
LocalVariableTable:
Start Length Slot Name Signature
0 29 0 this LRange;
0 29 1 min D
0 29 3 max D
StackMapTable: number_of_entries = 1
frame_type = 255 /* full_frame */
offset_delta = 18
locals = [ class Range, double, double ]
stack = []
MethodParameters:
Name Flags
min mandated
max mandated
前半にminとmaxの比較を行って、minが大きければ例外をスローする処理が記述されています。18行目からコンストラクターの引数をフィールド(レコードコンポーネント)に代入する処理です。
フィールドに代入する処理から行われると思っていたら、それは最後なんですね。ちょっと意外でした。
まとめ
レコードクラスはデータを扱うのに便利なクラスですが、その中身がどうなっているのかを紹介しました。
まとめてみると
- レコードクラスはRecordクラスのサブクラスでfinalクラス
- レコードコンポーネントはfinalなインスタンスフィールド
- カノニカルコンストラクターが生成される
- アクセッサーメソッドはgetter相当
- equals(), hashCode(), toString()は実行時に動的に作成
- カノニカルコンストラクターでフィールドへの代入は最後
レコードクラス自体は単純ですが、その裏側はなかなかおもしろかったですね。
OpenJDKのソースを読むのはたいへんですが、このぐらいの小さいところから読み始めるというのはいいかもしれません。
0 件のコメント:
コメントを投稿