陷阱共享变量需要适当的同步

考虑这个例子:

public class ThreadTest implements Runnable {
   
    private boolean stop = false;
    
    public void run() {
        long counter = 0;
        while (!stop) {
            counter = counter + 1;
        }
        System.out.println("Counted " + counter);
    }

    public static void main(String[] args) {
        ThreadTest tt = new ThreadTest();
        new Thread(tt).start();    // Create and start child thread
        Thread.sleep(1000);
        tt.stop = true;            // Tell child thread to stop.
    }
}

该程序的目的是启动一个线程,让它运行 1000 毫秒,然后通过设置 stop 标志使其停止。

它会按预期工作吗?

可能是,可能不是。

main 方法返回时,应用程序不一定会停止。如果已创建另一个线程,并且该线程尚未标记为守护程序线程,则应用程序将在主线程结束后继续运行。在此示例中,这意味着应用程序将继续运行,直到子线程结束。当 tt.stop 设置为 true 时,应该会发生这种情况。

但事实并非如此。实际上,子线程在观察到值为 truestop 后会停止。那会发生吗?可能是,可能不是。

Java 语言规范保证线程中的内存读取和写入对于该线程是可见的,根据源代码中的语句顺序。但是,通常,当一个线程写入而另一个线程(随后)读取时,不保证这一点。为了获得有保证的可见性,需要在写入和后续读取之间存在一系列先发生关系。在上面的示例中,没有用于更新 stop 标志的链,因此无法保证子线程将 stop 更改为 true

(作者注意:Java 内存模型应该有一个单独的主题,以深入了解技术细节。)

我们如何解决这个问题?

在这种情况下,有两种简单的方法可以确保 stop 更新可见:

  1. 宣称 stopvolatile; 即

     private volatile boolean stop = false;
    

    对于 volatile 变量,JLS 指定一个线程的写入和第二个线程的后一个读取之间存在一个先发生的关系。

  2. 使用互斥锁进行同步,如下所示:

public class ThreadTest implements Runnable {
   
    private boolean stop = false;
    
    public void run() {
        long counter = 0;
        while (true) {
            synchronize (this) {
                if (stop) {
                    break;
                }
            }
            counter = counter + 1;
        }
        System.out.println("Counted " + counter);
    }

    public static void main(String[] args) {
        ThreadTest tt = new ThreadTest();
        new Thread(tt).start();    // Create and start child thread
        Thread.sleep(1000);
        synchronize (tt) {
            tt.stop = true;        // Tell child thread to stop.
        }
    }
}

除了确保存在互斥之外,JLS 还指定在一个线程中释放互斥锁与在第二个线程中获得相同的互斥锁之间存在先发生关系。

但是不是分配原子?

是的!

但是,这一事实并不意味着更新的效果将同时显示在所有线程上。只有恰当的先发生过关系链才能保证这一点。

他们为什么这么做?

在 Java 中进行多线程编程的程序员第一次发现内存模型具有挑战性。程序的行为方式不直观,因为自然的期望是写入是统一可见的。那么 Java 设计师为什么要这样设计内存模型呢?

它实际上归结为性能和易用性之间的折衷(对程序员而言)。

现代计算机体系结构由具有单独寄存器组的多个处理器(核)组成。主存储器可供所有处理器或处理器组访问。现代计算机硬件的另一个特性是访问寄存器的访问速度通常比访问主存储器快几个数量级。随着核心数量的增加,很容易看出对主存储器的读写可能成为系统的主要性能瓶颈。

通过在处理器核和主存储器之间实现一个或多个级别的存储器高速缓存来解决这种不匹配。每个核心通过其缓存访问存储器单元。通常,只有在存在高速缓存未命中时才会发生主存储器读取,而只有在需要刷新高速缓存行时才会发生主存储器写入。对于每个内核的工作内存位置都适合其缓存的应用程序,核心速度不再受主内存速度/带宽的限制。

但是当多个内核读写共享变量时,这给我们带来了新的问题。最新版本的变量可能位于一个核心的缓存中。除非该核心将高速缓存行刷新到主存储器,并且其他核心使其旧版本的高速缓存副本无效,否则其中一些核心可能会看到该变量的陈旧版本。但是,如果每次有高速缓存写入(以防万一存在另一个核的读取)时,高速缓存被刷新到内存中,这将不必要地消耗主存储器带宽。

硬件指令集级别使用的标准解决方案是提供高速缓存失效和高速缓存直写的指令,并将其留给编译器决定何时使用它们。

回到 Java。内存模型的设计使得 Java 编译器不需要在不需要它们的情况下发出缓存失效和直写指令。假设程序员将使用适当的同步机制(例如原始互斥体,volatile,更高级别的并发类等)来指示它需要内存可见性。在没有事先发生关系的情况下,Java 编译器可以自由地假设不需要缓存操作(或类似)。

这对于多线程应用程序具有显着的性能优势,但缺点是编写正确的多线程应用程序并不是一件简单的事情。程序员必须了解他或她在做什么。

为什么我不能重现这个?

有很多原因导致像这样的问题难以重现:

  1. 如上所述,不正确处理内存可见性问题的后果通常是编译后的应用程序无法正确处理内存缓存。但是,正如我们上面提到的,内存缓存无论如何都经常被刷新。

  2. 更改硬件平台时,内存缓存的特征可能会更改。如果你的应用程序未正确同步,这可能会导致不同的行为。

  3. 你可能正在观察偶然同步的影响。例如,如果添加跟踪,则通常会在 I / O 流的幕后发生一些同步,从而导致缓存刷新。因此,添加跟踪图通常会导致应用程序的行为不同。

  4. 在调试器下运行应用程序会导致 JIT 编译器对其进行不同的编译。断点和单步踩踏加剧了这种情况。这些效果通常会改变应用程序的行为方式。

这些因素导致同步不充分的错误特别难以解决。