KVMに関する記事は読者にとって興味深いものであることが判明したため、本日、SergeZaitsevの記事の新しい翻訳を公開します。Java仮想マシンが内部でどのように機能するかです。
好むと好まざるとにかかわらず、Javaは最も広く使用され広く使用されているプログラミング言語の1つです。ただし、すべてのJava開発者が、内部を調べてJVMがどのように機能するかを確認することに興味があるわけではありません。
おもちゃの(そして不完全な)JVMを書いて、それがどのように機能するかの基本原則を示します。この記事があなたの興味をそそり、JVMをさらに探求するきっかけになることを願っています。
私たちの謙虚な目標
簡単に始めましょう:
public class Add {
public static int add(int a, int b) {
return a + b;
}
}
javac Add.javaを使用してクラスをコンパイルする と、結果は Add.classになります。このクラスファイルは、JVMが実行できるバイナリファイルです。正しく実行するJVMを作成するだけです。 Add.classの
内部を16進ダンプで見る と、おそらくそれほど感銘を受けることはないでしょう。 ここではまだ明確な構造は見られませんが、それを解析する方法を見つける必要があります。 ()Vと (II)I、 < init>そしてなぜファイルは「cafebabe」で始まるのですか?
00000000 ca fe ba be 00 00 00 34 00 0f 0a 00 03 00 0c 07 |.......4........|
00000010 00 0d 07 00 0e 01 00 06 3c 69 6e 69 74 3e 01 00 |........<init>..|
00000020 03 28 29 56 01 00 04 43 6f 64 65 01 00 0f 4c 69 |.()V...Code...Li|
00000030 6e 65 4e 75 6d 62 65 72 54 61 62 6c 65 01 00 03 |neNumberTable...|
00000040 61 64 64 01 00 05 28 49 49 29 49 01 00 0a 53 6f |add...(II)I...So|
00000050 75 72 63 65 46 69 6c 65 01 00 08 41 64 64 2e 6a |urceFile...Add.j|
00000060 61 76 61 0c 00 04 00 05 01 00 03 41 64 64 01 00 |ava........Add..|
00000070 10 6a 61 76 61 2f 6c 61 6e 67 2f 4f 62 6a 65 63 |.java/lang/Objec|
00000080 74 00 21 00 02 00 03 00 00 00 00 00 02 00 01 00 |t.!.............|
00000090 04 00 05 00 01 00 06 00 00 00 1d 00 01 00 01 00 |................|
000000a0 00 00 05 2a b7 00 01 b1 00 00 00 01 00 07 00 00 |...*............|
000000b0 00 06 00 01 00 00 00 01 00 09 00 08 00 09 00 01 |................|
000000c0 00 06 00 00 00 1c 00 02 00 02 00 00 00 04 1a 1b |................|
000000d0 60 ac 00 00 00 01 00 07 00 00 00 06 00 01 00 00 |`...............|
000000e0 00 03 00 01 00 0a 00 00 00 02 00 0b |............|
クラスファイルをアンロードする別の方法に精通していると思いますが、多くの場合、より便利であることがわかります。 これで、クラス、そのコンストラクタ、およびメソッドが表示されます。コンストラクターとメソッドの両方に複数の命令が含まれています。add()メソッドが何をするかが多かれ少なかれ明確になります。2つの引数(iload_0とiload_1)をロードし 、それらを 合計して結果を返します。JVMはスタックマシンであるため、レジスタはなく、すべての命令引数は内部スタックに格納され、結果もスタックにプッシュされます。
$ javap -c Add
Compiled from "Add.java"
public class Add {
public Add();
Code:
0: aload_0
1: invokespecial #1 // Method java/lang/Object."<init>":()V
4: return
public static int add(int, int);
Code:
0: iload_0
1: iload_1
2: iadd
3: ireturn
}
クラスローダー
javapプログラムが示したのと同じ結果をどのように達成しますか?クラスファイルを解析する方法は? JVM仕様
を見る と、クラスファイルの構造がわかり ます。常に4バイトの署名(CAFEBABE)で始まり、その後にバージョンの2 +2バイトが続きます。シンプルに聞こえます! バイナリファイルからbytes、short、int、およびbyteシーケンスを読み取る必要があるため、次のようにローダーの実装を開始しましょう。
type loader struct {
r io.Reader
err error
}
func (l *loader) bytes(n int) []byte {
b := make([]byte, n, n)
// ,
//
// ,
if l.err == nil {
_, l.err = io.ReadFull(l.r, b)
}
return b
}
func (l *loader) u1() uint8 { return l.bytes(1)[0] }
func (l *loader) u2() uint16 { return binary.BigEndian.Uint16(l.bytes(2)) }
func (l *loader) u4() uint32 { return binary.BigEndian.Uint32(l.bytes(4)) }
func (l *loader) u8() uint64 { return binary.BigEndian.Uint64(l.bytes(8)) }
// Usage:
f, _ := os.Open("Add.class")
loader := &loader{r: f}
cafebabe := loader.u4()
major := loader.u2()
minor := loader.u2()
次に、仕様は定数プールを解析するように指示します。それは何ですか?これは、クラスの実行に必要な定数を含むクラスファイルの特別な部分の名前です。すべての文字列、数値定数、および参照がそこに格納され、それぞれに一意のuint16インデックスがあります(したがって、クラスは最大64Kの定数を持つことができます)。
プールにはいくつかのタイプの定数があり、それぞれに独自の値のセットが含まれています。私たちは興味があるかもしれません:
- UTF8:単純な文字列リテラル
- クラス:クラス名を含む文字列のインデックス(間接参照)
- 名前とタイプ:フィールドとメソッドに使用されるタイプ名と記述子のインデックス
- フィールドとメソッドの参照:クラスに関連するインデックスと、タイプ名とタイプの定数。
ご覧のとおり、プール内の定数は相互に参照していることがよくあります。GoでJVMを作成していて、ユニオンタイプがないので、さまざまな可能な定数フィールドを含むConstタイプを作成しましょう。
type Const struct {
Tag byte
NameIndex uint16
ClassIndex uint16
NameAndTypeIndex uint16
StringIndex uint16
DescIndex uint16
String string
}
type ConstPool []Const
次に、JVM仕様に従って、次のように定数プールデータを取得できます。
func (l *loader) cpinfo() (constPool ConstPool) {
constPoolCount := l.u2()
// 1
for i := uint16(1); i < constPoolCount; i++ {
c := Const{Tag: l.u1()}
switch c.Tag {
case 0x01: // UTF8 string literal, 2 bytes length + data
c.String = string(l.bytes(int(l.u2())))
case 0x07: // Class index
c.NameIndex = l.u2()
case 0x08: // String reference index
c.StringIndex = l.u2()
case 0x09, 0x0a: // Field and method: class index + NaT index
c.ClassIndex = l.u2()
c.NameAndTypeIndex = l.u2()
case 0x0c: // Name-and-type
c.NameIndex, c.DescIndex = l.u2(), l.u2()
default:
l.err = fmt.Errorf("unsupported tag: %d", c.Tag)
}
constPool = append(constPool, c)
}
return constPool
}
これでタスクが大幅に簡略化されましたが、実際のJVMでは、JVM仕様に示されているように、long定数型とdouble定数型を均一に処理し、未使用の定数要素を追加する必要があります(定数要素は32ビットと見なされるため)。
インデックスによる文字列リテラルの取得を容易にするために、Resolve(index uint16)文字列メソッドを実装しましょう 。
func (cp ConstPool) Resolve(index uint16) string {
if cp[index-1].Tag == 0x01 {
return cp[index-1].String
}
return ""
}
次に、クラスのインターフェイス、フィールド、メソッド、およびそれらの属性のリストを解析するために、同様のヘルパーを追加する必要があります。
func (l *loader) interfaces(cp ConstPool) (interfaces []string) {
interfaceCount := l.u2()
for i := uint16(0); i < interfaceCount; i++ {
interfaces = append(interfaces, cp.Resolve(l.u2()))
}
return interfaces
}
// field
type Field struct {
Flags uint16
Name string
Descriptor string
Attributes []Attribute
}
//
// - Code,
type Attribute struct {
Name string
Data []byte
}
func (l *loader) fields(cp ConstPool) (fields []Field) {
fieldsCount := l.u2()
for i := uint16(0); i < fieldsCount; i++ {
fields = append(fields, Field{
Flags: l.u2(),
Name: cp.Resolve(l.u2()),
Descriptor: cp.Resolve(l.u2()),
Attributes: l.attrs(cp),
})
}
return fields
}
func (l *loader) attrs(cp ConstPool) (attrs []Attribute) {
attributesCount := l.u2()
for i := uint16(0); i < attributesCount; i++ {
attrs = append(attrs, Attribute{
Name: cp.Resolve(l.u2()),
Data: l.bytes(int(l.u4())),
})
}
return attrs
}
フィールドとメソッドの両方がフィールドとして表されます。これは非常に便利で時間を節約できます。最後に、すべてをまとめて、クラスを完全に解析できます。
type Class struct {
ConstPool ConstPool
Name string
Super string
Flags uint16
Interfaces []string
Fields []Field
Methods []Field
Attributes []Attribute
}
func Load(r io.Reader) (Class, error) {
loader := &loader{r: r}
c := Class{}
loader.u8() // magic (u32), minor (u16), major (u16)
cp := loader.cpinfo() // const pool info
c.ConstPool = cp
c.Flags = loader.u2() // access flags
c.Name = cp.Resolve(loader.u2()) // this class
c.Super = cp.Resolve(loader.u2()) // super class
c.Interfaces = loader.interfaces(cp)
c.Fields = loader.fields(cp) // fields
c.Methods = loader.fields(cp) // methods
c.Attributes = loader.attrs(cp) // methods
return c, loader.err
}
ここで、クラスに関する情報を見ると、彼にはフィールドがないことがわかります。2つのメソッド- <init> :()Vと add:(II of)Iof。括弧付きのローマ数字とは何ですか?これらは記述子です。それらは、メソッドが取る引数のタイプとそれが返すものを決定します。この場合、 <init>(オブジェクトの作成時にオブジェクトを初期化するために使用される合成メソッド)は引数をとらず、何も返しません(V = void)が、 "add"メソッドは2つのデータタイプint(I = int32)と整数を返します。
バイトコード
よく見ると、解析されたクラスの各メソッドには、「Code」という名前の1つの属性があることがわかります。この属性には、ペイロードとしてバイトの一部が含まれています。 Bytes: 仕様では、今回はbytecodeセクション で、「Code」属性がmaxstack値(2バイト)、maxlocals(2バイト)、コードの長さ(4バイト)、そして実際のコードで始まることを読みます。したがって、属性は次のように読み取ることができます。 はい、各メソッドには4バイトと5バイトのコードしかありません。これらのバイトはどういう意味ですか?
<init>:
[0 1 0 1 0 0 0 5 42 183 0 1 177 0 0 0 1 0 7 0 0 0 6 0 1 0 0 0 1]
add:
[0 2 0 2 0 0 0 4 26 27 96 172 0 0 0 1 0 7 0 0 0 6 0 1 0 0 0 3]
<init>: maxstack: 1, maxlocals: 1, code: [42 183 0 1 177]
add: maxstack: 2, maxlocals: 2, code: [26 27 96 172]
私が言ったように、JVMはスタッキングマシンです。各命令は1バイトとしてエンコードされ、その後に追加の引数が続く場合があります。仕様を見ると、「add」メソッドには次の命令があることがわかり ます。最初のjavap出力で見たものとまったく同じです。しかし、それを行う方法は?
26 = iload_0
27 = iload_1
96 = iadd
172 = ireturn
JVMフレーム
メソッドがJVM内で実行されると、一時オペランド用の独自のスタック、独自のローカル変数、および実行するコードがあります。これらのパラメータはすべて、単一の実行フレームに格納されます。さらに、フレームには、現在の命令へのポインター(バイトコードの実行がどれだけ進んだか)と、メソッドを含むクラスへのポインターが含まれています。後者は、クラス定数のプールやその他の詳細にアクセスするために必要です。
指定された引数で呼び出される特定のメソッドのフレームを作成するメソッドを作成しましょう。ここでは、値のタイプとしてインターフェイス{}を使用し ますが、正しいユニオンタイプを使用する方が安全です。
type Frame struct {
Class Class
IP uint32
Code []byte
Locals []interface{}
Stack []interface{}
}
func (c Class) Frame(method string, args ...interface{}) Frame {
for _, m := range c.Methods {
if m.Name == method {
for _, a := range m.Attributes {
if a.Name == "Code" && len(a.Data) > 8 {
maxLocals := binary.BigEndian.Uint16(a.Data[2:4])
frame := Frame{
Class: c,
Code: a.Data[8:],
Locals: make(
[]interface{},
maxLocals,
maxLocals
),
}
for i := 0; i < len(args); i++ {
frame.Locals[i] = args[i]
}
return frame
}
}
}
}
panic("method not found")
}
したがって、初期化されたローカル変数、空のスタック、およびプリロードされたバイトコードを含むフレームを取得しました。バイトコードを実行する時が来ました:
func Exec(f Frame) interface{} {
for {
op := f.Code[f.IP]
log.Printf("OP:%02x STACK:%v", op, f.Stack)
n := len(f.Stack)
switch op {
case 26: // iload_0
f.Stack = append(f.Stack, f.Locals[0])
case 27: // iload_1
f.Stack = append(f.Stack, f.Locals[1])
case 96:
a := f.Stack[n-1].(int32)
b := f.Stack[n-2].(int32)
f.Stack[n-2] = a + b
f.Stack = f.Stack[:n-1]
case 172: // ireturn
v := f.Stack[n-1]
f.Stack = f.Stack[:n-1]
return v
}
f.IP++
}
}
最後に、add()メソッドを呼び出すことで、すべてをまとめて実行できます。
f, _ := os.Open("Add.class")
class, _ := Load(f)
frame := class.Frame("add", int32(2), int32(3))
result := Exec(frame)
log.Println(result)
// OUTPUT:
OP:1a STACK:[]
OP:1b STACK:[2]
OP:60 STACK:[2 3]
OP:ac STACK:[5]
5
したがって、すべてが機能します。はい、これは最小限の非常に弱いJVMですが、それでもJVMが実行するはずのことを実行します。バイトコードをダウンロードして解釈します(もちろん、実際のJVMははるかに多くのことを実行します)。
何が欠けている?
残りの200の命令、ランタイム、OOPタイプのシステム、およびその他いくつかのもの。
指示には11のグループがあり、そのほとんどは一般的です。
- 定数(null、小さい数、または値を定数プールからスタックにプッシュします)。
- ロード(ローカル変数をスタックにプッシュします)。同様の指示32。
- Stores ( ). 32 .
- Stack (pop/dup/swap), .
- Math (add/sub/div/mul/rem/shift/logic). , 36 .
- Conversions (int short, int float ..).
- Comparisons (eq/ne/le/…). , if/else.
- Control (goto/return). .
- References. , , .
- Extended. . , , .
- Reserved. 0xca.
ほとんどの命令は実装が簡単です。スタックから1つまたは2つの引数を取り、それらに対して何らかの操作を実行して、結果を送信します。ここで覚えておくべき唯一のことは、long型とdouble型の命令は、各値がスタック上の2つのスロットを占めることを前提としているため、追加のpush()とpop()が必要になる場合があり、命令のグループ化が困難になります。
参照を実装するには、オブジェクトモデルについて考える必要があります。オブジェクトとそのクラスを格納する方法、継承を表す方法、インスタンスフィールドとクラスフィールドを格納する場所です。また、ここではメソッドのディスパッチに注意する必要があります。いくつかの「invoke」ステートメントがあり、それらの動作は異なります。
- invokestatic:クラスで静的メソッドを呼び出します。驚くことではありません。
- invokespecial: , , <init> .
- invokevirtual: .
- invokeinterface: , invokevirtual, .
- invokedynamic: call site, Java 7, MethodHandles.
ガベージコレクションのない言語でJVMを構築している場合は、その方法を検討する必要があります。参照カウント、マークアンドスイープアルゴリズムなどです。スローを実装して例外を処理 し、フレームを介して伝播し、処理します。例外テーブルの使用も興味深いトピックです。
最後に、ランタイムクラスがない場合、JVMは役に立ちません。なしで のjava / LANG /オブジェクト、あなたはほとんども、どのように見ることができない新しい作品 新しいオブジェクトを作成するときの指示。ランタイムは、java.lang、java.io、およびjava.utilパッケージからいくつかの汎用JREクラスを提供する場合もあれば、よりドメイン固有のものである場合もあります。ほとんどの場合、クラスの一部のメソッドは、Javaではなく、ネイティブに実装する必要があります。これは、そのようなメソッドを見つけて実行する方法の問題を提起し、JVMのもう1つのエッジケースになります。
言い換えれば、適切なJVMを構築することは簡単ではありませんが、それがどのように機能するかを正確に理解することは難しくありません。
たとえば、夏の週末は1回しかかかりませんでした。私のJVMにはまだ長い道のりがありますが、構造は多かれ少なかれ明確に見えます:https: //github.com/zserge/tojvm(コメントはいつでも歓迎です!)
この記事の実際のコードスニペットはさらに小さく、要点としてここにあります 。
詳細を知りたい場合は、小さなJVMの使用を検討してください。
- ミカ:https://github.com/kifferltd/open-mika
- 鳥類:https://github.com/ReadyTalk/avian
- NanoVM:https://github.com/harbaum/NanoVM
- Luje:https://github.com/davidgiven/luje(LuaJITに最適なJVM)
この記事がJavaへの関心を落とさないことを願っています。仮想マシンは楽しいです、そしてJava仮想マシンは本当によく見る価値があります。
私の記事を楽しんでいただけたでしょうか。Githubまたは Twitterで私の作業をフォローし、rssを介してサブスクライブ できます 。