Como escrever um (brinquedo) JVM

O artigo sobre KVM acabou sendo interessante para os leitores, então hoje publicamos uma nova tradução do artigo de Serge Zaitsev: como a Java Virtual Machine funciona nos bastidores.


Quer queiramos ou não, Java é uma das linguagens de programação mais comuns e amplamente utilizadas. No entanto, nem todo desenvolvedor Java é curioso o suficiente para examinar os bastidores e ver como a JVM funciona.



Tentarei escrever uma JVM de brinquedo (e incompleta) para mostrar os princípios básicos de como ela funciona. Espero que este artigo desperte seu interesse e inspire você a explorar mais a JVM.



Nosso humilde objetivo



Vamos começar de forma simples:



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

      
      





Compilamos nossa classe com javac Add.java e o resultado é Add.class . Este arquivo de classe é um arquivo binário que a JVM pode executar. Tudo o que precisamos fazer é criar um JVM que irá executá-lo corretamente.



Se olharmos dentro de Add.class com um hex dump, provavelmente não ficaremos muito impressionados: embora não vejamos uma estrutura clara aqui ainda, precisamos encontrar uma maneira de analisá-la: o que () V e (II) I , < init> e por que o arquivo começa com "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 |............|












Você provavelmente está familiarizado com outra maneira de descarregar arquivos de classe, muitas vezes é mais útil: agora vemos nossa classe, seu construtor e método. Tanto o construtor quanto o método contêm várias instruções. Torna-se mais ou menos claro o que nosso método add () faz: ele carrega dois argumentos ( iload_0 e iload_1 ), adiciona-os e retorna o resultado. A JVM é uma máquina de pilha, portanto, não tem registradores, todos os argumentos de instrução são armazenados em uma pilha interna e os resultados são colocados na pilha também.



$ 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

}












Carregador de classes



Como alcançamos o mesmo resultado que o programa javap mostrou? Como analisar um arquivo de classe?



Se olharmos para a especificação JVM , aprenderemos sobre a estrutura dos arquivos de classe . Sempre começa com uma assinatura de 4 bytes (CAFEBABE) seguida por 2 + 2 bytes para a versão. Parece simples!



Como teríamos que ler sequências de bytes, curtas, inteiras e bytes de um arquivo binário, vamos iniciar nossa implementação do carregador da seguinte maneira:



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





A especificação então nos diz para analisar o pool constante. O que é isso? Este é o nome de uma parte especial do arquivo de classe que contém as constantes necessárias para executar a classe. Todas as strings, constantes numéricas e referências são armazenadas lá, e cada uma tem um índice uint16 exclusivo (portanto, uma classe pode ter até 64 K constantes).



Existem vários tipos de constantes no pool, cada uma contendo seu próprio conjunto de valores. Podemos estar interessados ​​em:



  • UTF8: literal de string simples
  • Classe: índice da string contendo o nome da classe (referência indireta)
  • Nome e tipo: índice do nome do tipo e descritor usado para campos e métodos
  • Referências de campo e método: índices relacionados a classes e constantes de nome e tipo de tipo.


Como você pode ver, as constantes no pool geralmente se referem umas às outras. Como estamos escrevendo uma JVM em Go e não há tipos de união aqui, vamos criar um tipo Const que conterá vários campos constantes possíveis:



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





Então, seguindo as especificações JVM, podemos recuperar os dados do pool constante como este:



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
}
      
      





Agora estamos simplificando muito nossa tarefa, mas em uma JVM real teríamos que tratar os tipos de constante longa e dupla uniformemente, inserindo um elemento constante não utilizado adicional, como a especificação JVM nos diz (já que os elementos constantes são considerados de 32 bits).



Para facilitar a obtenção de literais de string por índices, vamos implementar o método de string Resolve (índice uint16) :



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





Agora precisamos adicionar ajudantes semelhantes para analisar a lista de interfaces, campos e métodos de classes e seus atributos:



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
}
      
      





Ambos os campos e métodos são representados como Campos, o que é muito conveniente e economiza tempo. Finalmente, podemos juntar tudo e analisar nossa classe completamente:



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
}
      
      





Agora, se olharmos as informações sobre a classe, veremos que ele não possui campos, dois métodos - <o init> :() o V e o add: (II de) I de . O que são números romanos com colchetes? Estes são descritores. Eles determinam quais tipos de argumentos um método aceita e o que ele retorna. Neste caso, <init> (o método sintético usado para inicializar objetos quando eles são criados) não leva nenhum argumento e não retorna nada (V = void), enquanto o método "add" aceita dois tipos de dados int (I = int32) e retorna um inteiro.



Bytecode



Se olharmos com atenção, percebemos que cada método em nossa classe analisada possui um atributo chamado “Código”. Este atributo contém uma fração dos bytes como carga útil. Bytes: Na especificação, desta vez na seção de bytecode , leremos que o atributo "Código" começa com um valor maxstack (2 bytes), depois maxlocals (2 bytes), o comprimento do código (4 bytes) e, em seguida, o código real. Portanto, nossos atributos podem ser lidos assim: Sim, temos apenas 4 e 5 bytes de código em cada método. O que esses bytes significam?



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












Como eu disse, a JVM é uma máquina de empilhamento. Cada instrução é codificada como um único byte, que pode ser seguido por argumentos adicionais. Olhando para as especificações, podemos ver que o método "add" tem as seguintes instruções: Exatamente o mesmo que vimos na saída javap no início! Mas como fazer isso?



26 = iload_0

27 = iload_1

96 = iadd

172 = ireturn












Frames JVM



Quando um método é executado dentro da JVM, ele tem sua própria pilha para operandos temporários, suas próprias variáveis ​​locais e um trecho de código a ser executado. Todos esses parâmetros são armazenados em um único quadro de execução. Além disso, os quadros contêm um ponteiro para a instrução atual (até onde chegamos na execução do bytecode) e um ponteiro para a classe que contém o método. O último é necessário para acessar o pool de constantes de classe, bem como outros detalhes.



Vamos criar um método que cria um quadro para um método específico chamado com os argumentos fornecidos. Usarei interface {} como o tipo de valor aqui, embora usar os tipos de união corretos seja obviamente mais seguro.



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





Portanto, obtivemos um Frame com variáveis ​​locais inicializadas, uma pilha vazia e bytecode pré-carregado. É hora de executar o 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++
	}
}

      
      





Finalmente, podemos colocar tudo junto e executá-lo chamando nosso método 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
      
      





Então tudo funciona. Sim, esta é uma JVM muito fraca no mínimo, mas ainda faz o que a JVM deve fazer: baixar o bytecode e interpretá-lo (é claro, a JVM real faz muito mais).



O que está faltando?



As 200 instruções restantes, tempos de execução, sistemas do tipo OOP e mais algumas coisas.



Existem 11 grupos de instruções, a maioria dos quais são comuns:



  • Constantes (coloca nulos, números pequenos ou valores do conjunto de constantes na pilha).
  • Carrega (coloca as variáveis ​​locais na pilha). Instruções semelhantes 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.


A maioria das instruções é fácil de implementar: elas pegam um ou dois argumentos da pilha, realizam alguma operação neles e enviam o resultado. A única coisa a ter em mente aqui é que as instruções para os tipos long e double pressupõem que cada valor ocupa dois slots na pilha, portanto, você pode precisar de push () e pop () adicionais, o que torna difícil agrupar instruções.



Para implementar referências, você precisa pensar sobre o modelo de objeto: como você deseja armazenar objetos e suas classes, como representar a herança, onde armazenar campos de instância e campos de classe. Além disso, você deve ter cuidado com o envio de métodos aqui - existem várias instruções "invoke" e se comportam de maneira diferente:



  • invokestatic: chama um método estático em uma classe, sem surpresas.
  • invokespecial: , , <init> .
  • invokevirtual: .
  • invokeinterface: , invokevirtual, .
  • invokedynamic: call site, Java 7, MethodHandles.


Se você está construindo uma JVM em uma linguagem sem coleta de lixo, então você deve pensar em como fazê-lo: contagem de referência, algoritmo de marca-e-varrer, etc. Manipulação de exceções através da implementação de athrow , propagando-los através de quadros, e manuseio o uso de tabelas de exceção é outro tópico interessante.



Finalmente, sua JVM será inútil se não houver classes de tempo de execução. Sem java / lang / Object, você mal consegue ver como o novo funciona instrução ao criar novos objetos. Seu tempo de execução pode fornecer algumas classes JRE genéricas dos pacotes java.lang, java.io e java.util, ou pode ser algo mais específico do domínio. Muito provavelmente, alguns métodos em classes devem ser implementados nativamente, e não em Java. Isso levantará a questão de como localizar e executar esses métodos e será outro caso extremo para sua JVM.



Em outras palavras, construir a JVM certa não é fácil, mas descobrir exatamente como ela funciona não é difícil.



Por exemplo, levei apenas um fim de semana de verão. Minha JVM ainda tem um longo caminho a percorrer, mas a estrutura parece mais ou menos clara: https://github.com/zserge/tojvm (comentários são sempre bem-vindos!)



Os trechos de código reais deste artigo são ainda menores e estão disponíveis como um resumo aqui .



Se quiser saber mais, você pode considerar o uso de pequenas JVMs:





Espero que este artigo não tenha desencorajado seu interesse por Java. As máquinas virtuais são divertidas e a máquina virtual Java realmente merece um olhar mais atento.



Eu espero que você tenha gostado do meu artigo. Você pode acompanhar meu trabalho no Github ou Twitter e se inscrever via rss .



All Articles