为什么我们需要volatile关键字?

19-05-25 banq
                   

volatile字段以确保多个线程始终看到最新值,即使缓存系统或编译器优化正在起作用。从volatile变量读取始终返回此变量的最新写入值。java.uti.concurrent包中的大多数类的方法也具有此属性。通常在内部使用volatile字段。

关于volatile关键字让我着迷的是它是必须的,因为我的软件仍然在硅芯片上运行。即使我的应用程序在Java虚拟机中的虚拟机上运行在云中。但是,尽管所有这些软件层都抽象掉底层硬件,但由于我的软件运行的处理器缓存,仍然需要volatile关键字。

处理器会在每个内核缓存中缓存主内存值,这样提高内存访问性能。虽然从CPU寄存器读取大约300皮秒,但从主存储器读取需要50-100纳秒。通过使用高速缓存,可以减少到大约1纳秒。

现在问题是核心应该何时检查缓存的值是否在另一个核的缓存中被修改了,这是由volatile字段注释完成的。通过将字段声明为volatile,我们告诉JVM,当线程读取volatile字段时,我们希望看到最新的写入值。JVM使用特殊指令告诉CPU它应该同步其缓存。对于x86处理器系列,这些指令称为内存屏障,如此处所述

处理器不仅可以同步volatile字段的值,还可以同步整个缓存。因此,如果我们从volatile字段读取,我们会看到其他内核上的所有写入此变量以及写入volatile变量之前写入这些内核的值。

测试

现在让我们看看它在实践中是如何运作的。让我们看看当我们使用没有volatile注释的字段时我们是否读取过时的值:

public class Termination {
   private int v;
   public void runTest() throws InterruptedException   {
       Thread workerThread = new Thread( () -> { 
           while(v == 0) {
               // spin
           }
       });
       workerThread.start();
       v = 1;
       workerThread.join();  // test might hang up here 
   }
 public static void main(String[] args)  throws InterruptedException {
       for(int i = 0 ; i < 1000 ; i++) {
           new Termination().runTest();
       }
   }    
}

当在一个核中写入线程实现更新字段v,同时读取线程在另一个线程中读取字段v时,测试时应该挂起并永远运行。但至少当我在我的机器上运行测试时,测试永远不会挂起。原因是测试需要很少的CPU周期,两个线程通常在同一个内核上运行。当两个线程在同一个内核上运行时,它们会读取并写入同一个缓存。

幸运的是,OpenJDK提供了一个工具jcstress,它可以帮助进行这类测试。jcstress使用多个技巧,测试的线程在不同的核心上运行。这里上面的例子被重写为jcstress测试:

@JCStressTest(Mode.Termination)
@Outcome(id = "TERMINATED", expect = Expect.ACCEPTABLE, desc = "Gracefully finished.")
@Outcome(id = "STALE", expect = Expect.ACCEPTABLE_INTERESTING, desc = "Test hung up.")
@State
public class APISample_03_Termination {
    int v;
    @Actor
    public void actor1() {
        while (v == 0) {
            // spin
        }
    }
    @Signal
    public void signal() {
        v = 1;
    }
}

此测试来自jcstress示例。通过使用注释@JCStressTest注释类,我们告诉jcstress这个类是一个jcstress测试。jcstress在一个单独的线程中运行用@Actor和@Signal注释的方法。jcstress首先启动actor线程,然后运行信号线程。如果测试在合理的时间内退出,jcstress会记录“TERMINATED”结果,否则结果为“STALE”。

我已经在我的开发机器上运行了这个测试,一次使用普通测试,一次使用volatile字段v。对于volatile字段的测试看起来像这样:

public class APISample_03_Termination {
   volatile int v;
   // methods omitted
}

jcstress使用不同的JVM参数多次运行测试用例。

测试结果表明:使用没有volatile注释的字段确实会挂起线程。挂起线程的百分比取决于JVM标志和环境,JDK版本等。

何时使用volatile字段

volatile字段的另一种用法是使用volatile字段进行读取和锁定以进行写入。或者您可以将它们与JDK 9 VarHandle一起使用以实现原子操作。这里描述了如何实现这些技术。

与happens-before相关

一般情况下人们不直接使用volatile字段。我宁愿使用java.util.concurrent包中的数据结构进行并发编程。其中内部使用volatile字段。

在这些类的文档中,我们经常阅读关于内存一致性效果的事情,与happens-before有关:

内存一致性效果:happen-before异步计算所采取的操作发生在另一个线程中相应的Future.get()之后。

现在,凭借我们对volatile字段的了解,我们可以解码此文档。如果我们从volatile字段读取,我们会看到其他内核上的所有写入此变量。用java.util.concurrent文档的话来说,我们会说对volatile变量的读取会创建happen-before关系到此变量的写入。

所以上面的语句意味着调用Future.get()的线程总是会读取在另外一个线程调用Future接口方法的写入的最新写入值。

我们使用FutureTask类在两个线程之间传输数据作为示例。FutureTask实现接口Future,因此调用方法FutureTask.get()总是能看到通过另一个方法(例如FutureTask.set())写入的最新值。

用于检测缺少的volatile注释的工具

如果您忘记将字段声明为volatile,则线程可能会读取过时的值。但是在测试期间看到这个的机会相当低。由于读取和写入必须几乎在同一时间并且在不同的核心上发生以读取过时值,因此这仅在重负载和长时间运行之后发生,例如在生产中。

因此,在测试运行中存在检测此类问题的工具并不奇怪:

  • ThreadSanitizer可以检测C ++程序中缺少的volatile注释。有一个Java增强提议草案JEP草案:Java Thread Sanitizer将ThreadSanitizer包含在OpenJDK JVM中。这将允许我们在JVM中以及在JVM执行的Java应用程序中找到缺少的volatile注释。
  • vmlens是我编写的用于测试并发java的工具,它可以检测Java测试运行中缺少的volatile注释。

                   

1
zhendong
2019-05-29 00:05

您好,大佬。关于Termination.java测试有些疑问。能问下线程为什么会一直挂起?就算当时读的是过时数据,难道之后不会重新刷新缓存数据变成1吗?

banq
2019-05-29 08:15

挂起线程的百分比取决于JVM标志和环境,JDK版本等。这是一个百分率的挂起问题,类似死锁争夺,所以Redis是单线程写入,避免共享资源争用,过后缓存可能会刷新,但是属于最终一致性了,这里面有点CAP定理里面