Cómo escribir una JVM (de juguete)

El artículo sobre KVM resultó ser interesante para los lectores, por lo que hoy publicamos una nueva traducción del artículo de Serge Zaitsev: cómo funciona la máquina virtual Java bajo el capó.


Nos guste o no, Java es uno de los lenguajes de programación más comunes y utilizados. Sin embargo, no todos los desarrolladores de Java tienen la curiosidad de mirar bajo el capó y ver cómo funciona la JVM.



Intentaré escribir una JVM de juguete (e incompleta) para mostrar los principios básicos de cómo funciona. Espero que este artículo despierte su interés y lo inspire a explorar más la JVM.



Nuestro humilde objetivo



Empecemos simple:



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

      
      





Compilamos nuestra clase con javac Add.java y el resultado es Add.class . Este archivo de clase es un archivo binario que la JVM puede ejecutar. Todo lo que tenemos que hacer es crear una JVM que la ejecute correctamente.



Si echamos un vistazo dentro de Add.class con un volcado hexadecimal, probablemente no nos impresionará demasiado: aunque todavía no vemos una estructura clara aquí, necesitamos encontrar una manera de analizarla: ¿qué significa () V y (II) I , < init> y ¿por qué el archivo comienza con "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 |............|












Probablemente esté familiarizado con otra forma de descargar archivos de clase, a menudo resulta más útil: ahora vemos nuestra clase, su constructor y método. Tanto el constructor como el método contienen varias instrucciones. Se vuelve más o menos claro lo que hace nuestro método add (): carga dos argumentos ( iload_0 e iload_1 ), los suma y devuelve el resultado. La JVM es una máquina de pila, por lo que no hay registros en ella, todos los argumentos de instrucción se almacenan en la pila interna y los resultados también se envían a la pila.



$ 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

}












Cargador de clases



¿Cómo logramos el mismo resultado que mostró el programa javap? ¿Cómo analizar un archivo de clase?



Si miramos la especificación JVM , aprendemos sobre la estructura de los archivos de clase . Siempre comienza con una firma de 4 bytes (CAFEBABE) seguida de 2 + 2 bytes para la versión. ¡Suena sencillo!



Dado que tendríamos que leer bytes, secuencias cortas, int y bytes de un archivo binario, comencemos nuestra implementación del cargador de la siguiente manera:



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





Luego, la especificación nos dice que analicemos el grupo constante. ¿Qué es? Este es el nombre de la parte especial del archivo de clase que contiene las constantes necesarias para ejecutar la clase. Todas las cadenas, constantes numéricas y referencias se almacenan allí, y cada una tiene un índice uint16 único (por lo que una clase puede tener hasta 64K constantes).



Hay varios tipos de constantes en el grupo, cada una de las cuales contiene su propio conjunto de valores. Nos puede interesar:



  • UTF8: literal de cadena simple
  • Clase: índice de la cadena que contiene el nombre de la clase (referencia indirecta)
  • Nombre y tipo: índice del nombre del tipo y descriptor utilizado para campos y métodos
  • Referencias de campo y método: índices relacionados con clases y constantes de tipo nombre y tipo.


Como puede ver, las constantes en el grupo a menudo se refieren entre sí. Dado que estamos escribiendo una JVM en Go y no hay tipos de unión, creemos un tipo Const que contendrá varios campos constantes posibles:



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





Luego, siguiendo la especificación de JVM, podríamos recuperar los datos del grupo 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
}
      
      





Ahora estamos simplificando mucho nuestra tarea, pero en una JVM real tendríamos que tratar los tipos constantes long y double de manera uniforme, insertando un elemento constante adicional sin usar, como nos dice la especificación JVM (ya que los elementos constantes se consideran de 32 bits).



Para facilitar la obtención de literales de cadena por índices, implementemos el método de cadena Resolve (index uint16) :



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





Ahora necesitamos agregar ayudantes similares para analizar la lista de interfaces, campos y métodos de clases y sus 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
}
      
      





Tanto los campos como los métodos se representan como Campos, lo cual es muy conveniente y ahorra tiempo. Finalmente, podemos juntarlo todo y analizar nuestra clase 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
}
      
      





Ahora, si miramos la información sobre la clase, veremos que no tiene campos, dos métodos - <el init> :() el V y el add: (II de) I de . ¿Qué son los números romanos entre paréntesis? Estos son descriptores. Determinan qué tipos de argumentos toma un método y qué devuelve. En este caso, <init> (el método sintético utilizado para inicializar objetos cuando se crean) no toma argumentos y no devuelve nada (V = void), mientras que el método "agregar" acepta dos tipos de datos int (I = int32) y devuelve un número entero.



Bytecode



Si miramos de cerca, nos damos cuenta de que cada método en nuestra clase analizada tiene un atributo llamado "Código". Este atributo contiene una fracción de los bytes como carga útil. Bytes: En la especificación, esta vez en el código de bytes de la sección , vamos a leer que el "código" de atributo comienza con un valor maxstack (2 bytes), luego maxlocals (2 bytes), la longitud del código (4 bytes), y luego el código real. Entonces, nuestros atributos se pueden leer así: Sí, solo tenemos 4 y 5 bytes de código en cada método. ¿Qué significan estos bytes?



<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 dije, la JVM es una máquina apiladora. Cada instrucción se codifica como un solo byte, que puede ir seguido de argumentos adicionales. Mirando la especificación, podemos ver que el método "agregar" tiene las siguientes instrucciones: ¡ Exactamente lo mismo que vimos en la salida de javap al principio! ¿Pero cómo hacer eso?



26 = iload_0

27 = iload_1

96 = iadd

172 = ireturn












Tramas JVM



Cuando se ejecuta un método dentro de la JVM, tiene su propia pila para operandos temporales, sus propias variables locales y un fragmento de código para ejecutar. Todos estos parámetros se almacenan en un solo marco de ejecución. Además, los marcos contienen un puntero a la instrucción actual (cuánto hemos avanzado en la ejecución del bytecode) y un puntero a la clase que contiene el método. Este último es necesario para acceder al grupo de constantes de clase, así como a otros detalles.



Creemos un método que crea un marco para un método específico llamado con los argumentos dados. Usaré interface {} como el tipo de valor aquí, aunque por supuesto sería más seguro usar los tipos de unión correctos.



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





Entonces obtuvimos un marco con variables locales inicializadas, una pila vacía y un código de bytes precargado. Es hora de ejecutar el código de bytes:



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 juntar todo y ejecutarlo llamando a nuestro 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
      
      





Entonces todo funciona. Sí, esta es una JVM muy débil en mínima, pero aún hace lo que se supone que debe hacer la JVM: descargar el código de bytes e interpretarlo (por supuesto, la JVM real hace mucho más).



¿Lo que falta?



Las 200 instrucciones restantes, tiempos de ejecución, sistemas de tipo POO y algunas cosas más.



Hay 11 grupos de instrucciones, la mayoría de las cuales son comunes:



  • Constantes (empuja nulos, números pequeños o valores del grupo constante a la pila).
  • Cargas (empuja las variables locales a la pila). Instrucciones similares 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 mayoría de las instrucciones son fáciles de implementar: toman uno o dos argumentos de la pila, realizan alguna operación en ellos y envían el resultado. Lo único que debe tener en cuenta aquí es que las instrucciones para los tipos long y double asumen que cada valor ocupa dos espacios en la pila, por lo que es posible que necesite push () y pop () adicionales, lo que dificulta las instrucciones de agrupación.



Para implementar Referencias, debe pensar en el modelo de objetos: cómo desea almacenar los objetos y sus clases, cómo representar la herencia, dónde almacenar los campos de instancia y los campos de clase. Además, debe tener cuidado con el envío de métodos aquí; hay varias declaraciones de "invocación" y se comportan de manera diferente:



  • invokestatic: llama a un método estático en una clase, sin sorpresas.
  • invokespecial: , , <init> .
  • invokevirtual: .
  • invokeinterface: , invokevirtual, .
  • invokedynamic: call site, Java 7, MethodHandles.


Si está construyendo una JVM en un lenguaje sin recolección de basura, entonces debería pensar en cómo hacerlo: conteo de referencias, algoritmo de marca y barrido, etc. Manejo de excepciones implementando una línea , propagándolas a través de marcos y manejando el uso de tablas de excepciones es otro tema interesante.



Finalmente, su JVM será inútil si no hay clases en tiempo de ejecución. Sin java / lang / Object, apenas se puede ver cómo funciona el nuevoinstrucción al crear nuevos objetos. Su tiempo de ejecución puede proporcionar algunas clases JRE genéricas de los paquetes java.lang, java.io y java.util, o puede ser algo más específico del dominio. Lo más probable es que algunos métodos de las clases se implementen de forma nativa y no en Java. Esto planteará la cuestión de cómo encontrar y ejecutar dichos métodos, y será otro caso de borde para su JVM.



En otras palabras, construir la JVM correcta no es fácil, pero descubrir exactamente cómo funciona no es difícil.



Me tomó, por ejemplo, solo un fin de semana de verano. Mi JVM todavía tiene un largo camino por recorrer, pero la estructura parece más o menos clara: https://github.com/zserge/tojvm (¡los comentarios siempre son bienvenidos!)



Los fragmentos de código reales de este artículo son aún más pequeños y están disponibles como un resumen aquí .



Si desea obtener más información, puede considerar el uso de pequeñas JVM:





Espero que este artículo no haya desanimado su interés en Java. Las máquinas virtuales son divertidas y la máquina virtual Java realmente merece una mirada más cercana.



Espero que hayas disfrutado de mi artículo. Puedes seguir mi trabajo en Github o Twitter y suscribirte a través de rss .



All Articles