Ensúciese las manos con Scala JVM Bytecode

Publicado: 2022-03-11

El lenguaje Scala ha seguido ganando popularidad en los últimos años, gracias a su excelente combinación de principios de desarrollo de software funcional y orientado a objetos, y su implementación sobre la máquina virtual Java (JVM) comprobada.

Aunque Scala compila en el código de bytes de Java, está diseñado para mejorar muchas de las deficiencias percibidas del lenguaje Java. La sintaxis central de Scala, que ofrece soporte completo de programación funcional, contiene muchas estructuras implícitas que los programadores de Java deben construir explícitamente, algunas de las cuales involucran una complejidad considerable.

La creación de un lenguaje que compile en el código de bytes de Java requiere una comprensión profunda del funcionamiento interno de la máquina virtual de Java. Para apreciar lo que los desarrolladores de Scala han logrado, es necesario ir debajo del capó y explorar cómo el compilador interpreta el código fuente de Scala para producir un código de bytes JVM eficiente y efectivo.

Echemos un vistazo a cómo se implementa todo esto.

requisitos previos

Leer este artículo requiere una comprensión básica del código de bytes de Java Virtual Machine. La especificación completa de la máquina virtual se puede obtener de la documentación oficial de Oracle. Leer la especificación completa no es fundamental para comprender este artículo, por lo que, para una introducción rápida a los conceptos básicos, he preparado una breve guía al final del artículo.

Haga clic aquí para leer un curso intensivo sobre los conceptos básicos de JVM.

Se necesita una utilidad para desensamblar el código de bytes de Java para reproducir los ejemplos proporcionados a continuación y continuar con la investigación. El kit de desarrollo de Java proporciona su propia utilidad de línea de comandos, javap , que usaremos aquí. Una demostración rápida de cómo funciona javap se incluye en la guía en la parte inferior.

Y, por supuesto, es necesaria una instalación funcional del compilador Scala para los lectores que quieran seguir los ejemplos. Este artículo fue escrito usando Scala 2.11.7. Las diferentes versiones de Scala pueden producir códigos de bytes ligeramente diferentes.

Getters y Setters predeterminados

Aunque la convención de Java siempre proporciona métodos getter y setter para atributos públicos, los programadores de Java deben escribirlos ellos mismos, a pesar de que el patrón de cada uno no ha cambiado en décadas. Scala, por el contrario, proporciona captadores y definidores predeterminados.

Veamos el siguiente ejemplo:

 class Person(val name:String) { }

Echemos un vistazo dentro de la clase Person . Si compilamos este archivo con scalac , ejecutar $ javap -p Person.class nos da:

 Compiled from "Person.scala" public class Person { private final java.lang.String name; // field public java.lang.String name(); // getter method public Person(java.lang.String); // constructor }

Podemos ver que para cada campo de la clase Scala se genera un campo y su método getter. El campo es privado y final, mientras que el método es público.

Si reemplazamos val con var en la fuente Person y volvemos a compilar, entonces el modificador final del campo se descarta y también se agrega el método setter:

 Compiled from "Person.scala" public class Person { private java.lang.String name; // field public java.lang.String name(); // getter method public void name_$eq(java.lang.String); // setter method public Person(java.lang.String); // constructor }

Si se define cualquier val o var dentro del cuerpo de la clase, entonces se crean los métodos de acceso y el campo privado correspondientes, y se inicializan apropiadamente al crear la instancia.

Tenga en cuenta que tal implementación de los campos val y var a nivel de clase significa que si algunas variables se usan a nivel de clase para almacenar valores intermedios y el programador nunca accede directamente a ellas, la inicialización de cada campo agregará uno o dos métodos al huella de clase. Agregar un modificador private para dichos campos no significa que se eliminarán los accesores correspondientes. Simplemente se volverán privados.

Definiciones de variables y funciones

Supongamos que tenemos un método, m() , y creamos tres referencias de estilo Scala diferentes para esta función:

 class Person(val name:String) { def m(): Int = { // ... return 0 } val m1 = m var m2 = m def m3 = m }

¿Cómo se construye cada una de estas referencias a m ? ¿Cuándo se ejecuta m en cada caso? Echemos un vistazo al código de bytes resultante. El siguiente resultado muestra los resultados de javap -v Person.class (omitiendo muchos resultados superfluos):

 Constant pool: #22 = Fieldref #2.#21 // Person.m1:I #24 = Fieldref #2.#23 // Person.m2:I #30 = Methodref #2.#29 // Person.m:()I #35 = Methodref #4.#34 // java/lang/Object."<init>":()V // ... public int m(); Code: // other methods refer to this method // ... public int m1(); Code: // get the value of field m1 and return it 0: aload_0 1: getfield #22 // Field m1:I 4: ireturn public int m2(); Code: // get the value of field m2 and return it 0: aload_0 1: getfield #24 // Field m2:I 4: ireturn public void m2_$eq(int); Code: // get the value of this method's input argument 0: aload_0 1: iload_1 // write it to the field m2 and return 2: putfield #24 // Field m2:I 5: return public int m3(); Code: // execute the instance method m(), and return 0: aload_0 1: invokevirtual #30 // Method m:()I 4: ireturn public Person(java.lang.String); Code: // instance constructor ... // execute the instance method m(), and write the result to field m1 9: aload_0 10: aload_0 11: invokevirtual #30 // Method m:()I 14: putfield #22 // Field m1:I // execute the instance method m(), and write the result to field m2 17: aload_0 18: aload_0 19: invokevirtual #30 // Method m:()I 22: putfield #24 // Field m2:I 25: return

En el grupo de constantes, vemos que la referencia al método m() se almacena en el índice #30 . En el código del constructor, vemos que este método se invoca dos veces durante la inicialización, con la instrucción invokevirtual #30 que aparece primero en el byte de desplazamiento 11, luego en el desplazamiento de byte 19. La primera invocación es seguida por la instrucción putfield #22 que asigna el resultado de este método al campo m1 , referenciado por el índice #22 en el grupo de constantes. La segunda invocación es seguida por el mismo patrón, esta vez asignando el valor al campo m2 , indexado en #24 en el grupo de constantes.

En otras palabras, asignar un método a una variable definida con val o var solo asigna el resultado del método a esa variable. Podemos ver que los métodos m1() y m2() que se crean son simplemente captadores de estas variables. En el caso de var m2 , también vemos que se crea el setter m2_$eq(int) , que se comporta como cualquier otro setter, sobrescribiendo el valor en el campo.

Sin embargo, usar la palabra clave def da un resultado diferente. En lugar de obtener un valor de campo para devolver, el método m3() también incluye la instrucción invokevirtual #30 . Es decir, cada vez que se llama a este método, llama a m() y devuelve el resultado de este método.

Entonces, como podemos ver, Scala proporciona tres formas de trabajar con campos de clase, y estos se especifican fácilmente a través de las palabras clave val , var y def . En Java, tendríamos que implementar explícitamente los setters y getters necesarios, y dicho código repetitivo escrito manualmente sería mucho menos expresivo y más propenso a errores.

Valores perezosos

Se produce un código más complicado cuando se declara un valor perezoso. Supongamos que hemos agregado el siguiente campo a la clase previamente definida:

 lazy val m4 = m

Ejecutar javap -p -v Person.class ahora revelará lo siguiente:

 Constant pool: #20 = Fieldref #2.#19 // Person.bitmap$0:Z #23 = Methodref #2.#22 // Person.m:()I #25 = Fieldref #2.#24 // Person.m4:I #31 = Fieldref #27.#30 // scala/runtime/BoxedUnit.UNIT:Lscala/runtime/BoxedUnit; #48 = Methodref #2.#47 // Person.m4$lzycompute:()I // ... private volatile boolean bitmap$0; private int m4$lzycompute(); Code: // lock the thread 0: aload_0 1: dup 2: astore_1 3: monitorenter // check the flag for whether this field has already been set 4: aload_0 5: getfield #20 // Field bitmap$0:Z // if it has, skip to position 24 (unlock the thread and return) 8: ifne 24 // if it hasn't, execute the method m() 11: aload_0 12: aload_0 13: invokevirtual #23 // Method m:()I // write the method to the field m4 16: putfield #25 // Field m4:I // set the flag indicating the field has been set 19: aload_0 20: iconst_1 21: putfield #20 // Field bitmap$0:Z // unlock the thread 24: getstatic #31 // Field scala/runtime/BoxedUnit.UNIT:Lscala/runtime/BoxedUnit; 27: pop 28: aload_1 29: monitorexit // get the value of field m4 and return it 30: aload_0 31: getfield #25 // Field m4:I 34: ireturn // ... public int m4(); Code: // check the flag for whether this field has already been set 0: aload_0 1: getfield #20 // Field bitmap$0:Z // if it hasn't, skip to position 14 (invoke lazy method and return) 4: ifeq 14 // if it has, get the value of field m4, then skip to position 18 (return) 7: aload_0 8: getfield #25 // Field m4:I 11: goto 18 // execute the method m4$lzycompute() to set the field 14: aload_0 15: invokespecial #48 // Method m4$lzycompute:()I // return 18: ireturn

En este caso, el valor del campo m4 no se calcula hasta que se necesita. El método especial y privado m4$lzycompute() se produce para calcular el valor perezoso y el campo bitmap$0 para rastrear su estado. El método m4() comprueba si el valor de este campo es 0, lo que indica que m4 aún no se ha inicializado, en cuyo caso se invoca m4$lzycompute() , rellenando m4 y devolviendo su valor. Este método privado también establece el valor de bitmap$0 en 1, de modo que la próxima vez que se llame a m4() se saltará la invocación del método de inicialización y, en su lugar, simplemente devolverá el valor de m4 .

Los resultados de la primera llamada a un valor perezoso de Scala.

El código de bytes que Scala produce aquí está diseñado para ser tanto seguro como efectivo. Para ser seguro para subprocesos, el método de cálculo diferido utiliza el par de instrucciones monitorenter / monitorexit . El método sigue siendo eficaz, ya que la sobrecarga de rendimiento de esta sincronización solo se produce en la primera lectura del valor diferido.

Solo se necesita un bit para indicar el estado del valor perezoso. Entonces, si no hay más de 32 valores perezosos, un solo campo int puede rastrearlos a todos. Si se define más de un valor perezoso en el código fuente, el compilador modificará el código de bytes anterior para implementar una máscara de bits para este propósito.

Nuevamente, Scala nos permite aprovechar fácilmente un tipo específico de comportamiento que tendría que implementarse explícitamente en Java, ahorrando esfuerzo y reduciendo el riesgo de errores tipográficos.

Función como valor

Ahora echemos un vistazo al siguiente código fuente de Scala:

 class Printer(val output: String => Unit) { } object Hello { def main(arg: Array[String]) { val printer = new Printer( s => println(s) ); printer.output("Hello"); } }

La clase Printer tiene un campo, output , con el tipo String => Unit : una función que toma una String y devuelve un objeto de tipo Unit (similar a void en Java). En el método principal, creamos uno de estos objetos y asignamos este campo para que sea una función anónima que imprima una cadena determinada.

La compilación de este código genera cuatro archivos de clase:

El código fuente se compila en cuatro archivos de clase.

Hello.class es una clase contenedora cuyo método principal simplemente llama a Hello$.main() :

 public final class Hello // ... public static void main(java.lang.String[]); Code: 0: getstatic #16 // Field Hello$.MODULE$:LHello$; 3: aload_0 4: invokevirtual #18 // Method Hello$.main:([Ljava/lang/String;)V 7: return

La clase Hello$.class oculta contiene la implementación real del método principal. Para echar un vistazo a su código de bytes, asegúrese de escapar correctamente $ de acuerdo con las reglas de su shell de comandos, para evitar su interpretación como un carácter especial:

 public final class Hello$ // ... public void main(java.lang.String[]); Code: // initialize Printer and anonymous function 0: new #16 // class Printer 3: dup 4: new #18 // class Hello$$anonfun$1 7: dup 8: invokespecial #19 // Method Hello$$anonfun$1."<init>":()V 11: invokespecial #22 // Method Printer."<init>":(Lscala/Function1;)V 14: astore_2 // load the anonymous function onto the stack 15: aload_2 16: invokevirtual #26 // Method Printer.output:()Lscala/Function1; // execute the anonymous function, passing the string "Hello" 19: ldc #28 // String Hello 21: invokeinterface #34, 2 // InterfaceMethod scala/Function1.apply:(Ljava/lang/Object;)Ljava/lang/Object; // return 26: pop 27: return

El método crea una Printer . Luego crea un Hello$$anonfun$1 , que contiene nuestra función anónima s => println(s) . La Printer se inicializa con este objeto como campo de output . Luego, este campo se carga en la pila y se ejecuta con el operando "Hello" .

Echemos un vistazo a la clase de función anónima, Hello$$anonfun$1.class , a continuación. Podemos ver que extiende la Function1 1 de Scala (como AbstractFunction1 ) al implementar el método apply() . En realidad, crea dos métodos apply() , uno que envuelve al otro, que juntos realizan la verificación de tipo (en este caso, que la entrada es un String ) y ejecutan la función anónima (imprimiendo la entrada con println() ).

 public final class Hello$$anonfun$1 extends scala.runtime.AbstractFunction1<java.lang.String, scala.runtime.BoxedUnit> implements scala.Serializable // ... // Takes an argument of type String. Invoked second. public final void apply(java.lang.String); Code: // execute Scala's built-in method println(), passing the input argument 0: getstatic #25 // Field scala/Predef$.MODULE$:Lscala/Predef$; 3: aload_1 4: invokevirtual #29 // Method scala/Predef$.println:(Ljava/lang/Object;)V 7: return // Takes an argument of type Object. Invoked first. public final java.lang.Object apply(java.lang.Object); Code: 0: aload_0 // check that the input argument is a String (throws exception if not) 1: aload_1 2: checkcast #36 // class java/lang/String // invoke the method apply( String ), passing the input argument 5: invokevirtual #38 // Method apply:(Ljava/lang/String;)V // return the void type 8: getstatic #44 // Field scala/runtime/BoxedUnit.UNIT:Lscala/runtime/BoxedUnit; 11: areturn

Mirando hacia atrás en el método Hello$.main() anterior, podemos ver que, en el desplazamiento 21, la ejecución de la función anónima se activa mediante una llamada a su método apply( Object ) .

Finalmente, para completar, veamos el código de bytes para Printer.class :

 public class Printer // ... // field private final scala.Function1<java.lang.String, scala.runtime.BoxedUnit> output; // field getter public scala.Function1<java.lang.String, scala.runtime.BoxedUnit> output(); Code: 0: aload_0 1: getfield #14 // Field output:Lscala/Function1; 4: areturn // constructor public Printer(scala.Function1<java.lang.String, scala.runtime.BoxedUnit>); Code: 0: aload_0 1: aload_1 2: putfield #14 // Field output:Lscala/Function1; 5: aload_0 6: invokespecial #21 // Method java/lang/Object."<init>":()V 9: return

Podemos ver que la función anónima aquí se trata como cualquier variable val . Se almacena en la output del campo de clase y se crea la output() . La única diferencia es que esta variable ahora debe implementar la interfaz de Scala scala.Function1 (lo que hace AbstractFunction1 ).

Por lo tanto, el costo de esta elegante característica de Scala son las clases de utilidad subyacentes, creadas para representar y ejecutar una sola función anónima que se puede usar como valor. Debe tener en cuenta la cantidad de tales funciones, así como los detalles de la implementación de su máquina virtual, para averiguar qué significa para su aplicación en particular.

Explorando bajo el capó con Scala: explore cómo se implementa este poderoso lenguaje en el código de bytes de JVM.
Pío

Rasgos de Scala

Los rasgos de Scala son similares a las interfaces en Java. La siguiente característica define dos firmas de métodos y proporciona una implementación predeterminada de la segunda. Veamos cómo se implementa:

 trait Similarity { def isSimilar(x: Any): Boolean def isNotSimilar(x: Any): Boolean = !isSimilar(x) } 

El código fuente se compila en dos archivos de clase.

Se producen dos entidades: Similarity.class , la interfaz que declara ambos métodos, y la clase sintética, Similarity$class.class , que proporciona la implementación predeterminada:

 public interface Similarity { public abstract boolean isSimilar(java.lang.Object); public abstract boolean isNotSimilar(java.lang.Object); }
 public abstract class Similarity$class public static boolean isNotSimilar(Similarity, java.lang.Object); Code: 0: aload_0 // execute the instance method isSimilar() 1: aload_1 2: invokeinterface #13, 2 // InterfaceMethod Similarity.isSimilar:(Ljava/lang/Object;)Z // if the returned value is 0, skip to position 14 (return with value 1) 7: ifeq 14 // otherwise, return with value 0 10: iconst_0 11: goto 15 // return the value 1 14: iconst_1 15: ireturn public static void $init$(Similarity); Code: 0: return

Cuando una clase implementa este rasgo y llama al método isNotSimilar , el compilador de Scala genera la instrucción de código de invokestatic para llamar al método estático proporcionado por la clase adjunta.

Se pueden crear polimorfismos complejos y estructuras de herencia a partir de rasgos. Por ejemplo, varios rasgos, así como la clase de implementación, pueden anular un método con la misma firma, llamando a super.methodName() para pasar el control al siguiente rasgo. Cuando el compilador de Scala encuentra tales llamadas:

  • Determina qué rasgo exacto asume esta llamada.
  • Determina el nombre de la clase adjunta que proporciona el código de bytes del método estático definido para el rasgo.
  • Produce la instrucción invokestatic necesaria.

Por lo tanto, podemos ver que el poderoso concepto de rasgos se implementa en el nivel de JVM de manera que no genera una sobrecarga significativa, y los programadores de Scala pueden disfrutar de esta función sin preocuparse de que sea demasiado costosa en el tiempo de ejecución.

solteros

Scala proporciona la definición explícita de clases singleton utilizando la palabra clave object . Consideremos la siguiente clase singleton:

 object Config { val home_dir = "/home/user" }

El compilador produce dos archivos de clase:

El código fuente se compila en dos archivos de clase.

Config.class es bastante simple:

 public final class Config public static java.lang.String home_dir(); Code: // execute the method Config$.home_dir() 0: getstatic #16 // Field Config$.MODULE$:LConfig$; 3: invokevirtual #18 // Method Config$.home_dir:()Ljava/lang/String; 6: areturn

Esto es solo un decorador para la clase Config$ sintética que incorpora la funcionalidad del singleton. Examinar esa clase con javap -p -c produce el siguiente código de bytes:

 public final class Config$ public static final Config$ MODULE$; // a public reference to the singleton object private final java.lang.String home_dir; // static initializer public static {}; Code: 0: new #2 // class Config$ 3: invokespecial #12 // Method "<init>":()V 6: return public java.lang.String home_dir(); Code: // get the value of field home_dir and return it 0: aload_0 1: getfield #17 // Field home_dir:Ljava/lang/String; 4: areturn private Config$(); Code: // initialize the object 0: aload_0 1: invokespecial #19 // Method java/lang/Object."<init>":()V // expose a public reference to this object in the synthetic variable MODULE$ 4: aload_0 5: putstatic #21 // Field MODULE$:LConfig$; // load the value "/home/user" and write it to the field home_dir 8: aload_0 9: ldc #23 // String /home/user 11: putfield #17 // Field home_dir:Ljava/lang/String; 14: return

Consiste en lo siguiente:

  • La variable sintética MODULE$ , a través de la cual otros objetos acceden a este objeto singleton.
  • El inicializador estático {} (también conocido como <clinit> , el inicializador de clase) y el método privado Config$ , usado para inicializar MODULE$ y establecer sus campos a valores predeterminados
  • Un método getter para el campo estático home_dir . En este caso, es solo un método. Si el singleton tiene más campos, tendrá más getters, así como setters para campos mutables.

El singleton es un patrón de diseño popular y útil. El lenguaje Java no proporciona una forma directa de especificarlo a nivel de lenguaje; más bien, es responsabilidad del desarrollador implementarlo en el código fuente de Java. Scala, por otro lado, proporciona una forma clara y conveniente de declarar un singleton explícitamente usando la palabra clave object . Como podemos ver mirando debajo del capó, se implementa de una manera asequible y natural.

Conclusión

Ahora hemos visto cómo Scala compila varias funciones de programación implícitas y funcionales en estructuras sofisticadas de código de bytes de Java. Con este vistazo al funcionamiento interno de Scala, podemos obtener una apreciación más profunda del poder de Scala, ayudándonos a aprovechar al máximo este poderoso lenguaje.

Ahora también tenemos las herramientas para explorar el lenguaje nosotros mismos. Hay muchas funciones útiles de la sintaxis de Scala que no se tratan en este artículo, como clases de casos, curry y listas de comprensión. ¡Te animo a que investigues la implementación de Scala de estas estructuras tú mismo, para que puedas aprender a ser un ninja de Scala del siguiente nivel!


La máquina virtual de Java: un curso acelerado

Al igual que el compilador de Java, el compilador de Scala convierte el código fuente en archivos .class , que contienen código de bytes de Java para ser ejecutado por la máquina virtual de Java. Para comprender en qué se diferencian los dos idiomas bajo el capó, es necesario comprender el sistema al que se dirigen ambos. Aquí, presentamos una breve descripción general de algunos de los elementos principales de la arquitectura de la máquina virtual de Java, la estructura de archivos de clase y los conceptos básicos del ensamblador.

Tenga en cuenta que esta guía solo cubrirá el mínimo para permitir el seguimiento junto con el artículo anterior. Aunque muchos de los componentes principales de la JVM no se analizan aquí, los detalles completos se pueden encontrar en los documentos oficiales, aquí.

Descompilación de archivos de clase con javap
Grupo constante
Tablas de campos y métodos
Código de bytes JVM
Llamadas a métodos y la pila de llamadas
Ejecución en la pila de operandos
Variables locales
volver a la cima

Descompilación de archivos de clase con javap

Java se envía con la utilidad de línea de comandos javap , que descompila archivos .class en un formato legible por humanos. Dado que los archivos de clase de Scala y Java apuntan a la misma JVM, se puede usar javap para examinar los archivos de clase compilados por Scala.

Vamos a compilar el siguiente código fuente:

 // RegularPolygon.scala class RegularPolygon( val numSides: Int ) { def getPerimeter( sideLength: Double ): Double = { println( "Calculating perimeter..." ) return sideLength * this.numSides } }

Compilar esto con scalac RegularPolygon.scala producirá RegularPolygon.class . Si luego ejecutamos javap RegularPolygon.class veremos lo siguiente:

 $ javap RegularPolygon.class Compiled from "RegularPolygon.scala" public class RegularPolygon { public int numSides(); public double getPerimeter(double); public RegularPolygon(int); }

Este es un desglose muy simple del archivo de clase que simplemente muestra los nombres y tipos de los miembros públicos de la clase. Agregar la opción -p incluirá miembros privados:

 $ javap -p RegularPolygon.class Compiled from "RegularPolygon.scala" public class RegularPolygon { private final int numSides; public int numSides(); public double getPerimeter(double); public RegularPolygon(int); }

Esto todavía no es mucha información. Para ver cómo se implementan los métodos en el código de bytes de Java, agreguemos la opción -c :

 $ javap -p -c RegularPolygon.class Compiled from "RegularPolygon.scala" public class RegularPolygon { private final int numSides; public int numSides(); Code: 0: aload_0 1: getfield #13 // Field numSides:I 4: ireturn public double getPerimeter(double); Code: 0: getstatic #23 // Field scala/Predef$.MODULE$:Lscala/Predef$; 3: ldc #25 // String Calculating perimeter... 5: invokevirtual #29 // Method scala/Predef$.println:(Ljava/lang/Object;)V 8: dload_1 9: aload_0 10: invokevirtual #31 // Method numSides:()I 13: i2d 14: dmul 15: dreturn public RegularPolygon(int); Code: 0: aload_0 1: iload_1 2: putfield #13 // Field numSides:I 5: aload_0 6: invokespecial #38 // Method java/lang/Object."<init>":()V 9: return }

Eso es un poco más interesante. Sin embargo, para comprender realmente la historia completa, debemos usar la opción -v o -verbose , como en javap -p -v RegularPolygon.class :

El contenido completo de un archivo de clase Java.

Aquí finalmente vemos lo que realmente hay en el archivo de clase. ¿Qué significa todo esto? Echemos un vistazo a algunas de las partes más importantes.

Grupo constante

El ciclo de desarrollo de aplicaciones C++ incluye etapas de compilación y vinculación. El ciclo de desarrollo de Java omite una etapa de vinculación explícita porque la vinculación ocurre en tiempo de ejecución. El archivo de clase debe admitir esta vinculación en tiempo de ejecución. Esto significa que cuando el código fuente hace referencia a cualquier campo o método, el código de bytes resultante debe mantener las referencias relevantes en forma simbólica, listas para ser desreferenciadas una vez que la aplicación se haya cargado en la memoria y las direcciones reales puedan ser resueltas por el enlazador en tiempo de ejecución. Esta forma simbólica debe contener:

  • nombre de la clase
  • nombre de campo o método
  • clasificar información

La especificación de formato de archivo de clase incluye una sección del archivo llamada conjunto de constantes , una tabla de todas las referencias que necesita el enlazador. Contiene entradas de diferentes tipos.

 // ... Constant pool: #1 = Utf8 RegularPolygon #2 = Class #1 // RegularPolygon #3 = Utf8 java/lang/Object #4 = Class #3 // java/lang/Object // ...

El primer byte de cada entrada es una etiqueta numérica que indica el tipo de entrada. Los bytes restantes proporcionan información sobre el valor de la entrada. El número de bytes y las reglas para su interpretación dependen del tipo indicado por el primer byte.

Por ejemplo, una clase de Java que usa un entero constante 365 puede tener una entrada de grupo constante con el siguiente código de bytes:

 x03 00 00 01 6D

El primer byte, x03 , identifica el tipo de entrada, CONSTANT_Integer . Esto informa al enlazador que los siguientes cuatro bytes contienen el valor del entero. (Tenga en cuenta que 365 en hexadecimal es x16D ). Si esta es la entrada número 14 en el grupo de constantes, javap -v lo representará así:

 #14 = Integer 365

Muchos tipos de constantes se componen de referencias a tipos de constantes más "primitivos" en otras partes del conjunto de constantes. Por ejemplo, nuestro código de ejemplo contiene la declaración:

 println( "Calculating perimeter..." )

El uso de una constante de cadena producirá dos entradas en el conjunto de constantes: una entrada de tipo CONSTANT_String y otra entrada de tipo CONSTANT_Utf8 . La entrada de tipo Constant_UTF8 contiene la representación UTF8 real del valor de cadena. La entrada de tipo CONSTANT_String contiene una referencia a la entrada CONSTANT_Utf8 :

 #24 = Utf8 Calculating perimeter... #25 = String #24 // Calculating perimeter...

Tal complicación es necesaria porque hay otros tipos de entradas de pool constante que se refieren a entradas de tipo Utf8 y que no son entradas de tipo String . Por ejemplo, cualquier referencia a un atributo de clase producirá un tipo CONSTANT_Fieldref , que contiene una serie de referencias al nombre de la clase, el nombre del atributo y el tipo de atributo:

 #1 = Utf8 RegularPolygon #2 = Class #1 // RegularPolygon #9 = Utf8 numSides #10 = Utf8 I #12 = NameAndType #9:#10 // numSides:I #13 = Fieldref #2.#12 // RegularPolygon.numSides:I

Para obtener más detalles sobre el grupo de constantes, consulte la documentación de JVM.

Tablas de campos y métodos

Un archivo de clase contiene una tabla de campos que contiene información sobre cada campo (es decir, atributo) definido en la clase. Estas son referencias a entradas de grupo constante que describen el nombre y el tipo del campo, así como indicadores de control de acceso y otros datos relevantes.

Una tabla de métodos similar está presente en el archivo de clase. Sin embargo, además de la información de nombre y tipo, para cada método no abstracto, contiene las instrucciones de bytecode reales que ejecutará la JVM, así como las estructuras de datos utilizadas por el marco de pila del método, que se describen a continuación.

Código de bytes JVM

La JVM utiliza su propio conjunto de instrucciones internas para ejecutar el código compilado. Ejecutar javap con la opción -c incluye las implementaciones del método compilado en la salida. Si examinamos nuestro archivo RegularPolygon.class de esta manera, veremos el siguiente resultado para nuestro método getPerimeter() :

 public double getPerimeter(double); Code: 0: getstatic #23 // Field scala/Predef$.MODULE$:Lscala/Predef$; 3: ldc #25 // String Calculating perimeter... 5: invokevirtual #29 // Method scala/Predef$.println:(Ljava/lang/Object;)V 8: dload_1 9: aload_0 10: invokevirtual #31 // Method numSides:()I 13: i2d 14: dmul 15: dreturn

El código de bytes real podría verse así:

 xB2 00 17 x12 19 xB6 00 1D x27 ...

Cada instrucción comienza con un código de operación de un byte que identifica la instrucción JVM, seguido de cero o más operandos de instrucción con los que operar, según el formato de la instrucción específica. Por lo general, estos son valores constantes o referencias al conjunto de constantes. javap traduce de manera útil el código de bytes a un formato legible por humanos que muestra:

  • El desplazamiento o posición del primer byte de la instrucción dentro del código.
  • El nombre legible por humanos, o mnemotécnico , de la instrucción.
  • El valor del operando, si lo hay.

Los operandos que se muestran con un signo de libra, como #23 , son referencias a entradas en el conjunto de constantes. Como podemos ver, javap también produce comentarios útiles en la salida, identificando a qué se hace referencia exactamente desde el grupo.

Discutiremos algunas de las instrucciones comunes a continuación. Para obtener información detallada sobre el conjunto completo de instrucciones de JVM, consulte la documentación.

Llamadas a métodos y la pila de llamadas

Cada llamada de método debe poder ejecutarse con su propio contexto, que incluye cosas como variables declaradas localmente o argumentos que se pasaron al método. Juntos, forman un marco de pila . Al invocar un método, se crea un nuevo marco y se coloca encima de la pila de llamadas . Cuando el método regresa, el marco actual se elimina de la pila de llamadas y se descarta, y se restaura el marco que estaba en vigor antes de que se llamara al método.

Un marco de pila incluye algunas estructuras distintas. Dos importantes son la pila de operandos y la tabla de variables locales , que se analizan a continuación.

La pila de llamadas de JVM.

Ejecución en la pila de operandos

Muchas instrucciones JVM operan en la pila de operandos de su marco. En lugar de especificar un operando constante explícitamente en el código de bytes, estas instrucciones toman como entrada los valores en la parte superior de la pila de operandos. Normalmente, estos valores se eliminan de la pila en el proceso. Algunas instrucciones también colocan nuevos valores en la parte superior de la pila. De esta forma, las instrucciones de JVM se pueden combinar para realizar operaciones complejas. Por ejemplo, la expresión:

 sideLength * this.numSides

se compila a lo siguiente en nuestro método getPerimeter() :

 8: dload_1 9: aload_0 10: invokevirtual #31 // Method numSides:()I 13: i2d 14: dmul 

Las instrucciones JVM pueden operar en la pila de operandos para realizar funciones complejas.

  • La primera instrucción, dload_1 , empuja la referencia del objeto desde la ranura 1 de la tabla de variables locales (que se analiza a continuación) a la pila de operandos. En este caso, este es el argumento del método sideLength .- La siguiente instrucción, aload_0 , empuja la referencia del objeto en la ranura 0 de la tabla de variables locales a la pila de operandos. En la práctica, esta es casi siempre la referencia a this , la clase actual.
  • Esto configura la pila para la siguiente llamada, invokevirtual #31 , que ejecuta el método de instancia numSides() . invokevirtual el operando superior (la referencia a this ) de la pila para identificar desde qué clase debe llamar al método. Una vez que el método regresa, su resultado se coloca en la pila.
  • En este caso, el valor devuelto ( numSides ) está en formato de número entero. Debe convertirse a un formato de punto flotante doble para poder multiplicarlo con otro valor doble. La instrucción i2d el valor entero de la pila, lo convierte al formato de coma flotante y lo vuelve a colocar en la pila.
  • En este punto, la pila contiene el resultado de coma flotante de this.numSides en la parte superior, seguido del valor del argumento sideLength que se pasó al método. dmul estos dos valores superiores de la pila, realiza una multiplicación de coma flotante sobre ellos y empuja el resultado a la pila.

Cuando se llama a un método, se crea una nueva pila de operandos como parte de su marco de pila, donde se realizarán las operaciones. We must be careful with terminology here: the word “stack” may refer to the call stack , the stack of frames providing context for method execution, or to a particular frame's operand stack , upon which JVM instructions operate.

Local Variables

Each stack frame keeps a table of local variables . This typically includes a reference to this object, any arguments that were passed when the method was called, and any local variables declared within the method body. Running javap with the -v option will include information about how each method's stack frame should be set up, including its local variable table:

 public double getPerimeter(double); // ... Code: 0: getstatic #23 // Field scala/Predef$.MODULE$:Lscala/Predef$; 3: ldc #25 // String Calculating perimeter... // ... LocalVariableTable: Start Length Slot Name Signature 0 16 0 this LRegularPolygon; 0 16 1 sideLength D

In this example, there are two local variables. The variable in slot 0 is named this , with the type RegularPolygon . This is the reference to the method's own class. The variable in slot 1 is named sideLength , with the type D (indicating a double). This is the argument that is passed to our getPerimeter() method.

Instructions such as iload_1 , fstore_2 , or aload [n] , transfer different types of local variables between the operand stack and the local variable table. Since the first item in the table is usually the reference to this , the instruction aload_0 is commonly seen in any method that operates on its own class.

This concludes our walkthrough of JVM basics.

Relacionado: Reduzca el código repetitivo con Scala Macros y Quasiquotes