多线程(三) -- 内存(二) -- Volatile详解

本章内容,建议和JMM详解一起看

1. Volatile原理:

volatile的底层实现原理是内存屏障,Memory Barrier(Memory Fence)

  • 对volatile变量的写指令后加入写屏障
  • 对volatile变量的读指令前会加入读屏障

2. Volatile的作用:

先说结论:

  1. 保证变量对所有线程的可见性,但不能保证原子性
  2. 禁止指令重排序优化
  3. volatile不会导致阻塞

3. JMM,内存与缓存与并发:

要了解volatile,就要先对CPU,CPU cache和主存有所有了解。

主存就是我们平时说的内存,它的读写速度很慢,而CPU运算速度很快,为了弥补二者的差异,在CPU和主存之间引入了CPU Cache,
现在很多CPU都会有三层Cache,分别称为L1,L2和L3,其中L1最靠近CPU,缓存速度也最快,L1这块缓存被分为了两个部分,一部分是指令缓存,称为L1i,一部分是数据缓存,称为L1d.

只要有缓存,就会出现缓存不一致的问题,比如i++这个操作。
在有缓存时候的计算流程是:

  1. 从主存中读取i的值到缓存中
  2. 在缓存中把i的值加1
  3. 把缓存中的值刷回到主存中。

这样的模型在单线程下没有任何问题,但是在多线程中,就会出现问题,比如在步骤二中执行,此时有一个线程直接给i重新赋值了,这时候就会出现问题。

解决这个问题的一个有效办法就是缓存一致性协议,这个协议大体思路是:

  1. 当CPU操作Cache中的副本时,会去查看该副本是不是在其他Cache中也存在一份
  2. 如果不存在的话,CPU就可以对Cached中的副本进行任意的操作了
  3. 如果存在的话, CPU先把Cached中的副本读取到自己的寄存器中,然后通知其他的CPU说"你们保存的Cache无效了"
  4. 其他CPU收到这个通知之后,如果要操作这个变量的副本,就会从主存里在读取一遍。

那么我们接下来看一下Java的内存模型,Java的内存模型一个很重要的作用就是规定了:一个线程对共享变量(主内存中的变量)的修改,何时对其他线程可见。具体可看:JMM详解

具体来说就是:

  1. 每个线程都有自己的工作内存,称为缓存。
  2. 线程只能操作自己的工作内存,不能直接操作主内存
  3. 共享变量在工作内存中有一个副本
  4. JVM决定何时把工作内存中的副本刷回主内存。
    多线程(三) -- 内存(二) -- Volatile详解_第1张图片

需要注意的是,JMM是个抽象的内存模型,所以所谓的本地内存,主内存都是抽象概念,并不一定就真实的对应cpu缓存和物理内存

接下来了解一下并发编程必须要掌握的几个概念:

3.1 原子性

一组操作要么不做,要么保证全部做完,不会被中断
volatile关键字无法保证原子性,synchronized可以保证原子性

3.2 可见性

一个线程对一个变量进行了修改,另一个线程可以马上感知到这种修改。
volatile关键字可以保证可见性,synchronized和final也可以保证可见性

3.3 有序性

主要就是防止进行指令重排,因为为了优化执行顺序,JVM并不一定会按照书写的顺序去执行,会进行优化,优化的原则是:不保证执行的顺序,只保证重排之后的执行结果和重排之前一样。

4. volatile可见性:如何保证可见性

  • 写屏障:保证在该屏障之前的,对共享资源的改动,都同步到主存当中
public void actor2(I_Result r) {
	num = 2;
	ready = true; // ready是被volatile修饰的 ,赋值带写屏障
	// 写屏障
}
  • 读屏障:保证在该屏障之后,对共享变量的读取,加载的是主存中的最新数据
public void actor1(I_Result r) {
	// 读屏障
	//  ready是被volatile修饰的 ,读取值带读屏障
	if(ready) {
		r.r1 = num + num;
	} else {
		r.r1 = 1;
	}
}

多线程(三) -- 内存(二) -- Volatile详解_第2张图片

4.1 代码示例:

看一个例子:

public class TestVolatile implements Runnable{
    private boolean flag = true;

    public void setFlag(boolean flag) {
        this.flag = flag;
    }

    @Override
    public void run() {
        System.out.println("我run了");
        while(flag){}
        System.out.println("我out了");
    }

    public static void main(String[] args) {
        try {
            TestVolatile testVolatile = new TestVolatile();
            new Thread(testVolatile).start();
            Thread.sleep(1000);
            testVolatile.setFlag(false);
            System.out.println("main end");
        } catch (InterruptedException e) {
            e.printStackTrace();
        }
    }
}

结果:
多线程(三) -- 内存(二) -- Volatile详解_第3张图片
可以看到程序仍然在执行,并且没有输出停止语句。可是明明已经设置成false了,为什么还没有停止?

根据JMM,Java中有一块主内存,不同的线程有自己的工作内存(JIT高速缓存),同一个变量值在主内存中有一份,如果线程大量用到了这个变量的话,自己的工作内存中有一份一模一样的拷贝。每次进入线程从主内存中拿到变量值,每次执行完线程将变量从工作内存同步回主内存中。

出现打印结果现象的原因就是主内存和工作内存中数据的不同步造成的。因为执行run()方法的时候拿到一个主内存isRunning的拷贝,而设置isRunning是在main函数中做的,换句话说 ,设置的isRunning设置的是主内存中的isRunning,更新了主内存的isRunning,线程工作内存中的isRunning没有更新,当然一直死循环了,因为对于线程来说,它的isRunning依然是true。

解决这个问题很简单,给isRunning关键字加上volatile。加上了volatile的意思是,每次读取isRunning的值的时候,都先从主内存中把isRunning同步到线程的工作内存中,在当前时刻最新的isRunning。看一下给isRunning加了volatile关键字的运行效果:

多线程(三) -- 内存(二) -- Volatile详解_第4张图片
看到这下线程停止了,因为从主内存中读取了最新的isRunning值,线程工作内存中的isRunning变成了false,自然while循环就结束了。

volatile的作用就是这样,被volatile修饰的变量,保证了每次读取到的都是最新的那个值
线程安全围绕的是可见性和原子性这两个特性展开的,volatile解决的是变量在多个线程之间的可见性,但是无法保证原子性

synchronized除了保障了原子性外,其实也保障了可见性。因为synchronized无论是同步的方法还是同步的代码块,都会先把主内存的数据拷贝到工作内存中,同步代码块结束,会把工作内存中的数据更新到主内存中,这样主内存中的数据一定是最新的。

5. volatile有序性:禁止指令重排,保证有序性

  • 写屏障会确保指令重排序时,不会将写屏障之前的代码排在写屏障之后
  • 读屏障会确保指令重排序时,不会将读屏障之后的代码排在读屏障之前

多线程(三) -- 内存(二) -- Volatile详解_第5张图片

5.1 什么是指令重排序:

普通的变量仅会保证在该方法的执行过程中所有依赖赋值的结果的地方都能获取到正确的结果,而不能保证变量赋值操作的顺序与程序代码中的顺序一致。

5.2 示例一:

private boolean isInit = false;
private Manager mManger;
public Manager getManager(){
  if(!isInit){
    mManger = initManager();
    isInit = true;
  }
  return mManager;
}

上面那个例子中,单线程情况下不会有任何问题,在多线程情况下,如果不进行指令重排也不会有问题。
但是如果进行了指令重排,比如指令重排之后,把isInit = true放在了mManager = initManager()之上,很可能在多线程的情况下出现mManager为空的情况,从而出现空指针异常。而且这种异常还很难发现,通常大家都是一脸懵逼,说mManager肯定不可能为空呀。

那现在我们把多线程情况下的指令重排造成的mManager为空的情况说一下:

  1. 线程1执行getManager方法,由于指令重排,isInit = true先执行,然后去真正初始化mManager,最后返回mManager实例,不会有任何问题。
  2. 但是当线程1执行完isInit = true之后,线程2开始执行getManager方法,发现isInit为true,就直接返回了mManager,而此时mManager还没有初始化,所以线程2中会出现空指针异常。

5.3 示例二:双检锁

我们拿常用的双检索的单例模式举例:

public final class Singleton {
    private Singleton() { }
    private static volatile Singleton INSTANCE = null;
    public static Singleton getInstance() {
        if(INSTANCE == null) { // t2
            // 首次访问会同步,而之后的使用没有 synchronized
            synchronized(Singleton.class) {
                if (INSTANCE == null) { // t1
                    INSTANCE = new Singleton();
                }
            }
        }
        return INSTANCE;
    }
}

可以看到定义的单例对象上加了volatile关键字。

5.3.1 加上volatile关键字的原因:

如果不加volatile关键字,我们查看下从if条件判断开始的这段代码的字节码:

0: getstatic #2 // Field INSTANCE:Lcn/itcast/n5/Singleton;
3: ifnonnull 37
// ldc是获得类对象
6: ldc #3 // class cn/itcast/n5/Singleton
// 复制操作数栈栈顶的值放入栈顶, 将类对象的引用地址复制了一份
8: dup
// 操作数栈栈顶的值弹出,即将对象的引用地址存到局部变量表中
// 将类对象的引用地址存储了一份,是为了将来解锁用
9: astore_0
10: monitorenter
11: getstatic #2 // Field INSTANCE:Lcn/itcast/n5/Singleton;
14: ifnonnull 27
// 新建一个实例
17: new #3 // class cn/itcast/n5/Singleton
// 复制了一个实例的引用
20: dup
// 通过这个复制的引用调用它的构造方法
21: invokespecial #4 // Method "":()V
// 最开始的这个引用用来进行赋值操作
24: putstatic #2 // Field INSTANCE:Lcn/itcast/n5/Singleton;
27: aload_0
28: monitorexit
29: goto 37
32: astore_1
33: aload_0
34: monitorexit
35: aload_1
36: athrow
37: getstatic #2 // Field INSTANCE:Lcn/itcast/n5/Singleton;
40: areturn

其中:

17 表示创建对象,将对象引用入栈 // new Singleton
20 表示复制一份对象引用 // 复制了引用地址
21 表示利用一个对象引用,调用构造方法 // 根据复制的引用地址调用构造方法
24 表示利用一个对象引用,赋值给 static INSTANCE //设置instance指向刚分配的地址

也许 jvm 会优化为:先执行 24,再执行 21。如果两个线程 t1,t2 按如下时间序列执行:
多线程(三) -- 内存(二) -- Volatile详解_第6张图片

  • 由于t1线程执行了24导致instance指向了一段地址;
  • 此时t2线程判断instance不为null,会直接返回一个未初始化的对象。

有些人会问,不是synchronized可以阻止重排序么?synchronized并不能阻止重排序,volatile才可以,在这里synchronized内部的代码是串行的,但是synchronized并不能阻止t2线程执行if条件判断。

这个问题的出现是因为instance是可以被2个线程访问,同时,synchronized锁内部发生了指令重排序导致的。

5.3.2 简单解释(和上述一致,上述看懂了可以不看):

需要volatile关键字的原因是,在并发情况下,如果没有volatile关键字,第五行会出现问题。INSTANCE = new Singleton();可以分解为3行伪代码:

  • a:memory = allocate() //分配内存
  • b:ctorInstanc(memory) //初始化对象
  • c:INSTANCE = memory //设置instance指向刚分配的地址

上面的代码在编译运行时,可能会出现重排序从a-b-c排序为a-c-b。在多线程的情况下会出现以下问题:

  1. 当线程t1在执行赋值操作时时,t2线程进来执行到if判断。
  2. 假设此时t1执行的过程中发生了指令重排序,即先执行了a和c,没有执行b。那么由于t1线程执行了c导致INSTANCE 指向了一段地址;
  3. 此时t2线程判断instance不为null,会直接返回一个未初始化的对象。
5.3.3 加入volatile的后的效果:
// -------------------------------------> 加入对 INSTANCE 变量的读屏障
0: getstatic #2 // Field INSTANCE:Lcn/itcast/n5/Singleton;
3: ifnonnull 37
6: ldc #3 // class cn/itcast/n5/Singleton
8: dup
9: astore_0
10: monitorenter -----------------------> 保证原子性、可见性
11: getstatic #2 // Field INSTANCE:Lcn/itcast/n5/Singleton;
14: ifnonnull 27
17: new #3 // class cn/itcast/n5/Singleton
20: dup
21: invokespecial #4 // Method "":()V
24: putstatic #2 // Field INSTANCE:Lcn/itcast/n5/Singleton;
// -------------------------------------> 加入对 INSTANCE 变量的写屏障
27: aload_0
28: monitorexit ------------------------> 保证原子性、可见性
29: goto 37
32: astore_1
33: aload_0
34: monitorexit
35: aload_1
36: athrow
37: getstatic #2 // Field INSTANCE:Lcn/itcast/n5/Singleton;
40: areturn

如上面的注释内容所示,读写 volatile 变量操作(即getstatic操作和putstatic操作)时会加入内存屏障(Memory Barrier(Memory Fence)),保证下面两点:

  1. 可见性
    1. 写屏障(sfence)保证在该屏障之前的 t1 对共享变量的改动,都同步到主存当中
    2. 而读屏障(lfence)保证在该屏障之后 t2 对共享变量的读取,加载的是主存中最新数据
  2. 有序性
    1. 写屏障会确保指令重排序时,不会将写屏障之前的代码排在写屏障之后
    2. 读屏障会确保指令重排序时,不会将读屏障之后的代码排在读屏障之前
  3. 更底层是读写变量时使用 lock 指令来多核 CPU 之间的可见性与有序性

加入了volatile保证了写屏障之前代码都是有序的,所以在put之前的代码都是有序执行的,t1已经正确的初始化示例了。而读屏障保障保证,读取到的数据已经是最新的数据了。
多线程(三) -- 内存(二) -- Volatile详解_第7张图片
像之前出现的先get再put并不会导致问题了,因为此时get到的数据是null
多线程(三) -- 内存(二) -- Volatile详解_第8张图片

6. 非原子性示例:不能解决指令交错(指令交错并不是指令重排序):

上述说到了volatile能够保证变量的一致性,但是并不能保证volatile变量的运算在并发下是一定安全的

  1. 写屏障仅仅是保证之后的读能够读到最新的结果,但不能保证其它线程的读跑到它前面去
  2. 而有序性的保证也只是保证了本线程内相关代码不被重排序

多线程(三) -- 内存(二) -- Volatile详解_第9张图片

代码示例:

public class TestVolatile1 {
    public static volatile int race = 0;

    public static void increase() {
        race++;
    }

    private static final int THREADS_COUNT = 10;

    public static void main(String[] args) {
        Thread[] threads = new Thread[THREADS_COUNT];
        for (int i = 0; i < THREADS_COUNT; i++) {
            threads[i] = new Thread(() -> {
                for (int k = 10000; k > 0; k--) {
                    increase();
                }
            });
            threads[i].start();
        }

        // 等待所有累加线程都结束
        // activeCount方法返回活动线程的当前线程的线程组中的数量。
        // IDEA用户此处不能写大于1,因为IDE会自动创建自动创建一个名为Monitor Ctrl_Break的线程
        while(Thread.activeCount() > 2) {
            Thread.yield();
        }

        System.out.println(race);
    }
}

按正常情况,这个输出结果应该是10万,可是无论我们运行多少次,输出结果都是一个小于10万的数,而且几乎每次都不一样。这是为什么呢?

之前无论是学习JVM还是多线程,包括上文我们提到过++或者–操作都不是个原子性的操作,通过反编译文件,我们可以看到race++实际上是四个字节码指令:

0 getstatic #7 	//
3 iconst_1		
4 iadd			//执行
5 putstatic #7 <com/yhx/juc/Volatile/TestVolatile1.race>
8 return

从字节码可以看出:

  1. 首先getstatic取race的值,放到栈顶,volatile保证了一致性
  2. 但是iconst_1和iadd对race进行操作的时候,可能其他的线程已经将race进行过操作了,此时栈顶的数据已经变成了过期数据
  3. putstatic最后将一个相对较小的race值同步到了主内存中

6.1 volatile完全不能保证原子性么?

虽然volatile只能保证可见性不能保证原子性,但用volatile修饰long和double可以保证其操作原子性

所以从Oracle Java Spec里面可以看到:

  • 对于64位的long和double,如果没有被volatile修饰,那么对其操作可以不是原子的。在操作的时候,可以分成两步,每次对32位操作。
  • 如果使用volatile修饰long和double,那么其读写都是原子操作
  • 对于64位的引用地址的读写,都是原子操作
  • 在实现JVM时,可以自由选择是否把读写long和double作为原子操作
  • 推荐JVM实现为原子操作

7. 汇编代码解析:

将volatile那段代码编译成汇编语言:
多线程(三) -- 内存(二) -- Volatile详解_第10张图片
通过对比方法在赋值后(mov %eax…即赋值语句)多执行了一个:lock addl $0x0,(%esi)的操作,这个操作的作用相当于一个内存屏障,指重排序时不能把后面的指令重排序到内存屏障之前的位置

这句指令中的"addl $0x0,(%esp)"(把esp寄存器的值加0)显然是一个空操作(采用这个空操作而不是空指令nop是因为IA32手册规定lock前缀不允许配合nop指令使用),关键在于lock前缀,查询IA32手册,它的作用是将本CPU的Cache写入了内存,该写入动作也会引起别的CPU或者别的内核无效化其Cache(意思是其他的缓存无效),这种操作相当于对Cache中的变量做了一次"store和write"操作,所以通过这样一个空操作,可让前面volatile变量的修改对其他CPU立即可见

上面这段话整理一下如下:

  1. 它确保指令重排序时不会把其后面的指令排到内存屏障之前的位置,也不会把前面的指令排到内存屏障的后面;即在执行到内存屏障这句指令时,在它前面的操作已经全部完成;
  2. 它会强制将对缓存的修改操作立即写入主存;
  3. 如果是写操作,它会导致其他CPU中对应的缓存行无效。

8. happens-before:

happens-before规定了对共享变量的写操作对其他线程的读操作可见,它是可见性与有序性的一套规则总结。

抛开以下happens-before规则,JMM并不能保证一个线程对共享变量的写,对于其他线程对该共享变量的读可见。

  1. 线程解锁 m 之前对变量的写,对于接下来对 m 加锁的其它线程对该变量的读可见
static int x;
static Object m = new Object();
new Thread(()->{
    synchronized(m) {
        x = 10;
    }
},"t1").start();
new Thread(()->{
    synchronized(m) {
        System.out.println(x);
    }
},"t2").start();
  1. 线程对 volatile 变量的写,对接下来其它线程对该变量的读可见
volatile static int x;
new Thread(()->{
	x = 10;
},"t1").start();
new Thread(()->{
	System.out.println(x);
},"t2").start();
  1. 线程 start 前对变量的写,对该线程开始后对该变量的读可见
static int x;
x = 10;
new Thread(()->{
	System.out.println(x);
},"t2").start();
  1. 线程结束前对变量的写,对其它线程得知它结束后的读可见(比如其它线程调用 t1.isAlive() 或 t1.join()等待它结束)
static int x;
Thread t1 = new Thread(()->{
	x = 10;
},"t1");
t1.start();
t1.join();
System.out.println(x);

  1. 线程 t1 打断 t2(interrupt)前对变量的写,对于其他线程得知 t2 被打断后对变量的读可见(通过 t2.interrupted 或 t2.isInterrupted)
static int x;
public static void main(String[] args) {
    Thread t2 = new Thread(()->{
        while(true) {
            if(Thread.currentThread().isInterrupted()) {
                System.out.println(x);
                break;
            }
        }
    },"t2");
    t2.start();
    new Thread(()->{
        sleep(1);
        x = 10;
        t2.interrupt();
    },"t1").start();
    while(!t2.isInterrupted()) {
        Thread.yield();
    }
    System.out.println(x);
}
  1. 对变量默认值(0,false,null)的写,对其它线程对该变量的读可见

  2. 具有传递性,如果 x hb-> y 并且 y hb-> z 那么有 x hb-> z ,配合 volatile 的防指令重排,有下面的例子

volatile static int x;
static int y;
new Thread(()->{
     y = 10;
     x = 20;
},"t1").start();
new Thread(()->{
    // x=20 对 t2 可见, 同时 y=10 也对 t2 可见
    System.out.println(x);
 },"t2").start();

深入研究底层原理,请看:https://www.cnblogs.com/xrq730/p/7048693.html

你可能感兴趣的:(多线程,java,缓存,开发语言)