计数器变量有时打印小于 10 000 的数字

Spe*_*ami 1 java multithreading synchronization

当我运行代码时,System.out.println(counter);有时会打印小于 10 000 的数字。在运行代码之前,我认为计数器应该在 [10 000, 20 000] 之间,具体取决于操作系统调度程序是否决定在单独的核心上或在同一核心上通过在它们之间切换或两者的组合来执行主线程和 t1 。

public class App  {
    private  static int counter = 0;

    public static void main(String[] args) {

        Thread t1 = new Thread(new Runnable() {
            @Override
            public void run() {
                for(int i = 0; i < 10_000; i++) {
                    counter++;
                }
            }
        });

        t1.start();

        for(int i = 0; i < 10_000; i++) {
            counter++;
        }
        System.out.println(counter); // *****

    }
}
Run Code Online (Sandbox Code Playgroud)

我相信这个逻辑可以解释原因(虽然我不确定我是否正确)

  1. 操作系统调度程序决定 t1 执行 0.1 毫秒,在这段时间内,计数器 = 567 ,但是当 t1 读取计数器的值时(即下一步是将计数器增加 1),t1 的执行停止(因为已经过去了 0.1 毫秒)。
  2. 然后主线程执行了 1 毫秒。在这段时间内,counter = 5699 ,但与 t1 不同的是,主线程的执行在主线程递增计数器变量后立即停止。
  3. 然后执行 t1 但请注意...因为 t1在读取计数器变量后停止了第一次执行,所以 t1 变量将使用它在步骤 1 中读取的值。即 567。然后假设 t1 将计数器从 567 增加到 3056。
  4. 注意,因为主线程在步骤 2 中没有读取计数器变量。然后,主线程将使用现在等于的计数器,即 3056(即使步骤 2 中的计数器是一个更大的数字,5699。),因此这解释了为什么我的程序有时会打印小于 10 000 的数字的逻辑。

我的解释可以解释为什么代码有时会打印小于 10 000 的数字吗?该程序可以打印大于 20 000 的数字吗?(因为我相信答案是肯定的,因为上面的逻辑类似(我已经执行了该程序很多次,但我还没有看到控制台打印大于 20 000 的数字)。

rzw*_*oot 6

\n

我的解释可以解释为什么代码有时会打印小于 10 000 的数字吗?

\n
\n

不。

\n
\n

在这段时间内,计数器 = 5699

\n
\n

你的思想有问题。

\n

不存在“价值counter”。那不是一件事。

\n

这就是“该领域众多克隆之一的价值”。

\n

相关文档/规范是Java 内存模型 (JMM)(请参阅Java 语言规范中描述的Java 内存模型部分 (\xc2\xa717.4) )。它解释了Java 虚拟机 (JVM)实现有多种选择,并且 Java 程序员有责任编写代码,以便无论 JVM 做出什么选择,代码都能正常工作。

\n

JMM中规定的具体规则涉及可观察性。它定义了以下条件:

\n
    \n
  • 可观察性明确不包括时间。如果您可以仅根据计时器和运行时间来“观察到”事物 A 必须在事物 B 之前运行,那么这并不违反规则“JVM 必须确保 A 不能在 B 之前被观察到”。因为 JMM 谈论“观察”时,时间并不重要。
  • \n
  • JMM 中的所有规则都采用以下形式:“JVM 实现必须保证 X”。几乎所有这些都是这样的形式:“当执行字节码 A 时,JVM [必须/不得/可以以任一方式执行]是否可以观察字节码 B 对任何字段所做的任何更改”。默认行为是“可以任意选择”。JMM 准确地枚举了可观察性中必须存在的排序,因此列表中未列出的所有内容都意味着:“无论如何 - 你可能会观察到 A 所做的更改,但你可能不会 - 如果你的代码取决于它是否存在,那么你的代码坏了。在某种程度上很难测试,所以,哎哟”。
  • \n
\n

JVM充分利用它在 JMM 下获得的权利。这不仅仅是关于哪个语句在另一个语句之前运行”,甚至一个线程所做的任何更新是否同步到另一个线程。它甚至与 JVM 的完全重新排序有关。

\n

该列表通常称为“发生于”列表,因为 JMM 使用的术语是“发生于”。以下是该列表中最相关的部分:

\n
    \n
  • 线程内“自然”HB:如果 A 和 B 在同一线程中运行,则 HB(A, B) 成立,并且根据执行规则,A 先于 B。这就是“duh,显然”规则。鉴于:x = 5; x = 10; y = x;JVM 重新排序是非法的x = 5;,现在是x = 10;5。y然而,这只是关于可观察性。因此,给定:x = 5; y = 10;JVM 可以自由地为x = 5; y = 10;首先设置 y 然后设置 x 生成优化的机器代码。有时它实际上会这样做,以优化 CPU 流水线。这并不意味着 JVM 总是会这样做,或者永远会这样做 - 它只是意味着如果JVM 实现这样做,那么它没有任何问题。有些人确实这样做。JVM 甚至不必保持一致;它现在可能会这样做,明天就不会了:这也不仅仅是为了扰乱你,例如,JVM 会跟踪if最近执行中分支 (an) 的走向,以及热点时的情况。机器代码,将使用它来编写分支预测循环(在机器代码中,没有 while 或 if,有(条件)GOTO(JMP)。随着 CPU“向前看”和预执行,跳转往往会更慢-处理即将到来的指令,如果跳转则毫无意义。分支预测循环或 if 是机器代码不会跳转到最常见路径的循环。如果“最近执行”略有不同,JVM 可能会生成不同的分支预测代码,并且从该分歧中,任何事情都可能发生。

    \n
  • \n
  • Thread ops HB:HB(t.start(), run)建立在t.start()启动线程的代码所在的位置,并且run是该线程的方法中的第一行run()。类似地,HB(end, join)定义为:线程的最后一行代码发生t.join()在该线程返回之前。

    \n
  • \n
  • synchronized:存在一种线程在同一监视器上通过同步块的顺序,即如果一个线程有synchronized (x) {}并且另一个线程也有该代码(其中 x 指向同一个对象),您可以准确列出线程进入这些块的顺序。HB(end, start)成立,其中“end”是首先经过的线程的同步块中的最后一行,“start”是最后经过的线程的同步块中的第一行。关于访问变量也存在类似的规则,但通常在涉及变量volatile时要弄清楚哪一个是“之前”、哪一个是“之后”要困难得多。volatile

    \n
  • \n
\n

还有一些。重要的是要认识到大量的java.*API 调用会建立 HB。例如,尝试通过发出一堆System.out.println语句来见证这些东西的运行只会让你感到困惑,因为这会建立各种 HB,从而扰乱任何重现某些竞争条件的尝试。这东西是火箭科学。

\n

以上是 JMM 的简化,但相当准确。在实践中这可以归结为两件事:

\n
    \n
  • 重新排序。JVM 只会无序地执行某些操作。只要它混乱的代码没有观察到彼此的影响,JVM 就可以这样做。
  • \n
  • 变量缓存。如今的 CPU 甚至无法读取主内存。完全没有。他们唯一能做的就是从本地缓存中读取(这是在芯片上的,即每个芯片都有自己的缓存),并向内存控制器发送消息以刷新整个页面的值(64k 或更多) (页面很大)返回到主内存,然后将整个页面的值加载到现在空闲的缓存页面中,然后进入睡眠状态 500 到 1500 个周期,因为内存控制器需要一段时间才能完成此操作。当然,“将其全部写回主内存”是非常慢的,因为每次读取或写入都要 1500 个周期。因此,显然,为了保持速度,每个 CPU 核心只会读取和写入其片内缓存页面之一内的某个插槽,这实际上意味着通常在线程中主动写入/读取的每个字段都是一个“副本”并且所有这些副本都是独立的,一旦这些线程结束,不知道哪个“副本”最终“获胜”。其中一份副本“获胜”并最终进入主内存。最终。
  • \n
\n

那么低于 10000 的结果是怎么回事呢?

\n
    \n
  1. 线程 T1 开始看到该字段为“0”,并循环 2500 次。它的克隆现在是2500。主内存值仍然是0。这是一个效率核心。
  2. \n
  3. 在此过程中,线程 M(主)现在拿起球并运行 5000 次,其计数器现在为 5000。它比 T1 更快,因为它是动力核心。
  4. \n
  5. CPU 稍微过热,或者只是为了一般的电池节省目的,主处理器被抢占,并且该核心只是进入睡眠状态一段时间。在此架构上,这意味着片上缓存丢失,因此它们被刷新到磁盘。主内存count现在是5000
  6. \n
  7. 您的音乐播放器需要解码更多的 MP3,因为它已经用完了。您的操作系统抢占 T1(这就是它在循环中停止 2500 次的原因),并使用它来解码一些 MP3。在此过程中,T1 带有计数器的缓存页被刷新到主内存,因此主内存现在有counter = 2500其中了。
  8. \n
  9. MP3 解码完成,T1 被重新拾取,一直竞争到最后,完成,并将其值刷新回主内存。
  10. \n
  11. 就在刷新发生之前,M获取核心并需要加载counter,并且它在 T1 写出之前进入,因此加载了 2500。紧接着,T1 将其 10000 写入。
  12. \n
  13. M 运行剩余的 5000 个循环,现在是它的副本counter是 7500。它已经完成,因此将其写回内存。
  14. \n
\n

多田:里面不到一万。

\n

警告:这个故事是虚构的。重点并不是所有 CPU 硬件都以这种方式工作。要点是:这是 [A] 一个看似合理的故事(CPU 确实以这种方式工作),并且 [B] 该故事中没有任何内容违反 JMM 规范。

\n

“为什么会发生这种情况”通常不适用于 java 代码,因为 java 运行在大量的硬件上,并且具有几乎同样大量的工作方式。这就是为什么你必须回到 JMM:JVM可以做什么?您能否想出一种方法,让 JVM 执行您的代码,同时遵守 JMM 中规定的保证,但会导致您的代码不执行您想要的操作?如果答案是“是”,那么您的代码就完全损坏了,并且您无法设法找到架构、操作系统和系统负载的组合来实际导致问题并不相关:您的代码已损坏,如果你继续这样做,有一天它会失败,这将是你的错。那一天可能永远不会到来,直到它运行在全新的硬件(例如,ARM Mac,如果您测试代码的全部是英特尔芯片,则架构完全不同),甚至是新的JDK版本。

\n