【问题标题】:Java multithreading - joining a CPU heavy thread and volatile keywordJava 多线程 - 加入 CPU 繁重的线程和 volatile 关键字
【发布时间】:2017-08-27 23:26:25
【问题描述】:

所以,在一些工作面试之后,我想编写一个小程序来检查 i++ 在 java 中是否真的是非原子的,并且在实践中应该添加一些锁定来保护它。事实证明你应该这样做,但这不是这里的问题。

所以我在这里写了这个程序只是为了检查它。

问题是,它挂了。似乎主线程卡在t1.join() 行,即使两个工作线程都应该因为上一行的stop = true 而完成。

我发现悬挂停止如果:

  • 我在工作线程中添加了一些打印(如在 cmets 中),可能导致工作线程有时放弃 CPU 或
  • 如果我将标志boolean stop 标记为volatile,导致立即写入 被工作线程看到,或者
  • 如果我将计数器标记为 tas volatile... 我不知道是什么原因导致未挂起。

有人可以解释发生了什么吗?为什么我会看到挂起,为什么在这三种情况下它会停止?

public class Test {   

    static /* volatile */ long t = 0;
    static long[] counters = new long[2]; 
    static /* volatile */ boolean stop = false;

    static Object o = new Object();
    public static void main(String[] args) 
    {
        Thread t1 = createThread(0);
        Thread t2 = createThread(1);

        t1.start();
        t2.start();

        Thread.sleep(1000);

        stop = true;

        t1.join();
        t2.join();

        System.out.println("counter : " + t + " counters : " + counters[0] + ", " + counters[1]  + " (sum  : " + (counters[0] + counters[1]) + ")");

    }

    private static Thread createThread(final int i)
    {
        Thread thread = new Thread() { 
            public void run() {
                while (!stop)
                {
//                  synchronized (o) {                      
                        t++;
//                  }

//                  if (counters[i] % 1000000 == 0)
//                  {
//                      System.out.println(i + ")" + counters[i]); 
//                  }
                    counters[i]++;
                }
            }; 
        };
        return thread;
    }
}

【问题讨论】:

    标签: java multithreading volatile


    【解决方案1】:

    似乎主线程卡在t1.join() 行,即使两个工作线程都应该完成,因为上一行的stop = true

    在没有volatile、锁定或其他安全发布机制的情况下,JVM 没有义务永远使stop = true 对其他线程可见。特别适用于您的情况,当您的主线程休眠一秒钟时,JIT 编译器将您的 while (!stop) 热循环优化为相当于

    if (!stop) {
        while (true) {
            ...
        }
    }
    

    这种特殊的优化被称为“提升”循环外的读取操作。

    我发现如果:

    • 我在工作线程中添加了一些打印(如在 cmets 中),可能导致工作线程有时会放弃 CPU

    不,这是因为PrintStream::println 是一个同步方法。所有已知的 JVM 都会在 CPU 级别发出内存栅栏,以确保“获取”操作(在本例中为锁定获取)的语义,这将强制重新加载 stop 变量。这不是规范要求的,只是一种实现选择。

    • 如果我将标志 boolean stop 标记为 volatile,则会导致工作线程立即看到写入

    该规范实际上没有关于何时必须对其他线程可见易失性写入的挂钟时间要求,但在实践中,可以理解它必须“很快”变得可见。因此,此更改是确保对stop 的写入安全发布到其他读取它的线程并随后被其他线程观察的正确方法。

    • 如果我将计数器 t 标记为易失性...为此,我不知道是什么导致了取消挂起。

    这些又是 JVM 为确保 volatile 读取的语义所做的间接影响,这是另一种“获取”线程间操作。

    总而言之,除了使stop 成为易失性变量的更改之外,由于底层 JVM 实现的意外副作用,您的程序从永远挂起切换到完成,为简单起见,它会进行更多的线程刷新/无效- 超出规范要求的本地状态。

    【讨论】:

    • 很好的答案,马尔科!我发现您在下面的评论特别有趣,您可以将其添加为答案的一部分吗? “更重要的是,循环内部的 volatile 操作阻止了提升循环外停止读取的优化”
    • 它已经存在了,我添加了术语“吊装”以将其连接到现有材料。
    • 哦,我的错,错过了:D
    • @Marko 感谢您的出色回答。它完整​​、清晰且内容丰富。你能提供一些链接以供进一步阅读吗?什么是“记忆栅栏”?再次感谢!
    • @YossiVainshtein 嗯,我猜“阅读 JCIP”的刻板印象也适合我。内存栅栏是 CPU 架构的一个概念,你最好用谷歌搜索一下。这里没有足够的空间来伸张正义。
    【解决方案2】:

    这些可能是可能的原因:

    如果您有兴趣深入研究该主题,那么我建议您阅读“Brian Goetz 的 Java 并发实践”一书。

    【讨论】:

    • 刚刚更新了答案。将“t”标记为 volatile 也会使线程从内存中重新读取“停止”,因为它们可能存储在同一个“缓存行”中。我认为在这些字段的定义中添加 @Contended 可能会阻止第二点起作用,因为 JVM 可能会以某种方式排列它们,因此它们不再缓存在一起。
    • this behavior might also reread "stop" from the memory---更重要的是,循环内部的易失性操作阻止了将stop的读取提升到循环外的优化。
    • "将 "t" 标记为 volatile 触发器,每次访问 "t" 时都会从内存中读取" 这不正确 - 不仅在理论上,甚至在实践中也不是 volatile 所做的(它会保持正常例如在 x86 上的缓存中)。人们真的不得不停止思考“从记忆中阅读”。
    • makes sure that only after a write the caches are either purged or synchronized---这也是错误的。它的作用取决于底层架构以及正在编译的特定代码习惯用法。在 x86 上没有进行缓存的刷新/清除。在一些非常简单的情况下,编译器甚至可以证明没有其他线程正在读取 volatile 变量并将其视为本地 var。唯一合理的模型是 JMM 本身,它实际上比您要参考的现实要简单得多。
    • @Ruben 这个解释的问题在于它定义不明确,导致错误的假设,甚至没有涵盖 volatile 最重要的部分(毕竟它是关于重新排序和可见性)。例如,您说它的意思是“从记忆中读取新的价值”。但是为什么 volatile 会保证我会看到非易失性变量的更新(在某些情况下)?所以这行不通。还是您的意思是“确保所有值都是从内存中读取的”?但是为什么synchronized(new Object()) 没有那个效果呢?以此类推。
    【解决方案3】:

    将变量标记为 volatile 是 JVM 的一个提示,当该变量更新时,它会在线程/内核之间刷新/同步相关的缓存段。将 stop 标记为 volatile 会有更好的行为(但并不完美,在线程看到更新之前,您可能需要在线程上执行一些额外的操作)。

    t 标记为 volatile 让我不明白为什么它会起作用,可能是因为这是一个如此小的程序,tstop 在缓存中位于同一行,所以当一个被刷新时/synced 另一个也一样。

    System.out.println 是线程安全的,所以内部会进行一些同步。同样,这可能会导致缓存的某些部分在线程之间同步。

    如果有人可以对此进行补充,请这样做,我也很想听到更详细的答案。

    【讨论】:

      【解决方案4】:

      实际上,它确实做到了——在多个线程之间提供对字段的一致访问,您可以看到它。

      如果没有volatile关键字,多线程对字段的访问不能保证一致,编译器可以引入一些优化,比如缓存在CPU寄存器中,或者不从CPU内核本地缓存写出到外部内存或共享缓存.


      对于具有非易失性stop 和易失性t 的部分

      根据 JSR-133 Java 内存模型规范,在 volatile 字段更新之前的所有写入(对任何其他字段)都是可见的,它们是 happened-before 操作。

      当您在增加t 后设置stop 标志时,循环中的后续读取将看不到它,但下一次增加 (volatile-write) 将使其可见。 p>


      另见

      Java Language Specification: 8.3.1.4. volatile Fields

      An article about Java Memory Model, from the author of Java theory and practice

      【讨论】:

      • when you set stop flag after incrementing t---这实际上从未在 OP 的代码中发生。如果确实发生了,那只是意味着线程观察到它自己对该字段的写入。
      • 日常生活中的“之后”和“当”的意思是“如果事情这样发展”。写入可以按任何顺序发生,但最明显的情况是在写入 volatile 字段后发生从其他线程对stop 的写入,但尚未测试循环条件,在这种情况下,循环将再迭代一次,然后对计数器的 volatile 写入会刷新标志值,就像在条件检查之后发生的一样(实际上并非如此,但代码会以这种方式观察它)
      • 如果您在谈论指定的行为,那么您所描述的内容并不能保证。 stop 是一个非易失性变量,主线程和工作线程之间没有发生之前。
      • stop 是非易失性且t 是易失性,但@987654333 @ 更改在线程之间是可见的,因为它们发生在写入 volatile 字段 t 之前,对于 JSR-133。
      • all writes (to any other field) before volatile field update are made visible --- 这是不正确的。只有在 volatile 字段写入之前按程序顺序发生的写入才可见。换句话说,只有在写入 volatile 字段之前由同一线程执行的写入。由于主线程除了 stop 变量外不更新任何内容,因此实际上没有 volatile write 可言。