Javaの「暗黙的に」を可視化してみた

Java

はじめに

こんにちは。株式会社divxのエンジニア二階堂です。
突然ですが、Javaのプログラムを実行する時に「特に記述しなくても裏で勝手に動いてる処理」が果たして本当に動いているのか、裏でどのような処理が行われているのか気になりませんか?
その時、某人気曲の「見えないものを見ようとして〜」の歌詞のように”望遠鏡”ではない何かをのぞき込むことで裏側を見られるのではないか?という好奇心が生まれました。

Javaではソースコードをコンパイルしたときに「暗黙的に」行われている処理があります。
言い換えると「ソースコードに明示的に記述しなくてもコンパイル時に自動的に行われる処理」のことです。自動で行われているので気にしなくていいことでもありますが、「暗黙的に行われている処理は本当に行われていて、それを実際に可視化することは可能なのか」という疑問を抱いたので、本記事の執筆に至りました。

そこで、本記事では暗黙的に行われている処理をクラスファイルに対してjavapコマンドを実行して処理を可視化できるのか試みました。

まず、可視化する方法を説明します。
Javaの実行の過程でコンパイル時に生成される「バイトコード」や「クラスファイル」について説明します。そして、クラスファイルの中身を見るためのjavapコマンドについて説明します。

次に、実際のコードを例に暗黙的に行われている処理が、クラスファイルに対してjavapコマンドを実行したら可視化できるのか調査します。

終わりに「見えないものを見ようとした」結果何がわかったのか気づきをまとめましたので、読者の方の参考になれば幸いです。

どうやったら可視化できるのか?

暗黙的な処理を可視化する方法は、Javaのソースコードをコンパイルした後に生成される「クラスファイル」をjavapコマンドで実行することによって可視化できます。

まず前提として、Javaの実行の流れを説明した上で、その過程で生成される「バイトコード」「クラスファイル」について説明します。

その後、クラスファイルの中身を確認するために使用するjavapコマンドについて説明します。

Javaが実行されるまでの流れ

Javaのソースコードがコンパイルされて実行されるまでの処理の流れは以下のようになっています。

JavaのソースコードをJavaのコンパイラ(javac)がコンパイルを行いバイトコードを生成します。 バイトコードはクラスファイル(*.classファイル)に書き込まれます。 その後、JVM(Java仮想マシン)がバイトコードを解釈することにより異なるプラットフォームでJavaを実行することができます。

上記の過程でコンパイル後に生成される「バイトコード」、「クラスファイル」とは何なのでしょうか?

バイトコードとクラスファイル

バイトコードとは、JVMで実行するために生成される中間コードのことです。

Javaのコンパイラはバイトコードを生成し、そのバイトコードをクラスファイルに書き込みます。クラスファイルには .classという拡張子がつきます。

以下のように、Main.javaをjavacコマンドでコンパイルするとMain.classが生成されます。

% javac Main.java

コンパイル後に生成されるファイルなので、このクラスファイルの中身を見ることができればコンパイラが自動で行っている暗黙的な処理を見ることができそうです。

では、どのような方法でクラスファイルの中身を見ることができるのでしょうか?

その方法として「javap」コマンドを使用します。

javapコマンド

javapとは、クラスファイルを逆アセンブルするコマンドです。

逆アセンブルとは、機械語などで書かれたオブジェクトコード(もしくはバイトコード)を人間が理解しやすいアセンブリ言語に変換することです。

よって、javapコマンドを実行するとクラスファイルの処理がアセンブル言語として出力されます。

コマンドの説明

以下は、コマンドの使用方法です。

% javap [オプション] [クラス名]

※オプションを指定しない場合は、対象のクラスのprotectedおよびpublicのフィールドとメソッドを出力します。

オプションの指定で出力結果が異なります。今回は以下のオプションを使用します。

オプション 説明
-vまたはverbose クラスに関する詳細な情報を出力します。主にメソッドのスタックサイズ、コンスタントプール、 localsと args の数などを出力します。
-c クラスのメソッドごとに、逆アセンブルされるコードを出力します。すなわち、Javaバイトコードで構成される命令を表示します。

vオプション指定して実行するとcオプション内容も出力されます。
※その他のオプション、詳細は参考資料をご確認ください。

以下のように、Main.classをjavapコマンドのcオプションをつけると逆アセンブルされた結果が出力されます。

% javap -c Main


Compiled from "Main.java"
public class Main {
  public Main();
    Code:
       0: aload_0
       1: invokespecial #1                  // Method java/lang/Object."<init>":()V
       4: return

  public static void main(java.lang.String[]);
    Code:
       0: getstatic     #2                  // Field java/lang/System.out:Ljava/io/PrintStream;
       3: ldc           #3                  // String Hello Java
       5: invokevirtual #4                  // Method java/io/PrintStream.println:(Ljava/lang/String;)V
       8: return
}

では、次章から実際にjavapコマンドを使用して暗黙的な処理を可視化してみましょう。

「暗黙的」な処理を可視化してみる

本章では、実際にコンパイルされると「暗黙的に」行っている処理の例を4つ紹介します。

クラスファイルに対してjavapコマンドを実行し「暗黙的に」行われている処理や動きが可視化できるのか確認します。

①デフォルトコンストラクタは生成されているのか

デフォルトコンストラクタとは

クラス内でコンストラクタを1つも明示的に定義していない場合、引数無しで生成される中身が空のコンストラクタのことです。

この時Javaコンパイラが「暗黙的に」で追加してくれているのですが、本当にデフォルトコンストラクタが追加されているのか確認してみましょう。

Main.java

public class Main {
  public static void main(String[] args) {
    Sample a = new Sample();
  }
}

Sample.java

public class Sample {

}
% javac Sample.java

コンストラクタは明示的に記述しない場合publicなフィールドになるため、オプションを指定せずにjavapコマンドを実行します。

% javap Sample
Compiled from "Sample.java"
public class Sample {
  public Sample();
}

実行するとpublic Sample(); というデフォルトコンストラクタが生成されていることが確認できました。

②スーパークラスのコンストラクタは先に呼ばれているのか

サブクラスのコンストラクタで明示的にスーパークラスのコンストラクタの呼び出しを行わない場合、「暗黙的に」super();が呼び出されます。

なぜかというと、クラスのインスタンス化時は、まずスーパークラスのインスタンスが生成されてからその中に内包されるようにサブクラスのインスタンスが生成されるからです。

よって、サブクラスに明示的にsuper();を記述しなくても継承元のスーパークラスから先にコンストラクタが生成されることになります。
※スーパークラスのコンストラクタに引数を渡したい場合などは明示的に書く必要があります。

本当にスーパークラスのコンストラクタが先に呼ばれているのか確認してみましょう。

Main.java

public class Main {
  public static void main(String[] args) {
    Child child = new Child();
  }
}

Parent.java

public class Parent {
  public Parent(){
    System.out.println("Parentクラスのコンストラクタ");
  }
}

Child.java

public class Child extends Parent{
  public Child() {
    // super(); ←暗黙的に呼び出される
    System.out.println("Childクラスのコンストラクタ");
  }
}
% javac Main.java Parent.java Child.java

% java Main
Parentクラスのコンストラクタ
Childクラスのコンストラクタ

実行するとスーパークラスのコンストラクタ(Parentクラスのコンストラクタ)が先に呼ばれています。

% javap -v Child


//省略
public class Child extends Parent
  minor version: 0
  major version: 55
  flags: (0x0021) ACC_PUBLIC, ACC_SUPER
  this_class: #5                          // Child
  super_class: #6                         // Parent
  interfaces: 0, fields: 0, methods: 1, attributes: 1
Constant pool:
   #1 = Methodref          #6.#13         // Parent."<init>":()V
   #2 = Fieldref           #14.#15        // java/lang/System.out:Ljava/io/PrintStream;
   #3 = String             #16            // Childクラスのコンストラクタ
   #4 = Methodref          #17.#18        // java/io/PrintStream.println:(Ljava/lang/String;)V
   #5 = Class              #19            // Child
   #6 = Class              #20            // Parent
   #7 = Utf8               <init>
   #8 = Utf8               ()V
   #9 = Utf8               Code
  #10 = Utf8               LineNumberTable
  #11 = Utf8               SourceFile
  #12 = Utf8               Child.java
  #13 = NameAndType        #7:#8          // "<init>":()V
  #14 = Class              #21            // java/lang/System
  #15 = NameAndType        #22:#23        // out:Ljava/io/PrintStream;
  #16 = Utf8               Childクラスのコンストラクタ
  #17 = Class              #24            // java/io/PrintStream
  #18 = NameAndType        #25:#26        // println:(Ljava/lang/String;)V
  #19 = Utf8               Child
  #20 = Utf8               Parent
  #21 = Utf8               java/lang/System
  #22 = Utf8               out
  #23 = Utf8               Ljava/io/PrintStream;
  #24 = Utf8               java/io/PrintStream
  #25 = Utf8               println
  #26 = Utf8               (Ljava/lang/String;)V
{
//省略
  public Child();
    descriptor: ()V
    flags: (0x0001) ACC_PUBLIC
    Code:
      stack=2, locals=1, args_size=1
         0: aload_0
         1: invokespecial #1                  // Method Parent."<init>":()V
         4: getstatic     #2                  // Field java/lang/System.out:Ljava/io/PrintStream;
         7: ldc           #3                  // String Childクラスのコンストラクタ
         9: invokevirtual #4                  // Method java/io/PrintStream.println:(Ljava/lang/String;)V
        12: return
      LineNumberTable:
        line 5: 0
        line 7: 4
        line 8: 12
}
SourceFile: "Child.java"

まず、Javaのバイトコードで構成される命令に着目します。

//省略

  public Child();
    descriptor: ()V
    flags: (0x0001) ACC_PUBLIC
    Code:
      stack=2, locals=1, args_size=1
         0: aload_0
         1: invokespecial #1                  // Method Parent."<init>":()V
         4: getstatic     #2                  // Field java/lang/System.out:Ljava/io/PrintStream;
         7: ldc           #3                  // String Childクラスのコンストラクタ
         9: invokevirtual #4                  // Method java/io/PrintStream.println:(Ljava/lang/String;)V
        12: return

//省略

invokespecialとは

インスタンスメソッド、スーパークラス、インスタンス初期化のメソッド(コンストラクタメソッド)の呼び出し処理を行う命令です。

今回はスーパークラスのコンストラクタメソッド、つまりParentクラスのコンストラクタを呼び出しています。

invokespecialの横の#1は、Constant pool内のインデックスを参照して識別してます。

Constant poolとは

クラスやインタフェースごとに実行時、表現されるものです。この中には、コンパイル時の既知のリテラル、実行時に行われるメソッド、フィールドの参照などいくつかの種類の定数が含まれています。

Constant pool内を確認してみると、1番上の#1のインデックスでParentクラスのメソッドの参照が保存されています。

public class Child extends Parent
//省略
Constant pool:
   #1 = Methodref          #6.#13         // Parent."<init>":()V
   #2 = Fieldref           #14.#15        // java/lang/System.out:Ljava/io/PrintStream;
   #3 = String             #16            // Childクラスのコンストラクタ
   #4 = Methodref          #17.#18        // java/io/PrintStream.println:(Ljava/lang/String;)V
   #5 = Class              #19            // Child
   #6 = Class              #20            // Parent
   #7 = Utf8               <init>
   #8 = Utf8               ()V
   #9 = Utf8               Code
  #10 = Utf8               LineNumberTable
  #11 = Utf8               SourceFile
  #12 = Utf8               Child.java
  #13 = NameAndType        #7:#8          // "<init>":()V
  #14 = Class              #21            // java/lang/System
  #15 = NameAndType        #22:#23        // out:Ljava/io/PrintStream;
  #16 = Utf8               Childクラスのコンストラクタ
  #17 = Class              #24            // java/io/PrintStream
  #18 = NameAndType        #25:#26        // println:(Ljava/lang/String;)V
  #19 = Utf8               Child
  #20 = Utf8               Parent
  #21 = Utf8               java/lang/System
  #22 = Utf8               out
  #23 = Utf8               Ljava/io/PrintStream;
  #24 = Utf8               java/io/PrintStream
  #25 = Utf8               println
  #26 = Utf8               (Ljava/lang/String;)V
{
//省略

よって、暗黙的に処理されるChildクラス内のsuper();の動きは以下のようになります。

  1. Constant poolにセットされた継承元のParentクラスを参照する
  2. invokespecial命令でParentクラスのコンストラクタメソッド内の処理が呼び出される
  3. 「Parentクラスのコンストラクタ」という出力結果が先に表示される

以上の動きからスーパークラスのコンストラクタが先に呼ばれていることが確認できました。

③int型とInteger型を比較したときになぜtrueになるのか

int型とInteger型を等式演算子で比較したときに、trueが返されます。

なぜ、「基本データ型」と「参照型」の異なった型を比較しているのにtrueになるのでしょうか?
その理由を可視化して確認してみましょう。

Main.java

public class Main {
  public static void main(String[] args) {
    int num = 10;
    Integer val = Integer.valueOf(10);
    System.out.println(num == val);
  }
}

valueOfメソッドとは

IntegerクラスのvalueOfメソッドは、指定されたint値を表すIntegerインスタンスを返します。

% javac Main.java
% java Main
true
% javap -c Main

Compiled from "Main.java"
public class Main {
  public Main();
    Code:
       0: aload_0
       1: invokespecial #1                  // Method java/lang/Object."<init>":()V
       4: return

  public static void main(java.lang.String[]);
    Code:
       0: bipush        10
       2: istore_1
       3: bipush        10
       5: invokestatic  #2                  // Method java/lang/Integer.valueOf:(I)Ljava/lang/Integer;
       8: astore_2
       9: getstatic     #3                  // Field java/lang/System.out:Ljava/io/PrintStream;
      12: iload_1
      13: aload_2
      14: invokevirtual #4                  // Method java/lang/Integer.intValue:()I
      17: if_icmpne     24
      20: iconst_1
      21: goto          25
      24: iconst_0
      25: invokevirtual #5                  // Method java/io/PrintStream.println:(Z)V
      28: return
}

まず、14: invokevirtual #4 // Method java/lang/Integer.intValue:()Iに注目します。

invokevirtualとは

インスタンスメソッドを呼ぶ命令です。

この命令で「暗黙的に」IntegerクラスのintValueメソッド呼ばれています。

intValueメソッドとは

IntegerクラスのintValueメソッドは、Integer型のインスタンスの値をint型に変換して返します。

なぜ、intValueメソッドが呼ばれているのでしょうか?
intValueメソッドの役割からこの処理がtrueになる理由を紐解いていきます。
理由は、等式演算子(==)で比較したときの処理にあります。

等式演算子(==)の挙動

以下は、等式演算子でオペランドを比較した場合の挙動です。

等式演算子のオペランドが両方とも数値型である場合、または一方が数値型であり、もう一方が数値型に変換可能である場合は数値に変換して実行されます。
Chapter 15. Expressions#Equality Operators

「もう一方が数値型に変換可能である場合」という部分に注目してみます。

今回はラッパークラスであるInteger型と基本データ型のint型を比較しています。
ラッパークラスから基本データ型へ自動変換できるアンボクシングという機能が暗黙的に行われてるので、Integer型からint型への変換は可能であると言えます。
アンボクシングが働くため、intValueメソッドによってInteger型のインスタンス変数valの値がint型に変換されていることがわかります。

以上の結果からこの処理がtrueになる理由をまとめます。

等式演算子の比較によって、アンボクシングが働くのでInteger型からint型に自動変換されます。
それにより暗黙的にintValueメソッドが呼ばれ、int型の値同士で比較されるためtrueが返されます。

補足として、ソースコードにintValueメソッドを明示的に記述した場合にjavapコマンドを実行するとどのように出力されるのか確認してみます。

Main.java

public class Main {
  public static void main(String[] args) {
    int num = 10;
    Integer val = Integer.valueOf(10);
    val = val.intValue();
    System.out.println(num == val);
  }
}
% javac Main.java
% java Main
true
% javap -c Main

Compiled from "Main.java"
public class Main {
  public Main();
    Code:
       0: aload_0
       1: invokespecial #1                  // Method java/lang/Object."<init>":()V
       4: return

  public static void main(java.lang.String[]);
    Code:
       0: bipush        10
       2: istore_1
       3: bipush        10
       5: invokestatic  #2                  // Method java/lang/Integer.valueOf:(I)Ljava/lang/Integer;
       8: astore_2
       9: aload_2
      10: invokevirtual #3                  // Method java/lang/Integer.intValue:()I
      13: invokestatic  #2                  // Method java/lang/Integer.valueOf:(I)Ljava/lang/Integer;
      16: astore_2
      17: getstatic     #4                  // Field java/lang/System.out:Ljava/io/PrintStream;
      20: iload_1
      21: aload_2
      22: invokevirtual #3                  // Method java/lang/Integer.intValue:()I
      25: if_icmpne     32
      28: iconst_1
      29: goto          33
      32: iconst_0
      33: invokevirtual #5                  // Method java/io/PrintStream.println:(Z)V
      36: return
}

invokevirtual命令でintValueメソッドが2回呼ばれています。
明示的にintValueメソッドを記述すると、同じ命令とメソッドが重複して呼ばれるので余計な処理が1回増えることになります。

このように比較してみると、あえて明示的に書かないほうがいいパターンもあることがわかりました。

④ストリングコンスタントプールはどのように利用されているなのか

ストリングコンスタントプールとは

プログラムで定義された文字列(String)が格納されるヒープ領域の場所です。
JavaのString型は不変(immutable)のため、一度オブジェクトが生成されるとプログラムの実行中は同じままです。String型の文字列を宣言すると、String型のオブジェクトがスタック領域に生成され、文字列の値はヒープ領域に生成されます。
もし同じ文字列の値が生成されたときに、ヒープ領域内のストリングコンスタントプールに格納されることで使い回すことができ、メモリ使用量も節約できる仕組みになります。

こちらの詳細は、弊社テックブログの記事をご覧ください。
インスタンスを作るって言うけど、パソコンのどこにできるの?

では、実際にストリングコンスタントプールの仕組みを可視化できるかString型の文字列同士の比較を例に確認してみましょう。

Main.java

public class Main {
  public static void main(String[] args) {
    String a = "Hello Java";
    String b = "Hello Java";
    System.out.println(a == b); 
  }
}
% javac Main.java
% java Main
true
% javap -v Main

//省略
public class Main
//省略
Constant pool:
   #1 = Methodref          #6.#19         // java/lang/Object."<init>":()V
   #2 = String             #20            // Hello Java
   #3 = Fieldref           #21.#22        // java/lang/System.out:Ljava/io/PrintStream;
   #4 = Methodref          #16.#23        // java/io/PrintStream.println:(Z)V
   #5 = Class              #24            // Main
   #6 = Class              #25            // java/lang/Object
   #7 = Utf8               <init>
   #8 = Utf8               ()V
   #9 = Utf8               Code
  #10 = Utf8               LineNumberTable
  #11 = Utf8               main
  #12 = Utf8               ([Ljava/lang/String;)V
  #13 = Utf8               StackMapTable
  #14 = Class              #26            // "[Ljava/lang/String;"
  #15 = Class              #27            // java/lang/String
  #16 = Class              #28            // java/io/PrintStream
  #17 = Utf8               SourceFile
  #18 = Utf8               Main.java
  #19 = NameAndType        #7:#8          // "<init>":()V
  #20 = Utf8               Hello Java
  #21 = Class              #29            // java/lang/System
  #22 = NameAndType        #30:#31        // out:Ljava/io/PrintStream;
  #23 = NameAndType        #32:#33        // println:(Z)V
  #24 = Utf8               Main
  #25 = Utf8               java/lang/Object
  #26 = Utf8               [Ljava/lang/String;
  #27 = Utf8               java/lang/String
  #28 = Utf8               java/io/PrintStream
  #29 = Utf8               java/lang/System
  #30 = Utf8               out
  #31 = Utf8               Ljava/io/PrintStream;
  #32 = Utf8               println
  #33 = Utf8               (Z)V
{
  public Main();
//省略
  public static void main(java.lang.String[]);
    descriptor: ([Ljava/lang/String;)V
    flags: (0x0009) ACC_PUBLIC, ACC_STATIC
    Code:
      stack=3, locals=3, args_size=1
         0: ldc           #2                  // String Hello Java
         2: astore_1
         3: ldc           #2                  // String Hello Java
         5: astore_2
         6: getstatic     #3                  // Field java/lang/System.out:Ljava/io/PrintStream;
         9: aload_1
        10: aload_2
        11: if_acmpne     18
        14: iconst_1
        15: goto          19
        18: iconst_0
        19: invokevirtual #4                  // Method java/io/PrintStream.println:(Z)V
        22: return
//省略
}
SourceFile: "Main.java"

コンスタントプールの中にString型の文字列Hello Javaが格納されています。

Constant pool:

#2 = String             #20            // Hello Java
//省略
#20 = Utf8             Hello Java

Mainクラス内の命令を確認してみます。

0: ldc           #2                  // String Hello Java
2: astore_1
3: ldc           #2                  // String Hello Java

ldcとは

コンスタントプールからスタックにプッシュする命令です。
Stringクラスのインスタンスへの参照やint,double型などの基本データ型は、ldc命令で管理されます。

ldc命令は2回実行されていますが、どちらもConstant pol内の#2のインデックス番号を指しているので、同じ文字列を参照しています。
このことからストリングコンスタントプールによってHello JavaのString型の文字列が使い回されていることがわかりました。

終わりに

今回は、Javaを例に暗黙的な処理をjavapコマンドで可視化する方法と実際のコードを例に中身を確認し本当に処理が動いているのか検証してみました。

本来、バイトコードで書かれているクラスファイルをjavapコマンドすることで、人間でも理解できるアセンブリ言語に変換してくれるのでどのような処理や命令が行われているのか確認できました。

「暗黙的な処理はコンパイラが裏で良しなにやってくれている」という認識でも問題はないのです。 しかし、「見えないものを見ようとしてjavapコマンドでのぞき込んだ」結果、実は隠れたメソッドが裏で動いてたり、処理の流れや順番を確認できたり、言語仕様をもう一段階理解できたりなど新たな発見や気づきが生まれました。気になった方はぜひクラスファイルをjavapコマンドでのぞいてみてください!

最後まで読んでいただきありがとうございました!ご参考になれば幸いです。

divxでは一緒に働ける仲間を募集しています。 興味があるかたはぜひ採用ページを御覧ください。 divx.co.jp

実行環境

JDK(Java Development Kit)
AdoptOpenJDK version11

% java -version
openjdk version "11.0.14.1" 2022-02-08
OpenJDK Runtime Environment Temurin-11.0.14.1+1 (build 11.0.14.1+1)
OpenJDK 64-Bit Server VM Temurin-11.0.14.1+1 (build 11.0.14.1+1, mixed mode)

MacBookAir(M1,2020)
macOS Big Surバージョン11.6.4

Linux OS
Darwin

% uname -a
Darwin XA-001 20.6.0 Darwin Kernel Version 20.6.0: Wed Jan 12 22:22:45 PST 2022; root:xnu-7195.141.19~2/RELEASE_ARM64_T8101 x86_64

参考資料

第 1 章 Java プログラミング環境の概要
javac
javap - Java クラスファイル逆アセンブラ
javapコマンド
Chapter 6. The Java Virtual Machine Instruction Set#invokespecial
The JavaTM Virtual Machine Specification : The Runtime Constant Pool
The Structure of the Java Virtual Machine#3.5.5 Runtime Constant Pool
java.lang.Integer
java.lang.Integer#intValue
java.lang.Integer#valueOf
Chapter 15. Expressions#Equality Operators
Chapter 15. Expressions#Unboxing Conversion
Chapter 3. Compiling for the Java Virtual Machine#Accessing the Run-Time Constant Pool