Подтвердить что ты не робот

Тестирование безопасности инициализации конечных полей

Я пытаюсь просто проверить безопасность инициализации конечных полей, как это гарантировано JLS. Это для бумаги, которую я пишу. Однако я не могу заставить его "сбой" на основе моего текущего кода. Может кто-то сказать мне, что я делаю неправильно, или если это просто что-то, что я должен повторять снова и снова, а затем увидеть неудачу с некоторыми неудачными сроками?

Вот мой код:

public class TestClass {

    final int x;
    int y;
    static TestClass f;

    public TestClass() {
        x = 3;
        y = 4;
    }

    static void writer() {
        TestClass.f = new TestClass();
    }

    static void reader() {
        if (TestClass.f != null) {
            int i = TestClass.f.x; // guaranteed to see 3
            int j = TestClass.f.y; // could see 0

            System.out.println("i = " + i);
            System.out.println("j = " + j);
        }
    }
}

и мои потоки называют это следующим образом:

public class TestClient {

    public static void main(String[] args) {

        for (int i = 0; i < 10000; i++) {
            Thread writer = new Thread(new Runnable() {
                @Override
                public void run() {
                    TestClass.writer();
                }
            });

            writer.start();
        }

        for (int i = 0; i < 10000; i++) {
            Thread reader = new Thread(new Runnable() {
                @Override
                public void run() {
                    TestClass.reader();
                }
            });

            reader.start();
        }
    }
}

Я использовал этот сценарий много раз. Мои текущие циклы порождают 10 000 потоков, но я сделал это с 1000, 100000 и даже миллионом. Все еще нет провала. Я всегда вижу 3 и 4 для обоих значений. Как я могу заставить это потерпеть неудачу?

4b9b3361

Ответ 1

Из Java 5.0 вы гарантируете, что все потоки будут видеть конечное состояние, заданное конструктором.

Если вы хотите, чтобы это произошло, вы можете попробовать более старую JVM, например 1.3.

Я бы не распечатывал каждый тест, я бы распечатывал ошибки. Вы можете получить один провал в миллионе, но пропустите его. Но если вы печатаете только ошибки, их следует легко заметить.

Более простой способ увидеть эту ошибку - добавить к писателю.

f.y = 5;

и проверьте

int y = TestClass.f.y; // could see 0, 4 or 5
if (y != 5)
    System.out.println("y = " + y);

Ответ 2

Я написал спецификацию. TL; DR-версия этого ответа заключается в том, что только потому, что он может видеть 0 для y, это не означает, что он гарантирован см. 0 для y.

В этом случае конечная спецификация поля гарантирует, что вы увидите 3 для x, как вы указываете. Подумайте о том, что писательский поток имеет 4 инструкции:

r1 = <create a new TestClass instance>
r1.x = 3;
r1.y = 4;
f = r1;

Причина, по которой вы можете не видеть 3 для x, заключается в том, что компилятор переупорядочил этот код:

r1 = <create a new TestClass instance>
f = r1;
r1.x = 3;
r1.y = 4;

То, как обычно реализуется гарантия для конечных полей, заключается в том, чтобы гарантировать, что конструктор завершит работу перед любыми последующими действиями программы. Представьте, что кто-то установил большой барьер между r1.y = 4 и f = r1. Итак, на практике, если у вас есть какие-либо конечные поля для объекта, вы, вероятно, получите видимость для всех.

Теперь, теоретически, кто-то может написать компилятор, который не реализован именно так. На самом деле, многие люди часто говорили о тестировании кода, написав наиболее вредоносный компилятор. Это особенно распространено среди людей С++, у которых много и много undefined углов их языка, что может привести к ужасным ошибкам.

Ответ 3

Я хотел бы увидеть тест, который не дает объяснений, почему это невозможно с текущими JVM.

Многопоточность и тестирование

Вы не можете доказать, что многопоточное приложение разбито (или нет) путем тестирования по нескольким причинам:

  • проблема может появляться только каждые x часов работы, x настолько высока, что маловероятно, что вы увидите ее в коротком тесте
  • проблема может возникнуть только с некоторыми комбинациями архитектуры JVM/процессоров.

В вашем случае для выполнения тестового разрыва (например, для наблюдения y == 0) потребуется, чтобы программа увидела частично сконструированный объект, где некоторые поля были правильно построены, а некоторые нет. Обычно это не происходит на x86/hotspot.

Как определить, нарушен ли многопоточный код?

Единственный способ доказать, что код действителен или нарушен, - это применить к нему правила JLS и посмотреть, каков результат. При публикации гонок данных (без синхронизации вокруг публикации объекта или y), JLS не гарантирует, что y будет рассматриваться как 4 (его можно увидеть с его значением по умолчанию 0).

Может ли этот код действительно сломаться?

На практике некоторые JVM лучше будут делать тест. Например, некоторые компиляторы (cf "Тест-сценарий, показывающий, что он не работает" в в этой статье) могут преобразовать TestClass.f = new TestClass(); в нечто вроде ( потому что он публикуется через гонку данных):

(1) allocate memory
(2) write fields default values (x = 0; y = 0) //always first
(3) write final fields final values (x = 3)    //must happen before publication
(4) publish object                             //TestClass.f = new TestClass();
(5) write non final fields (y = 4)             //has been reodered after (4)

JLS обязывает, что (2) и (3) происходят до публикации объекта (4). Однако из-за гонки данных не предоставляется никаких гарантий (5) - это фактически было бы законным исполнением, если нить никогда не наблюдала бы эту операцию записи. Поэтому при правильном чередовании потоков можно предположить, что если reader работает между 4 и 5, вы получите желаемый результат.

У меня нет симмантек JIT под рукой, поэтому не могу доказать это экспериментально: -)

Ответ 4

Здесь приведен пример значений по умолчанию для не конечных значений, которые наблюдаются, несмотря на то, что конструктор устанавливает их и не пропускает this. Это основано на моем другом вопросе, который немного сложнее. Я постоянно вижу, что люди говорят, что этого не может случиться на x86, но мой пример происходит на x64 linux openjdk 6...

Ответ 5

Что вы изменили конструктор для этого:

public TestClass() {
 Thread.sleep(300);
   x = 3;
   y = 4;
}

Я не эксперт в финалах и инициализаторах JLF, но здравый смысл подсказывает мне, что это должно задерживать установку x достаточно долго, чтобы авторы регистрировали другое значение?

Ответ 6

Лучшее понимание того, почему этот тест не подводит, может исходить из понимания того, что на самом деле происходит при вызове конструктора. Java - это язык на основе стека. TestClass.f = new TestClass(); состоит из четырех действий. Вызывается первая инструкция new, ее как malloc в C/С++, она выделяет память и помещает ссылку на нее в верхней части стека. Затем ссылка дублируется для вызова конструктора. Конструктор на самом деле похож на любой другой метод экземпляра, его вызывается с дублированной ссылкой. Только после того, как эта ссылка хранится в кадре метода или в поле экземпляра и становится доступной из любого места. До последнего шага ссылка на объект присутствует только на вершине создания стека потоков, и никакая другая фигура не может его увидеть. На самом деле нет никакой разницы, с каким полем вы работаете, оба будут инициализированы, если TestClass.f != null. Вы можете читать поля x и y из разных объектов, но это не приведет к y = 0. Для получения дополнительной информации вы должны увидеть Спецификация JVM и Язык, ориентированный на стекирование статьи.

UPD: Одна из важных вещей, о которых я забыл упомянуть. В java-памяти нет возможности увидеть частично инициализированный объект. Если вы не делаете самостоятельных публикаций внутри конструктора, обязательно.

JLS:

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

JLS:

До конца конструктора объект к началу финализатора для этого объекта.

Более подробное объяснение этой точки зрения:

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

UPD: такова была теория, пусть превращается в практику.

Рассмотрим следующий код с простыми не конечными переменными:

public class Test {

    int myVariable1;
    int myVariable2;

    Test() {
        myVariable1 = 32;
        myVariable2 = 64;
    }

    public static void main(String args[]) throws Exception {
        Test t = new Test();
        System.out.println(t.myVariable1 + t.myVariable2);
    }
}

Следующая команда отображает машинные инструкции, созданные java, как ее использовать, вы можете найти в wiki:

java.exe -XX: + UnlockDiagnosticVMOptions -XX: + PrintAssembly -Xcomp -XX: PrintAssemblyOptions = hsdis-print-bytes -XX: CompileCommand = print, * Test.main Test

Выход:

...
0x0263885d: movl   $0x20,0x8(%eax)    ;...c7400820 000000
                                    ;*putfield myVariable1
                                    ; - Test::<init>@7 (line 12)
                                    ; - Test::[email protected] (line 17)
0x02638864: movl   $0x40,0xc(%eax)    ;...c7400c40 000000
                                    ;*putfield myVariable2
                                    ; - Test::<init>@13 (line 13)
                                    ; - Test::[email protected] (line 17)
0x0263886b: nopl   0x0(%eax,%eax,1)   ;...0f1f4400 00
...

Заданиями по полю следует инструкция NOPL, одна из которых - предотвращать переупорядочение команд.

Почему это происходит? В соответствии с завершением спецификации происходит после возврата конструктора. Таким образом, поток GC не может видеть частично инициализированный объект. На уровне процессора нить GC не отличается от любого другого потока. Если такие гарантии предоставляются GC, они предоставляются в любую другую нить. Это наиболее очевидное решение такого ограничения.

Результаты:

1) Конструктор не синхронизирован, синхронизация выполняется с помощью других инструкций.

2) Назначение ссылки на объект не может произойти до возврата конструктора.

Ответ 7

Что делать, если один из них изменит сценарий на

public class TestClass {

    final int x;
    static TestClass f;

    public TestClass() {
        x = 3;
    }

    int y = 4;

    // etc...

}

?

Ответ 8

Что происходит в этой теме? Почему этот код не сработает в первую очередь?

Вы запускаете 1000 потоков, каждый из которых выполняет следующие действия:

TestClass.f = new TestClass();

Что это делает, в порядке:

  • оцените TestClass.f, чтобы узнать местоположение своей памяти.
  • Оценить new TestClass(): это создает новый экземпляр TestClass, конструктор которого будет инициализировать как x, так и y
  • присвойте правое значение левой части памяти

Назначение - это атомная операция, которая всегда выполняется после создания правого значения. Вот цитата из спецификации языка Java (см. первую маркированную точку), но она действительно применима к любому разумному языку.

Это означает, что, хотя конструктор TestClass() занимает свое время, чтобы выполнить свое задание, а x и y, возможно, все еще равны нулю, ссылка на частично инициализированный объект TestClass существует только в этом потоке стек или регистры процессора и не был записан в TestClass.f

Поэтому TestClass.f всегда будет содержать:

  • либо null, в начале вашей программы, прежде чем что-либо еще назначено ему,
  • или полностью инициализированный экземпляр TestClass.