Java并发中“程序顺序规则”的解释

2022-09-03 18:17:32

程序顺序规则声明“线程中的每个操作都发生在该线程中程序顺序后面的每个操作之前”

1.我在另一个线程中读到一个动作

  • 读取和写入变量
  • 锁定和解锁显示器
  • 使用线程启动和连接

这是否意味着读取和写入可以按顺序更改,但读取和写入不能使用第 2 行或第 3 行中指定的操作更改顺序?

2.“程序顺序”是什么意思?

用例子解释会很有帮助。

其他相关问题

假设我有以下代码:

long tick = System.nanoTime(); //Line1: Note the time
//Block1: some code whose time I wish to measure goes here
long tock = System.nanoTime(); //Line2: Note the time

首先,它是一个单线程应用程序,使事情变得简单。编译器注意到它需要检查时间两次,并且还注意到一个与周围的时间记号行没有依赖关系的代码块,因此它看到了重新组织代码的可能性,这可能导致Block1在实际执行期间没有被计时调用包围(例如,考虑这个顺序 Line1->Line2->Block1)。但是,作为一名程序员,我可以看到Line1,2和Block1之间的依赖关系。Line1 应紧跟在 Block1 之前,Block1 需要有限的时间才能完成,并立即由 Line2 接替。

所以我的问题是:我是否正确测量了块?

  • 如果是,则阻止编译器重新排列顺序的原因。
  • 如果不是,(在通过Enno的答案后认为这是正确的),我能做些什么来防止它。

P.S.:我从我最近在SO中提出的另一个问题中窃取了这个代码。


答案 1

它可能有助于解释为什么首先存在这样的规则。

Java是一种过程语言。也就是说,你告诉Java如何为你做某事。如果Java执行你的指令的顺序不是你写的顺序,它显然不起作用。例如,在下面的例子中,如果Java做2 ->1 ->3,那么炖菜就会被毁掉。

1. Take lid off
2. Pour salt in
3. Cook for 3 hours

那么,为什么规则不简单地说“Java按照你写的顺序执行你写的东西”呢?简而言之,因为Java很聪明。举个例子:

1. Take eggs out of the freezer
2. Take lid off
3. Take milk out of the freezer
4. Pour egg and milk in
5. Cook for 3 hours

如果Java像我一样,它会按顺序执行它。然而,Java足够聪明,可以理解它更有效率,如果它做1 ->3 ->2 ->4 -> 5,最终结果将是相同的(你不必再次走到冰箱,这不会改变配方)。

因此,规则“线程中的每个操作都发生 - 在程序顺序后面的该线程中的每个操作之前”试图说的是,“在单个线程中,您的程序将像按照您编写它的确切顺序执行一运行。我们可能会在幕后更改顺序,但我们确保这些都不会改变输出。

目前为止,一切都好。为什么它不能在多个线程上做同样的事情?在多线程编程中,Java不够聪明,无法自动完成。它将用于某些操作(例如,连接线程,启动线程,何时使用锁(监视器)等),但对于其他操作,您需要明确告诉它不要进行会更改程序输出的重新排序(例如 字段上的标记,锁的使用等)。volatile

注意:
关于“发生前关系”的快速附录。这是一种奇特的说法,无论对Java进行重新排序可能会做什么,A都会在B之前发生。在我们奇怪的炖菜示例中,“步骤1和3发生 - 在步骤4之前”倒鸡蛋和牛奶“ ”。例如,“步骤1和3不需要发生之前的关系,因为它们不以任何方式相互依赖”

关于附加问题和对评论的回应

首先,让我们确定“时间”在编程世界中的含义。在编程中,我们有“绝对时间”的概念(现在世界上的时间是多少?)和“相对时间”的概念(自x以来已经过去了多少时间?)。在一个理想的世界里,时间就是时间,但除非我们内置一个原子钟,否则绝对时间必须逐次校正。另一方面,对于相对时间,我们不希望更正,因为我们只对事件之间的差异感兴趣。

在Java中,处理绝对时间并处理相对时间。这就是为什么nanoTime的Javadoc指出,“这种方法只能用于测量经过的时间,与任何其他系统或挂钟时间的概念无关”。System.currentTime()System.nanoTime()

在实践中,currentTimeMillis和nanoTime都是本机调用,因此编译器实际上无法证明重新排序是否会影响正确性,这意味着它不会对执行进行重新排序。

但是让我们想象一下,我们想要编写一个编译器实现,它实际上会查看本机代码并重新排序所有内容,只要它是合法的。当我们查看JLS时,它告诉我们的只是“只要无法检测到,您就可以对任何内容进行重新排序”。现在,作为编译器编写者,我们必须确定重新排序是否会违反语义。对于相对时间(nanoTime),如果我们重新排序执行,它显然是无用的(即违反语义)。现在,如果我们对绝对时间(currentTimeMillis)重新排序,它会违反语义吗?只要我们能将世界时间源(比如系统时钟)的差异限制为我们决定的任何东西(比如“50ms”)*,我就说不。对于以下示例:

long tick = System.currentTimeMillis();
result = compute();
long tock = System.currentTimeMillis();
print(result + ":" + tick - tock);

如果编译器可以证明其占用的比我们可以允许的系统时钟的最大背离值都少,那么按如下方式重新排序是合法的:compute()

long tick = System.currentTimeMillis();
long tock = System.currentTimeMillis();
result = compute();
print(result + ":" + tick - tock);

因为这样做不会违反我们定义的规范,因此也不会违反语义。

您还询问为什么这不包括在JLS中。我认为答案是“保持JLS简短”。但我对这个领域了解不多,所以你可能想问一个单独的问题。

*:在实际实现中,这种差异取决于平台。


答案 2

程序顺序规则保证,在各个线程中,编译器引入的重新排序优化不会产生与程序以串行方式执行时发生的结果不同的结果。它不保证线程的操作在任何其他线程的状态下,如果这些线程在没有同步的情况下观察到其状态,则线程的操作在任何其他线程中可能显示为什么顺序。

请注意,此规则仅涉及程序的最终结果,而不涉及该程序中各个执行的顺序。例如,如果我们有一个方法对一些局部变量进行以下更改:

x = 1;
z = z + 1;
y = 1;

编译器可以自由地对这些操作进行重新排序,但它认为最适合提高性能。一种思考方式是:如果您可以在源代码中对这些操作重新排序,并且仍然获得相同的结果,则编译器可以自由地执行相同的操作。(事实上,它可以走得更远,完全放弃那些被证明没有结果的操作,比如调用空方法。

有了第二个要点,监视器锁定规则就会发挥作用:“监视器上的解锁发生在主监视器锁定上的每个后续锁定之前。“(Java 并发实践,第 341 页)这意味着获取给定锁的线程在释放该锁之前将具有在其他线程中发生的操作的一致视图。但是,请注意,此保证仅适用于两个不同的线程或相同的锁。如果线程 A 在释放 Lock X 之前做了一堆事情,然后线程 B 获取了 Lock Y,则线程 B 不能保证对 A 的 x 之前操作有一致的视图。releaseacquire

对变量的读取和写入可以重新排序为 a.)这样做不会破坏线程内程序的顺序,并且 b.)变量没有应用其他“发生之前”线程同步语义,例如将它们存储在字段中。startjoinvolatile

一个简单的例子:

class ThreadStarter {
   Object a = null;
   Object b = null;
   Thread thread;

   ThreadStarter(Thread threadToStart) {
      this.thread = threadToStart;
   }

   public void aMethod() {
      a = new BeforeStartObject();
      b = new BeforeStartObject();
      thread.start();
      a = new AfterStartObject();
      b = new AfterStartObject();

      a.doSomeStuff();
      b.doSomeStuff();
   }
}

由于字段和方法不以任何方式同步,并且启动操作不会更改对字段的写入结果(或对这些字段执行操作),因此编译器可以自由地重新排序到方法中的任何位置。它唯一不能对 的顺序执行的操作是在将一个 s 写入该字段后将其中一个 s 写入该字段的顺序,或者在将 移动到字段之前移动字段上的一个调用。(也就是说,假设这种重新排序会以某种方式更改调用的结果。abaMethod()threadthread.start()aMethod()BeforeStartObjectAfterStartObjectdoSomeStuff()AfterStartObjectdoSomeStuff()

这里要记住的关键是,在没有同步的情况下,启动的线程理论上可以观察其中一个或两个字段以及它们在执行期间所采用的任何状态(包括)。aMethod()abaMethod()null

其他问题答案

如果要在任何测量中实际使用代码,例如,通过计算它们之间的差值并将结果打印为输出,则不能相对于代码对代码的赋值进行重新排序。这种重新排序显然会破坏Java的线程内作为if串行语义。它更改了通过按指定的程序顺序执行指令而获得的结果。如果分配用于任何测量,并且对程序结果没有任何副作用,则编译器可能会将它们优化为无操作,而不是重新排序。ticktockBlock1


推荐