Java多线程-如何加入一个CPU密集型线程和使用volatile关键字

8
因此,在进行一些工作面试后,我想编写一个小程序来检查在Java中i++是否真的是非原子性的,并且应该在实践中添加一些锁来保护它。结果发现确实需要这样做,但这不是问题所在。
因此,我编写了这个程序来进行检查。
问题在于它挂起了。似乎主线程在t1.join()行上卡住了,尽管由于前一行的stop=true,两个工作线程都应该完成。
我发现如果:
  • 我在工作线程内部添加一些打印(如注释中所示),可能会导致工作线程有时放弃CPU。
  • 如果我将标志boolean stop标记为volatile,则会立即看到对工作线程的写入。
  • 如果我将计数器t标记为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;
    }
}
4个回答

8
似乎主线程在t1.join()行上卡住了,尽管两个工作线程因前一行的stop = true而应该完成。
如果没有volatile、锁定或其他安全发布机制,JVM没有义务将stop = true对其他线程可见。具体应用到您的情况中,当主线程睡眠一秒钟时,JIT编译器将您的while (!stop)热循环优化为等效形式。
if (!stop) {
    while (true) {
        ...
    }
}

这种优化被称为将“读取”操作从循环中提升出来。
我发现挂起的停止是因为:
- 我在工作线程中添加了一些打印(如注释中所示),可能导致工作线程有时放弃CPU。
不是这个原因,而是因为PrintStream::println是一个同步方法。所有已知的JVM都会在CPU级别发出内存屏障,以确保“获取”操作(在本例中是锁定获取)的语义,这将强制重新加载stop变量。这不是规范要求,而是实现选择。
如果我将标志boolean stop标记为volatile,则原因是写操作立即被工作线程看到
规范实际上没有关于易失性写操作何时必须对其他线程可见的墙壁时钟时间要求,但实践中理解必须很快变得“非常明显”。因此,这种更改是确保将写操作安全发布到并随后由读取它的其他线程观察的正确方式。
如果我将计数器t标记为volatile...对于这一点我不知道是什么导致未挂起。
这些再次是JVM为确保易失性读取的语义而执行的间接影响,这是另一种“获取”线程间操作。
总之,除了将stop变量更改为volatile变量外,您的程序从永久挂起切换到完成,是由于底层JVM实现的意外副作用造成的,为简单起见,它执行了比规范要求的更多的刷新/无效线程局部状态。

很棒的回答,Marko!我发现你下面的评论特别有趣,你能把它作为答案的一部分添加进去吗?“更重要的是,循环内的volatile操作阻止了将stop的读取提升到循环外进行优化。” - Andrew Williamson
已经有了,我添加了术语“提升”以将其与现有材料连接起来。 - Marko Topolnik
哦,我的错,没注意到:D - Andrew Williamson
@Marko 谢谢你提供这个好答案。它详尽、清晰且富有启发性。你能给一些进一步阅读的链接吗?另外,“memory fence”是什么?再次感谢! - Yossi Vainshtein
@YossiVainshtein 嗯,我猜想 "阅读JCIP" 这个刻板印象对我也适用。内存栅栏是 CPU 架构的一个概念,最好去谷歌一下了解一下。在这里没有足够的空间来充分解释它。 - Marko Topolnik

2
这可能是导致问题的原因:
- 将“stop”标记为volatile可以防止其值被缓存在工作线程状态中(例如寄存器)。 - 将“t”标记为volatile确保在访问“t”时读取更新后的值。但是,如果JVM将这些变量排列在一起,则此行为也可能获取“stop”的更新值,因此它们可能会被读取并存储在同一个“缓存行”中。尝试添加@Contended注释以查看在这种情况下行为是否持续。您可以在以下网站上找到更多信息:https://en.wikipedia.org/wiki/False_sharinghttps://mechanical-sympathy.blogspot.am/2011/07/false-sharing.htmlhttps://blogs.oracle.com/dave/entry/java_contented_annotation_to_help。 - 调用“System.out.println()”实际上会执行系统调用,因此会转换到“本机调用堆栈”,这可能也会使“处理器缓存”清除。What does a JVM have to do when calling a native method? 如果您对深入了解该主题感兴趣,我建议阅读Brian Goetz的《Java并发实践》一书。

刚刚更新了答案。将“t”标记为volatile也会使线程从内存中重新读取“stop”,因为它们可能存储在同一“缓存行”中。我认为在这些字段的定义中添加@Contended可能会防止第二点起作用,因为JVM可能会安排它们以便它们不再一起缓存。 - Ruben
1
这种行为还可能从内存中重新读取“stop”---更重要的是,循环内部的volatile操作阻止了将对stop的读取提升到循环外部的优化。 - Marko Topolnik
1
将“将“t”标记为volatile会在每次访问“t”时触发从内存中读取”的说法是不正确的 - 不仅在理论上,在实践中也确实不是volatile的作用(例如,在x86上,它将保留在缓存中)。人们真的必须停止以“从内存中读取”的方式思考。 - Voo
1
确保只有在写入后缓存才会被清除或同步,这也是错误的。它的行为取决于底层架构以及正在编译的特定代码习惯。在x86上没有刷新/清除缓存的操作。在一些非常简单的情况下,编译器甚至可以证明没有其他线程正在读取易失变量,并将其视为本地变量。唯一合理的模型是JMM本身,实际上比您试图引用的现实要简单得多。 - Marko Topolnik
1
@Ruben 那个解释的问题在于它没有明确定义,会导致错误的假设,并且甚至没有涵盖volatile最重要的部分(毕竟它是关于重新排序和可见性的)。例如,你说它意味着“从内存中读取新值”。但是为什么volatile可以保证我能看到对非volatile变量的更新(在某些情况下)?所以这不起作用。或者你的意思是“确保所有值都从内存中读取新值”?但是为什么synchronized(new Object())就没有这种效果呢?等等。 - Voo
显示剩余3条评论

0

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

t标记为volatile让我感到困惑,可能是因为这是一个非常小的程序,tstop在缓存中处于同一行,所以当一个被刷新/同步时,另一个也会被刷新/同步。

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

如果有人能够补充,请务必这样做,我也很想听到更详细的答案。


0

它确实执行了它所说的--在多个线程之间提供对字段的一致访问,并且您可以看到它。

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


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

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

当您在递增t之后设置stop标志时,在循环中进行后续读取时,它将不可见,但下一个递增(易失性写入)将使其可见。


参见

Java语言规范: 8.3.1.4. volatile字段

关于Java内存模型的文章,作者为Java理论与实践的作者


1
当您在增加t之后设置停止标志时,实际上在OP的代码中从未发生过这种情况。如果发生了这种情况,那只意味着线程观察到了它自己对该字段的写入。 - Marko Topolnik
在日常生活中,“after”代表“之后”,而“when”则表示“如果事情这样进行”。写入可以以任何顺序发生,但最明显的情况是在写入volatile字段之后,来自其他线程的写入stop,但循环条件尚未测试,在这种情况下,循环将再次迭代,然后volatile写入计数器清除标志值,就像在条件检查之后发生的一样(实际上并非如此,但代码会这样观察)。 - Pavlus
如果你在谈论指定的行为,那么你所描述的行为是不能保证的。由于stop是一个非易失性变量,在主线程和工作线程之间没有 happens-before 关系。 - Marko Topolnik
不,主线程对stop的写操作不会先于任何其他线程中的任何操作。主线程不执行任何线程间的release操作。 - Marko Topolnik
1
“所有在volatile字段更新之前的写操作(对任何其他字段)都会变得可见”这是错误的。只有在程序顺序中发生在volatile字段写入之前的写操作才会变得可见。换句话说,只有同一线程进行的写操作在它写入volatile字段之前才会执行。由于主线程除了stop变量外没有更新任何内容,实际上并没有什么需要强制刷新的volatile写操作。 - Marko Topolnik
显示剩余3条评论

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