はじめに
突然ですが、実はJavaのプログラムをコンパイルしたときに作られるのは機械語やアセンブラではありません。中間コードに翻訳され、それが各々のコンピューターにあるJVM(Java仮想環境)上で実行できます。
本記事ではJVM中間コード(所謂.classファイルの中身)の構造についてHelloWorldを例にしつつ説明します。
ちなみにこれが直接書けるようになれば、Javaを一切使わずにJavaコマンドで実行できるバイナリが作れますよ!
注意
- 本記事では「.class」ファイルを少しも触ったことのないヒトにも分かりやすく解説することを目指しているので、常に正しい表現をするとは限りませんし、大雑把に説明することや割愛することもままあります。
- Javaをやったことがあるヒトが対象です。
必要なもの
- JDK (本記事ではOracle JDK 13を対象としますが、他のだったり少し古くても動作は同じです)
- バイナリエディター
せめて一バイトずつ表示できるもの。文字列が表示できるならなおよし。
準備体操
概念1-スタック
JVMでプログラムが実行される場合、「スタック」という領域がJVM上に作成され、それにデータを出し入れしつつ実行されます。
大雑把に言えば、例えばJavaで文字列リテラルを書いたときにはその文字列をスタックに入れるという処理になる、といっても差し支えありません。
このスタックには一部を除いて「データを1つ入れろ」・「データを1つ取り出せ」という二種類の指示しか送ることができません。
そして、ここでいうスタックは所謂「後入れ先出し構造」をしています。つまり、後の方に入れたデータほど、先に取り出されます。
概念2-ランタイム定数フィールド
JVM中間コードのファイルには実は関数やクラスの情報だけではなく、まるで配列に定数情報が入っているかのように見える領域があります。これを「ランタイム定数フィールド」(直訳)と言います。重要度としては、JVM中間コードの8割はこれだといっても過言ではありません。ほぼ全ての命令はこれを参照します。
16進数
1バイトは16進数で2桁(00-ff)です。また、「0x~」は16進数であるということを表しています。
ツール
JDKが正常に入っているならばjavapという便利なツールを使用できます。これを使うと「.class」ファイル(中間コードが書かれている)の詳しい中身を表示してくれます。(文字列で出てきますが実際の中身はバイナリです)これの使用方法はコンソールで
$ javap -v 〈classfile-name〉
です。これやバイナリエディターで実際のファイルの中身を見ながら読み進める事を薦めます。但し、javapで出てきたものをコピペしても動きませんよ。
.classファイル作成
実際に「.class」ファイルがあると理解が進みます。ということで本記事では例として次のHelloWorldプログラムを元にします:
public class Hello{
public static void main(String[] args){
System.out.println("Hello World!")
}
}
これをjavacコマンドでコンパイルして「Hello.class」を作っておいてください。
「.class」ファイルの構造
MagicNumber
魔法の数字だよ、ハハッ!
最初の4バイトはこのファイルが何かを示すものです。
「0xCAFEBABE」になっているはずです。(そうでなければ.classファイルではありません。)
Version
次の4バイトは「.class」ファイルのバージョンです。最初の2バイトはマイナーバージョン、次の2バイトはメジャーバージョンです。
なお、仮に52がメジャーバージョンとしましょうか。その場合次はきっと「0x00000034」です。
ランタイム定数フィールド数
次2バイトはランタイム定数フィールドの数です。これは正直予測しづらいです。実際のファイルの中身を見て感覚をつかみましょう。
ランタイム定数フィールド
ランタイム定数フィールドには以下の種類があります。これが先ほどのランタイム定数フィールドの数だけ繰り返されます。**バイナリエディタで出てくる数値は16進数であることに注意!**実際のバイナリとjavapの結果をにらめっこしながら当てはめてみてください。
UTF8
これは所謂Stringそのものではないです。JVM中間コード内で情報として使用する文字列が書き込まれます。
文法として、0x01が1バイト、バイト数を2バイト、その語バイト数だけ文字列が書き込まれています。
Class
クラスの情報が書き込まれます。
まず0x07が1バイトで書かれています。その後、そのクラスの名前を表すランタイム定数フィールド(UTF8)へのインデックスが2バイトで書かれています。
NameAndType
メソッドやフィールドとかの名前や型の情報が書き込まれます。
まず0x0Cが1バイトで書かれています。その後、そのメソッドの名前を表すランタイム定数フィールド(UTF8)へのインデックスが2バイトで書かれています。その後メソッドの型を表すランタイム定数フィールド(UTF8)へのインデックスが2バイトで書かれています。
Methodref
クラスメソッドの情報が書き込まれます。
まず0x0Aが1バイトで書かれています。その後、そのメソッドの属するクラスを表すランタイム定数フィールド(Class)へのインデックスが2バイトで書かれています。その後メソッドの名前や型を表すランタイム定数フィールド(NameAndType)へのインデックスが2バイトで書かれています。
Fieldref
クラスフィールドの情報が書き込まれます。
まず0x09が1バイトで書かれています。その後、そのフィールドの属するクラスを表すランタイム定数フィールド(Class)へのインデックスが2バイトで書かれています。その後フィールドの名前や型を表すランタイム定数フィールド(NameAndType)へのインデックスが2バイトで書かれています。
String
Stringの情報が書き込まれます。これが定義されてはじめて、String定数が使用可能になります。
まず0x08が1バイトで書かれています。その後、そのStringに含まれる文字列を表すランタイム定数フィールド(UTF8)へのインデックスが2バイトで書かれています。
クラスのアクセス修飾子
ここからが実際のクラスの定義に入っていきます。初めの2バイトにはアクセス修飾子(所謂publicやprivate等)が書き込まれています。publicでfinalでsuperなクラスならそこはきっと0x0031です。
このクラスの情報
次には自身のクラスの情報を持つランタイム定数フィールド(Class)のインデックスが2バイトで書き込まれています。
親クラスの情報
次には継承元(所謂、親)のクラスの情報を持つランタイム定数フィールド(Class)のインデックスが2バイトで書き込まれています。普通ならきっとjava/lang/Objectを指す情報が示されています。
インターフェース個数
次には継承しているインターフェースの数が2バイトで書かれています。今回はきっと0x0000です。
クラスフィールド個数
次にはクラス内に定義されるフィールド(変数)の数が2バイトで書かれています。今回はきっと0x0000です。
メソッドの数
次にはこのクラスに定義されているメソッド数が2バイトで書かれています。Javaコンパイラが暗黙的に追加するコンストラクタがありますので、今回はmainメソッド含め2つです。0x0002と書かれているはずです。
メソッド定義
次からは1つ1つのメソッド定義が始まります。メソッド定義の決められた形式をメソッド数だけ繰り返します。
イニシャライザ
ここは、実行に直接関係ないのでスキップします。(また追記するかもしれません。)44バイト程先の「0x09」まで進めてください。直接書くとき必要ないならメソッドの数を0x0001にしましょう
main
設計
public static void main(String[] args){
System.out.println("Hello World!")
}
さて、ここで必要な仕様として、引数はローカル変数としても扱われることを忘れてはいけません。また、良く見慣れるSystem.out.println
というお馴染みの構文は、実はSystem.outインスタンスのprintlnメソッドを呼んでいる処理です。
これを踏まえここで何をしているかと言えば、
-
System.out
の参照をJVMスタックに入れます。 -
「Hello World!」
をJVMスタックに入れます。 - スタックよりインスタンスと引数の情報を取り出し
PrintStream.println()
を呼びます。 - 関数を終わります。
です。
これはそれぞれ、JVM内で使用されるコードでは「getstatic」,「ldc」,「invokevirtual」,「return」と書きます。このようなJVM内で使用されるコードをオペコードといいます。それぞれ必要なバイト数を合計すると9バイトです。これを念頭に置きながら読んでいきましょう。
アクセス修飾子
ここでもアクセス修飾子が書き込まれます。「public」かつ「static」を表す0x0009が書かれているはずです。
お名前
次にはこのメソッドの名前の情報があるランタイム定数フィールド(UTF8)のインデックスが2バイトで書き込まれています。
型情報
その次にはこのメソッドの型の情報があるランタイム定数フィールド(UTF8)のインデックスが2バイトで書き込まれています。ここのUTF8は特殊な書き方なので、実際のコードを見て感覚をつかみましょう。
メソッド属性
次の2バイトでメソッドの付加情報数が指定されます。「Code」という付加情報がありますので、 1つ情報があります。0x0001になっていると思います。
コード情報
次に「Code」そのもののランタイム定数フィールド(UTF8)のインデックスが2バイトで書き込まれています。
Code属性のバイト数
次には、コード情報(先ほどのCodeを表すもの)の2バイトと、これ自身の2バイトを除く、Code属性全体(LineNumberTableを含む)のバイト数が、4バイトで書き込まれています。
スタックに積まれる最大値
次2バイトはJVMスタックに積まれる最大値です。今回はきっと0x0002です。0xffffでもたぶん動きますが...。
ローカル変数個数
次2バイトはローカル変数の数 (引数も含む) です。今回はきっと引数の数である0x0001です。
オペコードのバイト数
次4バイトはオペコードのバイト数です。先ほど示した0x00000009が書かれていると思います。
getstatic
次に静的フィールドを取得するオペコードです。まず0xB2が書き込まれ、続けて得たい静的フィールドを表すランタイム定数フィールド(Fieldref)のインデックスが、2バイトで書かれています。
ldc
次にランタイム定数フィールドをJVMスタックに入れるオペコードです。まず0x12が書き込まれ、続けて入れたいデータを表すランタイム定数フィールド(StringやIntegerなど)のインデックスが、1バイトで書かれています。
invokevirtual
次にインスタンスのメソッドを必要な情報をJVMスタックより取り出しつつ呼ぶオペコードです。まず0xB6が書き込まれ、続けて呼びたいメソッドを表すランタイム定数フィールド(Methodref)のインデックスが、2バイトで書かれています。ここでJVMスタックに入れてあったSystem.out
インスタンスと"Hello World!"
が使用されます。
return
次にvoidを返しつつメソッドを終わらせるコードです。0xB1のみが書き込まれているはずです。
例外数
次には例外の数が2バイトで書き込まれています。きっと0x0000でしょう。
コード属性の付加属性数
次にはコード属性のオプションが含まれる数です。今回は「LineNumberTable」という、ソースコードの何行目に書かれているかの情報が含まれるものがあります。よって、0x0001が書かれていると思います。
LineNumberTable
その一つ一つのコードがソースコードの何行目に書かれているかの情報が含まれる領域です。実行に直接関係ないので割愛します。 私自身も分からないのです 17バイトほど、スキップし、0x0001まで進んでください。 直接書くとき必要ないならコード属性の付加属性数を0x0000にし、Code属性のバイト数を、この属性のバイト数だけ減らすなどして調整してください。
クラス属性の数
このクラスの付加情報です。今回は元々のソースコードの情報が含まれますので0x0001が書き込まれていると思います。
SourceFile
ソースコード情報です...が実行に関係ないので、 (僕には分からないので) 残りも割愛します。直接書くとき必要ないならクラス属性の数を0x0000にしましょう
終章
お疲れさまでした。本当に様々なものが詰められていることが分かったと思います。これを直接書けるようになれば好きなJVM言語が作れますよ!間違いはどんどん指摘してください!
参考物件及び私の更なる勉強の為に
OracleによるJVM仕様。英語。これを全て和訳するのが私の野望です。