volatile 原理和应用场景

本贴最后更新于 2090 天前,其中的信息可能已经沧海桑田

volatile 是 java 语言中的一个关键字,常用于并发编程,有两个重要的特点:具有可见性,java 虚拟机实现会为其满足 Happens before 原则;不具备原子性.用法是修饰变量,如:volatile int i.

volatile 原理

介绍其可见性先从 cpu,cpu 缓存和内存的关系入手.

cpu 缓存是一种加速手段,cpu 查找数据时会先从缓存中查找,如果不存在会从内存中查找,所以如果缓存中数据和内存中数据不一致,cpu 处理数据的一致性就无法保证.从机器语言角度来讲,有一些一致性协议来保证缓存一致,但是本文主要从抽象角度解释 volatile 为何能保证可见性.对于 volatile 变量的赋值,会刷入主内存,并且通知其他 cpu 核心,你们缓存中的数据无效了,这样所有 cpu 核心再想对该 volatile 变量操作首先会从主内存中重新拉取值.这就保证了对于 cpu 操作的数据是最新.

但是这并不能保证 volatile 修饰的变量的原子性.让我们想想一个场景,变量 volatile int count 存储在内存中,cpu 核心 1 和 cpu 核心 2 同时读取该数据,并存入缓存,然后进行 count++ 操作.count++ 实际可以分解为三步:

int tmp = count;
tmp = count + 1;
count = tmp;

count = tmp 执行结束,cpu 会把 count 刷入内存并通知其他 cpu 缓存无效,如果两个 cpu 核心同时将其刷入了内存,通知了缓存无效,那么我们是不是只得到了 count = 2,是不是丢失了一个 +1 的值.所以不要试图用 volatile 保证多步操作的原子性,原子性可以通过 synchronized 进行维护.

需要注意一点,long 类型和 double 类型的数据长度是 64 位的,JVM 规范允许对于 64 位类型数据分开赋值,即高位 32 位和低位 32 位可以分开赋值,对于这种情况可以使用 volatile 修饰保证其赋值是一次完成的.**但是!!!**虽然 JVM 是这样规定的,绝大多数虚拟机还是实现了 64 位数据赋值的原子性,即使不使用 volatile 关键字进行修饰也不会出现读取到只赋值一半的 64 位类型数据,所以不必要每个 longdouble 变量之前添加 volatile 关键字.

感受一下 volatile

了解完原理,来通过一段代码感受下 volatile.

public class Volatile implements Runnable{
    //自增变量i
    public /*volatile*/ int i = 0;
    @Override
    public void run() {
        while (true){
            i++; //不断自增
        }
    }

    public static void main(String[] args) throws InterruptedException {
        Volatile vt = new Volatile();
        Watcher watcher = new Watcher();
        watcher.v = vt;
        Thread t1 = new Thread(vt);
        Thread t2 = new Thread(watcher);
        t1.start();
        t2.start();
        Thread.sleep(10);
        //打印 i  和 s
        System.out.println("Volatile.i = " + vt.i + "\nwatcher.w  = " + watcher.monitor);
        System.exit(0);
    }
}
class Watcher implements Runnable{
    public  Volatile v;

    public  int monitor;
    @Override
    public void run() {
        while (true){
            monitor = v.i;//不断将v.i的值赋给s
        }
    }
}
// 这是未加volatile修饰的输出
Volatile.i = 2517483
watcher.w  = 1047805
// 打开volatile注释的输出结果
Volatile.i = 332754
watcher.w  = 333354    

第一个输出中未加 volatile 修饰的 i 的值和 watcher 读取的值相差太远,

第二个输出中相差就不多了.并且 i 的值比未加 volatile 关键字的值差很多,说明对 volatile 变量的赋值消耗会大一些,不过不用在意,我们很少对 volatile 关键字进行不断自增操作,一般都是作为状态或者保证对象完整性,而且 volatilesynchronized 轻量太多了,如果只为了保证可见性,volatile 一定是最优选.

哪些场景使用 volatile

状态变量

由于 boolean 的赋值是原子性的,所以 volatile 布尔变量作为多线程停止标志还简单有效的.

class Machine{
    volatile boolean stopped = false;

    void stop(){stopped = true;}
}

对象完整发布

这里要提到单例对象的双重检查锁,对象完整发布也依赖于 happens before 原则,有兴趣可以自己去查阅,这个原则是比较啰嗦,可以简单理解为我满足 happens before,那么我之前的代码已经执行了.

public class Singleton {
    //单例对象
    private static Singleton instance = null;
    //私有化构造器,避免外部通过构造器构造对象
    private Singleton(){}
    //这是静态工厂方法,用来产生对象
    public static Singleton getInstance(){
        if(instance ==null){
        //同步锁防止多次new对象
            synchronized (Singleton.class){
            //锁内非空判断也是为了防止创建多个对象
                if(instance == null){
                    instance = new Singleton();
                }
            }
        }
        return instance;
    }
}

这是一个会产生 bug 的双重检查锁代码,instance = new Singleton() 并不是一步完成的,他被分为这几步:

1.分配对象空间;
2.初始化对象;
3.设置instance指向刚刚分配的地址。

下面图中,线程 A 红色先获得锁,B 黄色后进入.

这种情况会出现 bug,但是由于 volatile 满足 happens before 原则,所以会等对象实例化之后再对地址赋值,我们需要将 private static Singleton instance = null; 改成 private static volatile Singleton instance = null; 即可.

其实还有几种场景,如果想了解更多建议阅读 IBM 的技术社区的文章 https://www.ibm.com/developerworks/cn/java/j-jtp06197.html

  • Java

    Java 是一种可以撰写跨平台应用软件的面向对象的程序设计语言,是由 Sun Microsystems 公司于 1995 年 5 月推出的。Java 技术具有卓越的通用性、高效性、平台移植性和安全性。

    3167 引用 • 8207 回帖 • 1 关注

相关帖子

欢迎来到这里!

我们正在构建一个小众社区,大家在这里相互信任,以平等 • 自由 • 奔放的价值观进行分享交流。最终,希望大家能够找到与自己志同道合的伙伴,共同成长。

注册 关于
请输入回帖内容 ...