Volatile笔记小记

Volatile

Valatile的作用:可以避免编译器的深度优化以及CPU缓存不一致的情况下带来问题,从而解决可见性。

首先来看一个例子

public class VolatileTest extends Thread {
    boolean flag = false;
    int i = 0;

    public void run() {
        while (!flag) {
            i++;
        }
    }

    public static void main(String[] args) throws Exception {
        VolatileTest vt = new VolatileTest();
        vt.start();
        Thread.sleep(2000);
        vt.flag = true;
        System.out.println("stop" + vt.i);
    }
}

从上面可以看出,正常情况下,这段代码的执行是main线程调用了VolatileTest 的线程后,启动线程,然后通过睡眠两秒后,VolatileTest 线程通过flag变量是可以停止掉while循环然后正常结束的。
可是当我们main运行起来后,你会发现这个VolatileTest 线程根本不会停止,这是什么原因导致的呢?

	这里会有两个原因导致,一个JIT的编译器深度优化,另一个是CPU缓存所带来的
  1. JIT编译器深度优化,JVM Server在编译的时候会把我们的代码进行优化,如上图中的VolatileTest 的run方法里面的while(flag),它把帮我们进行一个优化到一个这种语句while(true),这种优化叫做:活性失败。,这样不论怎么修改flag的值,都是对VolatileTest 的线程不可见的。
  2. CPU的高速缓存(L1,L2,L3),每个CPU核心都会有缓存,当cpu每个核心去内存拉取数据时,就要缓存到cpu的高速缓存中。就类似于我们平常所用redis一样,他会把数据先缓存到每个cpu中的缓存里去,例如两个处理任务,一个是设置a=1,另一个设置a=2,cpu0处理了a=1之后会把a=1这个数据缓存到缓存中,cpu1去修改a=2时,cpu0是不会读取到这个数值的,这就是cpu高速缓存所带来的一致性问题。

保证可见性

volatile关键字的作用就是保证线程之间的可见性。

  1. 解决JIT的优化所带来的可见性问题。加了Volatile关键字后,可以有效的禁用JIT深度优化

  2. 强制使用lock指令使用MESI保证,多个CPU核心的缓存一致性问题。
    使用缓存锁:这个缓存锁真是意义不是一个锁,是一个MESI协议,通过这个协议的4个状态来表示缓存的状态。
    M:modify,表示CPU核心中缓存被修改过
    E:Exclusive,表示该CPU核心中的缓存是此CPU核心中独有的,其他CPU核心未缓存该值
    S:Shard,共享,表示多个CPU核心之间都存在该数据的缓存
    I: Invalid,失效,缓存已失效,就是当前cpu0缓存的a=1的值已经被cpu1修改了a=2,cpu1去修改 时,就会同步到所有cpu核心去进行状态变更。cpu0的缓存失效了,下一次读取就要重新去内存中拉取数据。

    不同CPU核心使用MESI进行保证数据一致性演示链接

指令重排序

从上面CPU的缓存一致性协议MESI中,保证各个CPU核心的缓存一致性,就是当一个CPU核心中缓存的数据被modify了,他会去同步其他CPU缓存的数据,并且等待其他CPU核心同步成功的一个ack,这样才能最终保证一致性。那么,每个CPU阻塞去等待同步的ack是不是浪费了CPU的资源呢?

所以CPU做了一个优化:store buffer缓存,就是在发生modify的时候,cpu核心把修改的数据发送store buffer中,不去阻塞,当别的代码去执行的时候呢就会获取到之前未从store buffer加载未同步的数据

int a,b = 0;
method threadA(){
   a=1;
   b=1;
}

method threadB(){
	while(b==1){
		assert(a==1);
	}
}

threadA与threadB的执行会出现指令排序的流程图

上图大概流程解释一下
threadA执行流程:

  1. CPU0、CPU1开始执行对应步骤1,2。CPU0步骤2执行a=1,发现CPU0中未发现a的值,于是发送到store buffer中。而CPU1步骤1执行b==1,发现自己cache中有b的值,于是准备像CPU0中查询
  2. 由于CPU0中a=1的操作发送到了store buffer中,不会因为修改操作阻塞,所以执行到CPU0的步骤3,执行b=1时发现cache有b=0的独占缓存,所以直接修改值,并且修改为b=1 modify。CPU1查询b的时候发现b已经被CPU0修改为b=1,CPU1把b=1缓存到自己的cache,CPU0、CPU1的b=1的状态为S共享状态。
  3. CPU1判断a==1时,可能由于CPU0对a=1的操作发送到了store buffer中,未进行把a=1的modify状态同步给CPU1中,所以CPU1只会去读取CPU1的cache,所以a=0,这时就不会生效。assert失败
  4. CPU1断言失败之后,CPU0的store buffer对a的修改状态同步给CPU1,发现a=0,所以让a=0状态变为Invalid失效状态。
    从上面几个步骤可以看出,由于store buffer的未及时发送同步导致看似代码顺序执行会导致threadB的顺利断言。然而不然,最终看起来threadA的代码执行似乎变成了下面这样,所以这就是我们所说的指令重排序了,所以这都是store buffer引起的。
method threadA(){
      b=1;
      a=1;
}

下面演示一下会发生指令重排序的代码

public class ReorderDemo {


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

    public static void main(String[] args) throws InterruptedException {
        int i = 0;
        for (; ; ) {
            //循环次数
            i++;
            //重置值
            a = 0;
            b = 0;
            x = 0;
            y = 0;
            Thread threadA = new Thread(() -> {
                a = 1;
                x = b;
            });
            Thread threadB = new Thread(() -> {
                b = 1;
                y = a;
            });
            //启动线程A,B
            threadA.start();
            threadB.start();
            //等待线程A,B执行完成
            threadA.join();
            threadB.join();
            //上面A,B两个线程,正常会出现以下3种情况
            //1、threadA先执行:a=1,x=0,b=1,y=1
            //2、threadB先执行:b=1,y=0,a=1,x=1
            //3、A,B同时执行:a=1,b=1,x=1,y=1
            String result = "第" + i + "循环: x=" + x + ",y=" + y + ",a=" + a + ",b=" + b;
            System.out.println(result);
            if (x == 0 && y == 0) {
                System.out.println(result);
                break;
            }

        }
    }
}

最后的执行结果:
在这里插入图片描述
从上面结束代码可以看出:
在我们A,B两个线程的执行代码可能变成了这样:

		   Thread threadA = new Thread(() -> {
		   	   //发生重排序
               x = b;
               a = 1;
           });
           Thread threadB = new Thread(() -> {
               //发生重排序
               y = a;
               b = 1;
           });

总结:
由于CPU的高速缓存问题导致了指令的重排序而引发了可见性问题,通过Volatile关键字来对读,写进行加锁而让CPU直接进行内存读取数据不是用缓存的数据来进行处理,相当于对缓存加了一层屏障,不让cpu去访问。

	而不同的CPU实现的锁不一样,都是对缓存一致性的解决方法。针对CPU的不同,实现也不同。
	1、内存屏障指令(内存屏障)
	2、Lock指令(相当于内存屏障)
	但是这两种的差异怎么解决呢?所以就有一个JMM模型

JMM模型

JMM全称Java Memory Model ,Java内存模型,这是一个抽象的概念,并不真实存在,它描述的一组规则或规范,通过这组规范定义了程序中各个变量(包括实例字段、静态字段和构成数组对象的元素)的访问方式。JVM运行程序的实体是线程,而每个线程创建时 JVM 都会为其创建一个工作内存(有些地方称为栈空间),用于存储线程私有的数据,而Java 内存模型中规定所有变量都存储在主内存,其主内存是共享内存区域,所有线程都可以访问,但线程对变量的操作(读取赋值等)必须在工作内存中进行,首先要将变量从主内存考吧到增加的工作内存空间,然后对变量进行操作,操作完成后再将变量写回主内存,不能直接操作主内存中的变量,工作内存中存储这主内存中的变量副本拷贝,工作内存是每个线程的私有数据区域,因此不同的线程间无法访问对方的工作内存,线程间的通信(传值)必须通过主内存来完成。
JMM
示意图

JMM中Happens-before 原则

倘若在程序开发中,仅靠sychronized和volatile关键字来保证原子性、可见性以及有序性,那么编写并发程序可能会显得十分麻烦,幸运的是,在Java内存模型中,还提供了happens-before 原则来辅助保证程序执行的原子性、可见性以及有序性的问题,它是判断数据是否存在竞争、线程是否安全的依据,happens-before 原则内容如下:

  1. 程序顺序原则,即在一个线程内必须保证语义串行性,也就是说按照代码顺序执行
  2. 锁规则 解锁(unlock)操作必然发生在后续的同一个锁的加锁(lock)之前,也就是说,如果对于一个锁解锁后,再加锁,那么加锁的动作必须在解锁动作之后(同一个锁)。
  3. volatile规则 volatile变量的写,先发生于读,这保证了volatile变量的可见性,简单的理解就是,volatile变量在每次被线程访问时,都强迫从主内存中读该变量的值,而当该变量发生变化时,又会强迫将最新的值刷新到主内存,任何时刻,不同的线程总是能够看到该变量的最新值。
  4. 线程启动规则 线程的start()方法先于它的每一个动作,即如果线程A在执行线程B的start方法之前修改了共享变量的值,那么当线程B执行start方法时,线程A对共享变量的修改对线程B可见
  5. 传递性 A先于B ,B先于C 那么A必然先于C
  6. 线程终止规则 线程的所有操作先于线程的终结,Thread.join()方法的作用是等待当前执行的线程终止。假设在线程B终止之前,修改了共享变量,线程A从线程B的join方法成功返回后,线程B对共享变量的修改将对线程A可见
  7. 线程中断规则 对线程 interrupt()方法的调用先行发生于被中断线程的代码检测到中断事件的发生,可以通过Thread.interrupted()方法检测线程是否中断。
    As-if-serial:在单线程下,所有的优化操作导致的重排序之后产生的程序结果不变
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值