Java并发编程实战——彻底理解volatile

volatile实现原理

在学习本文之前我们知道,被volatile修饰的变量能够保证每个线程能够获取该变量的最新值,从而避免出现数据脏读的现象。

那么volatile是怎样实现的?比如一个很简单的Java代码:

instance = new Instancce() //instance是volatile变量

在生成汇编代码时会在volatile修饰的共享变量进行写操作的时候会多出Lock前缀的指令(具体的大家可以使用一些工具去看一下,这里我就只把结果说出来)。我们想这个Lock指令肯定有神奇的地方,那么Lock前缀的指令在多核处理器下会发现什么事情了?主要有这两个方面的影响:

  • 将当前处理器缓存行的数据写回系统内存;
  • 这个写回内存的操作会使得其他CPU里缓存了该内存地址的数据无效

为了提高处理速度,处理器不直接和内存进行通信,而是先将系统内存的数据读到内部缓存(L1,L2或其他)后再进行操作,但操作完不知道何时会写到内存。如果对声明了volatile的变量进行写操作,JVM就会向处理器发送一条Lock前缀的指令,将这个变量所在缓存行的数据写回到系统内存。但是,就算写回到内存,如果其他处理器缓存的值还是旧的,再执行计算操作就会有问题。所以,在多处理器下,为了保证各个处理器的缓存是一致的,就会实现缓存一致性协议,每个处理器通过嗅探在总线上传播的数据来检查自己缓存的值是不是过期了,当处理器发现自己缓存行对应的内存地址被修改,就会将当前处理器的缓存行设置成无效状态,当处理器对这个数据进行修改操作的时候,会重新从系统内存中把数据读到处理器缓存里。

这样针对volatile变量通过这样的机制就使得每个线程都能获得该变量的最新值。

volatile的happens-before关系

在六条happens-before规则中有一条是:volatile变量规则:对一个volatile域的写,happens-before于任意后续对这个volatile域的读。下面我们结合具体的代码,我们利用这条规则推导下:

public class VolatileExample {
    private int a = 0;
    private volatile boolean flag = false;
    public void writer(){
        a = 1;          //1
        flag = true;   //2
    }
    public void reader(){
        if(flag){      //3
            int i = a; //4
        }
    }
}

上面的实例代码对应的happens-before关系如下图所示:
在这里插入图片描述

volatile的内存语义

还是以上面的代码为例,假设线程A先执行writer方法,线程B随后执行reader方法,初始时线程的本地内存中flag和a都是初始状态,下图是线程A执行volatile写后的状态图。
在这里插入图片描述
当volatile变量写后,线程中本地内存中共享变量就会置为失效的状态,因此线程B再需要读取从主内存中去读取该变量的最新值。

在这里插入图片描述
从横向来看,线程A和线程B之间进行了一次通信,线程A在写volatile变量时,实际上就像是给B发送了一个消息告诉线程B你现在的值都是旧的了,然后线程B读这个volatile变量时就像是接收了线程A刚刚发送的消息。既然是旧的了,那线程B该怎么办了?自然而然就只能去主内存去取啦。

volatile重排序与JMM内存屏障

我们都知道,为了性能优化,JMM在不改变正确语义的前提下,会允许编译器和处理器对指令序列进行重排序,那如果想阻止重排序要怎么办了?答案是可以添加内存屏障。

内存屏障

JMM内存屏障分为四类见下图,
在这里插入图片描述
内存屏障分类表
java编译器会在生成指令系列时在适当的位置会插入内存屏障指令来禁止特定类型的处理器重排序。为了实现volatile的内存语义,JMM会限制特定类型的编译器和处理器重排序,JMM会针对编译器制定volatile重排序规则表:
在这里插入图片描述

“NO”表示禁止重排序。为了实现volatile内存语义时,编译器在生成字节码时,会在指令序列中插入内存屏障来禁止特定类型的处理器重排序。对于编译器来说,发现一个最优布置来最小化插入屏障的总数几乎是不可能的,为此,JMM采取了保守策略:

  • 在每个volatile写操作的前面插入一个StoreStore屏障;
  • 在每个volatile写操作的后面插入一个StoreLoad屏障;
  • 在每个volatile读操作的后面插入一个LoadLoad屏障;
  • 在每个volatile读操作的后面插入一个LoadStore屏障。

需要注意的是:volatile写是在前面和后面分别插入内存屏障,而volatile读操作是在后面插入两个内存屏障。这些屏障的作用如下:

  • StoreStore屏障:禁止上面的普通写和下面的volatile写重排序;
  • StoreLoad屏障:防止上面的volatile写与下面可能有的volatile读/写重排序
  • LoadLoad屏障:禁止下面所有的普通读操作和上面的volatile读重排序
  • LoadStore屏障:禁止下面所有的普通写操作和上面的volatile读重排序

通过屏障,volatile 变量不会像锁那样造成线程阻塞,volatile 变量可以提供优于锁的性能优势。

volatile的使用误区

把代码块声明为 synchronized,有两个重要结果,通常是指该代码具有 原子性(atomicity)和 可见性(visibility)

但是,把变量声明成为volatile,变量只具有 synchronized 的可见性特性,但是不具备原子性。

Volatile 变量可用于提供线程安全,但是只能应用于非常有限的一组用例:多个变量之间或者某个变量的当前值与修改后值之间没有约束。因此,单独使用 volatile 还不足以实现计数器、互斥锁或任何具有与多个变量相关的不变式(Invariants)的类。

简而言之,volatile的使用条件如下:

  1. 对变量的写操作不依赖于当前值。
  2. 该变量没有包含在具有其他变量的不变式中。

第一个条件的限制使 volatile 变量不能用作线程安全计数器。虽然增量操作(i++)看上去类似一个单独操作,实际上它是一个由(读取-修改-写入)操作序列组成的组合操作,必须以原子方式执行,而 volatile 不能提供必须的原子特性。

关于第二个条件,我们用实际代码来解释:
下面是一个非线程安全的数值范围类。它包含了一个不变式 —— 下界总是小于或等于上界。

@NotThreadSafe 
public class NumberRange {
    private volatile int lower, upper;
 
    public int getLower() { return lower; }
    public int getUpper() { return upper; }
 
    public void setLower(int value) { 
        if (value > upper) //1
            throw new IllegalArgumentException(...);
        lower = value;//2
    }
 
    public void setUpper(int value) { 
        if (value < lower) //3
            throw new IllegalArgumentException(...);
        upper = value;//4
    }
}

将 lower 和 upper 字段定义为 volatile 类型不能够充分实现类的线程安全;而仍然需要使用同步——使 setLower() 和 setUpper() 操作原子化。

否则,如果凑巧两个线程在同一时间使用不一致的值执行 setLower 和 setUpper 的话,则会使范围处于不一致的状态。例如,如果初始状态是(0, 5),同一时间内,线程 A 调用setLower(4) 并且线程 B 调用setUpper(3),显然这两个操作交叉存入的值是不符合条件的。但是如果A线程执行通过了//1而B线程也通过了//3的语句,那么实际上最后能够通过执行,使得最后的范围值是(4, 3) —— 一个无效值。

这实际上也是(读+写)。

volatile的适用场景


  • 模式 #1:状态标志

也许实现 volatile 变量的规范使用仅仅是使用一个布尔状态标志,用于指示发生了一个重要的一次性事件,例如完成初始化或请求停机。

volatile boolean shutdownRequested;
 
...
 
public void shutdown() { 
    shutdownRequested = true; 
}
 
public void doWork() { 
    while (!shutdownRequested) { 
        // do stuff
    }
}

线程1执行doWork()的过程中,可能有另外的线程2调用了shutdown,所以boolean变量必须是volatile。

而如果使用 synchronized 块编写循环要比使用 volatile 状态标志编写麻烦很多。由于 volatile 简化了编码,并且状态标志并不依赖于程序内任何其他状态,因此此处非常适合使用 volatile。

这种类型的状态标记的一个公共特性是:通常只有一种状态转换;shutdownRequested 标志从false 转换为true,然后程序停止


  • 模式 #2:一次性安全发布(one-time safe publication)

在缺乏同步的情况下,可能会遇到某个对象引用的更新值(由另一个线程写入)和该对象状态的旧值同时存在。

这就是造成著名的**双重检查锁定(double-checked-locking)**问题的根源,其中对象引用在没有同步的情况下进行读操作,产生的问题是您可能会看到一个更新的引用,但是仍然会通过该引用看到不完全构造的对象。参见:单例模式(以及多线程、无序写入、volatile对单例的影响)

//注意volatile!!!!!!!!!!!!!!!!!  
private volatile static Singleton instace;   
  
public static Singleton getInstance(){   
    //第一次null检查     
    if(instance == null){            
        synchronized(Singleton.class) {    //1     
            //第二次null检查       
            if(instance == null){          //2  
                instance = new Singleton();//3  
            }  
        }           
    }  
    return instance;        

如果不用volatile,则因为内存模型允许所谓的“无序写入”,可能导致失败。——某个线程可能会获得一个未完全初始化的实例。

考察上述代码中的 //3 行。此行代码创建了一个 Singleton 对象并初始化变量 instance 来引用此对象。这行代码的问题是:在Singleton 构造函数体执行之前,变量instance 可能成为非 null 的!
什么?这一说法可能让您始料未及,但事实确实如此。

在解释这个现象如何发生前,请先暂时接受这一事实,我们先来考察一下双重检查锁定是如何被破坏的。假设上述代码执行以下事件序列:

线程 1 进入 getInstance() 方法。
由于 instance 为 null,线程 1 在 //1 处进入synchronized 块。
线程 1 前进到 //3 处,但在构造函数执行之前,使实例成为非null。
线程 1 被线程 2 预占。
线程 2 检查实例是否为 null。因为实例不为 null,线程 2 将instance 引用返回,返回一个构造完整但部分初始化了的Singleton 对象。
线程 2 被线程 1 预占。
线程 1 通过运行 Singleton 对象的构造函数并将引用返回给它,来完成对该对象的初始化。


  • 模式 #3:独立观察(independent observation)

安全使用 volatile 的另一种简单模式是:定期 “发布” 观察结果供程序内部使用。【例如】假设有一种环境传感器能够感觉环境温度。一个后台线程可能会每隔几秒读取一次该传感器,并更新包含当前文档的 volatile 变量。然后,其他线程可以读取这个变量,从而随时能够看到最新的温度值。

使用该模式的另一种应用程序就是收集程序的统计信息。【例】如下代码展示了身份验证机制如何记忆最近一次登录的用户的名字。将反复使用lastUser 引用来发布值,以供程序的其他部分使用。

public class UserManager {
    public volatile String lastUser; //发布的信息
 
    public boolean authenticate(String user, String password) {
        boolean valid = passwordIsValid(user, password);
        if (valid) {
            User u = new User();
            activeUsers.add(u);
            lastUser = user;
        }
        return valid;
    }
} 

  • 模式 #4:“volatile bean” 模式

volatile bean 模式的基本原理是:很多框架为易变数据的持有者(例如 HttpSession)提供了容器,但是放入这些容器中的对象必须是线程安全的。

在 volatile bean 模式中,JavaBean 的所有数据成员都是 volatile 类型的,并且 getter 和 setter 方法必须非常普通——即不包含约束!

@ThreadSafe
public class Person {
    private volatile String firstName;
    private volatile String lastName;
    private volatile int age;
 
    public String getFirstName() { return firstName; }
    public String getLastName() { return lastName; }
    public int getAge() { return age; }
 
    public void setFirstName(String firstName) { 
        this.firstName = firstName;
    }
 
    public void setLastName(String lastName) { 
        this.lastName = lastName;
    }
 
    public void setAge(int age) { 
        this.age = age;
    }
}

  • 模式 #5:开销较低的“读-写锁”策略

如果读操作远远超过写操作,您可以结合使用内部锁和 volatile 变量来减少公共代码路径的开销。

如下显示的线程安全的计数器,使用 synchronized 确保增量操作是原子的,并使用 volatile 保证当前结果的可见性。如果更新不频繁的话,该方法可实现更好的性能,因为读路径的开销仅仅涉及 volatile 读操作,这通常要优于一个无竞争的锁获取的开销。

@ThreadSafe
public class CheesyCounter {
    // Employs the cheap read-write lock trick
    // All mutative operations MUST be done with the 'this' lock held
    @GuardedBy("this") private volatile int value;
 
    //读操作,没有synchronized,提高性能
    public int getValue() { 
        return value; 
    } 
 
    //写操作,必须synchronized。因为x++不是原子操作
    public synchronized int increment() {
        return value++;
    }

使用锁进行所有变化的操作,使用 volatile 进行只读操作。
其中,锁一次只允许一个线程访问值,volatile 允许多个线程执行读操作

发布了419 篇原创文章 · 获赞 327 · 访问量 17万+

猜你喜欢

转载自blog.csdn.net/No_Game_No_Life_/article/details/104650487