Comment écrire une JVM (jouet)

L'article sur KVM s'est avéré intéressant pour les lecteurs, c'est pourquoi nous publions aujourd'hui une nouvelle traduction de l'article de Serge Zaitsev: comment la machine virtuelle Java fonctionne sous le capot.


Que cela nous plaise ou non, Java est l'un des langages de programmation les plus courants et les plus utilisés. Cependant, tous les développeurs Java ne sont pas assez curieux pour regarder sous le capot et voir comment fonctionne la JVM.



Je vais essayer d'écrire une JVM jouet (et incomplète) pour montrer les principes de base de son fonctionnement. J'espère que cet article suscitera votre intérêt et vous inspirera à explorer davantage la JVM.



Notre humble objectif



Commençons simplement:



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

      
      





Nous compilons notre classe avec javac Add.java et le résultat est Add.class . Ce fichier de classe est un fichier binaire que la JVM peut exécuter. Tout ce que nous avons à faire est de créer une JVM qui l'exécutera correctement.



Si nous regardons dans Add.class avec un vidage hexadécimal, nous ne serons probablement pas trop impressionnés: bien que nous ne voyions pas encore de structure claire ici, nous devons trouver un moyen de l'analyser: que fait () V et (II) I , < init> et pourquoi le fichier commence-t-il par "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 |............|












Vous connaissez probablement une autre façon de décharger les fichiers de classe, souvent elle s'avère plus utile: nous voyons maintenant notre classe, son constructeur et sa méthode. Le constructeur et la méthode contiennent plusieurs instructions. Il devient plus ou moins clair ce que fait notre méthode add (): elle charge deux arguments ( iload_0 et iload_1 ), les résume et renvoie le résultat. La JVM est une machine à pile, donc elle n'a pas de registre, tous les arguments d'instruction sont stockés sur une pile interne et les résultats sont également poussés sur la pile.



$ 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

}












Chargeur de classe



Comment pouvons-nous obtenir le même résultat que celui montré par le programme javap? Comment analyser un fichier de classe?



Si nous regardons la spécification JVM , nous apprenons la structure des fichiers de classe . Il commence toujours par une signature de 4 octets (CAFEBABE) suivie de 2 + 2 octets pour la version. Cela semble simple!



Comme nous aurions à lire des octets, des séquences courtes, int et octets à partir d'un fichier binaire, commençons notre implémentation de chargeur comme suit:



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





La spécification nous dit ensuite d'analyser le pool de constantes. Qu'Est-ce que c'est? Il s'agit du nom de la partie spéciale du fichier de classe qui contient les constantes requises pour exécuter la classe. Toutes les chaînes, constantes numériques et références y sont stockées, et chacune possède un index uint16 unique (une classe peut donc avoir jusqu'à 64K constantes).



Il existe plusieurs types de constantes dans le pool, chacune contenant son propre ensemble de valeurs. Nous pouvons être intéressés par:



  • UTF8: littéral de chaîne simple
  • Classe: index de la chaîne contenant le nom de la classe (référence indirecte)
  • Nom et type: index du nom de type et descripteur utilisé pour les champs et méthodes
  • Références de champs et de méthodes: index relatifs aux classes et constantes de type nom et type.


Comme vous pouvez le voir, les constantes du pool se réfèrent souvent les unes aux autres. Puisque nous écrivons une JVM dans Go et qu'il n'y a pas de types d'union, créons un type Const qui contiendra différents champs constants possibles:



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





Ensuite, en suivant les spécifications de la JVM, nous pourrions récupérer les données du pool constant comme ceci:



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
}
      
      





Maintenant, nous simplifions considérablement notre tâche, mais dans une vraie JVM, nous devrions traiter les types de constantes longues et doubles de manière uniforme, en insérant un élément constant supplémentaire inutilisé, comme nous l'indique la spécification JVM (puisque les éléments constants sont considérés comme 32 bits).



Pour faciliter l'obtention de littéraux de chaîne par index, implémentons la méthode de chaîne Resolve (index uint16) :



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





Maintenant, nous devons ajouter des helpers similaires pour analyser la liste des interfaces, des champs et des méthodes de classes et de leurs attributs:



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
}
      
      





Les champs et les méthodes sont représentés sous forme de champs, ce qui est très pratique et permet de gagner du temps. Enfin, nous pouvons tout rassembler et analyser complètement notre classe:



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
}
      
      





Maintenant, si nous regardons les informations sur la classe, nous verrons qu'il n'a pas de champs, deux méthodes - <l'init> :() le V et l'ajout: (II of) I of . Que sont les nombres romains entre parenthèses? Ce sont des descripteurs. Ils déterminent quels types d'arguments une méthode prend et ce qu'elle renvoie. Dans ce cas, <init> (la méthode synthétique utilisée pour initialiser les objets lors de leur création) ne prend aucun argument et ne renvoie rien (V = void), tandis que la méthode "add" accepte deux types de données int (I = int32) et renvoie un entier.



Bytecode



Si nous regardons attentivement, nous nous rendons compte que chaque méthode de notre classe analysée a un attribut nommé «Code». Cet attribut contient une fraction des octets comme charge utile. Octets: Dans la spécification, cette fois dans la section bytecode , nous lirons que l'attribut "Code" commence par une valeur maxstack (2 octets), puis maxlocals (2 octets), la longueur du code (4 octets) et enfin le code réel. Ainsi, nos attributs peuvent être lus comme ceci: Oui, nous n'avons que 4 et 5 octets de code dans chaque méthode. Que signifient ces octets?



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












Comme je l'ai dit, la JVM est une machine à empiler. Chaque instruction est codée sous la forme d'un octet unique, qui peut être suivi d'arguments supplémentaires. En regardant la spécification, nous pouvons voir que la méthode "add" a les instructions suivantes: Exactement comme nous l'avons vu dans la sortie javap au début! Mais comment faire ça?



26 = iload_0

27 = iload_1

96 = iadd

172 = ireturn












Cadres JVM



Lorsqu'une méthode est exécutée dans la JVM, elle a sa propre pile d'opérandes temporaires, ses propres variables locales et un morceau de code à exécuter. Tous ces paramètres sont stockés dans une seule trame d'exécution. De plus, les frames contiennent un pointeur vers l'instruction courante (jusqu'où nous avons avancé dans l'exécution du bytecode) et un pointeur vers la classe qui contient la méthode. Ce dernier est nécessaire pour accéder au pool de constantes de classe, ainsi qu'à d'autres détails.



Créons une méthode qui crée un cadre pour une méthode spécifique appelée avec les arguments donnés. J'utiliserai interface {} comme type de valeur ici, bien que l'utilisation des types d'union corrects soit bien sûr plus sûre.



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





Nous avons donc un Frame avec des variables locales initialisées, une pile vide et un bytecode préchargé. Il est temps d'exécuter le 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++
	}
}

      
      





Enfin, nous pouvons tout rassembler et l'exécuter en appelant notre méthode 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
      
      





Donc tout fonctionne. Oui, c'est une JVM très faible sur un minimum, mais elle fait toujours ce que la JVM est censée faire: télécharger le bytecode et l'interpréter (bien sûr, la vraie JVM fait beaucoup plus).



Que manque-t-il?



Les 200 instructions restantes, les temps d'exécution, les systèmes de type POO et quelques autres choses.



Il existe 11 groupes d'instructions, dont la plupart sont courants:



  • Constantes (pousse null, petit nombre ou valeurs du pool de constantes sur la pile).
  • Charge (pousse les variables locales sur la pile). Instructions similaires 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.


La plupart des instructions sont faciles à implémenter: elles prennent un ou deux arguments de la pile, effectuent une opération sur eux et envoient le résultat. La seule chose à garder à l'esprit ici est que les instructions pour les types long et double supposent que chaque valeur occupe deux emplacements sur la pile, vous pouvez donc avoir besoin de push () et pop () supplémentaires, ce qui rend les instructions de regroupement difficiles.



Pour implémenter les références, vous devez penser au modèle d'objet: comment vous voulez stocker les objets et leurs classes, comment représenter l'héritage, où stocker les champs d'instance et les champs de classe. De plus, vous devez faire attention à la répartition des méthodes ici - il existe plusieurs instructions «invoke» et elles se comportent différemment:



  • invokestatic: appeler une méthode statique sur une classe, pas de surprise.
  • invokespecial: , , <init> .
  • invokevirtual: .
  • invokeinterface: , invokevirtual, .
  • invokedynamic: call site, Java 7, MethodHandles.


Si vous créez une JVM dans un langage sans garbage collection, vous devez réfléchir à la façon de le faire: comptage de références, algorithme de marquage et de balayage, etc. Gérer les exceptions en implémentant athrow , en les propageant à travers des cadres et en les gérant l'utilisation de tables d'exceptions est un autre sujet intéressant.



Enfin, votre JVM sera inutile s'il n'y a pas de classes d'exécution. Sans java / lang / Object, vous pouvez à peine voir comment fonctionne le nouveauinstruction lors de la création de nouveaux objets. Votre environnement d'exécution peut fournir des classes JRE génériques à partir des packages java.lang, java.io et java.util, ou il peut s'agir de quelque chose de plus spécifique au domaine. Très probablement, certaines méthodes des classes doivent être implémentées de manière native, et non en Java. Cela soulèvera la question de savoir comment trouver et exécuter de telles méthodes, et sera un autre cas de pointe pour votre JVM.



En d'autres termes, créer la bonne JVM n'est pas facile, mais comprendre exactement comment cela fonctionne n'est pas difficile.



Par exemple, il ne m'a fallu qu'un seul week-end d'été. Ma JVM a encore un long chemin à parcourir, mais la structure semble plus ou moins claire: https://github.com/zserge/tojvm (les commentaires sont toujours les bienvenus!)



Les extraits de code réels de cet article sont encore plus petites et sont disponibles en tant que point essentiel ici .



Si vous souhaitez en savoir plus, vous pouvez envisager d'utiliser de petites machines virtuelles Java:





J'espère que cet article n'a pas découragé votre intérêt pour Java. Les machines virtuelles sont amusantes et la machine virtuelle Java mérite vraiment d'être examinée de près.



J'espère que vous avez apprécié mon article. Vous pouvez suivre mon travail sur Github ou Twitter , et vous abonner via rss .



All Articles