Notas de lectura "Comprensión profunda de la máquina virtual Java" (7) -Motor de ejecución de código de bytes de máquina virtual (activado)

Tabla de contenido

 

Prefacio

1. Estructura del marco de pila en tiempo de ejecución

1.1 Tabla de variables locales

1.2 Pila de operandos

1.3 Conexión dinámica

1.4 Método de dirección de devolución

1.5 Información adicional

En segundo lugar, determine el método de ejecución.

2.1 Análisis

2.2 Envío

2.2.1 Envío estático

2.2.2 Despacho dinámico

2.2.3 Envío único y envío múltiple

2.2.4 Implementación de despacho dinámico de máquinas virtuales


Prefacio

Este capítulo describe principalmente cómo la máquina virtual determina la versión del método a llamar y cómo ejecutar el método.

1. Estructura del marco de pila en tiempo de ejecución

1.1 Tabla de variables locales

Se utiliza para almacenar los parámetros del método y las variables locales definidas en el método . En la etapa de compilación, el elemento de datos max_locals del atributo Code de la tabla del método determina el espacio máximo de la tabla de variables locales requerido por el método. Su capacidad se basa en la ranura variable ( ranura ) como la unidad más pequeña. La especificación de la máquina virtual no especifica claramente el tamaño del espacio que debe ocupar una ranura, pero se indica que cada ranura debe poder almacenar una boolean, byte, char, short, int, datos de tipo float, reference o returnAddress, estos 8 tipos de datos se pueden almacenar en una memoria de 32 bits o menor, pero la longitud de la ranura también puede variar con el procesador, operando sistema o máquina virtual, siempre y cuando se asegure de que incluso si se utiliza un espacio de memoria de 64 bits para implementar una ranura, la máquina virtual aún necesita usar alineación y relleno para que la ranura se vea coherente con la apariencia de la máquina virtual de 32 bits .

Nota: En cuanto al tipo de referencia, la especificación de la máquina virtual no especifica claramente su longitud, que puede ocupar 32 bits o 64 bits, y no indica claramente qué estructura debería ser. En general, la máquina virtual debería poder hacer al menos a través de esta referencia. Dos puntos:

  • A partir de esta referencia, puede encontrar directa o indirectamente el índice de la dirección inicial del almacenamiento de datos del objeto en el montón de Java.

  • A partir de esta referencia, puede encontrar directa o indirectamente la información de tipo almacenada en el área de método del tipo de datos del objeto.

Solo hay dos tipos de tipos de datos de 64 bits definidos por el lenguaje Java: largo y doble. Para los tipos de datos de 64 bits, la máquina virtual asigna dos espacios de ranura consecutivos de manera alineada. Debido a que la tabla de variables locales está construida en el espacio de pila privada del hilo, por lo que no importa si la lectura y escritura de dos ranuras consecutivas es una operación atómica, no causará problemas de seguridad de datos. Esto es diferente de los protocolos no atómicos de long y double que pueden causar problemas de seguridad .

La máquina virtual usa la tabla de variables locales por posicionamiento de índice , y el rango de índice es de 0 al número máximo de ranuras en la tabla de variables locales. Si accede a datos de 32 bits, el índice n representa la enésima ranura. Si accede a datos de 64 bits, significa que ambas ranuras n y n + 1 se utilizarán al mismo tiempo. Para dos ranuras adyacentes que almacenan datos de 64 bits juntos, no se permite utilizar ningún método para acceder a uno de ellos por separado.

Nota: Para los métodos no estáticos, la ranura con el índice 0 en la tabla de variables locales se usa de forma predeterminada para pasar la referencia de la instancia de objeto a la que pertenece el método, de modo que se pueda acceder al parámetro implícito a través de "this" palabra clave en el método.

Además, para ahorrar espacio en el marco de la pila, las ranuras se pueden reutilizar.Si el valor del contador del programa ha excedido el alcance de una variable, la ranura correspondiente a esta variable puede ser utilizada por otras variables.

Pero desde el modelo conceptual , la reutilización de ranuras puede causar problemas de GC: una variable local se refiere a un objeto grande, y ahora la variable excede su alcance. Es lógico pensar que el objeto grande es inútil en este momento y el GC puede reclamarlo Sin embargo, debido a la reutilización de la ranura, cuando la ranura no se ha reutilizado, aún mantiene una referencia a un objeto grande como GC Roots, lo que hace que el GC no pueda recuperarlo. Si hay algunas operaciones que consumen mucho tiempo en el código subyacente, el objeto grande ocupado en el frente es una gran carga, por lo que gradualmente hay una "recomendación" para establecer manualmente la variable en un valor nulo. Pero lo que el autor quiere decir es que esta operación solo se basa en la comprensión del modelo conceptual del motor de ejecución de código de bytes. Cuando la máquina virtual se ejecuta mediante un intérprete, suele estar cerca del modelo conceptual, pero después de la compilación JIT, es la forma principal para que la máquina virtual ejecute código, la operación de asignar un valor nulo se eliminará después de la compilación y optimización JIT, y después de la compilación JIT, cuando se hace referencia al objeto más allá del alcance, el GC generalmente se puede recuperar normalmente, por lo que no hay necesidad de confiar en esta "operación sao".

Las variables locales son diferentes de las variables de clase. Las variables locales no se pueden usar si están definidas sin valores iniciales. Si se usan variables locales no asignadas, el compilador informará un error durante la compilación. Si el código de bytes se genera manualmente, el compilador se saltará. Las comprobaciones también se encuentran durante la fase de verificación del código de bytes de la carga de clases.

1.2 Pila de operandos

Al igual que la estructura de datos de pila ordinaria, FILO, su profundidad máxima se escribe en el elemento max_statcks del atributo Code del método en el momento de la compilación y no cambiará más adelante. Durante la ejecución del método, varias instrucciones de código de bytes se empujarán / desplegarán continuamente en la pila de operandos. Los elementos de datos en la pila deben coincidir estrictamente con la secuencia de instrucciones de código de bytes , esto debe asegurarse cuando el compilador compila, y esto debe ser verificado nuevamente en la fase de verificación de clase (vía StackMapTable ). Por ejemplo, cuando se usa iadd para realizar la suma de datos enteros, los dos elementos en la parte superior de la pila deben ser de tipo int.

Además, en el modelo conceptual, los dos marcos de pila son independientes entre sí, pero en la mayoría de las implementaciones de máquinas virtuales, se realizarán algunas optimizaciones para que los dos marcos de pila se superpongan: deje que el operando se apile del marco de pila inferior y el superior. Las tablas de variables locales del marco de la pila se superponen, de modo que parte de los datos se pueden compartir cuando se llama al método, sin la necesidad de parametrización y transferencia adicionales.

1.3 Conexión dinámica

Hay una gran cantidad de referencias de símbolos en el grupo constante del archivo de clase, y la instrucción de llamada al método en el código de bytes toma la referencia de símbolo que apunta al método en el grupo constante como parámetro. Algunas de estas referencias de símbolos se convertirán directamente en referencias directas durante la etapa de carga de la clase o cuando se utilicen por primera vez. Esta conversión se denomina resolución estática . La otra parte se convertirá en una referencia directa durante cada ejecución. Esta parte se denomina vinculación dinámica .

1.4 Método de dirección de devolución

Hay dos formas de salir:

  • La primera es que el motor de ejecución encuentra cualquier instrucción de código de bytes devuelta. Si hay un valor de retorno y el tipo de valor de retorno se determinará de acuerdo con la instrucción de retorno del método encontrada. Este método de salida es la finalización normal de la salida .
  • La otra es que se encuentra una excepción durante la ejecución del método y la excepción no se procesa en el cuerpo del método (el manejador de excepciones no coincide en la tabla de excepciones de este método). Este método completa la salida de la excepción y no genera valor de retorno.

No importa cómo salga, después de que el método salga, debe volver al lugar donde se llamó al método antes de que el programa pueda continuar. En términos generales, cuando el método sale normalmente, el valor del contador de programa de la persona que llama al método puede usarse como la dirección de retorno y almacenarse en el marco de pila correspondiente al método; cuando el método sale de manera anormal, debe determinarse a través del tabla de manejo de excepciones.

1.5 Información adicional

La especificación de la máquina virtual permite que las implementaciones específicas de la máquina virtual agreguen información que no se describe en la especificación al marco de la pila, como información relacionada con la depuración, que es implementada por la propia máquina virtual.

En segundo lugar, determine el método de ejecución.

2.1 Análisis

El proceso de compilación del archivo de clase no incluye los pasos de vinculación en la compilación tradicional. Todas las llamadas a métodos se almacenan en el archivo de clase solo mediante referencias de símbolos, no la dirección de entrada (referencia directa) del método en la memoria de tiempo de ejecución real.

Algunas de las referencias de símbolos se convertirán en referencias directas durante la etapa de análisis de carga de clases La premisa de este análisis es que el método tiene una versión determinable antes de que se ejecute el programa y no se puede cambiar durante el tiempo de ejecución . Los métodos que cumplen con esta premisa incluyen principalmente métodos estáticos y métodos privados . Debido a que estos dos métodos no pueden anular otras versiones a través de la herencia u otros métodos, son adecuados para analizar en la etapa de carga de clases.

La máquina virtual Java proporciona cinco instrucciones de código de bytes de llamadas a métodos:

  • invokestatic: llamar a un método estático

  • invokespecial: invoca el método <init> del constructor de instancias, el método privado y el método de superclase (super.method (...))

  • invokevirtual: llamar a todos los métodos virtuales

  • invokeinterface: invoca el método de interfaz, y un objeto que implementa esta interfaz se determinará en tiempo de ejecución

  • invokedynamic: primero analiza dinámicamente el método al que hace referencia el calificador del sitio de llamada en tiempo de ejecución, y luego ejecuta el método

Entre ellos, los métodos llamados por invokestatic e invookespecial pueden convertir referencias de símbolos en referencias directas del método durante la etapa de análisis de carga de clases. Estos métodos incluyen métodos estáticos, métodos privados, métodos padre y métodos <init> , que se denominan non -virtual métodos . Método . Otros métodos se denominan métodos virtuales.

Nota:

1. Aunque el método modificado final se llama con la instrucción invokevirtual, no se puede sobrescribir, por lo que no habrá otra versión, que también es un método no virtual.

2. En cuanto al método de la clase principal es un método no virtual, el uso de invocar una llamada especial se refiere al caso de llamar al método de la clase principal a través de super. Si la subclase anula el método de la superclase, entonces el método de la subclase se pertenece a sí mismo, y el método de la clase principal está bien

2.2 Envío

Además del proceso de análisis mencionado anteriormente para determinar la versión del método de ejecución, existe otro método para determinar el método virtual: despacho . La distribución se divide en distribución estática y distribución dinámica , que se puede dividir en distribución única y distribución múltiple según la cantidad de la base de distribución . La combinación por pares constituye el envío único estático, el envío múltiple estático, el envío único dinámico y el envío múltiple dinámico.

2.2.1 Envío estático

La aplicación típica del envío estático es manejar la sobrecarga de métodos . El documento técnico en inglés se llama " Resolución de sobrecarga de métodos " (la explicación en el libro es que los materiales domésticos generalmente traducen este comportamiento en "envío estático"). Si el objeto A hereda el objeto B, entonces para la declaración: B b = nuevo A (); donde B se llama el tipo estático de la variable b (Tipo estático) , y A se llama el tipo real de la variable b (Tipo real ) . El tipo estático de los parámetros del método se puede cambiar. Por ejemplo, mediante una operación de conversión forzada, el tipo estático de b es B, pero el tipo estático de (A) b se convierte en A. Sin embargo, el tipo estático (B) de la variable en sí no cambiará, y el tipo estático final se conoce en el momento de la compilación; el tipo real solo se puede determinar en tiempo de ejecución, y el compilador no conoce el tipo real de un objeto en tiempo de compilación ¿Qué es?

La máquina virtual utiliza el tipo estático del parámetro en lugar del tipo real como base para juzgar cuando se procesa la sobrecarga y, como se mencionó anteriormente, el tipo estático se conoce en el momento de la compilación. Por lo tanto, en la fase de compilación, el compilador decidirá qué versión sobrecargada usar de acuerdo con el tipo estático del parámetro. Después de seleccionar la versión sobrecargada del método, el compilador escribirá la referencia simbólica de este método al parámetro del método. llamar a la instrucción de código de bytes. Por ejemplo, en el siguiente código de muestra, hay 3 versiones sobrecargadas del método say () (tenga en cuenta que el objeto principal aquí está determinado de forma única):

public class Main {

    static class A {
    }

    static class B extends A {
    }

    static class C extends B {
    }

    public void say(A a) {
        System.out.println("A");
    }

    public void say(B b) {
        System.out.println("B");
    }

    public void say(C c) {
        System.out.println("C");
    }

    public static void main(String[] args) throws Exception {
        Main main = new Main();
        B os = new C();
        main.say(os);//静态类型为B,实际类型为C,确定的say方法重载版本为say(B b)
        main.say((A) os);//最终静态类型转换为了A,实际类型为C,确定的say方法重载版本为say(A a)
        main.say((C) os);//最终静态类型转换为了C,实际类型为C,确定的say方法重载版本为say(C c)
        //输出 B A C
    }
}

Además, aunque el compilador puede determinar la versión sobrecargada del método, en muchos casos, esta versión sobrecargada no es "única" y solo puede determinar una versión "más adecuada". La razón principal de esta vaga conclusión es que no es necesario definir el literal, por lo que el tipo estático del literal no se muestra y su tipo estático solo se puede entender e inferir a través de las reglas del lenguaje.

Por ejemplo, para el método: say (...), hay 7 versiones sobrecargadas: say (char arg), say (int arg), say (long arg), say (Character arg), say (Serializable arg), decir (Objeto arg), decir (char ... arg). Si el programa ahora intenta llamar al método: diga ('a'); no es necesario definir 'a' y se puede usar directamente, por lo que no se muestra ningún tipo estático. ¿Qué versión sobrecargada debería elegir el compilador?

  • 'a' es primero un tipo char: correspondiente a say (char arg)

  • En segundo lugar, también puede representar el número 97 (consulte el código ASCII): correspondiente a say (int arg)

  • Después de convertirse a 97, también se puede convertir a tipo largo 97L: correspondiente a say (long arg)

  • Además, se puede empaquetar y empaquetar automáticamente como Carácter: correspondiente a decir (Carácter arg)

  • La clase de boxeo Character también implementa la interfaz serializable (si se implementan múltiples interfaces directa o indirectamente, la prioridad es la misma. Si hay múltiples métodos sobrecargados que pueden adaptarse a múltiples interfaces, indicará que el tipo sea ambiguo y se negará a compilar ) : Corresponde a decir (Serializable)

  • Y Character también hereda de Object (si hay varias clases principales, buscará de abajo hacia arriba en la relación de herencia, cuanto más cerca del nivel superior, menor sea la prioridad): correspondiente a say (Object arg)

  • Eventualmente, también puede coincidir con el tipo de longitud variable: correspondiente a say (char ... arg)

Lo que se describe anteriormente es en realidad el proceso de selección de destinos de envío estático durante la compilación Este proceso también es la esencia de la implementación del lenguaje Java de la sobrecarga de métodos.

Nota: La resolución y la asignación no son una relación alternativa, son un proceso de selección y determinación de métodos objetivo en diferentes niveles. Por ejemplo, los métodos estáticos serán referenciados directamente en la etapa de análisis de la carga de clases, y los métodos estáticos también pueden tener versiones sobrecargadas. El proceso de selección de versiones sobrecargadas también se realiza a través del envío estático.

2.2.2 Despacho dinámico

La aplicación típica del envío dinámico es la reescritura de métodos . En el caso de la reescritura de métodos, la máquina virtual Java distribuye la versión de ejecución del método a través del tipo real al llamar al método. Para el siguiente código:

public class Main {

    static class A {
        public void say() {
            System.out.println("A");
        }
    }

    static class B extends A {
        public void say() {
            System.out.println("B");
        }
    }

    static class C extends A {
        public void say() {
            System.out.println("C");
        }
    }

    public static void main(String[] args) throws Exception {
        A b = new B();
        A c = new C();
        b.say();
        c.say();
        //输出  B C
    }
}

Después de que el compilador compila las llamadas b.say () y c.say (), la instrucción de código de bytes de llamada al método (invokevirtual aquí) y el parámetro de la instrucción (la referencia de símbolo de A.say ()) son iguales, pero las metas de ejecución final no son las mismas (una B, una C). Esto involucra el proceso de búsqueda polimórfica de la instrucción virtual invoke:

  1. Encuentre el tipo real del objeto al que apunta el primer elemento en la parte superior de la pila de operandos y regístrelo como M
  2. Si un método que coincide con el descriptor y el nombre simple en la constante se encuentra en el tipo M, entonces se realiza la verificación del permiso de acceso. Si pasa, se devuelve la referencia directa de este método y el proceso de búsqueda finaliza; de lo contrario, IllegalAccessError es regresado.
  3. De lo contrario, siga la relación de herencia de abajo hacia arriba para realizar el segundo paso del proceso de búsqueda y verificación para cada clase principal de M
  4. Si no se encuentra un método adecuado, se lanza una excepción AbstractMethodError

b.say (); El proceso de ejecución de la declaración es primero empujar el objeto de instancia b a la parte superior de la pila, y luego llamarlo a través de la instrucción invokevirtual. Este objeto b se llama receptor del método say () . Como se puede ver en los pasos anteriores, el primer paso es determinar el tipo real de receptor que ejecuta el método, por ejemplo, como B durante el tiempo de ejecución. c.say (); La oración es la misma. Entonces, las referencias de símbolos A.say () en las dos llamadas se resuelven en referencias directas diferentes Este proceso es la esencia de la reescritura del método Java. Este tipo de proceso de envío en el que la versión de ejecución del método se determina de acuerdo con el tipo real en tiempo de ejecución se denomina envío dinámico.

2.2.3 Envío único y envío múltiple

Los métodos de los destinatarios y los parámetros del método denominados colectivamente el método - variables . Según cuántos tipos de cantidades se basan en la distribución, la distribución se puede dividir en dos tipos: distribución única y distribución múltiple. Volviendo al ejemplo de envío estático en 2.2.1. En este ejemplo, el objeto principal está determinado de forma única. Ahora el código está ajustado:

public class Main {

    static class A {
    }

    static class B extends A {
    }

    static class C extends B {
    }

    public void say(A a) {
        System.out.println("A");
    }

    public void say(B b) {
        System.out.println("B");
    }

    public void say(C c) {
        System.out.println("C");
    }

    public static void main(String[] args) throws Exception {
        Main main = new Main();
        Main superMain = new Super();
        B os = new C();
        main.say(os);
        superMain.say((A) os);
        //输出 B S-A
    }
}

class Super extends Main {
    public void say(A a) {
        System.out.println("S-A");
    }

    public void say(B b) {
        System.out.println("S-B");
    }

    public void say(C c) {
        System.out.println("S-C");
    }
}

Para main.say (os) y superMain.sauy (os).

  • Primero observe la elección del compilador en la etapa de compilación, es decir , el proceso de envío estático :

En este momento, la selección del método objetivo se basa en dos puntos: uno es si el tipo estático del receptor del método es Principal o Super, y el otro es si el tipo estático del parámetro del método es B o C. Debido a que los tipos estáticos de main y superMain (receptores de método) son Main, y los tipos estáticos de parámetros de método son uno B y el otro A. Por lo tanto, los parámetros de las dos instrucciones invokevitrual generadas esta vez son, respectivamente, las referencias simbólicas a los métodos de Main.say (B) y Main.say (A) en el grupo constante. Debido a que esta selección se basa en dos argumentos, el envío estático del lenguaje Java se denomina envío múltiple.

  • Veamos la selección de máquinas virtuales en la fase de ejecución, que es el proceso de asignación dinámica :

De la introducción del despacho dinámico en la Sección 2.2.2 y los resultados del despacho estático anterior, sabemos que cuando se ejecutan las instrucciones invokevirtual de main.say (os) y superMain.say ((A) os), la firma del método ya está despachado estáticamente El proceso está confirmado, debe decirse (B) y decir (A) respectivamente. La máquina virtual no se preocupa por el tipo estático y el tipo real del parámetro en este momento. Solo el tipo real del receptor del método afectará la elección de la versión del método, es decir, solo hay un argumento como base para la selección , por lo que el envío dinámico del lenguaje Java pertenece al tipo de envío único.

Por lo tanto, el lenguaje Java actual es un lenguaje de envío múltiple estático y un lenguaje dinámico de envío único.

2.2.4 Implementación de despacho dinámico de máquinas virtuales

El envío dinámico es una acción muy frecuente, y el proceso de selección de la versión del método del envío dinámico debe buscar un método de destino adecuado en los metadatos del método de la clase en tiempo de ejecución. Por lo tanto, debido a consideraciones de rendimiento, la máquina virtual se optimiza: la clase está en el área de método Crear una tabla de método virtual (Tabla de método virtual , correspondiente a esto, la tabla de método de interfaz --- Tabla de método de interfaz ) también se usa cuando se ejecuta invokeinterface . La tabla de método virtual almacena la dirección de entrada real de cada método .

Si un método no se anula en la subclase, entonces la entrada de dirección en la tabla de métodos virtuales de la subclase es la misma que la entrada de dirección del mismo método en la clase principal, y todas apuntan a la entrada de implementación de la clase principal. ; si se reemplaza la subclase Para este método, la dirección en la tabla de método virtual en la tabla de método de subclase será reemplazada con la dirección de entrada que apunta a la versión de implementación de la subclase. De esta manera, a través del almacenamiento redundante, al buscar el método de destino en tiempo de ejecución, no es necesario buscar cada clase principal del objeto a su vez.

Al mismo tiempo, los métodos con la misma firma deben tener el mismo número de índice en la tabla de método virtual de la clase principal y la subclase. De esta manera, cuando se convierte el tipo, solo la tabla de métodos que se va a buscar debe ser cambiado, y puede ser de una tabla de método virtual diferente Convierta la dirección de entrada deseada de acuerdo con el índice. La tabla de métodos generalmente se inicializa en la fase de conexión (fase de preparación) de la carga de la clase Después de preparar el valor inicial de la variable de clase, la máquina virtual también inicializa la tabla de métodos de la clase.

Además de utilizar la tabla de métodos, cuando las condiciones lo permitan, la máquina virtual también utilizará Inline Cache y Guarded Inlining basado en la tecnología "Class Hierarchy Analysis (CHA)" Formas de obtener un mayor rendimiento.

Supongo que te gusta

Origin blog.csdn.net/huangzhilin2015/article/details/114437682
Recomendado
Clasificación