为什么 i++ 不是原子操作?

108
为什么Java中的i++不是原子操作?
为了更深入地了解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 1! 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();
    }
}

16
你为什么不试试使用AtomicInteger - Braj
4
JVM有一个用于递增整数的“iinc”操作,但这仅适用于本地变量,不考虑并发情况。对于字段,编译器会单独生成读取-修改-写入指令。 - Silly Freak
15
你为什么期望它是原子性的呢? - Hot Licks
2
@Silly Freak:即使有一个针对字段的iinc指令,单个指令也不能保证原子性。例如,非“volatile”longdouble字段访问无论如何都不能保证是原子性的,尽管它是由单个字节码指令执行的。 - Holger
显示剩余3条评论
11个回答

136

i++在Java中可能不是原子操作,因为原子性是一个特殊需求,在大多数i++的使用中并不存在这种需求。这个需求有很大的开销: 在使增量操作变成原子操作时,需要在软件和硬件层面上进行同步,而在普通增量操作中不需要。

你可以认为i++应该被设计和记录为特别执行原子增量,以便使用i = i + 1执行非原子增量。然而,这会破坏Java、C和C++之间的"文化兼容性"。此外,它将夺走程序员熟悉C语言类似记号的便利性,给它赋予了只适用于有限情况的特殊含义。

for (i = 0; i < LIMIT; i++)这样的基本C或C++代码,在Java中会被翻译成for (i = 0; i < LIMIT; i = i + 1); 因为使用原子的i++是不适当的. 更糟糕的是,从C或其他类似C语言的语言转到Java的程序员仍会使用i++,导致不必要地使用原子指令。

即使在机器指令集层面上,增量类型的操作通常也不是原子操作,因为这样做有助于提高性能。在x86中,必须使用一个特殊的指令"锁定前缀"来使inc指令成为原子操作: 原因与上述相同。如果inc总是原子操作,那么当需要非原子inc时,程序员和编译器会生成加载、加1和存储的代码,因为这样会更快。


在一些指令集架构中,可能没有原子inc操作,或者根本就没有inc操作;要在MIPS上进行原子inc操作,必须编写一个使用llsc的软件循环。其中load-linked读取单词,store-conditional存储新值,如果单词未更改,则存储新值,否则操作失败(这将被检测到并导致重试)。


2
由于Java没有指针,增加本地变量本质上是线程安全的,因此在循环中问题通常不会那么严重。当然,你提到的最小惊讶原则是正确的。另外,目前而言,“i = i + 1”是“++i”的翻译,而不是“i++”。 - Silly Freak
23
问题的第一个单词是“为什么”。目前为止,这是回答“为什么”问题的唯一答案。其他答案只是重申问题。因此加1。 - Dawood ibn Kareem
3
值得注意的是,原子性保证不能解决非volatile字段更新的可见性问题。因此,除非您将每个字段视为隐式的volatile,一旦一个线程对其使用了++运算符,这样的原子性保证将无法解决并发更新问题。因此,如果不能解决问题,为什么要为之浪费性能呢? - Holger
1
@DavidWallace 你是不是想说 ++? ;) - Dan Hlavenka

42

i++ 包含两个操作:

  1. 读取 i 的当前值
  2. 将值加1并将其赋值给 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

17
+1,但是“1. A,2. B和C”听起来像是三个操作,而不是两个。 :) (翻译:加一分,但“1. A, 2. B 和 C”听起来像是三个步骤,而不是两个。 :)) - yshavit
3
请注意,即使使用单个机器指令来原地增加存储位置的操作,也不能保证它是线程安全的。机器仍然需要获取该值、增加它并将其存回,另外该存储位置可能具有多个缓存副本。 - Hot Licks
3
如果两个处理器同时对同一存储位置执行相同的操作,且该位置上没有“保留”广播,它们几乎肯定会干扰并产生错误结果。是的,这种操作有可能是“安全”的,但即使在硬件层面也需要特殊的努力。 - Hot Licks
7
但我认为问题是“为什么”,而不是“发生了什么”。 - Sebastian Mach
谢谢,不过我认为,1)读取当前值;2)增加;3)再赋回去仍然是三个操作 :) - Giorgi Tsiklauri
显示剩余3条评论

15

Java规范

重要的是JLS(Java语言规范),而不是JVM的各种实现是否已经实现了语言的某个特性。

JLS在第15.14.2条款中定义了++后缀运算符,其中说“将值1添加到变量的值中,并将总和存回到变量中”。它没有提及或暗示多线程或原子性。

对于多线程或原子性,JLS提供了volatilesynchronized。另外还有Atomic...类。


6
为什么在Java中i++不是原子操作?
让我们把增量操作分成多个语句:
线程1和2:
1.从内存中获取total的值
2.将1添加到该值
3.写回到内存中
如果没有同步,那么假设线程一已经读取了值3并将其增加到4,但还没有写回去。此时发生上下文切换,线程二会读取值3并将其增加,然后发生上下文切换。虽然两个线程都增加了total的值,但它仍然是4-竞争条件。

3
我不明白这个回答与问题有什么关系。一个语言可以将任何功能定义为原子性,无论是增量还是独角兽。你只是举例说明了不具备原子性的后果。 - Sebastian Mach
是的,一种语言可以将任何特性定义为原子性,但就Java而言,递增运算符(这是OP发布的问题)不是原子性的,我的答案说明了原因。 - Aniket Thakur
2
但是,原因似乎是“因为如果它是原子的,那么就不会有竞争条件”。也就是说,竞争条件似乎是可取的。 - Sebastian Mach
@phresnel 在保持增量原子性所引入的开销非常大且很少需要的情况下,大多数时候希望保持操作廉价并因此非原子性。 - josefx
5
请注意,我并不质疑事实,而是对这个回答中的推理提出疑问。它基本上说“在Java中i++不是原子操作,因为它可能存在竞争条件”,这就像是说“汽车没有安全气囊是因为可能会发生撞车”或者“你点脆皮香肠时没有刀子是因为香肠可能需要切开”。因此,我认为这并不是一个答案。问题不是“i++做什么?”或者“i++不同步的后果是什么?”。 - Sebastian Mach

5

i++ 是一种简单的语句,涉及三个操作:

  1. 读取当前值
  2. 写入新值
  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)

Time 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的用途,并且在您的情况下,它可能非常合适。

P.S.

覆盖所有这些问题以及更多问题的优秀书籍是这本书: Java并发编程实战


2
嗯。一种语言可以将任何特性定义为原子性,无论是增量还是独角兽。你只是举例说明了不具备原子性的后果。 - Sebastian Mach
@phresnel 没错。但我也指出,这不是一个单一的操作,这意味着将多个这样的操作转化为原子操作的计算成本要高得多,这反过来又部分地证明了为什么 i++ 不是原子操作。 - stratis
2
虽然我理解你的观点,但是你的回答对于学习来说有些令人困惑。我看到了一个例子和一个结论,结论中说“因为例子中的情况”,但在我看来这是不完整的推理。:( - Sebastian Mach
1
@phresnel 或许不是最具教育性的回答,但这是我目前能提供的最好的答案。希望它能帮助人们而不是使他们感到困惑。无论如何,感谢您的批评。我会在未来的帖子中尽量更加精确。 - stratis

2
在JVM中,增量操作涉及读取和写入,因此它不是原子性的。

1

有两个步骤:

  1. 从内存中获取i
  2. 将i+1设置为i

因此它不是原子操作。 当线程1执行i ++,线程2执行i ++时,最终值可能是i + 1。


1
如果操作i++是原子的,您就没有机会从中读取值。这正是您使用i++想要做的事情(而不是使用++i)。
例如,请查看以下代码:
public static void main(final String[] args) {
    int i = 0;
    System.out.println(i++);
}

在这种情况下,我们期望输出为:0(因为我们进行了后增操作,例如先读取,然后更新)。
这是该操作无法成为原子操作的原因之一,因为您需要读取值(并执行某些操作),然后更新该值。
另一个重要原因是,进行原子操作通常需要更长的时间,因为需要进行锁定。如果所有原语操作都需要花费一点时间来处理很少的想进行原子操作的情况,那么就太傻了。这就是为什么他们将AtomicInteger其他原子类添加到该语言中的原因。

3
这是具有误导性的。你必须区分执行和获取结果,否则你将无法从任何原子操作中获取值。 - Sebastian Mach
不是这样的,这就是为什么Java的AtomicInteger有get()、getAndIncrement()、getAndDecrement()、incrementAndGet()、decrementAndGet()等方法。 - Roy van Rijn
2
Java语言本可以将i++定义为扩展到i.getAndIncrement()。这种扩展并不新鲜。例如,C++中的lambda表达式被扩展为匿名类定义。 - Sebastian Mach
1
鉴于原子操作 i++,可以轻松地创建一个原子操作 ++i 或反之亦然。它们等效于另一个加一的结果。 - David Schwartz

1
在Java中,i++操作不是原子性的,因为它实际上是由多个步骤组成的,这些步骤可以被其他线程中断。i++操作包括三个不同的步骤:读取i的当前值、将该值增加1、并将更新后的值存储回i。每个步骤都是单独的操作,可以与其他线程的操作交错进行,从而导致潜在的竞态条件。
竞态条件发生在多个线程同时访问共享资源,并且操作的结果取决于执行顺序的特定顺序的情况下。在i++的情况下,如果两个或更多线程尝试同时增加i,它们可能会读取相同的初始值i,分别递增它,然后将各自的结果存储回i。这可能会导致丢失更新,其中一个或多个递增被覆盖,导致i的最终值不正确。
为确保原子性并避免竞态条件,Java提供了AtomicInteger类,该类封装了一个整数值并提供对该值的原子操作。您可以使用AtomicInteger i而不是int i,并使用incrementAndGet()方法执行原子递增。该方法保证递增操作是原子性的,并消除了竞态条件的可能性。
以下是演示使用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();
    }
}

-1
在JVM或任何虚拟机中,i++等同于以下内容:
int temp = i;     // 1. read
i = temp + 1;    // 2. increment the value then 3. write it back

这就是为什么 i++ 不是原子操作。


这是它为什么不是原子性的方式,而不是为什么,因为其他许多答案和评论已经涵盖了这一点。 - Matthew Read

网页内容由stack overflow 提供, 点击上面的
可以查看英文原文,
原文链接