Rompiendo optimizaciones JIT con reflexión

9

Al jugar con las pruebas unitarias para una clase singleton altamente concurrente, me topé con el siguiente comportamiento extraño (probado en JDK 1.8.0_162):

private static class SingletonClass {
    static final SingletonClass INSTANCE = new SingletonClass(0);
    final int value;

    static SingletonClass getInstance() {
        return INSTANCE;
    }

    SingletonClass(int value) {
        this.value = value;
    }
}

public static void main(String[] args) throws NoSuchFieldException, IllegalAccessException {

    System.out.println(SingletonClass.getInstance().value); // 0

    // Change the instance to a new one with value 1
    setSingletonInstance(new SingletonClass(1));
    System.out.println(SingletonClass.getInstance().value); // 1

    // Call getInstance() enough times to trigger JIT optimizations
    for(int i=0;i<100_000;++i){
        SingletonClass.getInstance();
    }

    System.out.println(SingletonClass.getInstance().value); // 1

    setSingletonInstance(new SingletonClass(2));
    System.out.println(SingletonClass.INSTANCE.value); // 2
    System.out.println(SingletonClass.getInstance().value); // 1 (2 expected)
}

private static void setSingletonInstance(SingletonClass newInstance) throws NoSuchFieldException, IllegalAccessException {
    // Get the INSTANCE field and make it accessible
    Field field = SingletonClass.class.getDeclaredField("INSTANCE");
    field.setAccessible(true);

    // Remove the final modifier
    Field modifiersField = Field.class.getDeclaredField("modifiers");
    modifiersField.setAccessible(true);
    modifiersField.setInt(field, field.getModifiers() & ~Modifier.FINAL);

    // Set new value
    field.set(null, newInstance);
}

Las últimas 2 líneas del método main () no están de acuerdo con el valor de INSTANCE: supongo que JIT eliminó el método por completo ya que el campo es estático final. Al eliminar la palabra clave final, el código genera valores correctos.

Dejando a un lado su simpatía (o falta de ella) por los singletons y olvidando por un minuto que usar una reflexión como esta es un problema, ¿es correcto asumir que las optimizaciones JIT son las culpables? Si es así, ¿están limitados solo a campos finales estáticos?

Kelm
fuente
1
Un singleton es una clase para la que solo puede existir una instancia. Por lo tanto, no tienes un singleton, solo tienes una clase con un static finalcampo. Además de eso, no importa si este truco de reflexión se rompe debido a JIT o concurrencia.
Holger
@Holger este truco se realizó en pruebas unitarias solo como un intento de burlarse del singleton para múltiples casos de prueba de una clase que lo usa. No veo cómo la concurrencia podría haberlo causado (no hay ninguno en el código anterior) y realmente me gustaría saber qué sucedió.
Kelm
1
Bueno, dijiste "clase singleton altamente concurrente" en tu pregunta y yo digo " no importa " lo que hace que se rompa. Entonces, si su código de ejemplo particular se rompe debido a JIT y encuentra una solución para eso y luego, el código real cambia de romper debido a JIT a romper debido a la concurrencia, ¿qué ha ganado?
Holger
@ Holger está bien, la redacción era demasiado fuerte allí, perdón por eso. Lo que quise decir fue esto: si no entendemos por qué algo sale tan mal, somos propensos a ser mordidos por lo mismo en el futuro, por lo que preferiría saber la razón que asumir "simplemente sucede". De todos modos, ¡gracias por tomarte tu tiempo para responder!
Kelm

Respuestas:

7

Tomando su pregunta literalmente, “ … ¿es correcta mi suposición en que las optimizaciones JIT son las culpables? ", La respuesta es sí, es muy probable que las optimizaciones JIT sean responsables de este comportamiento en este ejemplo específico.

Pero dado que cambiar los static finalcampos está completamente fuera de especificación, hay otras cosas que pueden romperlo de manera similar. Por ejemplo, el JMM no tiene una definición para la visibilidad de la memoria de dichos cambios, por lo tanto, no se especifica por completo si otros subprocesos notan dichos cambios. Ni siquiera se les exige que lo noten de manera consistente, es decir, pueden usar el nuevo valor, seguido de usar el valor anterior nuevamente, incluso en presencia de primitivas de sincronización.

Sin embargo, el JMM y el optimizador son difíciles de separar de todos modos aquí.

Su pregunta " ... ¿están limitados a los campos finales estáticos solamente? "Es mucho más difícil de responder, ya que las optimizaciones, por supuesto, no se limitan a los static finalcampos, pero el comportamiento de, por ejemplo, los finalcampos no estáticos , no es el mismo y también tiene diferencias entre la teoría y la práctica.

Para finalcampos no estáticos , se permiten modificaciones a través de Reflection en ciertas circunstancias. Esto se indica por el hecho de que setAccessible(true)es suficiente para hacer posible tal modificación, sin hackear la Fieldinstancia para cambiar el modifierscampo interno .

La especificación dice:

17.5.3. Modificación posterior de finalcampos

En algunos casos, como la deserialización, el sistema necesitará cambiar los finalcampos de un objeto después de la construcción. finallos campos se pueden cambiar a través de la reflexión y otros medios dependientes de la implementación. El único patrón en el que esto tiene una semántica razonable es uno en el que se construye un objeto y luego finalse actualizan los campos del objeto. El objeto no debe hacerse visible para otros subprocesos, ni deben finalleerse los campos, hasta que finalse completen todas las actualizaciones de los campos del objeto. Las congelaciones de un finalcampo se producen tanto al final del constructor en el que finalse establece el campo como inmediatamente después de cada modificación de un finalcampo a través de la reflexión u otro mecanismo especial.

...

Otro problema es que la especificación permite una optimización agresiva de los finalcampos. Dentro de un hilo, está permitido reordenar las lecturas de un finalcampo con las modificaciones de un finalcampo que no tienen lugar en el constructor.

Ejemplo 17.5.3-1. Optimización agresiva de finalcampos
class A {
    final int x;
    A() { 
        x = 1; 
    } 

    int f() { 
        return d(this,this); 
    } 

    int d(A a1, A a2) { 
        int i = a1.x; 
        g(a1); 
        int j = a2.x; 
        return j - i; 
    }

    static void g(A a) { 
        // uses reflection to change a.x to 2 
    } 
}

En el dmétodo, el compilador puede reordenar las lecturas xy la llamada a glibremente. Por lo tanto, new A().f()podría volver -1, 0o 1.

En la práctica, determinar los lugares correctos donde las optimizaciones agresivas son posibles sin romper los escenarios legales descritos anteriormente, es un problema abierto , por lo que, a menos que -XX:+TrustFinalNonStaticFieldsse haya especificado, el HotSpot JVM no optimizará los finalcampos no estáticos de la misma manera que los static finalcampos.

Por supuesto, cuando no declara el campo como final, el JIT no puede suponer que nunca cambiará, aunque, en ausencia de primitivas de sincronización de subprocesos, puede considerar las modificaciones reales que ocurren en la ruta de código que optimiza (incluido el reflexivos). Por lo tanto, aún puede optimizar agresivamente el acceso, pero solo como si las lecturas y escrituras aún ocurrieran en el orden del programa dentro del hilo en ejecución. Por lo tanto, solo notaría las optimizaciones al mirarlo desde un hilo diferente sin construcciones de sincronización adecuadas.

Holger
fuente
Parece que muchas personas intentan explotar esto final, pero, aunque algunos han demostrado tener un mejor rendimiento, algunos ahorros nsno valen la pena romper muchos otros códigos. Razón por la cual Shenandoah está retrocediendo en algunas de sus banderas, por ejemplo
Eugene