Безопасна ли! = Проверка потоков?


140

Я знаю, что составные операции, например i++, не являются потокобезопасными, поскольку включают в себя несколько операций.

Но является ли проверка ссылки на самой себе поточно-ориентированной операцией?

a != a //is this thread-safe

Я пытался запрограммировать это и использовать несколько потоков, но ничего не вышло. Думаю, я не мог смоделировать гонку на своей машине.

РЕДАКТИРОВАТЬ:

public class TestThreadSafety {
    private Object a = new Object();

    public static void main(String[] args) {

        final TestThreadSafety instance = new TestThreadSafety();

        Thread testingReferenceThread = new Thread(new Runnable() {

            @Override
            public void run() {
                long countOfIterations = 0L;
                while(true){
                    boolean flag = instance.a != instance.a;
                    if(flag)
                        System.out.println(countOfIterations + ":" + flag);

                    countOfIterations++;
                }
            }
        });

        Thread updatingReferenceThread = new Thread(new Runnable() {

            @Override
            public void run() {
                while(true){
                    instance.a = new Object();
                }
            }
        });

        testingReferenceThread.start();
        updatingReferenceThread.start();
    }

}

Это программа, которую я использую для проверки безопасности потоков.

Странное поведение

Поскольку моя программа запускается между некоторыми итерациями, я получаю значение флага вывода, что означает, что !=проверка ссылки не выполняется для той же ссылки. НО после нескольких итераций вывод становится постоянным значением, falseа затем выполнение программы в течение длительного времени не генерирует ни одного trueвывода.

Как видно из выходных данных, после некоторых n (не фиксированных) итераций выход кажется постоянным и не изменяется.

Выход:

Для некоторых итераций:

1494:true
1495:true
1496:true
19970:true
19972:true
19974:true
//after this there is not a single instance when the condition becomes true

2
Что вы имеете в виду под «потокобезопасностью» в этом контексте? Вы спрашиваете, гарантированно ли всегда возвращается false?
JB Nizet

@JBNizet да. Вот о чем я думал.
Нарендра Патаи

5
Он даже не всегда возвращает false в однопоточном контексте. Это может быть NaN ..
Гарольд

4
Возможное объяснение: код был скомпилирован точно в срок, и скомпилированный код загружает переменную только один раз. Это ожидаемо.
Марко Топольник

3
Печать индивидуальных результатов - плохой способ проверки гонок. Печать (как форматирование, так и запись результатов) относительно дорога по сравнению с вашим тестом (и иногда ваша программа будет блокировать запись, когда пропускная способность соединения с терминалом или сам терминал медленный). Кроме того, ввод-вывод часто содержит собственные мьютексы, которые изменяют порядок выполнения ваших потоков (обратите внимание, что ваши отдельные строки 1234:trueникогда не разбивают друг друга ). Гоночный тест требует более жесткого внутреннего цикла. Напечатайте сводку в конце (как кто-то сделал ниже, используя среду модульного тестирования).
Бен Джексон

Ответы:


124

При отсутствии синхронизации этот код

Object a;

public boolean test() {
    return a != a;
}

может производить true. Это байт-код дляtest()

    ALOAD 0
    GETFIELD test/Test1.a : Ljava/lang/Object;
    ALOAD 0
    GETFIELD test/Test1.a : Ljava/lang/Object;
    IF_ACMPEQ L1
...

как мы можем видеть, он aдважды загружает поле в локальные вары, это неатомарная операция, если она aбыла изменена между ними другим сравнением потоков false.

Также здесь актуальна проблема видимости памяти, нет гарантии, что изменения, aвнесенные другим потоком, будут видны текущему потоку.


22
Хотя байт-код веское доказательство, на самом деле это не доказательство. Это тоже должно быть где-то в JLS ...
Марко Топольник

10
@Marko Я согласен с вашим мнением, но не обязательно с вашим выводом. Для меня приведенный выше байт-код является очевидным / каноническим способом реализации !=, который включает загрузку LHS и RHS отдельно. И поэтому, если JLS не упоминает ничего конкретного об оптимизации, когда LHS и RHS синтаксически идентичны, тогда будет применяться общее правило, что означает загрузку aдважды.
Анджей Дойл

20
Собственно, если предположить, что сгенерированный байт-код соответствует JLS, это доказательство!
proskor

6
@Adrian: Во-первых: даже если это предположение неверно, существования единственного компилятора, в котором он может оценивать значение «истина», достаточно, чтобы продемонстрировать, что иногда он может оценивать значение «истина» (даже если спецификация запрещает это - что она нет). Во-вторых: Java хорошо определена, и большинство компиляторов ей полностью соответствуют. В этом отношении имеет смысл использовать их как справочные. В-третьих: вы используете термин «JRE», но я не думаю, что он означает то, что вы думаете. . .
ruakh

2
@AlexanderTorstling - «Я не уверен, что этого достаточно для исключения оптимизации однократного чтения». Этого недостаточно. Фактически, в отсутствие синхронизации (и дополнительных отношений «происходит до», которые накладывают), оптимизация действительна,
Стивен С.

47

Является ли проверка a != aпотокобезопасной?

Если aпотенциально может быть обновлен другим потоком (без надлежащей синхронизации!), То Нет.

Я пытался запрограммировать это и использовать несколько потоков, но безуспешно. Думаю, не смог смоделировать гонку на своей машине.

Это ничего не значит! Проблема в том, что если выполнение, в котором aобновляется другой поток, разрешено JLS, то код не является потокобезопасным. Тот факт, что вы не можете вызвать состояние гонки с конкретным тестовым примером на конкретной машине и конкретной реализации Java, не препятствует тому, чтобы это произошло в других обстоятельствах.

Означает ли это, что a! = A может вернуться true.

Да, теоретически, при определенных обстоятельствах.

Как вариант, a != aможно было вернуться, falseдаже если aменялось одновременно.


Относительно «странного поведения»:

Поскольку моя программа запускается между некоторыми итерациями, я получаю значение флага вывода, что означает, что проверка ссылки! = Не выполняется для той же ссылки. НО после некоторых итераций вывод становится постоянным значением false, а затем выполнение программы в течение длительного времени не генерирует ни одного истинного вывода.

Это «странное» поведение согласуется со следующим сценарием выполнения:

  1. Программа загружается, и JVM начинает интерпретировать байт-коды. Поскольку (как мы видели из вывода javap) байт-код выполняет две загрузки, вы (по-видимому) время от времени видите результаты состояния гонки.

  2. Через некоторое время код компилируется JIT-компилятором. Оптимизатор JIT замечает, что есть две загрузки одного и того же слота памяти ( a) близко друг к другу, и оптимизирует второй. (На самом деле, есть шанс, что он полностью оптимизирует тест ...)

  3. Теперь состояние гонки больше не проявляется, потому что больше нет двух нагрузок.

Обратите внимание, что все это согласуется с тем, что JLS позволяет реализации Java.


@kriss так прокомментировал:

Похоже, это может быть то, что программисты C или C ++ называют «неопределенным поведением» (зависит от реализации). Похоже, что в таких угловых случаях в java может быть несколько UB.

Модель памяти Java (указанная в JLS 17.4 ) определяет набор предварительных условий, при которых один поток гарантированно увидит значения памяти, записанные другим потоком. Если один поток пытается прочитать переменную, записанную другим, и эти предварительные условия не выполняются, то может быть несколько возможных выполнений ... некоторые из которых, вероятно, будут неправильными (с точки зрения требований приложения). Другими словами, набор возможных вариантов поведения (т. Е. Набор «правильно сформированных выполнений») определен, но мы не можем сказать, какое из этих поведений произойдет.

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

  • при выполнении одним потоком и
  • при выполнении разными потоками, которые синхронизируются правильно (в соответствии с моделью памяти).

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


Означает ли это, что это a != aможет вернуть истину?
proskor

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

@NarendraPathai - Нет теоретической причины, по которой вы не можете это продемонстрировать. Возможно, этому есть практическая причина ... а может, тебе просто не повезло.
Stephen C

Пожалуйста, проверьте мой обновленный ответ с помощью программы, которую я использую. Проверка иногда возвращает истину, но на выходе наблюдается странное поведение.
Нарендра Патаи

1
@NarendraPathai - см. Мое объяснение.
Stephen C

27

Подтверждено с помощью test-ng:

public class MyTest {

  private static Integer count=1;

  @Test(threadPoolSize = 1000, invocationCount=10000)
  public void test(){
    count = new Integer(new Random().nextInt());
    Assert.assertFalse(count != count);
  }

}

У меня 2 фэйла на 10 000 обращений. Так что нет , это НЕ потокобезопасно


6
Ты даже на равенство не проверяешь ... Random.nextInt()деталь лишняя. Вы могли бы протестировать с new Object()таким же успехом.
Марко Топольник

@MarkoTopolnik Пожалуйста, проверьте мой обновленный ответ с помощью программы, которую я использую. Проверка иногда возвращает истину, но на выходе наблюдается странное поведение.
Нарендра Патаи

1
Примечание: объекты Random обычно предназначены для повторного использования, а не создаются каждый раз, когда вам нужен новый int.
Саймон Форсберг

15

Нет это не так. Для сравнения виртуальная машина Java должна поместить два значения для сравнения в стек и запустить инструкцию сравнения (которая зависит от типа «a»).

Виртуальная машина Java может:

  1. Прочтите «а» два раза, положите каждую в стек, а затем сравните результаты.
  2. Прочтите "a" только один раз, поместите его в стек, продублируйте (инструкция "dup") и выполните сравнение
  3. Полностью удалите выражение и замените его на false

В первом случае другой поток может изменить значение «a» между двумя чтениями.

Выбор стратегии зависит от компилятора Java и среды выполнения Java (особенно JIT-компилятора). Это может даже измениться во время выполнения вашей программы.

Если вы хотите убедиться, как осуществляется доступ к переменной, вы должны сделать это volatile(так называемый «половинный барьер памяти») или добавить полный барьер памяти ( synchronized). Вы также можете использовать API более высокого уровня (например, AtomicIntegerкак упомянул Джунед Ахасан).

Дополнительные сведения о безопасности потоков см. В JSR 133 ( модель памяти Java ).


Объявление aas по- volatileпрежнему будет подразумевать два разных чтения с возможностью изменения между ними.
Holger

6

Все это было хорошо объяснено Стивеном С. Ради интереса вы можете попробовать запустить тот же код со следующими параметрами JVM:

-XX:InlineSmallCode=0

Это должно предотвратить оптимизацию, выполняемую JIT (это происходит на сервере точки доступа 7), и вы увидите true всегда (я остановился на 2000000, но я полагаю, что после этого она продолжится).

Для информации ниже приведен код JIT. Честно говоря, я недостаточно бегло читаю сборку, чтобы знать, действительно ли проведен тест или откуда берутся две нагрузки. (строка 26 - это тест, flag = a != aа строка 31 - закрывающая фигурная скобка while(true)).

  # {method} 'run' '()V' in 'javaapplication27/TestThreadSafety$1'
  0x00000000027dcc80: int3   
  0x00000000027dcc81: data32 data32 nop WORD PTR [rax+rax*1+0x0]
  0x00000000027dcc8c: data32 data32 xchg ax,ax
  0x00000000027dcc90: mov    DWORD PTR [rsp-0x6000],eax
  0x00000000027dcc97: push   rbp
  0x00000000027dcc98: sub    rsp,0x40
  0x00000000027dcc9c: mov    rbx,QWORD PTR [rdx+0x8]
  0x00000000027dcca0: mov    rbp,QWORD PTR [rdx+0x18]
  0x00000000027dcca4: mov    rcx,rdx
  0x00000000027dcca7: movabs r10,0x6e1a7680
  0x00000000027dccb1: call   r10
  0x00000000027dccb4: test   rbp,rbp
  0x00000000027dccb7: je     0x00000000027dccdd
  0x00000000027dccb9: mov    r10d,DWORD PTR [rbp+0x8]
  0x00000000027dccbd: cmp    r10d,0xefc158f4    ;   {oop('javaapplication27/TestThreadSafety$1')}
  0x00000000027dccc4: jne    0x00000000027dccf1
  0x00000000027dccc6: test   rbp,rbp
  0x00000000027dccc9: je     0x00000000027dcce1
  0x00000000027dcccb: cmp    r12d,DWORD PTR [rbp+0xc]
  0x00000000027dcccf: je     0x00000000027dcce1  ;*goto
                                                ; - javaapplication27.TestThreadSafety$1::run@62 (line 31)
  0x00000000027dccd1: add    rbx,0x1            ; OopMap{rbp=Oop off=85}
                                                ;*goto
                                                ; - javaapplication27.TestThreadSafety$1::run@62 (line 31)
  0x00000000027dccd5: test   DWORD PTR [rip+0xfffffffffdb53325],eax        # 0x0000000000330000
                                                ;*goto
                                                ; - javaapplication27.TestThreadSafety$1::run@62 (line 31)
                                                ;   {poll}
  0x00000000027dccdb: jmp    0x00000000027dccd1
  0x00000000027dccdd: xor    ebp,ebp
  0x00000000027dccdf: jmp    0x00000000027dccc6
  0x00000000027dcce1: mov    edx,0xffffff86
  0x00000000027dcce6: mov    QWORD PTR [rsp+0x20],rbx
  0x00000000027dcceb: call   0x00000000027a90a0  ; OopMap{rbp=Oop off=112}
                                                ;*aload_0
                                                ; - javaapplication27.TestThreadSafety$1::run@2 (line 26)
                                                ;   {runtime_call}
  0x00000000027dccf0: int3   
  0x00000000027dccf1: mov    edx,0xffffffad
  0x00000000027dccf6: mov    QWORD PTR [rsp+0x20],rbx
  0x00000000027dccfb: call   0x00000000027a90a0  ; OopMap{rbp=Oop off=128}
                                                ;*aload_0
                                                ; - javaapplication27.TestThreadSafety$1::run@2 (line 26)
                                                ;   {runtime_call}
  0x00000000027dcd00: int3                      ;*aload_0
                                                ; - javaapplication27.TestThreadSafety$1::run@2 (line 26)
  0x00000000027dcd01: int3   

1
Это хороший пример кода, который JVM действительно создаст, когда у вас есть бесконечный цикл, и все можно более или менее поднять. Фактический «цикл» здесь - это три инструкции от 0x27dccd1до 0x27dccdf. Цикл jmpin безусловен (поскольку цикл бесконечен). Единственные две другие инструкции в цикле add rbc, 0x1- которые увеличиваются countOfIterations(несмотря на то, что цикл никогда не будет завершен, поэтому это значение не будет прочитано: возможно, это необходимо, если вы нарушите его в отладчике), .. .
BeeOnRope

... и странно выглядящая testинструкция, которая на самом деле существует только для доступа к памяти (обратите внимание, что eaxона даже не задана в методе!): это специальная страница, которая установлена ​​как нечитаемая, когда JVM хочет запустить все потоки чтобы достичь точки безопасности, чтобы он мог выполнить gc или другую операцию, которая требует, чтобы все потоки находились в известном состоянии.
BeeOnRope

Более того, JVM полностью исключила instance. a != instance.aсравнение из цикла и выполняет его только один раз перед тем, как войти в цикл! Он знает, что перезагружать их не требуется instanceили aони не объявлены изменчивыми, и нет другого кода, который мог бы изменить их в том же потоке, поэтому он просто предполагает, что они одинаковы в течение всего цикла, что разрешено памятью. модель.
BeeOnRope

5

Нет, a != aне является потокобезопасным. Это выражение состоит из трех частей: загрузить a, aснова загрузить и выполнить !=. Другой поток может получить внутреннюю блокировку aродительского элемента и изменить значение aмежду двумя операциями загрузки.

Другой фактор - это то, является ли aэто местным. Если aлокальный, то никакие другие потоки не должны иметь к нему доступа и, следовательно, должны быть потокобезопасными.

void method () {
    int a = 0;
    System.out.println(a != a);
}

также всегда следует печатать false.

Объявление aas volatileне решит проблему для if ais staticили instance. Проблема не в том, что потоки имеют разные значения a, а в том, что один поток aдважды загружается с разными значениями. Это может фактически сделать случай менее потокобезопасным. Если aнет, volatileто aможет быть кэширован, и изменение в другом потоке не повлияет на кешированное значение.


Ваш пример с synchronizedневерно: для этого кода гарантирована печатью false, все методы, набор a должны быть synchronizedтоже.
ruakh

Почему так? Если метод синхронизирован, как любой другой поток получит внутреннюю блокировку aродительского элемента во время выполнения метода, необходимого для установки значения a.
DoubleMx2

1
Ваши посылки неверны. Вы можете установить поле объекта, не получая его внутренней блокировки. Java не требует, чтобы поток получил внутреннюю блокировку объекта перед установкой его полей.
ruakh

3

Что касается странного поведения:

Поскольку переменная aне помечена как volatile, в какой-то момент ее значение aможет быть кэшировано потоком. Оба as of a != aявляются кэшированной версией и, следовательно, всегда одинаковы ( flagтеперь это означает всегда false).


0

Даже простое чтение не атомарно. Еслиa есть longи не помечено как, volatileто на 32-битных JVM long b = aне является потокобезопасным.


volatile и атомарность не связаны. даже если я
отмечу

Назначение изменчивого длинного поля всегда атомарно. Другие операции вроде ++ - нет.
ZhekaKozlov 04
Используя наш сайт, вы подтверждаете, что прочитали и поняли нашу Политику в отношении файлов cookie и Политику конфиденциальности.
Licensed under cc by-sa 3.0 with attribution required.