Mecanismo de carga de clases en la JVM

Desde que una clase o interfaz se carga en memoria hasta que se descarga, atraviesa varias etapas: carga, vinculación (verificación, preparación y resolución), inicialización, uso y descarga. La secuencia de carga, verificación, preparación, inicialización y descarga es fija; en cambio, la resolución puede retrasarse hasta después de la inicialización para permitir el enlace dinámico propio de Java.

Normalmente estas fases no se ejecutan de forma aislada, sino que se solapan. La especificación de la JVM no define exactamente cuándo debe empezar la fase de carga, pero sí enumera seis escenarios que obligan a iniciar la inicialización (y, por tanto, las fases previas) de una clase.

Casos que fuerzan la inicialización

  1. Al ejecutar las instrucciones de bytecodes new, getstatic, putstatic o invokestatic, si la clase aún no se ha inicializado. Esto ocurre, por ejemplo, al instanciar con new, leer o modificar un campo static (excepto una constante final resuelta en tiempo de compilación), o invocar un método static.
  2. Al usar reflexión (java.lang.reflect) sobre una clase que no se ha inicializado.
  3. Durante la inicialización de una clase, si su superclase no está inicializada, se inicializa primero la superclase. Las interfaces superiores no se inicializan salvo que se utilicen sus constantes.
  4. Cuando arranca la JVM y se indica la clase principal (main), ésta se inicializa antes de ejecutarse.
  5. Con el soporte de lenguajes dinámicos del JDK 7, si un MethodHandle se resuelve como REF_getStatic, REF_putStatic, REF_invokeStatic o REF_newInvokeSpecial y la clase asociada no está inicializada.
  6. En JDK 8+, si una interfaz define un método por defecto y una clase que la implementa se inicializa, la interfaz se inicializa antes.

Etapas del proceso de carga

1. Carga

En esta fase la JVM:

  1. Obtiene el flujo de bytes que representa la clase a partir de su nombre completamente calificado.
  2. Convierte esa estructura estática en la representación en tiempo de ejecución del área de métodos.
  3. Crea el objeto java.lang.Class que actúa como puerta de acceso a los metadatos de la clase.

El trabajo puede realizarlo el cargador de clases por defecto o un cargador definido por el usuario, redefiniendo findClass() o loadClass(). Los arrays constituyen un caso especial: el tipo array no se crea mediante un cargador, sino que la JVM lo construye directamente. Si el tipo componente es una referencia, se carga recursivamente con el cargador correspondiente; si es primitivo, se asocia al cargador raíz.

2. Verificación

La verificación garantiza que el flujo de bytes cumple la especificación y no compromete la seguridad de la JVM. Se divide en cuatro pasos:

  • Formato del archivo: comprueba que el flujo sea un archivo .class válido y procesable. Solo tras superar este paso se almacena en el área de métodos.
  • Metadatos: valida la semántica de la información descrita (herencia, implementaciones de interfaces, etc.).
  • Bytecode: analiza flujo de datos y de control del cuerpo de los métodos para detectar operaciones ilegales o inseguras.
  • Referencias simbólicas: verifica que las dependencias externas (clases, métodos, campos) sean accesibles y compatibles.

En entornos controlados se puede reducir esta sobrecarga usando el modificador -Xverify:none, aunque esto se desaconseja en producción con código no auditado.

3. Preparación

La JVM asigna memoria para las variables de clase (campos static) y les asigna el valor por defecto del tipo (0, false, null, etc.). No se tocan las variables de instancia, que se reservarán al crear el objeto. Excepción: las constantes static final con valor constante conocido en compliación se inicializan directamente mediante el atributo ConstantValue.

4. Resolución

Consiste en sustituir las referencias simbólicas del pool de constantes por referencias directas (punteros, desplazamientos o manejadores). La JVM puede cachear el resultado de la primera resolución para evitar repetir el trabajo, salvo para la instrucción invokedynamic, donde cada punto de invocación se resuelve en tiempo de ejecución.

Se resuelven referencias a clases/interfacse, campos, métodos de clase, métodos de interfaz, tipos de métodos, manejadores y sitios de llamada. Cada tipo de resolución incluye verificaciones de accesibilidad y, desde JDK 9, de límites entre módulos.

5. Inicialización

Es la fase en que la JVM ejecuta el método generado por el compilador <clinit>(), que agrupa las asignaciones a variables de clase y los bloques static en el orden en que aparecen en el fuente. No se puede leer una variable de clase antes de su declaración dentro del mismo bloque estático, aunque sí asignarle un valor.

public class Demo {
    static {
        x = 10;                // válido
        System.out.println(x); // error: referencia hacia adelante ilegal
    }
    static int x = 20;
}

<clinit>() difiere del constructor: la JVM invoca primero el de la superclase, por lo que los bloques static del padre se ejecutan antes que las asignaciones del hijo.

class Base {
    public static int valor = 1;
    static { valor = 2; }
}
class Derivada extends Base {
    public static int numero = valor;
}
public class Orden {
    public static void main(String[] args) {
        System.out.println(Derivada.numero); // 2
    }
}

Si una clase carece de variables estáticas y bloques static, el compilador puede omitir el método <clinit>(). Las interfaces también lo generan por sus constantes, pero no garantizan la ejecución previa del <clinit>() de sus interfaces padre.

La JVM sincroniza la ejecución de <clinit>(): varios hilos que intenten inicializar la misma clase compiten por un único bloqueo, por lo que una inicialización costosa puede bloquear al resto.

public class BloqueoInicializacion {
    static class Tarea {
        static {
            if (true) {
                System.out.println(Thread.currentThread().getName()
                                   + " ejecutando <clinit>");
                while (true) { }
            }
        }
    }
    public static void main(String[] args) {
        Runnable r = () -> {
            System.out.println(Thread.currentThread() + " inicia");
            Tarea t = new Tarea();
            System.out.println(Thread.currentThread() + " finaliza");
        };
        new Thread(r, "H-1").start();
        new Thread(r, "H-2").start();
    }
}

Etiquetas: jvm java ClassLoader bytecode HotSpot

Publicado el 10-8 02:48