Есть ли оптимизация для обеспечения безопасности потоков в цикле Java?
У меня есть фрагмент кода, который изменяет счетчик в двух потоках. Это не потокобезопасно, потому что я не поместил ни одной атомарной переменной или блокировки в коде. Это дает правильный результат, как я и ожидал, если код запускается только один раз, но я хочу запустить его несколько раз, поэтому я поместил код в цикл for. И вопрос в том, что только первый или первые два цикла дадут ожидаемый результат. Для остальных циклов результаты всегда равны 0, что кажется поточно-ориентированным. Есть ли какой-либо внутренний оператор в виртуальной машине Java, приводящий к такой вещи?
Я попытался изменить количество циклов, и первые один или два всегда соответствуют ожидаемым, но остальные равны 0 независимо от того, сколько существует циклов.
Счетчик:
private static class Counter {
private int count;
public void increase() {
count++;
}
public void decrease() {
count--;
}
public int getCount() {
return count;
}
}
Человек:
// This is just a thread to increase and decrease the counter for many times.
private static class Person extends Thread {
private Counter c;
public Person(Counter c) {
this.c = c;
}
@Override
public void run() {
for (int i = 0; i < 100000; i++) {
c.increase();
c.decrease();
}
}
}
Основной метод:
public static void main(String[] args) throws InterruptedException {
for (int i = 0; i < 10; i++) {
Counter c = new Counter();
Person p1 = new Person(c);
Person p2 = new Person(c);
p1.start();
p2.start();
p1.join();
p2.join();
System.out.println("run "+i+": "+c.getCount());
}
}
Выход:
run 0: 243
run 1: 12
run 2: 0
run 3: 0
run 4: 0
run 5: 0
run 6: 0
run 7: 0
run 8: 0
run 9: 0
Я не знаю, почему остальные результаты всегда равны 0. Но я думаю, что это оптимизация JVM. Правильно ли, что JVM оптимизирует код, когда некоторые циклы выполнены, и пропускает остальные циклы и всегда дает 0 в качестве ответа?
Ответы
Ответ 1
Я думаю, что JVM оптимизирует здесь, как вы сказали.
К вашему вопросу я добавил некоторые выводы с временными интервалами, которые ясно показывают, что там происходит оптимизация.
public static void main(String[] args) throws InterruptedException {
for (int i = 0; i < 10; i++) {
final long startTime = System.currentTimeMillis();
Counter c = new Counter();
Person p1 = new Person(c);
Person p2 = new Person(c);
p1.start();
p2.start();
p1.join();
p2.join();
final long endTime = System.currentTimeMillis();
System.out.println(String.format("run %s: %s (%s ms)", i, c.getCount(), endTime - startTime));
}
}
Результаты:
run 0: 1107 (8 ms)
run 1: 1 (1 ms)
run 2: 0 (2 ms)
run 3: 0 (0 ms)
run 4: 0 (0 ms)
run 5: 0 (0 ms)
run 6: 0 (1 ms)
run 7: 0 (0 ms)
run 8: 0 (0 ms)
run 9: 0 (0 ms)
На первых итерациях программе требуется много времени, а в последующем выполнении почти не используется время.
Кажется законным подозревать оптимизацию этого поведения.
Использование volatile int count
:
run 0: 8680 (15 ms)
run 1: 6943 (12 ms)
run 2: 446 (7 ms)
run 3: -398 (7 ms)
run 4: 431 (8 ms)
run 5: -5489 (6 ms)
run 6: 237 (7 ms)
run 7: 122 (7 ms)
run 8: -87 (7 ms)
run 9: 112 (7 ms)
Ответ 2
Вы не можете быть уверены, что многопоточный код, увеличивающий и уменьшающий переменную, всегда будет давать 0 в качестве результата.
Чтобы быть уверенным, что вы можете:
- Синхронизировать доступ к объекту
Counter
- Используйте внутри объекта
Counter
объект AtomicInteger
Infact код count++
или count--
не является потокобезопасным. Внутренне это эквивалентно чему-то похожему на следующее:
load count - load count from ram to the registry
increment count - increment by 1
store count - save from the registry to ram
Но этот код может иметь такое поведение, если вызывается двумя потоками
first second ram
---------- -------- ------
count = 0
load count
load count
(here count in registry == 0) (here count in the second registry == 0)
increment count
increment count
(here count in registry == 1) (here count in the second registry == 1)
store count
store count
count == 1
Зная, что вы не можете ничего предположить о реальном поведении этого несинхронизированного кода.
Это зависит от многих факторов, например:
- количество процессоров
- скорость выполнения инкрементного и декрементного кода
- тип процессоров (поведение может быть разным для машины I7 и для процессора Atom)
- Реализация JVM (у Open JDK или Oracle JVM может быть другое поведение)
- Нагрузка на процессор
- Отсутствие или наличие исполнения процесса GC
Вы знаете, что этот код небезопасен. Вы не можете попытаться предсказать какое-либо поведение в этом коде, которое можно воспроизвести на другом компьютере или с использованием других конфигураций, или же на той же машине с той же конфигурацией, потому что вы не можете контролировать то, что происходит за пределами JVM (загрузка ЦП посредством другие приложения).
Дополнительное примечание: микробенчмарки имеют побочный эффект, связанный с тем, что некоторые ресурсы еще не загружены. В вашем коде условие гонки может быть более частым на первых итерациях, поскольку классы Counter
и Person
еще не загружены (обратите внимание, что время выполнения для первой итерации намного больше, чем для других).
Ответ 3
Это приняло удивительный поворот.
Первое, что можно сказать (относительно точно), это то, что эффект вызван JIT. Я объединил фрагменты кода в этот MCVE:
public class CounterJitTest
{
private static class Counter
{
private int count;
public void increase()
{
count++;
}
public void decrease()
{
count--;
}
public int getCount()
{
return count;
}
}
private static class Person extends Thread
{
private Counter c;
public Person(Counter c)
{
this.c = c;
}
@Override
public void run()
{
for (int i = 0; i < 1000000; i++)
{
c.increase();
c.decrease();
}
}
}
public static void main(String[] args) throws InterruptedException
{
for (int i = 0; i < 10; i++)
{
Counter c = new Counter();
Person p1 = new Person(c);
Person p2 = new Person(c);
p1.start();
p2.start();
p1.join();
p2.join();
System.out.println("run " + i + ": " + c.getCount());
}
}
}
Запуск с
java CounterJitTest
вызывает вывод, который был упомянут в вопросе:
run 0: 6703
run 1: 178
run 2: 1716
run 3: 0
run 4: 0
run 5: 0
run 6: 0
run 7: 0
run 8: 0
run 9: 0
Отключите JIT с помощью -Xint
(интерпретированный режим), то есть запустите его как
java -Xint CounterJitTest
вызывает следующие результаты:
run 0: 38735
run 1: 53174
run 2: 86770
run 3: 27244
run 4: 61885
run 5: 1746
run 6: 32458
run 7: 52864
run 8: 75978
run 9: 22824
Чтобы глубже погрузиться в то, что на самом деле делает JIT, я начал все это с дизассемблера VM HotSpot, чтобы взглянуть на сгенерированную сборку. Однако время выполнения было настолько коротким, что я подумал: ну, я просто увеличу счетчик for
-loop:
for (int i = 0; i < 1000000; i++)
Но даже увеличение его до 100000000
привело к немедленному завершению программы. Это уже вызвало подозрение. После генерации разборки с
java -server -XX:+UnlockDiagnosticVMOptions -XX:+TraceClassLoading -XX:+LogCompilation -XX:+PrintAssembly -XX:+PrintInlining CounterJitTest
Я посмотрел на скомпилированные версии методов increase
и decrease
, но не нашел ничего очевидного. Тем не менее, метод run
казалось, был виновником здесь. Изначально сборка метода run
содержала ожидаемый код (здесь размещались только наиболее важные части):
Decoding compiled method 0x0000000002b32fd0:
Code:
[Entry Point]
[Constants]
# {method} {0x00000000246d0f00} 'run' '()V' in 'CounterJitTest$Person'
...
[Verified Entry Point]
...
0x0000000002b33198: je 0x0000000002b33338 ;*iconst_0
; - CounterJitTest$Person::[email protected] (line 35)
0x0000000002b3319e: mov $0x0,%esi
0x0000000002b331a3: jmpq 0x0000000002b332bc ;*iload_1
; - CounterJitTest$Person::[email protected] (line 35)
0x0000000002b331a8: mov 0x178(%rdx),%edi ; implicit exception: dispatches to 0x0000000002b3334f
0x0000000002b331ae: shl $0x3,%rdi ;*getfield c
; - CounterJitTest$Person::[email protected] (line 37)
0x0000000002b331b2: cmp (%rdi),%rax ;*invokevirtual increase
; - CounterJitTest$Person::[email protected] (line 37)
; implicit exception: dispatches to 0x0000000002b33354
...
0x0000000002b33207: je 0x0000000002b33359
0x0000000002b3320d: mov 0xc(%rdi),%ebx ;*getfield count
; - CounterJitTest$Counter::[email protected] (line 9)
; - CounterJitTest$Person::[email protected] (line 37)
0x0000000002b33210: inc %ebx
0x0000000002b33212: mov %ebx,0xc(%rdi) ;*putfield count
; - CounterJitTest$Counter::[email protected] (line 9)
; - CounterJitTest$Person::[email protected] (line 37)
...
0x0000000002b3326f: mov %ebx,0xc(%rdi) ;*putfield count
; - CounterJitTest$Counter::[email protected] (line 14)
; - CounterJitTest$Person::[email protected] (line 38)
...
Я не очень "понимаю" это, по общему признанию, но можно видеть, что это делает getfield c
, и некоторые вызовы (частично встроенные?) Методов increase
и decrease
.
Однако окончательная скомпилированная версия метода run
выглядит так:
Decoding compiled method 0x0000000002b34590:
Code:
[Entry Point]
[Constants]
# {method} {0x00000000246d0f00} 'run' '()V' in 'CounterJitTest$Person'
# [sp+0x20] (sp of caller)
0x0000000002b346c0: mov 0x8(%rdx),%r10d
0x0000000002b346c4:
<writer thread='2060'/>
[Loaded java.lang.Shutdown from C:\Program Files\Java\jre1.8.0_131\lib\rt.jar]
<writer thread='5944'/>
shl $0x3,%r10
0x0000000002b346c8: cmp %r10,%rax
0x0000000002b346cb: jne 0x0000000002a65f60 ; {runtime_call}
0x0000000002b346d1: data32 xchg %ax,%ax
0x0000000002b346d4: nopw 0x0(%rax,%rax,1)
0x0000000002b346da: nopw 0x0(%rax,%rax,1)
[Verified Entry Point]
0x0000000002b346e0: mov %eax,-0x6000(%rsp)
0x0000000002b346e7: push %rbp
0x0000000002b346e8: sub $0x10,%rsp ;*synchronization entry
; - CounterJitTest$Person::[email protected] (line 35)
0x0000000002b346ec: cmp 0x178(%rdx),%r12d
0x0000000002b346f3: je 0x0000000002b34701
0x0000000002b346f5: add $0x10,%rsp
0x0000000002b346f9: pop %rbp
0x0000000002b346fa: test %eax,-0x1a24700(%rip) # 0x0000000001110000
; {poll_return}
0x0000000002b34700: retq
0x0000000002b34701: mov %rdx,%rbp
0x0000000002b34704: mov $0xffffff86,%edx
0x0000000002b34709: xchg %ax,%ax
0x0000000002b3470b: callq 0x0000000002a657a0 ; OopMap{rbp=Oop off=80}
;*aload_0
; - CounterJitTest$Person::[email protected] (line 37)
; {runtime_call}
0x0000000002b34710: int3 ;*aload_0
; - CounterJitTest$Person::[email protected] (line 37)
0x0000000002b34711: hlt
0x0000000002b34712: hlt
0x0000000002b34713: hlt
0x0000000002b34714: hlt
0x0000000002b34715: hlt
0x0000000002b34716: hlt
0x0000000002b34717: hlt
0x0000000002b34718: hlt
0x0000000002b34719: hlt
0x0000000002b3471a: hlt
0x0000000002b3471b: hlt
0x0000000002b3471c: hlt
0x0000000002b3471d: hlt
0x0000000002b3471e: hlt
0x0000000002b3471f: hlt
[Exception Handler]
[Stub Code]
0x0000000002b34720: jmpq 0x0000000002a8c9e0 ; {no_reloc}
[Deopt Handler Code]
0x0000000002b34725: callq 0x0000000002b3472a
0x0000000002b3472a: subq $0x5,(%rsp)
0x0000000002b3472f: jmpq 0x0000000002a67200 ; {runtime_call}
0x0000000002b34734: hlt
0x0000000002b34735: hlt
0x0000000002b34736: hlt
0x0000000002b34737: hlt
Это полная сборка метода! И это... ну, в принципе ничего.
Чтобы подтвердить свое подозрение, я явно отключил встраивание метода increase
, начав с
java -XX:CompileCommand=dontinline,CounterJitTest$Counter.increase CounterJitTest
И результат снова оказался ожидаемым:
run 0: 3497
run 1: -71826
run 2: -22080
run 3: -20893
run 4: -17
run 5: -87781
run 6: -11
run 7: -380
run 8: -43354
run 9: -29719
Итак, мой вывод:
JIT включает методы increase
и decrease
. Они только увеличивают и уменьшают одно и то же значение. И после встраивания JIT достаточно умен, чтобы понять, что последовательность вызовов
c.increase();
c.decrease();
по сути не работает, и, следовательно, просто делает именно это: ничего.