volatile关键字

volatile,在英文中的含义是 不稳定的,音标是 [ˈvɒlətaɪl]。

1. 它能干啥?

让我们先执行一段代码,代码来源

public class Demo1 {
    static int x = 0, y = 0;
    static int a = 0, b = 0;

    public static void main(String[] args) throws InterruptedException {
        int i = 0;
        for (; ; ) {
            i++;
            x = 0;
            y = 0;
            a = 0;
            b = 0;
            Thread one = new Thread(new Runnable() {
                @Override
                public void run() {
                    //由于线程one先启动,下面这句话让它等一等线程two. 读着可根据自己电脑的实际性能适当调整等待时间.
                    shortWait(100000);
                    a = 1;
                    x = b;
                }
            });

            Thread other = new Thread(new Runnable() {
                @Override
                public void run() {
                    b = 1;
                    y = a;
                }
            });
            one.start();
            other.start();
            one.join();
            other.join();
            String result = "第" + i + "次 (" + x + "," + y + ")";
            if (x == 0 && y == 0) {
                System.err.println(result);
                break;
            } else {
                System.out.println(result);
            }
        }
    }


    public static void shortWait(long interval) {
        long start = System.nanoTime();
        long end;
        do {
            end = System.nanoTime();
        } while (start + interval >= end);
    }
}

这段代码说的是,在一个类中,有a,b,x,y四个静态变量,代码启动时会产生一个死循环,循环中,不停的产生2个线程进行执行代码,其中一个线程对变量a进行赋值为1的操作,且把变量b的值赋值给x,而另一个线程对变量b进行赋值,且把a的值赋值给y,画一个图进行表示一下
image.png
我们仅仅从代码,图上能够预料的结果无非也就 (1,1) (1,0)
(0,1) 。但是实际执行的时候却是会产生 (0,0) 的结果
image.png

当然,并不是每个人在第9万次的时候必定会产生,这取决于电脑的性能。但是这里出现的问题是 程序产生了意料之外的结果

2. (0,0)的产生

(0,0) 的产生原因来自于 指令重排。那么什么是 指令重排

2.1 指令重排

指令重排:只要程序的最终结果和它顺序化情况的结果相等,那么指令的执行顺序可以与代码顺序不一致,此过程叫指令的重排序。

举个栗子,以下代码中

a=1;
b=1;
a=b;
c=b;

进行指令重排后,代码可能实际执行的顺序为

b=1;
a=1;
c=b;
a=b;

2.2 为什么会产生指令重排,以及产生指令重排有什么意义?

JVM能够根据处理器特性(CPU多级缓存系统、多核处理器等)适当的对机器指令进行重排序,时机器指令能更符合CPU的执行特性,最大限度的发挥机器性能。

讲了这么一大顿理论的东西,说的其实就是,你写的代码顺序和机器实际执行的顺序并不一样。
那么既然是CPU对我们的代码执行逻辑进行优化的,那么什么样的代码能被指令重排呢?

2.3 什么样的代码能被指令重排

如果要知道什么样的代码能被指令重排,那么得说到 as-if-serial语义。它的意思是指,不管怎么排序,在单线程程序执行结果不能被改变。编译器、runtime和处理器都必须遵守 as-if-serial 语义。

为了遵守 as-if-serial 语义,编译器和处理器不会对存在数据依赖关系的操作做重排序,因为这种重排序会改变执行结果。但是如果操作之间不存在依赖关系,操作就可能被编译器和处理器重排序。

当然,这里得提到以下 重排序 发生的时间点,是在什么时候发生的。如下图所示
image.png
我们说的重排序通常是指的 1 和 2 阶段

3. (0,0)的解决

这么文章里面,说的自然是让大家把关键词 volatile 加上。那么为什么我们需要加上 volatilevolatile 是什么解决刚刚的指令重排问题的?这才应该是我们关注的重点。
很多时候我们只知道要用某个东西,但是完全不知道为什么要用它,以及它真正产生的作用,一知半解的写下可能会在某天冒雷。

咱们先试试加上 volatile ,真的解决这个问题了吗?
我给上述代码中的某一个静态变量加上 volatile 进行修饰。

static int x = 0, y = 0;
static volatile int a = 0, b = 0;

然后重新进行启动执行。
答案是肯定的,肯定可以解决。在程序运行一两分钟后,仍然没有出现 (0,0) 的结果。
image.png

那么问题来了,在加上 volatile 之后,程序发生了什么变化?

3.1 volatile的内存语义

volatile是Java虚拟机提供的轻量级的同步机制。volatile关键词有如下两个作用。

  1. 保证被volatile修饰的共享变量对所有线程总数是可见的,也就是当一个线程修改了一个被volatile修饰共享变量的值,新值总是可以被其他线程立即得知
  2. 禁止指令的重排序优化

诶!你看,这不就是,咱那么问题就是因为重排序优化导致的,现在volatile给咱禁止了重排序,那可不就直接完美了。

好了,再深入的问个问题,volatile是怎么实现禁止指令重排序的?

3.2 volatile怎么禁止指令重排序

在说这个前,需要先知道一个东西,那就是 内存屏障 ,英文名呢是 Memory Barrier内存屏障 ,也可以叫 内存栅栏 ,是一个CPU指令,它的作用有两个

  1. 保证特定操作的执行顺序
  2. 保证某些变量的内存可见性

volatile 就是利用了 内存屏障 中的保证某些变量的内存可见性的特性实现的可见性。

由于编译器和处理器都能执行指令重排序。如果在指令间插入一条 Memory Barrier 则会告诉编译器和CPU,不管什么指令都不能和这条 Memory Barrier 指令重排序,也就是说通过插入 内存屏障 禁止在 内存屏障 前后的指令执行重排序优化。 Memory Barrier 的另一个作用就是强制刷出各种CPU的缓存数据,因此任何CPU上的线程都能读取到这些数据的最新版本。而 volatile 就是靠着内存屏障实现在内存中的语义,即 可见性禁止重排优化

这里就不得不再提一下volatile内存语义的实现了

3.3 volatile内存语义的实现

之前说到重排序分为编译器重排序和处理器重排序。为了实现 volatile ,JMM会分别限制这两种类型的重排序类型。

来个图,JMM针对编译器制定的 volatile 重排序规则表。
image.png

举个栗子!
第三行 (普通读/写 - volatile写 )对应的 NO 表示的意思为:在程序中,当第一个操作为普通变量的读或写时,如果第二个操作为 volatile写 ,那么编译器不能重排序这两个操作。

总结上述图的话:当第二个操作是 volatile写 时,不管第一个操作是什么,都不能重排序。这个规则确保 volatile写 之前的操作不会被编译重排序到 volatile写 之后。
当一个操作时 volatile读 时,不管第二个操作是什么,都不能重排序。这个操作确保 volatile读 之后的操作不会重排序到 volatile读 之前。
当第一个操作是 volatile写 ,第二个操作时 volatile读 时,不能重排序。

通过这个表也可以检查在代码使用的情况下会不会存在volatile不生效的情况,但是又以为写的没问题。

说完了这个问题,利用 volatile 的特性能有啥用?

4. volatile实际应用

4.1 状态标志

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

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

线程1执行 doWork()的过程中,可能另外的线程调用了 shutDown,所以boolean变量必须是volatile 而如果使用 synchronized块编写循环要比volatile状态标志编写麻烦的多。由于volatile简化了编码,并且状态标志并不依赖程序内的任何状态,因此此处非常适合使用volatile。

这种类型的状态标记的一个公共特性是:通常只有一种状态转换。shutdownRequested标志从false转换为true,然后程序停止。这种模式可以扩展到来回切换的状态标志,但是只有在转换周期不被察觉的情况下才能扩展(从false到true,再转换到false)。此外,还需要某些原子状态转换机制,例如原子变量。

这里可以再扩展一个leetcode的多线程题目例子

4.1.1 交替打印FooBar

题目地址
有兴趣的可以先点开看看题目,在这道题目中,大家很容易想到的解法是通过 CountDownLatchCyclicBarrier去进行解决,但是其实,通过volatile也可以进行解决。在这个题目中,很明显,我们需要一个状态值,来进行通知其他的线程执行。
所以可以利用volatile的可见性,解法如下:

public class FooBar {

    private int n;
    private volatile boolean flag = false;
    public FooBar(int n) {
        this.n = n;
    }

    public void foo(Runnable printFoo) throws InterruptedException {
        for (int i = 0; i < n; i++) {
            while(flag){
                Thread.yield();
            }
            printFoo.run();
            flag = true;
        }
    }

    public void bar(Runnable printBar) throws InterruptedException {
        for (int i = 0; i < n; i++) {
            while(!flag){
                Thread.yield();
            }
            printBar.run();
            flag = false;
        }
    }

    public static void main(String[] args) throws InterruptedException {
        Thread fooThread = new Thread(()->{
            System.out.print("foo");
        });

        Thread barThread = new Thread(()->{
            System.out.print("bar");
        });

        FooBar fooBar = new FooBar(100);

        new Thread(()->{
            try {
                fooBar.bar(barThread);
            } catch (InterruptedException e) {
                e.printStackTrace();
            }
        }).start();

        new Thread(()->{
            try {
                fooBar.foo(fooThread);
            } catch (InterruptedException e) {
                e.printStackTrace();
            }
        }).start();
    }
}

在上述代码中,
yield :暂停当前正在执行的线程对象, yield() 只是使当前线程重新回到可执行状态,所以执行 yield() 的线程有可能在进入到可执行状态后马上又被执行。
volatile :保证了不同线程对这个变量进行操作时的可见性,即一个线程修改了某个变量的值,这新值对其他线程来说是立即可见的。 (实现可见性)
禁止进行指令重排序。(实现有序性)

4.2 一次性安全发布(one-time safe publication)

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

这句话表示的便是双重锁检查锁定问题的根源,其中对象引用在没有同步的情况下进行读操作,产生的问题是可能会看到一个更新的引用,但是仍然会通过该引用看到不完全构造的对象。

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;   
}

4.3 独立观察

安全使用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.4 volatile bean 模式

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

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

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;  
    }  
}

4.5 开销低的“读-写锁”策略

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

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

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允许多个线程执行读操作。

5. volatile的注意事项

volatile虽然能够保证变量的可见性,但是也是存在代价的,在内存中的总线会不停的和多个CPU进行通信,确保变量的值是最新的,当这个变量被很多个线程读取,而且修改频繁的时候,那么便有可能引起 总线风暴

5.1 总线风暴

来解释解释,什么是 总线风暴
总线风暴 由于volatile的 mesi缓存一致性协议 需要不断的从主内存嗅探和cas不断循环无效交互导致总线带宽达到峰值

这时候可能又产生了新的问题了,什么是 MESI缓存一致性协议 呢?
要说这个之前,先从一个问题进行引入吧,那就是 缓存一致性问题

缓存一致性问题
在多个处理器系统中,每个处理器都有自己的高速缓存,而它们又共享同一内存(MainMemory)。基于高速缓存的存储交互很好的解决了处理器和内存之间的速度矛盾,但是也引入了新的问题:缓存一致性(CacheCoherence)。当多个处理器的运算任务都涉及同一块主内存区域时,将可能导致各自的缓存数据不一致的情况,如果真的发生这种情况,那么同步回到主内存时以谁的缓存数据以谁为准呢?
而为了解决一致性问题,需要各个处理器访问缓存时都遵循一些协议,在读写时要根据协议来进行操作,MESI只是其中的一个,还有MSI,MOSI等等。

所以当volatile变量写入过多的时候,总线会不停的进行嗅探是否存在变化,导致不断循环无效交互,引起总线风暴。

这类的解决办法自然就是不用volatile,通过其他的方式实现,比如synchronized

# Java  volatile 

评论

Your browser is out-of-date!

Update your browser to view this website correctly. Update my browser now

×