Java并发编程 - volatile 怎么保障内存可见性 & 防止指令重排序?

内存可见性

首先,要明确一下这个内存的含义,内存包括共享主存和高速缓存(工作内存),Volatile关键字标识的变量,是指CPU从缓存读取数据时,要判断数据是否有效,如果缓存没有数据,则再从主存读取,主存就不存在是否有效的说法了。而内存一致性协议也是针对缓存的协议。

内存可见性意思是一个CPU核心对数据的修改,对其他CPU核心立即可见,这句话拆开了理解:

1、CPU修改数据,首先是对工作内存的修改,也有人说被volatile修饰的变量不会拷贝副本到工作内存,而是直接修改主存,我觉得这个说法是不对的,CPU对数据的修改总是先修改工作内存,然后再同步回主内存,只不过是对被volatile修饰变量的修改,会立刻同步回主内存,假如只有一个线程修改volatile变量,那么这个变量在工作内存的副本会一直有效,CPU也不会每次修改都从主存读取volatile变量,只是每次修改后都会及时更新主存罢了。

2、对其他核心立即可见,这个的意思是,当一个CPU核心A修改完volatile变量,并且立即同步回主存,如果CPU核心B的工作内存中也缓存了这个变量,那么B的这个变量将立即失效,当B想要修改这个变量的时候,B必须从主存重新获取变量的值。

说了这么多,volatile有什么用呢?哎,这个作用一定要说清楚,不然很容易忘记!

举个例子:

public class VolatileTest implements Runnable {
 
    static boolean flag = true;
 
    @Override
    public void run() {
        while (flag) {
        }
        System.out.println("end......");
    }
 
    public static void main(String[] args) {
        new Thread(new VolatileTest()).start();
        try {
            Thread.sleep(1000);
        }
        catch (InterruptedException e) {
            e.printStackTrace();
        }
        flag = false;
        System.out.println("end main......");
    }
}

// 输出
end main......

上面这个例子,子线程会一直卡住,原因就是flag不具备可见性,主线程和子线程刚开始都缓存了flag,且值是true,后来主线程把flag改成了false,但是子线程并不知道,仅此而已,仅此而已!!!!如果把flag用volatile修改,那么主线修改成false后,子线程再次while循环的时候,就会发现它缓存的flag已经失效了,它会去主存重新读取flag的值。

实现的原理一般都是基于CPU的MESI协议(缓存一致性协议),其中E表示独占Exclusive,S表示Shared,M表示Modify,I表示Invalid,如果一个核心修改了数据,那么这个核心的数据状态就会更新成M,同时其他核心上的数据状态更新成I,这个是通过CPU多核之间的嗅探机制实现的。

但是,这样是否就能保证多线程操作一个共享变量的时候,保证线程安全呢?其实不然,否则我怎么说是仅此而已呢!

volatile限定的是从缓存读取时刻的校验,如果两个CPU同时从各自缓存读取一个变量n=1(此时,变量n在各个CPU缓存上都是有效的),并且同时修改了变量n=n+1,再写回缓存,这个时候n的值等于2,而不是等于3。因此,在多线程操作共享变量(例如:计数器)的时候,正确的方式是使用同步或者Atomic工具类。

指令有序性

这个涉及到内存屏障(Memory Barrier),内存屏障有两个能力:

a、就像一套栅栏分割前后的代码,阻止栅栏前后的没有数据依赖性的代码进行指令重排序,保证程序在一定程度上的有序性。
b、强制把写缓冲区/高速缓存中的脏数据等写回主内存,让缓存中相应的数据失效,保证数据的可见性。

首先,指令并不是代码行,指令是原子的,通过javap命令可以看到一行代码编译出来的指令,当然,像int i=1;这样的代码行也是原子操作。

在单例模式中,Instance inst = new Instance();   这一句,就不是原子操作,它可以分成三步原子指令:

  1. 分配内存地址
  2. new一个Instance对象
  3. 将内存地址赋值给inst

CPU为了提高执行效率,这三步操作的顺序可以是123,也可以是132,如果是132顺序的话,当把内存地址赋给inst后,inst指向的内存地址上面还没有new出来单例对象,这时候,如果就拿到inst的话,它其实就是空的,会报空指针异常。这就是为什么双重检查单例模式中,单例对象要加上volatile关键字。

内存屏障有三种类型和一种伪类型

a、lfence:即读屏障(Load Barrier),在读指令前插入读屏障,可以让高速缓存中的数据失效,重新从主内存加载数据,以保证读取的是最新的数据。

b、sfence:即写屏障(Store Barrier),在写指令之后插入写屏障,能让写入缓存的最新数据写回到主内存,以保证写入的数据立刻对其他线程可见。

c、mfence,即全能屏障,具备ifence和sfence的能力。

d、Lock前缀:Lock不是一种内存屏障,但是它能完成类似全能型内存屏障的功能。

并发三特性总结

发布了1005 篇原创文章 · 获赞 1889 · 访问量 89万+

猜你喜欢

转载自blog.csdn.net/Dream_Weave/article/details/105491766