Почему я ++ не атомарный?
Почему i++
не атомарный в Java?
Чтобы углубиться в Java, я попытался посчитать, как часто выполняется цикл в потоках.
Так что я использовал
private static int total = 0;
в основном классе.
У меня есть две темы.
- Тема 1: Печать
System.out.println("Hello from Thread 1!");
- Тема 2: печать
System.out.println("Hello from Thread 2!");
И я считаю строки, напечатанные потоком 1 и потоком 2. Но строки потока 1 + строки потока 2 не соответствуют общему количеству напечатанных строк.
Вот мой код:
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;
import java.util.logging.Level;
import java.util.logging.Logger;
public class Test {
private static int total = 0;
private static int countT1 = 0;
private static int countT2 = 0;
private boolean run = true;
public Test() {
ExecutorService newCachedThreadPool = Executors.newCachedThreadPool();
newCachedThreadPool.execute(t1);
newCachedThreadPool.execute(t2);
try {
Thread.sleep(1000);
}
catch (InterruptedException ex) {
Logger.getLogger(Test.class.getName()).log(Level.SEVERE, null, ex);
}
run = false;
try {
Thread.sleep(1000);
}
catch (InterruptedException ex) {
Logger.getLogger(Test.class.getName()).log(Level.SEVERE, null, ex);
}
System.out.println((countT1 + countT2 + " == " + total));
}
private Runnable t1 = new Runnable() {
@Override
public void run() {
while (run) {
total++;
countT1++;
System.out.println("Hello #" + countT1 + " from Thread 2! Total hello: " + total);
}
}
};
private Runnable t2 = new Runnable() {
@Override
public void run() {
while (run) {
total++;
countT2++;
System.out.println("Hello #" + countT2 + " from Thread 2! Total hello: " + total);
}
}
};
public static void main(String[] args) {
new Test();
}
}
11 ответов
i++
вероятно, не является атомарным в Java, потому что атомарность является особым требованием, которого нет в большинстве случаев использования i++
, Это требование сопряжено со значительными накладными расходами: создание операции инкремента требует больших затрат; это включает синхронизацию как на уровне программного, так и на аппаратном уровне, которые не обязательно должны присутствовать в обычном приращении.
Вы могли бы сделать аргумент, что i++
должен был быть спроектирован и задокументирован как специально выполняющий атомарное приращение, чтобы неатомарное приращение выполнялось с использованием i = i + 1
, Однако это нарушит "культурную совместимость" между Java и C и C++. Кроме того, это лишило бы удобной нотации, которую программисты, знакомые с C-подобными языками, принимают как должное, придавая ей особое значение, которое применяется только в ограниченных обстоятельствах.
Базовый код C или C++, например for (i = 0; i < LIMIT; i++)
будет переводить на Java как for (i = 0; i < LIMIT; i = i + 1)
; потому что было бы неуместно использовать атомную i++
, Что еще хуже, программисты, переходящие с C или других C-подобных языков на Java, будут использовать i++
в любом случае, приводя к ненужному использованию атомарных инструкций.
Даже на уровне набора машинных инструкций операция типа приращения обычно не является атомарной по соображениям производительности. В x86 специальная инструкция "префикс блокировки" должна использоваться для inc
инструкция атомная: по тем же причинам, что и выше. Если inc
всегда были атомарными, они никогда не использовались бы, когда требовался неатомарный инк; программисты и компиляторы будут генерировать код, который загружает, добавляет 1 и сохраняет, потому что это будет намного быстрее.
В некоторых архитектурах набора команд нет атомарного inc
или, возможно, нет inc
совсем; чтобы сделать атомарный inc на MIPS, вы должны написать программный цикл, который использует ll
а также sc
: связанный с нагрузкой и условный для магазина. С привязкой к нагрузке считывает слово, а условное хранилище сохраняет новое значение, если слово не изменилось, или же оно терпит неудачу (что обнаруживается и вызывает повторную попытку).
i++
включает в себя две операции:
- прочитать текущее значение
i
- увеличить значение и присвоить его
i
Когда два потока выполняют i++
для одной и той же переменной в одно и то же время они могут получить одинаковое текущее значение i
, а затем увеличить и установить его i+1
, так что вы получите одно увеличение вместо двух.
Пример:
int i = 5;
Thread 1 : i++;
// reads value 5
Thread 2 : i++;
// reads value 5
Thread 1 : // increments i to 6
Thread 2 : // increments i to 6
// i == 6 instead of 7
Важным является JLS (спецификация языка Java), а не то, как различные реализации JVM могут реализовывать или не реализовывать определенную функцию языка. JLS определяет постфиксный оператор ++ в пункте 15.14.2, который говорит, что "значение 1 добавляется к значению переменной, а сумма сохраняется обратно в переменную". Нигде это не упоминает и не намекает на многопоточность или атомарность. Для этого JLS обеспечивает энергозависимость и синхронизацию. Кроме того, есть пакет java.util.concurrent.atomic (см. http://docs.oracle.com/javase/7/docs/api/java/util/concurrent/atomic/package-summary.html).
Почему i++ не атомарен в Java?
Давайте разберем операцию приращения на несколько операторов:
Тема 1 и 2:
- Получить общее значение из памяти
- Добавьте 1 к значению
- Напиши обратно в память
Если синхронизации нет, допустим, что Thread один прочитал значение 3 и увеличил его до 4, но не записал его обратно. В этот момент происходит переключение контекста. Второй поток читает значение 3, увеличивает его, и происходит переключение контекста. Хотя оба потока увеличили общее значение, оно все равно будет состоять из 4 рас.
i++
это утверждение, которое просто включает в себя 3 операции:
- Прочитать текущее значение
- Написать новое значение
- Сохранить новое значение
Эти три операции не предназначены для выполнения за один шаг, другими словами i++
это не сложная операция. В результате все виды вещей могут пойти не так, когда более чем один поток вовлечен в одну, но не составную операцию.
В качестве примера представим такой сценарий:
Время 1:
Thread A fetches i
Thread B fetches i
Время 2:
Thread A overwrites i with a new value say -foo-
Thread B overwrites i with a new value say -bar-
Thread B stores -bar- in i
// At this time thread B seems to be more 'active'. Not only does it overwrite
// its local copy of i but also makes it in time to store -bar- back to
// 'main' memory (i)
Время 3:
Thread A attempts to store -foo- in memory effectively overwriting the -bar-
value (in i) which was just stored by thread B in Time 2.
Thread B has nothing to do here. Its work was done by Time 2. However it was
all for nothing as -bar- was eventually overwritten by another thread.
И там у вас есть это. Состояние гонки.
Вот почему i++
не атомно. Если бы это было так, ничего бы не случилось, и каждый fetch-update-store
будет происходить атомарно. Это именно то, что AtomicInteger
для и в вашем случае это, вероятно, вписывается прямо в.
PS
Отличная книга, охватывающая все эти вопросы, а затем некоторые из них: Java Concurrency in Practice
В JVM приращение включает чтение и запись, поэтому оно не является атомарным.
Если операция i++
было бы атомарно, у вас не было бы возможности прочитать значение из него. Это именно то, что вы хотите сделать, используя i++
(Вместо того, чтобы использовать ++i
).
Например, посмотрите на следующий код:
public static void main(final String[] args) {
int i = 0;
System.out.println(i++);
}
В этом случае мы ожидаем, что результат будет: 0
(потому что мы публикуем инкремент, например сначала читаем, потом обновляем)
Это одна из причин, по которой операция не может быть атомарной, потому что вам нужно прочитать значение (и что-то с ним сделать), а затем обновить значение.
Другая важная причина в том, что выполнение чего-либо атомарно обычно занимает больше времени из-за блокировки. Было бы глупо, если бы все операции с примитивами занимали немного больше времени в тех редких случаях, когда люди хотят выполнять атомарные операции. Вот почему они добавили AtomicInteger
и другие атомарные занятия по языку.
Есть два шага:
- вытащить меня из памяти
- установите я +1 на я
так что это не атомарная операция. Когда thread1 выполняет i++, а thread2 выполняет i++, конечное значение i может быть i+1.
В Java операция i++ не является атомарной, поскольку на самом деле она представляет собой комбинацию нескольких шагов, которые могут быть прерваны другими потоками. Операция i++ состоит из трех отдельных этапов: чтение текущего значения i, увеличение значения на 1 и сохранение обновленного значения обратно в i. Каждый из этих шагов представляет собой отдельную операцию и может чередоваться с операциями других потоков, что может привести к возникновению условий гонки.
Состояние гонки возникает, когда несколько потоков одновременно обращаются к общему ресурсу, и результат операции зависит от конкретного порядка выполнения. В случае i++, если два или более потоков пытаются увеличить i одновременно, они могут прочитать одно и то же начальное значение i, увеличить его отдельно, а затем сохранить свои отдельные результаты обратно в i. Это может привести к потере обновлений, когда один или несколько приращений будут перезаписаны, что приведет к неправильному конечному значению i.
Чтобы обеспечить атомарность и избежать состояний гонки, Java предоставляет класс AtomicInteger, который инкапсулирует целочисленное значение и обеспечивает атомарные операции над этим значением. Вместо использования inti вы можете использовать AtomicInteger i и выполнять атомарные приращения с помощью метода приращенияAndGet(). Этот метод гарантирует, что операция приращения выполняется атомарно, и исключает возможность возникновения состояний гонки.
Вот пример, демонстрирующий атомарное приращение с использованием AtomicInteger:
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;
import java.util.concurrent.atomic.AtomicInteger;
import java.util.logging.Level;
import java.util.logging.Logger;
public class Test {
private static AtomicInteger total = new AtomicInteger(0);
private static AtomicInteger countT1 = new AtomicInteger(0);
private static AtomicInteger countT2 = new AtomicInteger(0);
private boolean run = true;
public Test() {
ExecutorService newCachedThreadPool = Executors.newCachedThreadPool();
newCachedThreadPool.execute(t1);
newCachedThreadPool.execute(t2);
try {
Thread.sleep(1000);
} catch (InterruptedException ex) {
Logger.getLogger(Test.class.getName()).log(Level.SEVERE, null, ex);
}
run = false;
try {
Thread.sleep(1000);
} catch (InterruptedException ex) {
Logger.getLogger(Test.class.getName()).log(Level.SEVERE, null, ex);
}
System.out.println((countT1.get() + countT2.get() + " == " + total.get()));
}
private Runnable t1 = new Runnable() {
@Override
public void run() {
while (run) {
total.incrementAndGet();
countT1.incrementAndGet();
System.out.println("Hello #" + countT1.get() + " from Thread 1! Total hello: " + total.get());
}
}
};
private Runnable t2 = new Runnable() {
@Override
public void run() {
while (run) {
total.incrementAndGet();
countT2.incrementAndGet();
System.out.println("Hello #" + countT2.get() + " from Thread 2! Total hello: " + total.get());
}
}
};
public static void main(String[] args) {
new Test();
}
}
В JVM или любой виртуальной машине
i++
эквивалентно следующему:
int temp = i; // 1. read
i = temp + 1; // 2. increment the value then 3. write it back
вот почему i++ не атомарен.
Параллелизм (Thread
класс и т. д.) является дополнительной функцией в v1.0 Java. i++
был добавлен в бета-версию до этого, и, как таковой, он все еще более чем вероятен в своей (более или менее) оригинальной реализации.
Программист должен синхронизировать переменные. Посмотрите учебник Oracle по этому вопросу.
Редактировать: чтобы уточнить, i++ является четко определенной процедурой, предшествующей Java, и поэтому разработчики Java решили сохранить первоначальную функциональность этой процедуры.
Оператор ++ был определен в B (1969), который предшествовал Java и многопоточности.