Java中volatile关键字的最简单易懂的示例

85

我正在阅读有关Java中volatile关键字的内容,并完全理解了其理论部分。

但是,我正在寻找一个好的案例示例,展示如果变量不是volatile会发生什么以及如果它是volatile会发生什么。

下面的代码片段不能按预期工作(摘自这里):

class Test extends Thread {

    boolean keepRunning = true;

    public void run() {
        while (keepRunning) {
        }

        System.out.println("Thread terminated.");
    }

    public static void main(String[] args) throws InterruptedException {
        Test t = new Test();
        t.start();
        Thread.sleep(1000);
        t.keepRunning = false;
        System.out.println("keepRunning set to false.");
    }
}

理想情况下,如果keepRunning没有volatile修饰符,线程应该无限期运行。但是,它在几秒钟后停止了。
我有两个基本问题:
  • 有人可以举例说明volatile吗?不要用JLS的理论解释。
  • volatile是否可以代替同步?它是否实现原子性操作?

一篇过去的帖子详细讨论了它 https://dev59.com/aVrUa4cB1Zd3GeqPmLDK - AurA
6
你的想法是错误的。理想情况下,如果keepRunning不是volatile,线程应该一直运行下去。事实上正好相反:添加“volatile”可以确保对字段的更改可见。如果没有这个关键字,就没有任何保证,可能会发生任何事情;你不能说线程应该一直运行下去。 - Bruno Reis
3
事情是这样的:由于其本质,内存可见性错误很难(不可能?)通过一个简单的每次都会失败的示例进行演示。假设您拥有一个多核计算机,如果您经常运行示例(比如1000次),则该示例可能会至少失败几次。如果您拥有一个大型程序 - 例如整个程序及其对象无法适应CPU缓存 - 那么就增加了发现错误的概率。基本上,并发错误是这样的,如果理论上说它可能会出错,那么它可能只会在生产环境中每隔几个月才出现一次。 - yshavit
已经有一个很好的例子列在了https://dev59.com/hm025IYBdhLWcg3wwYz4 - gurubelli
这里有一个示例和说明:http://vanillajava.blogspot.co.uk/2012/01/demonstrating-when-volatile-is-required.html - Peter Lawrey
@PeterLawrey - 那篇文章充满了误导性的猜测和错误信息,RequiresVolatileMain无法显示volatile的必要性。如果每个线程都缓存了自己的"value"副本,我们将始终看到这样的结果:Sets true: value=true target=trueSets false: value=false target=false,但是:大量情况可能会导致相同的输出... 尝试在blogspot上发布相同内容,但“评论仅限于团队成员”。 - Vlad
13个回答

52

易失性变量 --> 保证可见性但不保证原子性

同步(加锁)--> 保证可见性和原子性(如果正确使用)

易失性变量不能替代同步

只有在更新引用而不执行其他操作时才使用volatile。

示例:

volatile int i = 0;

public void incrementI(){
   i++;
}

如果不使用同步或AtomicInteger作为增量是一个复合操作,那么程序将无法保证线程安全。

为什么程序不会无限运行?

这取决于各种情况。在大多数情况下,JVM足够聪明以刷新内容。

正确使用volatile讨论了volatile的各种可能用途。正确使用volatile很棘手,我会说“当你有疑问时,请忽略它”,改用同步块。

此外:

同步块可以用来代替volatile,但反过来则不成立


4
这是错误的。volatile 保证了原子性。Oracle 文档明确指出了这一点。请参见 http://docs.oracle.com/javase/tutorial/essential/concurrency/atomic.html。 - Kalpa Gunarathna
5
在Java中,当我们有多个线程时,每个线程都有自己的堆栈(内存空间),并且在初始化时,每个线程都有它自己可以访问的变量的副本。如果没有使用volatile关键字来修饰int i,则每个线程可能会在其执行中使用它。声明为volatile后,每个线程必须直接从/到主内存中读取/写入i的值,而不是从/到本地副本。因此,在每个线程的角度看来,对/来自变量i的操作是原子的。 - Kalpa Gunarathna
1
“atomicity” 部分的答案有些令人困惑。同步提供了“互斥访问”和“可见性”。volatile 只提供“可见性”。此外,volatile 会使得对 longdouble 的读/写操作具有原子性(同步也通过其互斥性质实现了这一点)。 - IliaEremin

28
针对您的特定示例:如果未声明为volatile,则服务器JVM可能会将keepRunning变量提升出循环,因为它在循环中没有被修改(将其转换为无限循环),但客户端JVM不会。这就是为什么您看到不同结果的原因。
关于volatile变量的一般解释如下:
当一个字段被声明为volatile时,编译器和运行时会注意到这个变量是共享的,并且对它的操作不应该与其他内存操作重新排序。volatile变量不会被缓存在寄存器或缓存中,因此读取volatile变量总是返回任何线程最近写入的值
volatile变量的可见性效果超出了volatile变量本身的值。当线程A写入volatile变量并随后线程B读取相同的变量时,A在写入volatile变量之前可见的所有变量的值在读取volatile变量后对B可见。
volatile变量最常见的用途是作为完成、中断或状态标志:
  volatile boolean flag;
  while (!flag)  {
     // do something untill flag is true
  }

易失性变量可用于其他类型的状态信息,但在尝试时需要更加小心。例如,除非您可以保证仅从单个线程写入变量,否则易失性的语义不足以使增量操作(count++)成为原子操作。

锁定可以保证可见性和原子性;易失性变量只能保证可见性。

只有满足以下所有条件时,才能使用易失性变量:

  • 对变量的写入不依赖于其当前值,或者您可以确保仅有一个线程更新该值;
  • 该变量不参与与其他状态变量的不变式;并且
  • 在访问变量时不需要出于任何其他原因进行锁定。

调试提示:在调用JVM时,请务必始终指定-server JVM命令行开关,即使是在开发和测试中也要如此。服务器JVM执行比客户端JVM更多的优化,例如将未在循环中修改的变量提升出循环;在开发环境(客户端JVM)中可能有效的代码可能会在部署环境(服务器JVM)中出现问题。

这是来自 《Java并发编程实战》 的摘录,是这个领域最好的书籍。

19

我稍微修改了你的示例。现在使用带有volatile和非volatile成员的示例:

class TestVolatile extends Thread{
    //volatile
    boolean keepRunning = true;

    public void run() {
        long count=0;
        while (keepRunning) {
            count++;
        }

        System.out.println("Thread terminated." + count);
    }

    public static void main(String[] args) throws InterruptedException {
        TestVolatile t = new TestVolatile();
        t.start();
        Thread.sleep(1000);
        System.out.println("after sleeping in main");
        t.keepRunning = false;
        t.join();
        System.out.println("keepRunning set to " + t.keepRunning);
    }
}

很好的例子。这在我这里完美地运作了。如果没有在keepRunning上加上volatile,线程将永远挂起。一旦将keepRunning标记为volatile,它在t.keepRunning = false;之后停止。 - Boris
6
这个例子对我非常有帮助,我一直在寻找实用的例子。我点赞是因为它对我很有帮助,并且缺少解释并不会带来负面影响,所以不应该被踩。 - John Doe
1
嗨paritosht和@John Doe,你们能帮忙解释一下为什么你们的代码是一个可行的例子吗?当我的机器执行问题中提供的代码时,无论是否使用volatile关键字,它都会停止。 - shanwu
1
我在这里使用和不使用votalite得到了相同的结果。 - W W

19

什么是volatile关键字?volatile关键字可以防止变量被缓存。

考虑以下代码,首先没有使用volatile关键字:

class MyThread extends Thread {
    private boolean running = true;   //non-volatile keyword

    public void run() {
        while (running) {
            System.out.println("hello");
        }
    }

    public void shutdown() {
        running = false;
    }
}

public class Main {

    public static void main(String[] args) {
        MyThread obj = new MyThread();
        obj.start();

        Scanner input = new Scanner(System.in);
        input.nextLine(); 
        obj.shutdown();   
    }    
}
理想情况下,这个程序应该打印出hello直到按下Return键。但是在某些机器上,变量running可能被缓存,你无法从shutdown()方法中改变它的值,这导致hello文本无限打印。 因此,使用volatile关键字可以确保您的变量不会被缓存,并且代码将在所有机器上正常运行。
private volatile boolean running = true;  //volatile keyword

使用volatile关键字是一种好的、更安全的编程实践。


使用volatile关键字是一种良好且更安全的编程实践。但这并不意味着你应该毫无保留地将其添加到所有变量中。如果你有其他确保数据同步的方法,并且出于性能原因需要缓存,那么volatile并不是一个好的或更安全的选择。volatile是一种工具,就像其他任何东西一样,如果滥用会带来问题。 - ggorlen

7

变量易失性:易失关键字适用于变量。Java中的易失关键字保证易失变量的值始终从主内存中读取,而不是从线程的本地缓存中读取。

Access_Modifier volatile DataType Variable_Name;

易失性字段:向虚拟机表明多个线程可能同时尝试访问/更新字段的值。一种特殊类型的实例变量,必须在所有具有修改值的线程之间共享。类似于静态(类)变量,易失性值的唯一副本被缓存在主内存中,因此在进行任何ALU操作之前,每个线程都必须从主内存中读取更新后的值,然后在ALU操作之后直接写入主内存。(对易失性变量v的写入与任何线程后续读取v同步)这意味着对易失性变量的更改始终对其他线程可见。

enter image description here

如果线程t1更改了t1缓存中的值,对于非易失性变量,线程t2无法访问更改后的值,直到t1写入,并从主内存中读取最近修改的值,这可能导致数据不一致。

volatile cannot be cached - assembler

    +--------------+--------+-------------------------------------+
    |  Flag Name   |  Value | Interpretation                      |
    +--------------+--------+-------------------------------------+
    | ACC_VOLATILE | 0x0040 | Declared volatile; cannot be cached.|
    +--------------+--------+-------------------------------------+
    |ACC_TRANSIENT | 0x0080 | Declared transient; not written or  |
    |              |        | read by a persistent object manager.|
    +--------------+--------+-------------------------------------+

共享变量:可以在线程之间共享的内存称为共享内存或堆内存。所有实例字段、静态字段和数组元素都存储在堆内存中。

同步:synchronized 可应用于方法、块。它允许在对象上一次只执行一个线程。如果 t1 获取了控制权,则其余线程必须等待直到它释放控制权。

示例:

public class VolatileTest implements Runnable {

    private static final int MegaBytes = 10241024;

    private static final Object counterLock = new Object();
    private static int counter = 0;
    private static volatile int counter1 = 0;

    private volatile int counter2 = 0;
    private int counter3 = 0;

    @Override
    public void run() {
        for (int i = 0; i < 5; i++) {
            concurrentMethodWrong();
        }

    }

    void addInstanceVolatile() {
        synchronized (counterLock) {
            counter2 = counter2 + 1;
            System.out.println( Thread.currentThread().getName() +"\t\t « InstanceVolatile :: "+ counter2);
        }
    }

    public void concurrentMethodWrong() {
        counter = counter + 1;
        System.out.println( Thread.currentThread().getName() +" « Static :: "+ counter);
        sleepThread( 1/4 );

        counter1 = counter1 + 1;
        System.out.println( Thread.currentThread().getName() +"\t « StaticVolatile :: "+ counter1);
        sleepThread( 1/4 );

        addInstanceVolatile();
        sleepThread( 1/4 );

        counter3 = counter3 + 1;
        sleepThread( 1/4 );
        System.out.println( Thread.currentThread().getName() +"\t\t\t\t\t « Instance :: "+ counter3);
    }
    public static void main(String[] args) throws InterruptedException {
        Runtime runtime = Runtime.getRuntime();

        int availableProcessors = runtime.availableProcessors();
        System.out.println("availableProcessors :: "+availableProcessors);
        System.out.println("MAX JVM will attempt to use : "+ runtime.maxMemory() / MegaBytes );
        System.out.println("JVM totalMemory also equals to initial heap size of JVM : "+ runtime.totalMemory() / MegaBytes );
        System.out.println("Returns the amount of free memory in the JVM : "+ untime.freeMemory() / MegaBytes );
        System.out.println(" ===== ----- ===== ");

        VolatileTest volatileTest = new VolatileTest();
        Thread t1 = new Thread( volatileTest );
        t1.start();

        Thread t2 = new Thread( volatileTest );
        t2.start();

        Thread t3 = new Thread( volatileTest );
        t3.start();

        Thread t4 = new Thread( volatileTest );
        t4.start();

        Thread.sleep( 10 );;

        Thread optimizeation = new Thread() {
            @Override public void run() {
                System.out.println("Thread Start.");

                Integer appendingVal = volatileTest.counter2 + volatileTest.counter2 + volatileTest.counter2;

                System.out.println("End of Thread." + appendingVal);
            }
        };
        optimizeation.start();
    }

    public void sleepThread( long sec ) {
        try {
            Thread.sleep( sec * 1000 );
        } catch (InterruptedException e) {
            e.printStackTrace();
        }
    }
}

静态[类字段] vs 易失性[实例字段] - 两者都不被线程缓存

  • 静态字段对所有线程都是共用的,并存储在方法区中。静态字段与易失性无用。静态字段不能被序列化。

  • 易失性主要用于存储在堆区域中的实例变量。易失性的主要用途是在所有线程中维护更新的值。实例易失性字段可以序列化

@see


7
理想情况下,如果keepRunning不是volatile的,线程应该无限制地继续运行。但是,它在几秒钟后停止了。
如果您在单处理器上运行或者系统非常繁忙,操作系统可能会交换线程,这会导致某些级别的缓存失效。没有volatile并不意味着内存不会被共享,但是JVM为了性能而尝试不同步内存,因此内存可能不会被更新。
另一件需要注意的事情是System.out.println(...)是同步的,因为底层的PrintStream进行同步以停止重叠输出。所以你在主线程中获得了内存同步“免费”的好处。然而,这仍然不能解释为什么读取循环会看到更新。
无论println(...)行是否存在,在我的MacBook Pro上的Intel i7上使用Java6都可以运行您的程序。
有人能用例子解释一下volatile吗?不要用JLS中的理论。
我认为你的例子很好。不确定为什么删除所有System.out.println(...)语句后它不起作用。对我来说它有效。
volatile是否可以替代同步?它是否实现原子性?
就内存同步而言,volatile会引起与synchronized块相同的内存屏障,只是volatile屏障是单向的,而不是双向的。volatile读取会引起加载屏障,而写入则会引起存储屏障。synchronized块是一个双向屏障,加上互斥锁定。
就原子性而言,答案是“这取决于情况”。如果您从字段中读取或写入值,则volatile提供适当的原子性。然而,增加volatile字段会受到++实际上是3个操作的限制:读取、增加、写入。在这种情况下或更复杂的互斥情况下,可能需要完整的synchronized块。AtomicInteger通过一个复杂的测试和设置自旋循环解决了++问题。

我注释掉了两个SOPln语句,但是它仍然在几秒钟后停止执行...你能给我展示一个能够按预期运行的例子吗? - tmgr
你在使用单处理器系统吗,@tm99?因为在我的Macbook Pro Java6上,你的程序一直旋转。 - Gray
不要紧,@JeffStorey。任何synchronized块(或任何volatile字段)都会导致所有内存同步。 - Gray
2
“任何同步块(或任何易失性字段)都会导致所有内存同步” - 你确定吗?你能提供JLS参考吗?据我所记,唯一的保证是在释放锁L1之前对内存所做的修改对于在获取相同锁L1后的线程是可见的;对于易失性变量,所有在易失性写入F1之前进行的内存修改对于在读取相同字段F1的易失性读取之后的线程是可见的,这与说“所有”内存已同步非常不同。这并不像任何运行同步块的线程那么简单。 - Bruno Reis
1
当穿过任何内存屏障(使用synchronizedvolatile)时,所有内存都存在“先于发生”的关系。除非您在相同的监视器上锁定,否则无法保证锁和同步的顺序,这就是@BrunoReis所指的内容。但是,如果println(...)完成,则可以保证keepRunning字段已更新。 - Gray
显示剩余7条评论

3

当一个变量被标记为volatile时,它保证不会被缓存,不同的线程将看到更新后的值。然而,如果没有标记为volatile并不保证相反的情况。在JVM中,volatile是长期存在问题且仍然不太被理解的东西之一。


在现代的多处理器中,@Jeff,你最后的评论有些错误/误导。JVM非常聪明,不会刷新该值,因为这样做会影响性能。 - Gray
当主线程将keepRunning设置为false时,线程仍然可以看到更新,因为JVM会智能地刷新该值。但这并不是保证的(请参见@Gray的评论)。 - Jeff Storey

2
public class VolatileDemo {
    static class Processor {
        //without volatile program keeps running on my platform
        private boolean flag = false;

        public void setFlag() {
            System.out.println("setting flag true");
            this.flag = true;
        }

        public void process() {
            while(!flag) {
                int x = 5;
                // using sleep or sout will end the program without volatile.
                // Probably these operations, cause thread to be rescheduled, read from memory. Thus read new flag value and end.
            }

            System.out.println("Ending");
        }
    }

    public static void main(String[] args) throws InterruptedException {
        Processor processor = new Processor();
        Thread t1 = new Thread(processor::process);

        t1.start();

        Thread.sleep(2000);
        processor.setFlag();

    }
}

2
很多很好的例子,但我想补充一点,有许多情况下需要使用volatile,所以没有一个具体的例子可以支配它们。
  1. 您可以使用volatile来强制所有线程从主内存获取变量的最新值。
  2. 您可以使用synchronization来保护关键数据。
  3. 您可以使用Lock API。
  4. 您可以使用Atomic变量。
查看更多Java volatile examples

2

volatile 关键字不会在所有的JVM和编译器中都产生巨大的变化。但是,在许多情况下,它可以是优化时成功注意到变量更改与失败之间的区别。

基本上,优化器可以选择将非volatile变量放置在寄存器或堆栈上。如果另一个线程在堆上或类的原语中更改了它们,其他线程将继续在堆栈上查找它们,并且它们将是过时的。

volatile 确保这样的优化不会发生,并且所有的读写都直接在堆或其他所有线程都能看到的地方进行。


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