volatile关键字有什么用处?

805
今天在工作中,我遇到了Java中的volatile关键字。由于不是很熟悉它,我找到了这篇说明文章
鉴于该文章详细解释了关键字的相关内容,您是否曾经使用过它,或者是否可以想象出可以正确使用该关键字的情况?
25个回答

9
假设一个线程修改了共享变量的值,如果您没有为该变量使用volatile修饰符,则当其他线程想要读取此变量的值时,它们不会看到更新后的值,因为它们从CPU的缓存而不是RAM内存中读取变量的值。这个问题也被称为可见性问题
通过声明共享变量为volatile,所有对计数器变量的写入将立即写回主内存。此外,所有对计数器变量的读取将直接从主内存中读取。
public class SharedObject {
    public volatile int sharedVariable = 0;
}

使用非易失性变量时,Java虚拟机(JVM)读取数据从主存储器到CPU高速缓存或将数据从CPU高速缓存写入主存储器的时间没有保证。这可能会引起一些问题,接下来我将进行解释。

例子:

想象一种情况,其中两个或更多线程可以访问一个包含计数器变量的共享对象,该变量声明如下:

public class SharedObject {
    public int counter = 0;
}

假设只有线程1增加计数器变量,但线程1和线程2都可能不时地读取计数器变量。如果未将计数器变量声明为易失性的,则无法保证何时将计数器变量的值从 CPU 缓存写回主内存。这意味着,CPU 缓存中的计数器变量值可能与主内存中的值不同。此情况在此处说明:

volatile

线程无法看到变量的最新值,因为它还没有被另一个线程写回主内存,这就是所谓的“可见性”问题。一个线程的更新对其他线程不可见。

主线程(父线程)通常直接更新RAM中的所有内容吗?还是主线程也有缓存? - Anand Kumar
在Java中,一般情况下(非多线程场景),缓存何时更新RAM? - Anand Kumar
@Anand Kumar,线程是CPU核心在特定时间执行某些代码。每个核心都有自己的私有L1和L2缓存,L3通常在核心之间共享。当CPU执行内存写入时,它首先进入缓存。在x86上,缓存是写回的:缓存行在不可预测的时间刷新到RAM,通常是在缓存行被驱逐以便从RAM加载另一个缓存行时。Java与此无关,除非变量是易失性的,在这种情况下,我期望它执行缓存行刷新指令。 - raiks

5
如果您正在开发一个多线程应用程序,比如桌面应用程序,那么您需要使用“volatile”关键字或“synchronized”和其他并发控制工具和技术。如果您正在开发将部署到应用服务器(Tomcat、JBoss AS、Glassfish等)的应用程序,则不必自己处理并发控制,因为应用服务器已经解决了这个问题。事实上,如果我记得正确,Java EE标准禁止在servlets和EJB中进行任何并发控制,因为它是“基础设施”层的一部分,您应该从中解脱出来。只有在实现单例对象时才需要在此类应用程序中执行并发控制。如果您使用Spring等框架编织组件,则已经解决了这个问题。因此,在大多数Java开发的情况下,如果应用程序是Web应用程序并且使用IoC框架(如Spring或EJB),则无需使用“volatile”。

5

volatile只能保证所有线程(包括自己)都在增加。例如:计数器同时看到变量的同一面。它不是用来代替synchronized、atomic或其他东西的,它完全使读取同步。请不要将其与其他Java关键字进行比较。正如下面的示例所示,volatile变量操作也是原子的,它们会一次性失败或成功。

package io.netty.example.telnet;

import java.util.ArrayList;
import java.util.List;

public class Main {

    public static volatile  int a = 0;
    public static void main(String args[]) throws InterruptedException{

        List<Thread> list = new  ArrayList<Thread>();
        for(int i = 0 ; i<11 ;i++){
            list.add(new Pojo());
        }

        for (Thread thread : list) {
            thread.start();
        }

        Thread.sleep(20000);
        System.out.println(a);
    }
}
class Pojo extends Thread{
    int a = 10001;
    public void run() {
        while(a-->0){
            try {
                Thread.sleep(1);
            } catch (InterruptedException e) {
                e.printStackTrace();
            }
            Main.a++;
            System.out.println("a = "+Main.a);
        }
    }
}

无论是否使用volatile关键字,结果都会不同。但是如果您像下面这样使用AtomicInteger,则结果将始终相同。这与synchronized关键字的作用相同。

    package io.netty.example.telnet;

    import java.util.ArrayList;
    import java.util.List;
    import java.util.concurrent.atomic.AtomicInteger;

    public class Main {

        public static volatile  AtomicInteger a = new AtomicInteger(0);
        public static void main(String args[]) throws InterruptedException{

            List<Thread> list = new  ArrayList<Thread>();
            for(int i = 0 ; i<11 ;i++){
                list.add(new Pojo());
            }

            for (Thread thread : list) {
                thread.start();
            }

            Thread.sleep(20000);
            System.out.println(a.get());

        }
    }
    class Pojo extends Thread{
        int a = 10001;
        public void run() {
            while(a-->0){
                try {
                    Thread.sleep(1);
                } catch (InterruptedException e) {
                    e.printStackTrace();
                }
                Main.a.incrementAndGet();
                System.out.println("a = "+Main.a);
            }
        }
    }

5

虽然我在这里看到了许多好的理论解释,但我在此添加一个实际例子并进行解释:

1.

不使用VOLATILE运行代码

public class VisibilityDemonstration {

private static int sCount = 0;

public static void main(String[] args) {
    new Consumer().start();
    try {
        Thread.sleep(100);
    } catch (InterruptedException e) {
        return;
    }
    new Producer().start();
}

static class Consumer extends Thread {
    @Override
    public void run() {
        int localValue = -1;
        while (true) {
            if (localValue != sCount) {
                System.out.println("Consumer: detected count change " + sCount);
                localValue = sCount;
            }
            if (sCount >= 5) {
                break;
            }
        }
        System.out.println("Consumer: terminating");
    }
}

static class Producer extends Thread {
    @Override
    public void run() {
        while (sCount < 5) {
            int localValue = sCount;
            localValue++;
            System.out.println("Producer: incrementing count to " + localValue);
            sCount = localValue;
            try {
                Thread.sleep(1000);
            } catch (InterruptedException e) {
                return;
            }
        }
        System.out.println("Producer: terminating");
    }
}
}

在上述代码中,有两个线程 -生产者和消费者。
生产者线程在循环中迭代5次(每次停留1000毫秒或1秒)。在每次迭代中,生产者线程将sCount变量的值增加1。因此,在所有迭代中,生产者将sCount的值从0更改为5。
消费者线程处于常量循环中,并在sCount的值发生更改时打印,直到该值达到5为止。
两个循环同时启动。因此,生产者和消费者都应打印出sCount的值5次。
输出结果:
Consumer: detected count change 0
Producer: incrementing count to 1
Producer: incrementing count to 2
Producer: incrementing count to 3
Producer: incrementing count to 4
Producer: incrementing count to 5
Producer: terminating

分析

在上面的程序中,当生产者线程更新sCount的值时,它确实会更新主内存中该变量的值(每个线程最初从其中读取变量值的内存)。但是,消费者线程仅在第一次从此主内存中读取sCount的值,然后将该变量的值缓存在其自己的内存中。因此,即使原始主内存中的sCount值已由生产者线程更新,消费者线程也正在从其未更新的缓存值中读取。这就是所谓的可见性问题

2.

使用volatile关键字运行的代码

在上面的代码中,将声明sCount的代码行替换为以下内容:

private volatile  static int sCount = 0;

输出

Consumer: detected count change 0
Producer: incrementing count to 1
Consumer: detected count change 1
Producer: incrementing count to 2
Consumer: detected count change 2
Producer: incrementing count to 3
Consumer: detected count change 3
Producer: incrementing count to 4
Consumer: detected count change 4
Producer: incrementing count to 5
Consumer: detected count change 5
Consumer: terminating
Producer: terminating

分析

当我们声明一个变量为volatile时,这意味着对此变量的所有读写都将直接进入主存储器。这些变量的值永远不会被缓存。

由于sCount变量的值从未被任何线程缓存,因此消费者总是从主存储器中读取sCount的原始值(在那里它被生产者线程更新)。 因此,在这种情况下,输出是正确的,两个线程各自打印了5次sCount的不同值。

通过这种方式,volatile关键字解决了可见性问题


有趣的是,如果在if语句之前打印出值,消费者就不会缓存该值。奇怪。 - Regyn
这是一个很好的例子! - stanley
我理解了一个要点,即在消费者线程中没有volatile关键字,sCount值只读取一次并存储在本地,且相同的值在消费者线程的整个生命周期中被读取。然而,在while()循环检查中,我们确实在生产者线程中读取了相同的sCount变量,但是生产者线程如何读取更新后的值而不是缓存值呢? - codingmonk21
@codingmonk21 生产者线程读取缓存值。 - Sergey

4

访问 volatile 字段的每个线程都会在继续之前读取其当前值,而不是(可能)使用缓存值。

只有成员变量可以是 volatile 或 transient。


4
当然,是的。(不仅在Java中,也适用于C#。)有时候你需要获取或设置一个在给定平台上保证原子操作的值,例如int或boolean,但不需要线程锁定的开销。volatile关键字允许你确保当你读取该值时,获得的是当前值,而不是另一个线程的写操作使缓存的值变为过时的情况。

4

是的,我经常使用它 - 它对于多线程代码非常有用。你指出的那篇文章很不错。但要注意两件重要的事情:

  1. 只有在完全理解volatile的作用及其与synchronized的区别后,才应该使用它。在许多情况下,volatile表面上看起来是一个更简单、更高效的替代方案,但更好地理解了volatile后才会清楚,synchronized才是唯一可行的选项。
  2. 在许多旧版的JVM中,volatile实际上并不起作用,而synchronized则可以。我记得曾经看到一份文件,提到不同JVM版本对volatile支持的各个级别,但不幸的是我现在找不到了。如果您使用的是Java 1.5之前的版本,或者无法控制程序将要运行的JVM,请务必查阅相关资料。

3

volatile关键字有两种不同的用法。

  1. 防止JVM从寄存器(假设为缓存)中读取值,并强制其值从内存中读取。
  2. 减少内存不一致性错误的风险。

防止JVM从寄存器中读取值,并强制其值从内存中读取。

一个繁忙标志用于在设备忙碌时阻止线程继续执行,而该标志未受锁保护:

while (busy) {
    /* do something else */
}

当另一个线程关闭“忙碌标志”时,测试线程将继续执行:

busy = 0;

然而,由于测试线程经常访问busy变量,JVM可能通过将busy的值放入寄存器中来优化测试,然后在每次测试之前测试寄存器的内容而不读取内存中的busy值。测试线程永远看不到busy的变化,而其他线程只会更改内存中的busy值,导致死锁。将“busy标志”声明为volatile可以强制在每次测试之前读取其值,从而降低内存一致性错误的风险。
使用volatile变量降低了内存一致性错误的风险,因为对volatile变量的任何写操作都会建立与该变量的后续读取之间的“先行发生”关系。这意味着对volatile变量的更改始终可见于其他线程。
读取、写入没有内存一致性错误的技术称为原子操作。
原子操作是一种有效地一次性完成的操作。原子操作不能在中途停止:它要么完全发生,要么根本不发生。在操作完成之前,原子操作的任何副作用都不可见。
以下是您可以指定的原子操作:
- 对于引用变量和大多数基本变量(除long和double类型外),读取和写入是原子的。 - 对于所有声明为volatile的变量(包括long和double变量),读取和写入都是原子的。
干杯!

2

volatile关键字的作用如下:

1> 不同线程对volatile变量的读写总是来自内存,而不是线程自己的缓存或CPU寄存器。因此,每个线程总是处理最新的值。 2> 当两个不同的线程在堆中使用相同的实例或静态变量时,一个线程可能会看到另一个线程的操作顺序混乱。参见Jeremy Manson的博客。但是,使用volatile可以解决这个问题。

以下完整运行的代码展示了多个线程如何按照预定义的顺序执行并输出结果,而不使用synchronized关键字。

thread 0 prints 0
thread 1 prints 1
thread 2 prints 2
thread 3 prints 3
thread 0 prints 0
thread 1 prints 1
thread 2 prints 2
thread 3 prints 3
thread 0 prints 0
thread 1 prints 1
thread 2 prints 2
thread 3 prints 3

为了实现这一点,我们可以使用以下完整的运行代码。
public class Solution {
    static volatile int counter = 0;
    static int print = 0;
    public static void main(String[] args) {
        // TODO Auto-generated method stub
        Thread[] ths = new Thread[4];
        for (int i = 0; i < ths.length; i++) {
            ths[i] = new Thread(new MyRunnable(i, ths.length));
            ths[i].start();
        }
    }
    static class MyRunnable implements Runnable {
        final int thID;
        final int total;
        public MyRunnable(int id, int total) {
            thID = id;
            this.total = total;
        }
        @Override
        public void run() {
            // TODO Auto-generated method stub
            while (true) {
                if (thID == counter) {
                    System.out.println("thread " + thID + " prints " + print);
                    print++;
                    if (print == total)
                        print = 0;
                    counter++;
                    if (counter == total)
                        counter = 0;
                } else {
                    try {
                        Thread.sleep(30);
                    } catch (InterruptedException e) {
                        // log it
                    }
                }
            }
        }
    }
}

以下的Github链接有一个readme文件,其中提供了适当的解释。 https://github.com/sankar4git/volatile_thread_ordering

1

如果您有一个多线程系统,这些多个线程在某些共享数据上工作,那么这些线程将在自己的缓存中加载数据。如果我们不锁定资源,则在一个线程中进行的任何更改都不会在另一个线程中生效。

enter image description here

通过锁定机制,我们为数据源添加读/写访问权限。如果一个线程修改了数据源,那么该数据将被存储在主内存中而不是缓存中。当其他线程需要这些数据时,它们将从主内存中读取。这将大大增加延迟。

为了减少延迟,我们将变量声明为volatile。这意味着无论哪个处理器修改了变量的值,其他线程都将被强制读取它。它仍然有一些延迟,但比从主内存中读取要好。


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