volatile是怎么保证可见性和有序性的,为什么无法保证原子性

在了解volatile之前,先认识一下JMM内存模型和并发编程的三大特性!

1. JMM内存模型

        Java内存模型(Java Memory Model简称JMM)是一种抽象的概念,并不真实存在,它描述的是一组规则或规范。
        与JVM内存模型不同的是,JMM规定所有变量都存储在主内存,主内存是共享内存区域,所有的线程都可以访问。当线程有对这个变量有操作时,必须把这个变量从主内存复制一份到自己的工作空间中进行操作,操作完成后,再把变量写回主内存,不能直接操作主内存的变量。不同的线程无法访问其他线程的工作内存。
在这里插入图片描述
JMM和硬件内存架构的关系
        Java内存模型对内存的划分对硬件内存并没有任何影响,因为JMM只是一种抽象的概念,是一组规则,并不实际存在。
        不管是工作内存的数据还是主内存的数据,对于计算机硬件来说都会存储在计算机的内存中,当然也有可能存储到CPU缓存或者寄存器中。
在这里插入图片描述
数据同步八大原子操作(硬件层面,cpu)

(1)lock(锁定):作用于主内存的变量,把一个变量标记为一条线程独占状态
(2)unlock(解锁):作用于主内存的变量,把一个处于锁定状态的变量释放出来,释放后 的变量才可以被其他线程锁定
(3)read(读取):作用于主内存的变量,把一个变量值从主内存传输到线程的工作内存 中,以便随后的load动作使用
(4)load(载入):作用于工作内存的变量,它把read操作从主内存中得到的变量值放入工 作内存的变量副本中
(5)use(使用):作用于工作内存的变量,把工作内存中的一个变量值传递给执行引擎
(6)assign(赋值):作用于工作内存的变量,它把一个从执行引擎接收到的值赋给工作内 存的变量
(7)store(存储):作用于工作内存的变量,把工作内存中的一个变量的值传送到主内存 中,以便随后的write的操作
(8)write(写入):作用于工作内存的变量,它把store操作从工作内存中的一个变量的值 传送到主内存的变量中

工作内存操作主内存数据的步骤如下:
在这里插入图片描述

2. 并发编程的三大特性

①:原子性
        原子性指的是一个操作是不可中断的,即使在多线程环境下,一个操作一旦开始就不会被其他线程影响!
        比如一个变量的++操作,代码中只有一行 i++,但在虚拟机底层并不是一步完成的,执行引擎最少需要经过赋值、自增1、写入工作内存等操作才完成,在这期间可能cpu被其他线程抢去,不能保证操作的原子性!一行代码已经如此,更何况真实开发的几十行代码更有原子性问题!

②:可见性
        可见性指的是当一个线程修改了某个共享变量的值,其他线程能够马上感知到这个修改的值。
        对于单线程来说,串行不存在可见性问题。对于多线程来说,由于不同线程对共享变量的操作都是在自己的工作内存中执行,这就可能存在 :线程A在自己的工作内存中修改了共享变量x的值,还没来得及写回主内存中,线程B把主内存中原来的值又加载到自己的工作内存中进行操作,线程A对共享变量的操作对线程B来说不可见,这种工作内存与主内存同步的延迟现象就造成了可见性问题!另外指令重排以及编译器优化也可能导致可见性问题

③:有序性
        有序性即程序执行的顺序是否按照代码的先后顺序执行。对于单线程来说,可以认为代码是按照顺序执行的,所有操作都视为有序行为。

//单线程就算发生指令重排也可以看做是有序的!!

int a = 10;    //语句1
int r = 2;    //语句2
a = a + 3;    //语句3
r = a*a;     //语句4

单线程中,上边四个语句可能是按1-2-3-4的顺序来执行,还有一个可能的执行顺序:2-1-3-4。那可不可能是这个执行顺序呢?2-1-4-3

答:不可能,因为处理器在进行重排序时是会考虑指令之间的数据依赖性,如果一个指令Instruction 2必须用到Instruction 1的结果,那么处理器会保证Instruction 1会在Instruction2之前执行。

        但对于多线程来说,由于程序编译成机器码指令后会出现指令重排现象,重排后的代码顺序未必一致,示例代码如下

//线程1:
context = loadContext();   //语句1
inited = true;             //语句2

 
//线程2:
while(!inited ){
  sleep()
}
doSomethingwithconfig(context);

        在上面代码中,由于语句1和语句2没有数据依赖性,可能会发生指令重排。假如发生了指令重排,语句2先于语句1执行,此时线程2抢到cpu,以为inited = true;已经执行完成,就会跳出死循环,执行doSomethingwithconfig(context)方法,而此时的形参context还未执行,并没有被初始化,就会导致程序报错!

什么是指令重排,作用是什么?
指令重排:在保证程序最终结果一致的前提下,JVM对机器指令进行重新排序。使代码指令更符合cpu执行特性,最大限度的发挥机器性能,提高程序运行效率。
发生指令重排的依据:
①:没有数据依赖性
②:最终结果一致

 

3. Volatile的原理分析

Volatile是JVM提供的一个轻量级的同步机制。有两个作用:
①:保证可见性(缓存一致性协议MESI)
②:保证有序性(禁止指令重排优化)

问题一:Volatile是怎么保证可见性的?
        答:Volatile是在汇编层面加Lock,使用缓存一致性协议(MESI)解决并发可见性的。

        首先我们理解一下汇编层面,在代码执行时,每个栈帧中的操作数栈会有压栈出栈的操作,类似这样的栈操作最底层其实都是cpu操作的,也就是说对栈的操作需要JVM和OS(硬件)的共同协作才能完成!
        当执行引擎执行到某一行压栈的字节码时,会经过解释执行器翻译成汇编语言,汇编再由硬件层翻译层二进制码(010100),二进制码才是会被cpu执行的命令。如下图所示:
在这里插入图片描述
        如果在某一个共享变量上增加Volatile,那么他对应的汇编指令上边会被加上Lock字段,如下图:
在这里插入图片描述
Lock在硬件层面会触发缓存锁定机制!
在这里插入图片描述
早期使用总线锁保证缓存一致,但由于性能较低,后来被缓存一致性协议(MESI)所替代!

什么是MESI协议?
        答:MESI协议其实是一个变量在内存中的不同状态!MESI 是指4中状态的首字母

M 修改 (Modified)	当一个线程要修改便令
E 独享、互斥 (Exclusive)  当一个线程拿到了共享变量,此时为独享状态!
S 共享 (Shared)  当多个线程都拿到了共享变量,此时为共享状态!
I 无效 (Invalid)   线程丢弃了自己工作内存中的变量,为无效状态!

MESI协议如何保证可见性?
        首先cpu会根据共享变量是否带有Volatile字段,来决定是否使用MESI协议保证缓存一致性。
         如果有Volatile,汇编层面会对变量加上Lock前缀,当一个线程修改变量的值后,会马上经过store、write等原子操作修改主内存的值(如果不加Lock前缀不会马上同步),为什么监听到修改会马上同步呢?就是为了触发cpu的嗅探机制,及时失效其他线程变量副本。
        cpu总线嗅探机制监听到这个变量被修改,就会把其他线程的变量副本由共享S置为无效I,当其他线程在使用变量副本时,发现其已经无效,就回去主内存中拿一个最新的值。

 

在写入主内存时为什么要加锁?在哪里加锁?
        变量被修改后同步到主内存的过程中会在store之前加锁,写完后解锁,这个锁只有在修改的时候才会加,锁粒度非常小。
        因为在store时可能已经经过了总线,但此时还没有write进主内存,总线却触发了嗅探机制,其他线程的变量已失效,当其他线程去主内存读最新数据时,新数据还未write进来,产生脏数据!

 

汇编中Lock前缀的作用?
①:使cpu缓存行数据立即写回主内存
②:触发总线嗅探机制和缓存一直性协议MESI失效其他线程的变量副本!
在这里插入图片描述

问题二:Volatile是怎么保证有序性的?
        答:多线程环境下,有序性问题产生的主要原因就是执行重排优化,而Volatile的另一个作用就是禁止指令重排优化。具体是通过对Volatile修饰的变量增加内存屏障来完成的!
        内存屏障的主要工作原理为:通过在指令间插入一条内存屏障并禁止cpu对Volatile修饰的变量进行重排序,也就是说通过插入内存屏障禁止在内存屏障前后执行重排序优化!

下图为JMM针对编译器制定的volatile重排序规则表!
在这里插入图片描述
注意:懒汉式可能有指令重排,需要用Volatile禁止指令重排!!

public class DoubleCheckLock {
    private volatile static DoubleCheckLock instance;
    private DoubleCheckLock(){}
    public static DoubleCheckLock getInstance(){
        //第一次检测
        if (instance==null){
            //同步
            synchronized (DoubleCheckLock.class){
            	//双重校验
                if (instance == null){
                    //多线程环境下可能会出现问题的地方
                    instance = new  DoubleCheckLock();
                }
            }
        }
        return instance;
    }
}

懒汉式在new对象的时候分为三步

memory = allocate();//1.分配对象内存空间
instance(memory);//2.初始化对象(填充数据)
instance = memory;//3.设置instance指向刚分配的内存地址,此时instance!=null

由于步骤2和步骤3不存在数据依赖关系,而且无论重排前还是重排后程序的执行结果在单线程中并没有改变,因此这种重排优化是允许的。如果发生指令重排,重排结果如下:

memory=allocate();//1.分配对象内存空间
instance=memory;//3.设置instance指向刚分配的内存地址,此时instance!=null,但是对象还没有初始化完成!
instance(memory);//2.初始化对象(填充数据)

可以看到指令重排之后,instance指向分配好的内存地址放在了前面,而这段内存的初始化被排在了后面,在线程A初始化完成这段内存之前,线程B虽然进不去同步代码块,但是在同步代码块之前的判断就会发现instance不为空,此时线程B获得instance对象进行使用就可能发生错误。

解决方案:使用volatile禁止instance变量被执行指令重排优化即可!!

 

问题三:为什么Volatile无法保证原子性?

比如,当前主内存的 count = 5,线程A 通过read、load等原子操作把 count = 5加载到本地内存中

  • 在执行引擎执行count++操作时,会有多步操作

  • ①:先初始化count = 5

  • ②:对count执行计算:count = 5+1

  • ③:把count=6 通过assign写入本地内存中

  • 刚好在①,②,③步中间,cpu被线程B抢去了,此时线程B的count也是5,执行count+1后,刷新主内存并通知了线程A.

  • 那么线程A原本的执行引擎中的count已经作废,会被丢弃,重新从主内存中获取最新的值,这样就平白无故丢了一次 +1 的操作,所以最后不等于200000!

  • 解决方案: 使用synchronized 既可以保证原子性 ,也可以保证可见性

代码如下:

 * 测试:20个线程。每个线程对count递增10000,看最后结果是不是200000?
 * 结果:测试结果 < 200000
 * 原因:volatile不能保证线程内部执行引擎 执行代码的原子性!

public class AtomicValid {

    volatile  static int count = 0;

    static  Object object = new Object();

    public static void main(String[] args) throws InterruptedException {

        for (int i = 0; i < 20; i++) {
            new Thread(() -> {
                for (int j = 0; j < 10000; j++) {
                    //使用synchronized 既可以保证原子性 ,也可以保证可见性
                    synchronized (object) {
                        count++;
                    }
                }
            }).start();
        }
        Thread.sleep(2000);
        System.out.println(count);

    }
}

一个简单的 count++ 都可能出现原子性问题,更别说十几行的代码了!

  • 2
    点赞
  • 2
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值