Cocinar bytecode en la cocina JVM

Mi nombre es Alexander Kotsyuruba, lidero el desarrollo de servicios internos en DomKlik. Muchos desarrolladores de Java con experiencia llegan a comprender la estructura interna de la JVM. Para facilitar este viaje del Java Samurai, decidí en un lenguaje simple esbozar los conceptos básicos de la Máquina Virtual Java (JVM) y trabajar con bytecode.

¿Qué es un bytecode misterioso y dónde vive?

Trataré de responder esta pregunta usando el ejemplo de decapado.



¿Por qué necesito una JVM y un bytecode?


La JVM se originó bajo el eslogan Write Once Run Anywhere (WORA) en Sun Microsystems. A diferencia del concepto de Escribir una vez compilar en cualquier lugar (WOCA) , WORA implica la presencia de una máquina virtual para cada sistema operativo que ejecuta código compilado una vez (bytecode).


Escribir una vez ejecutado en cualquier lugar (WORA)


Escribir una vez compilar en cualquier lugar (WOCA)

JVM y bytecode son la base del concepto WORA y nos salvan de los matices y la necesidad de compilar para cada sistema operativo.

Bytecode


Para entender qué es un bytecode, veamos un ejemplo. Por supuesto, este código no hace nada útil, solo servirá para un análisis más detallado.

Fuente:

class Solenya(val jarForPickles: Any? = Any(), var ingredientsCount: Int = 0) {


    /**
     *   
     *  @param ingredient -  
     */
    fun add(ingredient: Any) {
        ingredientsCount = ingredientsCount.inc()
        //- 
    }

    /**
     *   
     *  @param duration -   
     */
    fun warmUp(duration: Int) {
        for (x in 1..duration)
            println("Warming")
    }

    init {
        //   
        val jarForPickles = takeJarForPickles()
        // 
        val pickles = Any()
        // 
        val water = Any()

        //
        add(pickles)
        add(water)

        //
        warmUp(10)
    }

    /**
     *   
     */
    private fun takeJarForPickles(): Any = openLocker()

    /**
     *   
     */
    private fun openLocker(): Any = takeKeyForLocker()

    /**
     *     
     */
    private fun takeKeyForLocker(): Any = {}
}

Usando las herramientas integradas de Intellij IDEA ( Herramientas -> Kotlin -> Mostrar código de bytes de Kotlin ) obtenemos un código de bytes desmontado (solo se muestra una parte en el ejemplo):

...
   INVOKEVIRTUAL java/io/PrintStream.println (Ljava/lang/Object;)V
   L5
   L6
    LINENUMBER 12 L6
    RETURN
   L7
    LOCALVARIABLE this Lcom/company/Solenya; L0 L7 0
    LOCALVARIABLE ingredient Ljava/lang/Object; L0 L7 1
    LOCALVARIABLE $i$f$add I L1 L7 2
    MAXSTACK = 2
    MAXLOCALS = 5

  // access flags 0x11
  public final warmUp(I)V
    // annotable parameter count: 1 (visible)
    // annotable parameter count: 1 (invisible)
   L0
    LINENUMBER 19 L0
    ICONST_1
    ISTORE 2
...

A primera vista, un conjunto incomprensible de instrucciones. Para comprender cómo y con qué trabajan, deberá sumergirse en la cocina interior de la JVM.

Cocina JVM


Veamos la memoria de tiempo de ejecución JVM:



Podemos decir que JVM es nuestra cocina. A continuación, considere los participantes restantes:

Área de método - Libro de cocina



El área Método almacena el código compilado para cada función. Cuando un hilo comienza a realizar una función, en general, recibe instrucciones de esta área. De hecho, es un libro de recetas que describe en detalle cómo cocinar todo, desde huevos revueltos hasta zarzuela catalana.

Hilo 1..N - Cocineros del equipo



Las transmisiones siguen estrictamente las instrucciones prescritas por ellos (área de método), para ello tienen PC Register y JVM Stack. Puede comparar cada secuencia con un cocinero que realiza la tarea que se le asignó, siguiendo exactamente las recetas del libro de cocina.

Registro de PC - Notas de campo



Program Counter Register: el contador de comandos de nuestra transmisión. Almacena la dirección de la instrucción que se está ejecutando. En la cocina, estas serían algunas notas en qué página del libro de cocina estamos ahora.

Jvm stack


Pila de marcos. Se asigna un marco para cada función, dentro del cual el subproceso actual funciona con variables y operandos. Como parte de la analogía con la preparación de nuestros encurtidos, esto podría ser un conjunto de operaciones anidadas:

-> -> -> ...

Marco: escritorio



El marco actúa como el escritorio del cocinero, sobre el cual descansa una tabla de cortar y contenedores firmados.

Variables locales: contenedores firmados



Esta es una matriz de variables locales (tabla de variables locales) que, como su nombre lo indica, almacena los valores, el tipo y el alcance de las variables locales. Esto es similar a los contenedores firmados, donde puede agregar resultados intermedios de actividad profesional.

Pila de operandos - tabla de cortar



La pila de operandos almacena argumentos para las instrucciones JVM. Por ejemplo, valores enteros para la operación de suma, referencias a objetos de montón, etc.

El ejemplo más cercano que puedo dar es una tabla de cortar en la que un tomate y un pepino se convierten en una ensalada en un punto. A diferencia de las variables locales, ponemos en el tablero solo con lo que ejecutaremos la próxima instrucción.

Montón: tabla de distribución



Como parte del trabajo con el marco, operamos en enlaces a objetos; los objetos mismos se almacenan en el montón. Una diferencia importante es que el marco pertenece a un solo hilo, y las variables locales "viven" mientras el marco está vivo (la función se ejecuta). Y el montón es accesible para otras corrientes, y vive hasta que se enciende el recolector de basura. Por analogía con la cocina, podemos dar un ejemplo con una tabla de distribución, que solo es común. Y lo limpia un equipo separado de limpiadores.

Cocina JVM. Una mirada desde el interior. Trabajar con marco


Comencemos con la función warmUp:

    /**
     *   
     *  @param duration -   
     */
    fun warmUp(duration: Int) {
        for (x in 1..duration)
            println("Warming...")
    }

Función de código de bytes desmontada:

  public final warmUp(I)V
    // annotable parameter count: 1 (visible)
    // annotable parameter count: 1 (invisible)
   L0
    LINENUMBER 19 L0
    ICONST_1
    ISTORE 2
    ILOAD 1
    ISTORE 3
    ILOAD 2
    ILOAD 3
    IF_ICMPGT L1
   L2
    LINENUMBER 20 L2
    LDC "Warming..."
    ASTORE 4
   L3
    ICONST_0
    ISTORE 5
   L4
    GETSTATIC java/lang/System.out : Ljava/io/PrintStream;
    ALOAD 4
    INVOKEVIRTUAL java/io/PrintStream.println (Ljava/lang/Object;)V
   L5
   L6
    LINENUMBER 19 L6
    ILOAD 2
    ILOAD 3
    IF_ICMPEQ L1
    IINC 2 1
   L7
    GOTO L2
   L1
    LINENUMBER 21 L1
    RETURN
   L8
    LOCALVARIABLE x I L2 L7 2
    LOCALVARIABLE this Lcom/company/Solenya; L0 L8 0
    LOCALVARIABLE duration I L0 L8 1
    MAXSTACK = 2
    MAXLOCALS = 6

Inicialización del marco: preparación del lugar de trabajo


Para ejecutar esta función, se creará un marco en la secuencia de la pila JVM. Permítame recordarle que la pila consta de una matriz de variables locales y una pila de operandos.

  1. Para que podamos entender cuánta memoria asignar para este marco, el compilador proporcionó metainformación sobre esta función (explicación en el comentario del código):

        MAXSTACK = 2 //    2*32bit
        MAXLOCALS = 6 //    6*32bit
    
  2. También tenemos información sobre algunos elementos de la matriz de variables locales:

        LOCALVARIABLE x I L2 L7 2 //  x  Int(I),      L2-L7   2
        LOCALVARIABLE this Lcom/company/Solenya; L0 L8 0
        LOCALVARIABLE duration I L0 L8 1
    
  3. Los argumentos de la función al inicializar el marco caen en variables locales. En este ejemplo, el valor de duración se escribirá en la matriz con el índice 1.

Por lo tanto, inicialmente el marco se verá así:


Comience a ejecutar instrucciones


Para comprender cómo funciona el marco, solo arme una lista de instrucciones JVM ( listados de instrucciones de código de bytes Java ) y revise la etiqueta L0:

   L0
    LINENUMBER 19 L0 //     
    ICONST_1
    ISTORE 2
    ILOAD 1
    ISTORE 3
    ILOAD 2
    ILOAD 3
    IF_ICMPGT L1

ICONST_1 - add 1(int) en la pila de operandos:



istore 2 - Valor de tracción (tipo INT) de la pila de operandos y escrituras en las variables locales con el índice 2:



Estas dos operaciones pueden ser interpretados de Java de código: int x = 1.

ILoad 1 - cargar el valor de las variables locales con el índice 1 en la pila de operandos:



istore 3 - valor de tracción (el tipo Int) de la pila de operandos y escribe a las variables locales con un índice de 3:



Estas dos operaciones pueden ser interpretados en un Java-código: int var3 = duration.

ILOAD 2 : carga un valor de las variables locales con el índice 2 en la pila de operandos.

ILOAD 3 : valor de carga de variables locales con índice 3 en la pila de operandos:



IF_ICMPGT L1- instrucciones para comparar dos valores enteros de la pila. Si el valor "inferior" es mayor que el "superior", vaya a la etiqueta L1. Después de ejecutar esta instrucción, la pila quedará vacía.

Así es como se verían estas líneas de bytecode de Java:

      int x = 1;
      int var3 = duration;
      if (x > var3) {
         ....L1...

Descompilamos el código usando Intellij IDEA a lo largo de la ruta Kotlin -> Java :

   public final void warmUp(int duration) {
      int x = 1;
      int var3 = duration;
      if (x <= duration) {
         while(true) {
            String var4 = "Warming";
            boolean var5 = false;
            System.out.println(var4);
            if (x == var3) {
               break;
            }
            ++x;
         }
      }
   }

Aquí puede ver las variables no utilizadas ( var5) y la ausencia de una llamada a la función println(). No se preocupe, esto se debe a los detalles específicos de la compilación de funciones en línea ( println()) y expresiones lambda. Prácticamente no habrá gastos generales para la ejecución de estas instrucciones, además, el código muerto se eliminará gracias a JIT. Este es un tema interesante, que debería dedicarse a un artículo separado.

Dibujando una analogía con una cocina, esta función se puede describir como una tarea para un cocinero para "hervir agua durante 10 minutos". Además, nuestro profesional en su campo:

  1. abre un libro de cocina (área de método);
  2. encuentra instrucciones sobre cómo hervir agua ( warmUp());
  3. prepara el lugar de trabajo, asignando una placa caliente (pila de operandos) y contenedores (variables locales) para el almacenamiento temporal de productos.

Cocina JVM. Una mirada desde el interior. Trabajar con el montón


Considera el código:

val pickles = Any()

Código de bytes desmontado:

    NEW java/lang/Object
    DUP
    INVOKESPECIAL java/lang/Object.<init> ()V
    ASTORE 3

NUEVO java / lang / Object : asignación de memoria para un objeto de clase Objectdel montón. El objeto en sí no se colocará en la pila, sino un enlace a él en el montón:


DUP : duplicación del elemento "superior" de la pila. Se necesita un enlace para inicializar el objeto, el segundo para guardarlo en variables locales:


INVOKESPECIAL java / lang / Object. <init> () V - inicialización del objeto de la clase correspondiente ( Object) por el enlace de la pila:


ASTORE 3 es el último paso, guardando la referencia al objeto en variables locales con el índice 3.

Dibujando una analogía con la cocina, compararía la creación de un objeto de clase con cocinar en una mesa compartida (montón). Para hacer esto, debe asignar suficiente espacio para usted en la tabla de distribución, regresar al lugar de trabajo y lanzar una nota con la dirección (referencia) en el contenedor apropiado (variables locales). Y solo después de eso, comienza a crear un objeto de la clase.

Cocina JVM. Una mirada desde el interior. Multithreading


Ahora considere este ejemplo:

    fun add(ingredient: Any) {
        ingredientsCount = ingredientsCount.inc()
        //- 
    }

Este es un ejemplo clásico del problema de subprocesos. Tenemos un recuento de ingredientes ingredientsCount. Una función add, además de agregar un ingrediente, realiza un incremento ingredientsCount.

Un bytecode desmontado se ve así:

    ALOAD 0
    ALOAD 0
    GETFIELD com/company/Solenya.ingredientsCount : I
    ICONST_1
    IADD
    PUTFIELD com/company/Solenya.ingredientsCount : I

El estado de nuestra pila de operandos a medida que se ejecutan las instrucciones:


Cuando trabaje en un hilo, todo se ejecutará correctamente. Si hay varios subprocesos, puede ocurrir el siguiente problema. Imagine que ambos hilos obtuvieron simultáneamente el valor del campo ingredientsCounty lo escribieron en la pila. Entonces el estado de la pila de operandos y el campo ingredientsCountpodría verse así:


La función se ejecutó dos veces (una por cada subproceso) y el valor ingredientsCountdebería ser igual a 2. Pero, de hecho, uno de los subprocesos funcionó con un valor desactualizado ingredientsCounty, por lo tanto, el resultado real es 1 (problema de actualización perdida).

La situación es similar al trabajo paralelo de un equipo de chefs que agregan especias al plato. Imagina:

  1. Hay una tabla de distribución en la que se encuentra el plato (Montón).
  2. Hay dos cocineros en la cocina (Hilo * 2).
  3. Cada cocinero tiene su propia mesa de corte, donde prepara una mezcla de especias (JVM Stack * 2).
  4. Tarea: agregue dos porciones de especias al plato.
  5. En la tabla de distribución se encuentra un trozo de papel con el que leen y en el que escriben qué parte se agregó ( ingredientsCount). Y para guardar especias:
    • Antes de comenzar la preparación de las especias, el cocinero debe leer en una hoja de papel que la cantidad de especias agregadas no es suficiente;
    • Después de agregar especias, el cocinero puede escribir cuántas, en su opinión, se agregan especias al plato.

En tales condiciones, puede surgir una situación:

  1. El cocinero n. ° 1 leyó que se agregaron 3 porciones de especias.
  2. El cocinero # 2 leyó que se agregaron 3 porciones de especias.
  3. Ambos van a sus escritorios y preparan una mezcla de especias.
  4. Ambos chefs agregan especias (3 + 2) al plato.
  5. Cook # 1 escribe que se han agregado 4 porciones de especias.
  6. Cook # 2 escribe que se han agregado 4 porciones de especias.

En pocas palabras: faltaban los productos, el plato resultó picante, etc.

Para evitar tales situaciones, existen varias herramientas como cerraduras, funciones de seguridad de roscas, etc.

Para resumir


Es extremadamente raro que un desarrollador necesite rastrear en código de bytes, a menos que esto sea específico de su trabajo. Al mismo tiempo, comprender el trabajo de bytecode ayuda a comprender mejor los subprocesos múltiples y los beneficios de un idioma en particular, y también ayuda a crecer profesionalmente.

Vale la pena señalar que están lejos de todas las partes de la JVM. Hay muchas "cosas" más interesantes, por ejemplo, grupo constante, verificador de bytecode, JIT, caché de código, etc. Pero para no sobrecargar el artículo, me concentré solo en aquellos elementos que son necesarios para una comprensión común.

Enlaces útiles:


All Articles