【问题标题】:Why is i++ not atomic?为什么 i++ 不是原子的?
【发布时间】:2014-09-29 20:12:04
【问题描述】:

为什么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();
    }
}

【问题讨论】:

  • 你为什么不试试AtomicInteger
  • JVM 有一个用于递增整数的iinc 操作,但这仅适用于不考虑并发性的局部变量。对于字段,编译器分别生成read-modify-write命令。
  • 你为什么会期望它是原子的?
  • @Silly Freak:即使字段有iinc 指令,单条指令也不能保证原子性,例如非volatile longdouble 字段访问不能保证是原子的,不管它是由单个字节码指令执行的。

标签: java multithreading concurrency


【解决方案1】:

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

【讨论】:

    【解决方案2】:

    i++涉及两个操作:

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

    【讨论】:

    • (即使i++ 原子的,它也不是定义明确/线程安全的行为。)
    • +1,但“1.A、2.B 和 C”听起来像是三个操作,而不是两个。 :)
    • 请注意,即使该操作是使用单个机器指令实现的,该指令增加了一个存储位置,也不能保证它是线程安全的。机器仍然需要获取值、递增值并将其存储回来,加上该存储位置可能有多个缓存副本。
    • @Aquarelle - 如果两个处理器同时对同一个存储位置执行相同的操作,并且该位置没有“保留”广播,那么它们几乎肯定会干扰并产生虚假结果。是的,此操作可能是“安全的”,但需要特别努力,即使在硬件级别也是如此。
    • 但我认为问题是“为什么”而不是“会发生什么”。
    【解决方案3】:

    为什么 i++ 在 Java 中不是原子的?

    让我们将增量操作分解为多个语句:

    线程 1 和 2:

    1. 从内存中获取总计值
    2. 值加 1
    3. 写回内存

    如果没有同步,那么假设线程一已读取值 3 并将其增加到 4,但尚未将其写回。此时,上下文切换发生。线程二读取值 3,将其递增,然后发生上下文切换。虽然两个线程都增加了总值,但它仍然是 4 - 竞态条件。

    【讨论】:

    • 我不明白这应该是这个问题的答案。一种语言可以将任何特征定义为原子的,无论是增量还是独角兽。你只是举例说明了不是原子的结果。
    • 是的,一种语言可以将任何特性定义为原子的,但就 java 被认为是增量运算符(这是 OP 发布的问题)而言,它不是原子的,我的回答说明了原因。
    • (对不起,我在第一条评论中的语气很严厉)但是,原因似乎是“因为如果它是原子的,那么就不会有竞争条件”。即,听起来好像需要竞争条件。
    • @phresnel 为保持增量原子而引入的开销是巨大的并且很少需要,保持操作便宜,因此大多数时间都需要非原子。
    • @josefx:请注意,我不是在质疑事实,而是在质疑这个答案中的推理。它基本上说 “i++ 在 Java 中不是原子的,因为它具有竞争条件”,这就像在说 “汽车没有安全气囊,因为可能发生碰撞”“你点的咖喱香肠没有刀,因为香肠可能需要切开”。因此,我不认为这是一个答案。问题不是“i++ 做什么?”“i++ 不同步的后果是什么?”
    【解决方案4】:

    并发(Thread 类等)是Java 的 v1.0 中的新增功能。 i++ 在此之前的测试版中添加了,因此它仍然很有可能在其(或多或少)原始实现中。

    由程序员来同步变量。查看Oracle's tutorial on this

    编辑:澄清一下,i++ 是一个早于 Java 的定义良好的过程,因此 Java 的设计者决定保留该过程的原始功能。

    ++ 运算符是在 B (1969) 中定义的,它比 java 和线程早一点。

    【讨论】:

    • -1 "public class Thread ... 自:JDK1.0" 来源:docs.oracle.com/javase/7/docs/api/index.html?java/lang/…
    • 版本并不重要,因为它仍然在 Thread 类之前实现并且没有因此而改变,但我已经编辑了我的答案以取悦你。
    • 重要的是您声称“它仍然在 Thread 类之前实现”没有得到来源的支持。 i++ 不是原子是一个设计决策,而不是在不断发展的系统中的疏忽。
    • 仅仅因为一种语言特征是从其他语言的相应特征中借用或启发的,并不意味着它必然保持完全相同的基本特征。例如,考虑不同语言中的各种lambda 函数(例如,与真正的 Lisp lambdas symbo1ics.com/blog/?p=1292 相比,Python 的 lambda 的弱点)。即使在 C++ 中,从 C 中获取的某些特性与 C 对应的特性并不完全相同,尽管事实上 C++ 通常被认为与 C 向后兼容 (cprogramming.com/tutorial/c-vs-c++.html)。
    • @TheBat:但是许多借用的关键字和运算符并没有像借用的语言那样实现。例如,C++ 中的 classoperator. 与它们的 Java“等价物”完全正交。
    【解决方案5】:

    Java 规范

    重要的是JLS (Java Language Specification),而不是 JVM 的各种实现可能会或可能不会实现该语言的某个特性。

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

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

    【讨论】:

      【解决方案6】:

      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 始终是原子的,则在需要非原子公司时永远不会使用它;程序员和编译器会生成加载、加 1 和存储的代码,因为这样会更快。

      在某些指令集架构中,没有原子inc 或者根本没有inc;要在 MIPS 上进行 atomic inc,您必须编写一个使用 llsc 的软件循环:加载链接和条件存储。加载链接读取单词,如果单词没有更改,则存储条件存储新值,否则失败(检测到并导致重试)。

      【讨论】:

      • 由于 java 没有指针,增加局部变量本质上是线程保存,所以使用循环问题大多不会那么糟糕。当然,您关于最不意外的观点是成立的。同样,i = i + 1 将是++i 的翻译,而不是i++
      • 问题的第一个词是“为什么”。到目前为止,这是解决“为什么”问题的唯一答案。其他答案实际上只是重新陈述了这个问题。所以+1。
      • 可能值得注意的是,原子性保证不会解决非volatile 字段更新的可见性问题。因此,除非一旦一个线程在其上使用了++ 运算符,您就将每个字段都视为隐式volatile,否则这种原子性保证不会解决并发更新问题。那么,如果它不能解决问题,为什么还要浪费性能呢。
      • @DavidWallace 你不是说++吗? ;)
      【解决方案7】:

      如果i++ 操作是原子操作,您将没有机会从中读取值。这正是您想要使用i++(而不是使用++i)来做的事情。

      例如看下面的代码:

      public static void main(final String[] args) {
          int i = 0;
          System.out.println(i++);
      }
      

      在这种情况下,我们希望输出为:0 (因为我们发布增量,例如先读取,然后更新)

      这是操作不能是原子操作的原因之一,因为您需要读取值(并对其进行操作)并然后更新该值。

      另一个重要的原因是原子地做某事通常会因为锁定而花费更多时间。在人们想要进行原子操作的极少数情况下,让对原语的所有操作花费更长的时间是很愚蠢的。这就是为什么他们在语言中添加了AtomicIntegerother 原子类。

      【讨论】:

      • 这是误导。您必须分开执行和获取结果,否则您无法从 any 原子操作中获取值。
      • 不,不是,这就是为什么 Java 的 AtomicInteger 有 get()、getAndIncrement()、getAndDecrement()、incrementAndGet()、decrementAndGet() 等。
      • Java 语言可以定义 i++ 以扩展为 i.getAndIncrement()。这种扩张并不新鲜。例如,C++ 中的 lambda 被扩展为 C++ 中的匿名类定义。
      • 给定一个原子i++,可以轻松创建一个原子++i,反之亦然。一个等于另一个加一。
      【解决方案8】:

      i++ 是一个只涉及3个操作的语句:

      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)
      

      时间 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 的用途,在您的情况下,它可能正好适合。

      附言

      一本涵盖所有这些问题的优秀书籍,其中一些是这样的: Java Concurrency in Practice

      【讨论】:

      • 嗯。一种语言可以将任何特征定义为原子的,无论是增量还是独角兽。你只是举例说明了不是原子的结果。
      • @phresnel 没错。但我也指出,这不是一个单一的操作,它通过扩展意味着将多个此类操作转换为原子操作的计算成本要高得多,这反过来 - 部分地 - 证明了为什么 i++ 不是原子的。
      • 虽然我明白你的意思,但你的回答对学习来说有点混乱。我看到一个例子,一个结论是“因为例子中的情况”;恕我直言,这是一个不完整的推理:(
      • @phresnel 也许不是最具教学意义的答案,但这是我目前能提供的最好的答案。希望它能帮助人们而不是混淆他们。不过感谢批评。我会在以后的帖子中尽量做到更准确。
      【解决方案9】:

      有两个步骤:

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

      所以它不是原子操作。 当thread1执行i++,thread2执行i++时,i的最终值可能是i+1。

      【讨论】:

        【解决方案10】:

        在 JVM 或任何 VM 中,i++ 等价于以下内容:

        int temp = i;     // 1. read
        i = temp + 1;    // 2. increment the value then 3. write it back
        

        这就是为什么 i++ 是非原子的。

        【讨论】:

        • 这就是 如何 它是非原子的,而不是 why,正如许多其他答案和 cmets 所涵盖的那样。
        猜你喜欢
        • 1970-01-01
        • 1970-01-01
        • 2018-01-09
        • 1970-01-01
        • 2021-03-29
        • 1970-01-01
        • 2011-10-06
        • 1970-01-01
        相关资源
        最近更新 更多