Cara menulis JVM (mainan)

Artikel tentang KVM ternyata menarik untuk pembaca, jadi hari ini kami menerbitkan terjemahan baru dari artikel Serge Zaitsev: cara kerja Java Virtual Machine di bawah tenda.


Suka atau tidak suka, Java adalah salah satu bahasa pemrograman yang paling umum dan banyak digunakan. Namun, tidak semua pengembang Java cukup penasaran untuk melihat di balik terpal dan melihat cara kerja JVM.



Saya akan mencoba menulis mainan JVM (dan tidak lengkap) untuk menunjukkan prinsip dasar cara kerjanya. Saya harap artikel ini menarik minat Anda dan menginspirasi Anda untuk menjelajahi JVM lebih jauh.



Tujuan kami yang sederhana



Mari kita mulai dengan sederhana:



public class Add {
  public static int add(int a, int b) {
    return a + b;
  }
}

      
      





Kami mengkompilasi kelas kami dengan javac Add.java dan hasilnya adalah Add.class . File kelas ini adalah file biner yang dapat dijalankan JVM. Yang harus kita lakukan adalah membuat JVM yang akan menjalankannya dengan benar.



Jika kita melihat ke dalam Add.class dengan hex dump, kita mungkin tidak akan terlalu terkesan: Meskipun kita belum melihat struktur yang jelas di sini, kita perlu menemukan cara untuk menguraikannya: what does () V dan (II) I , < init> dan mengapa file dimulai dengan "cafe babe"?



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 |............|












Anda mungkin sudah familiar dengan cara lain untuk membongkar file kelas, seringkali lebih berguna: Sekarang kita melihat kelas kita, konstruktor dan metodenya. Baik konstruktor dan metode berisi banyak instruksi. Ini menjadi lebih atau kurang jelas apa yang dilakukan metode add () kami: ia memuat dua argumen ( iload_0 dan iload_1 ), menjumlahkannya dan mengembalikan hasilnya. JVM adalah mesin stack, sehingga tidak memiliki register, semua argumen instruksi disimpan pada stack internal, dan hasilnya juga didorong ke stack.



$ 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

}












Loader kelas



Bagaimana cara kami mencapai hasil yang sama seperti yang ditunjukkan oleh program javap? Bagaimana cara mengurai file kelas?



Jika kita melihat spesifikasi JVM , kita belajar tentang struktur file kelas . Itu selalu dimulai dengan tanda tangan 4 byte (CAFEBABE) diikuti oleh 2 + 2 byte untuk versi. Kedengarannya sederhana!



Karena kita harus membaca urutan byte, pendek, int dan byte dari file biner, mari kita mulai implementasi loader kita sebagai berikut:



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()
      
      





Spesifikasi kemudian memberi tahu kita untuk mengurai kumpulan konstan. Apa itu? Ini adalah nama bagian khusus dari file kelas yang berisi konstanta yang diperlukan untuk menjalankan kelas. Semua string, konstanta numerik, dan referensi disimpan di sana, dan masing-masing memiliki indeks uint16 yang unik (sehingga kelas dapat memiliki hingga 64K konstanta).



Ada beberapa jenis konstanta di kumpulan, yang masing-masing berisi kumpulan nilainya sendiri. Kami mungkin tertarik dengan:



  • UTF8: string literal sederhana
  • Kelas: indeks dari string yang berisi nama kelas (referensi tidak langsung)
  • Nama dan jenis: indeks nama jenis dan keterangan yang digunakan untuk bidang dan metode
  • Referensi bidang dan metode: indeks yang terkait dengan kelas dan konstanta dari nama dan tipe tipe.


Seperti yang Anda lihat, konstanta di kolam sering merujuk satu sama lain. Karena kita menulis JVM di Go dan tidak ada tipe gabungan, mari buat tipe Const yang akan berisi berbagai kemungkinan field konstan:



type Const struct {
	Tag              byte
	NameIndex        uint16
	ClassIndex       uint16
	NameAndTypeIndex uint16
	StringIndex      uint16
	DescIndex        uint16
	String           string
}
type ConstPool []Const
      
      





Kemudian, mengikuti spesifikasi JVM, kita bisa mengambil data kumpulan konstan seperti ini:



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
}
      
      





Sekarang kita sangat menyederhanakan tugas kita, tetapi dalam JVM nyata kita harus memperlakukan tipe konstanta panjang dan ganda secara seragam, memasukkan elemen konstanta tambahan yang tidak terpakai, seperti yang dikatakan spesifikasi JVM (karena elemen konstan dianggap 32-bit).



Untuk mempermudah mendapatkan literal string berdasarkan indeks, mari terapkan metode string Resolve (index uint16) :



func (cp ConstPool) Resolve(index uint16) string {
	if cp[index-1].Tag == 0x01 {
		return cp[index-1].String
	}
	return ""
}
      
      





Sekarang kita perlu menambahkan pembantu serupa untuk mengurai daftar antarmuka, bidang, dan metode kelas serta atributnya:



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
}
      
      





Baik bidang maupun metode direpresentasikan sebagai Bidang, yang sangat nyaman dan menghemat waktu. Akhirnya, kita bisa menggabungkan semuanya dan mengurai kelas kita sepenuhnya:



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
}
      
      





Sekarang, jika kita melihat informasi tentang kelas, kita akan melihat bahwa dia tidak memiliki field, dua metode - <the init> :() V dan add: (II of) I of . Apa angka romawi dengan tanda kurung? Ini adalah deskriptor. Mereka menentukan jenis argumen apa yang diambil metode dan apa yang dikembalikannya. Dalam kasus ini, <init> (metode sintetik yang digunakan untuk menginisialisasi objek saat dibuat) tidak menggunakan argumen dan tidak mengembalikan apa-apa (V = void), sedangkan metode "add" menerima dua tipe data int (I = int32) dan mengembalikan integer.



Bytecode



Jika kita melihat lebih dekat, kita menyadari bahwa setiap metode di kelas parsing kita memiliki satu atribut bernama "Kode". Atribut ini berisi sebagian kecil dari byte sebagai payload. Bytes: Dalam spesifikasi, kali ini di bagian bytecode , kita akan membaca bahwa atribut "Code" dimulai dengan nilai maxstack (2 bytes), kemudian maxlocals (2 bytes), panjang kode (4 bytes), dan kemudian kode sebenarnya. Jadi, atribut kami dapat dibaca seperti ini: Ya, kami hanya memiliki 4 dan 5 byte kode di setiap metode. Apa maksud dari byte ini?



<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]












Seperti yang saya katakan, JVM adalah mesin susun. Setiap instruksi dikodekan sebagai byte tunggal, yang dapat diikuti oleh argumen tambahan. Melihat speknya, kita dapat melihat bahwa metode "add" memiliki instruksi berikut: Persis seperti yang kita lihat pada keluaran javap di awal! Tapi bagaimana caranya?



26 = iload_0

27 = iload_1

96 = iadd

172 = ireturn












Bingkai JVM



Ketika sebuah metode dieksekusi di dalam JVM, ia memiliki tumpukannya sendiri untuk operan sementara, variabel lokalnya sendiri, dan potongan kode untuk dieksekusi. Semua parameter ini disimpan dalam satu kerangka eksekusi. Selain itu, frame berisi pointer ke instruksi saat ini (sejauh mana kita telah maju dalam mengeksekusi bytecode) dan pointer ke kelas yang berisi metode tersebut. Yang terakhir ini diperlukan untuk mengakses kumpulan konstanta kelas, serta detail lainnya.



Mari buat metode yang membuat bingkai untuk metode tertentu yang disebut dengan argumen yang diberikan. Saya akan menggunakan antarmuka {} sebagai tipe Nilai di sini, meskipun menggunakan tipe gabungan yang benar tentu saja akan lebih aman.



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")
}
      
      





Jadi kami mendapatkan Frame dengan variabel lokal yang diinisialisasi, tumpukan kosong, dan bytecode yang dimuat sebelumnya. Saatnya untuk mengeksekusi bytecode:



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++
	}
}

      
      





Akhirnya, kita bisa menggabungkan semuanya dan menjalankan dengan memanggil metode add () kita:



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
      
      





Jadi semuanya bekerja. Ya, ini adalah JVM yang sangat lemah pada minimal, tetapi tetap melakukan apa yang seharusnya dilakukan JVM: unduh bytecode dan menafsirkannya (tentu saja, JVM yang sebenarnya melakukan lebih banyak lagi).



Apa yang hilang?



Sisa 200 instruksi, runtime, sistem tipe OOP dan beberapa hal lainnya.



Ada 11 kelompok instruksi, yang kebanyakan umum:



  • Konstanta (mendorong null, angka kecil, atau nilai dari kumpulan konstan ke tumpukan).
  • Beban (mendorong variabel lokal ke tumpukan). Instruksi serupa 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.


Kebanyakan instruksi mudah diterapkan: instruksi mengambil satu atau dua argumen dari tumpukan, melakukan beberapa operasi padanya, dan mengirimkan hasilnya. Satu-satunya hal yang perlu diingat di sini adalah bahwa instruksi untuk tipe panjang dan ganda mengasumsikan bahwa setiap nilai menempati dua slot pada tumpukan, jadi Anda mungkin memerlukan tambahan push () dan pop (), yang membuat instruksi pengelompokan menjadi sulit.



Untuk mengimplementasikan Referensi, Anda perlu memikirkan model objek: bagaimana Anda ingin menyimpan objek dan kelasnya, bagaimana merepresentasikan pewarisan, di mana menyimpan field instance dan field kelas. Selain itu, Anda harus berhati-hati dengan pengiriman metode di sini - ada beberapa pernyataan "panggilan" dan berperilaku berbeda:



  • invokestatic: memanggil metode statis di kelas, tidak ada kejutan.
  • invokespecial: , , <init> .
  • invokevirtual: .
  • invokeinterface: , invokevirtual, .
  • invokedynamic: call site, Java 7, MethodHandles.


Jika Anda membuat JVM dalam bahasa tanpa pengumpulan sampah, Anda harus memikirkan cara melakukannya: penghitungan referensi, algoritme mark-and-sweep, dll. Menangani pengecualian dengan mengimplementasikan athrow , menyebarkannya melalui bingkai, dan menangani menggunakan tabel pengecualian adalah topik menarik lainnya.



Akhirnya, JVM Anda tidak akan berguna jika tidak ada kelas runtime. Tanpa java / lang / Object, Anda bahkan tidak dapat melihat cara kerja baruinstruksi saat membuat objek baru. Runtime Anda mungkin menyediakan beberapa kelas JRE umum dari paket java.lang, java.io dan java.util, atau mungkin sesuatu yang lebih spesifik untuk domain. Kemungkinan besar, beberapa metode di kelas harus diimplementasikan secara native, dan bukan di Java. Ini akan menimbulkan pertanyaan tentang bagaimana menemukan dan menjalankan metode seperti itu, dan akan menjadi kasus tepi lain untuk JVM Anda.



Dengan kata lain, membangun JVM yang tepat tidaklah mudah, tetapi mencari tahu cara kerjanya yang tepat tidaklah sulit.



Misalnya, saya hanya butuh satu akhir pekan musim panas. Jalan JVM saya masih panjang, tetapi strukturnya terlihat kurang lebih jelas: https://github.com/zserge/tojvm (komentar selalu diterima!)



Potongan kode sebenarnya dari artikel ini bahkan lebih kecil dan tersedia sebagai intinya di sini .



Jika Anda ingin mempelajari lebih lanjut, Anda dapat mempertimbangkan untuk menggunakan JVM kecil:





Saya harap artikel ini tidak menyurutkan minat Anda pada Java. Mesin virtual itu menyenangkan, dan mesin virtual Java benar-benar layak untuk dilihat dari dekat.



Saya harap Anda menikmati artikel saya. Anda dapat mengikuti pekerjaan saya di Github atau Twitter , dan berlangganan melalui rss .



All Articles