Есть ли оптимизация для обеспечения безопасности потоков в цикле 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} &apos;run&apos; &apos;()V&apos; in &apos;CounterJitTest$Person&apos;
  ...
[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} &apos;run&apos; &apos;()V&apos; in &apos;CounterJitTest$Person&apos;
  #           [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();

по сути не работает, и, следовательно, просто делает именно это: ничего.