Ломка JIT-оптимизаций с отражением

9

Когда я возился с модульными тестами для высококонкурентного синглтон-класса, я наткнулся на следующее странное поведение (проверено на 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);
}

Последние 2 строки метода main () расходятся во мнении о значении INSTANCE - я предполагаю, что JIT полностью избавился от метода, так как поле является статическим final. Удаление последнего ключевого слова приводит к тому, что код выводит правильные значения.

Оставляя в стороне вашу симпатию (или ее отсутствие) к одиночкам и на минуту забываем, что использование такого отражения вызывает проблемы - верно ли мое предположение в том, что виноваты оптимизации JIT? Если это так - ограничены ли они только статическими конечными полями?

Kelm
источник
1
Синглтон - это класс, для которого может существовать только один экземпляр. Следовательно, у вас нет синглтона, у вас просто есть класс с static finalполем. Кроме того, не имеет значения, сломался ли этот взлом отражения из-за JIT или параллелизма.
Хольгер
@Holger этот взлом был сделан в модульных тестах только как попытка смоделировать синглтон для нескольких тестовых случаев класса, который его использует. Я не понимаю, как это могло вызвать параллелизм (в приведенном выше коде его нет), и мне бы очень хотелось узнать, что произошло.
Кельм
1
Ну, вы сказали «высококонкурентный синглтон-класс» в своем вопросе, и я говорю « не имеет значения », что заставляет его ломаться. Так что, если ваш конкретный пример кода ломается из-за JIT, и вы находите обходное решение для этого, а затем реальный код меняется от ломки из-за JIT к ломке из-за параллелизма, что вы получили?
Хольгер
@ Хольгер, ладно, формулировка была слишком сильной, извините за это. Я имел в виду следующее: если мы не понимаем, почему что-то идет так ужасно неправильно, мы склонны к укушению той же самой вещи в будущем, поэтому я предпочел бы знать причину, чем предполагать, что «это просто происходит». В любом случае, спасибо, что нашли время ответить!
Кельм

Ответы:

7

Если взять ваш вопрос буквально: « … верно ли мое предположение, что виноваты оптимизации JIT? ”, Ответ - да, очень вероятно, что JIT-оптимизации ответственны за это поведение в этом конкретном примере.

Но так как изменение static finalполей полностью не соответствует спецификации, есть и другие вещи, которые могут нарушить его аналогичным образом. Например, JMM не имеет определения видимости таких изменений в памяти, следовательно, совершенно не определено, замечают ли другие потоки такие изменения или нет. Они даже не обязаны замечать это последовательно, то есть они могут использовать новое значение, после чего снова использовать старое значение, даже при наличии примитивов синхронизации.

Тем не менее, JMM и оптимизатор в любом случае здесь трудно разделить.

Ваш вопрос « … ограничены ли они только статическими конечными полями? ”Гораздо труднее ответить, так как оптимизация, конечно, не ограничивается static finalполями, но поведение, например, нестатических finalполей, не одинаково и имеет различия между теорией и практикой.

Для нестатических finalполей модификации через Reflection разрешены при определенных обстоятельствах. На это указывает тот факт, что этого setAccessible(true)достаточно, чтобы сделать возможной такую ​​модификацию, не взламывая Fieldэкземпляр, чтобы изменить внутреннее modifiersполе.

В спецификации сказано:

17.5.3. Последующая модификация finalполей

В некоторых случаях, таких как десериализация, система должна будет изменить finalполя объекта после построения. finalполя могут быть изменены с помощью отражения и других зависящих от реализации средств. Единственный шаблон, в котором это имеет разумную семантику, это шаблон, в котором объект создается, а затем finalполя объекта обновляются. Объект не должен быть видимым для других потоков, а также finalполя не должны быть прочитаны, пока не finalбудут завершены все обновления полей объекта. Замораживание finalполя происходит как в конце конструктора, в котором finalоно установлено, так и сразу после каждой модификации finalполя с помощью отражения или другого специального механизма.

...

Другая проблема заключается в том, что спецификация допускает агрессивную оптимизацию finalполей. Внутри потока допустимо переупорядочивать операции чтения finalполя с теми модификациями finalполя, которые не имеют места в конструкторе.

Пример 17.5.3-1. Агрессивная оптимизация finalполей
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 
    } 
}

В этом dметоде компилятору разрешено изменять порядок чтения xи вызова g. Таким образом, new A().f()может вернуться -1, 0или 1.

На практике определение правильных мест, где возможна агрессивная оптимизация без нарушения правовых сценариев, описанных выше, является открытой проблемой , поэтому, если -XX:+TrustFinalNonStaticFieldsэто не указано, JVM HotSpot не будет оптимизировать нестатические finalполя так же, как static finalполя.

Конечно, когда вы не объявляете поле как final, JIT не может предполагать, что оно никогда не изменится, хотя, в отсутствие примитивов синхронизации потоков, он может учитывать фактические изменения, происходящие в пути кода, который он оптимизирует (включая отражающие). Таким образом, он все еще может активно оптимизировать доступ, но только в том случае, если чтение и запись по-прежнему происходят в программном порядке в потоке выполнения. Таким образом, вы заметите оптимизации только при взгляде на него из другого потока без правильных конструкций синхронизации.

Holger
источник
кажется, что многие люди пытаются использовать это final, но, хотя некоторые из них оказались более производительными, экономия некоторых nsне стоит ломать много другого кода. Например,
Евгений