Java中的整数非同步读取是否线程安全?

12
我经常在某些开源软件的单元测试中看到这段代码,但它是线程安全的吗?while循环有保证能正确地看到invoc的值吗?
如果不行,那么谁知道在哪种CPU架构上可能会出现问题,就给予他们一些“码农点数”吧。
  private int invoc = 0;

  private synchronized void increment() {
    invoc++;
  }

  public void isItThreadSafe() throws InterruptedException {
      for (int i = 0; i < TOTAL_THREADS; i++) {
        new Thread(new Runnable() {
          public void run() {
             // do some stuff
            increment();
          }
        }).start();
      }
      while (invoc != TOTAL_THREADS) {
        Thread.sleep(250);
      }
  }

1
不能百分之百确定,但似乎非同步读取非易失性字段不能保证读取invoc的最新值(尽管由于increment()方法上的synchronized关键字而导致写入被刷新)。不过,我对Java内存模型还不够了解,无法确定。 - dlev
@dlev 这有点像是问题的意思 ;) - krosenvold
我本来要回答:“这对我一直有效!”但是我还发现了https://dev59.com/xXNA5IYBdhLWcg3wVcJx,其中说int类型的变量是“是”的,而long和double类型的变量则是“可能”的。 - david van brink
@david van brink:非原子64位操作是一个与此无关的单独问题。 - Nathan Hughes
@nathan 是的...我终于明白了它是关于缓存值和从另一个线程读取的问题...棘手的东西!现在我正在尝试让自己的代码更少地使用线程,更多地使用异步/回调方式。这样大脑会感觉不那么疼。 - david van brink
1
@david:是的,避免大脑受伤肯定是一件好事 :-) - Nathan Hughes
5个回答

17
不,它不是线程安全的。invoc需要被声明为volatile,或者在访问时同步使用相同的锁,或者改为使用AtomicInteger。仅使用同步方法来增加invoc,但不同步读取它是不够好的。
JVM会执行很多优化,包括CPU特定的缓存和指令重排序。它使用volatile关键字和锁定来决定何时可以自由优化,何时必须有最新值可供其他线程读取。因此,当读取器不使用锁时,JVM不能知道不要给它一个过时的值。
该引用来自《Java Concurrency in Practice》(第3.1.3节),讨论了如何同步写入和读取:
Intrinsic locking可以用于保证一个线程以可预测的方式看到另一个线程的效果,如图3.1所示。当线程A执行一个同步块,然后线程B进入一个由相同锁保护的同步块时,在释放锁之前对A可见的变量的值保证在获取锁时对B可见。换句话说,当它执行由相同锁保护的同步块时,B看到A在同步块中或之前所做的一切。如果没有同步,则没有这样的保证。
下一个章节(3.1.4)介绍了如何使用volatile:
Java语言还提供了一种更弱的同步形式,即volatile变量,以确保将对变量的更新可预测地传播到其他线程。当一个字段被声明为volatile时,编译器和运行时会注意到这个变量是共享的,并且它的操作不应与其他内存操作重排序。volatile变量不会缓存在寄存器或缓存中,从而隐藏在其他处理器中,因此对volatile变量的读取总是返回任何线程的最新写入。

回到我们所有人都使用单 CPU 台式机的时代,我们写代码时可能永远不会遇到问题,直到它在通常是生产环境中运行在多处理器箱子上。引起可见性问题的一些因素,例如 CPU 本地缓存和指令重新排序,是您预期从任何多处理器机器中看到的东西。虽然对于任何机器来说,消除明显不需要的指令都可能发生。 JVM 没有强制要求读者始终看到变量的最新值,这取决于 JVM 实现者的恩惠。因此,在我看来,该代码对于任何 CPU 架构都不是一个好的选择。


2

好的!

  private volatile int invoc = 0;

这样做会奏效。

并且查看Java原始类型int是通过设计还是意外实现原子性?,其中列出了一些相关的Java定义。显然,int类型是可以的,但double和long可能不行。


编辑,附加信息。问题问道,“看到invoc的正确值是什么?”“正确的值”是什么意思呢?就像时空连续体一样,线程之间实际上不存在同时性。上面的帖子之一指出该值最终将被刷新,并且其他线程将获得它。这段代码是否“线程安全”?我会说是“是”,因为它不会根据排序的巧合而“表现异常”,在这种情况下。


1
我不确定提到的问题中的任何引用是否实际上涵盖了对不同线程的可见性。 - krosenvold
1
不要陷入形而上学的讨论,我认为理论上外层循环可能永远只会看到0。 - krosenvold

1
理论上,读取可能会被缓存。Java内存模型中没有阻止这种情况的机制。
实际上,在您的特定示例中发生这种情况的可能性极小。问题是JVM是否可以跨方法调用进行优化。
read #1
method();
read #2

对于JVM来说,要让read#2能够重用read#1的结果(可以存储在CPU寄存器中),它必须确信method()不包含任何同步操作。这通常是不可能的 - 除非method()被内联,并且JVM可以从扁平化的代码中看到read#1和read#2之间没有同步/易失性或其他同步操作;然后它可以安全地消除read#2。

现在以你的例子为例,方法是Thread.sleep()。一种实现方式是根据CPU频率忙等待一定时间。然后JVM可能会将其内联,然后消除read#2。

但是,当然,这样实现sleep()是不现实的。它通常作为调用操作系统内核的本地方法来实现。问题是,JVM是否可以跨越这样的本地方法进行优化。

即使JVM了解某些本地方法的内部工作原理,因此可以跨越它们进行优化,也不太可能像处理其他方法那样处理sleep()sleep(1ms)需要数百万个CPU周期才能返回,真的没有必要围绕它进行优化以节省几次读取。

--

这个讨论揭示了数据竞争最大的问题 - 它需要太多的努力来进行推理。如果一个程序没有“正确同步”,它不一定是错误的,但要证明它不是错误的并不是一件容易的事情。如果一个程序被正确同步且不包含数据竞争,那么生活会变得简单得多。


奇怪的是,我几乎可以确定我看到过这种情况发生,无论是理论上还是实际上。 - krosenvold

0
据我理解代码,它应该是安全的。字节码可以被重新排序,是的。但最终invoc应该再次与主线程同步。同步保证了invoc正确递增,因此在某个寄存器中有一致的invoc表示。在某个时间点,这个值将被刷新,小测试就会成功。
这肯定不好,我会选择我投票的答案,并修复像这样的代码,因为它有问题。但是仔细考虑后,我认为它是安全的。

这更多关乎可见性,少一些重新排序。虚拟机可能会完全优化掉内存读取,并将_invoc_视为线程中未同步/不稳定访问的常量。因此,即使有时候或经常可以工作,也必须进行修复。 - Boris
我知道我应该更相信我的直觉感觉 :-) - Stefan Schubert-Peters

0

如果你不需要使用“int”,我建议使用AtomicInteger作为线程安全的替代方案。


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