多线程JUC 第2季 volatile变量的使用

本文详细解析了volatile变量的特性,包括可见性、禁止指令重排序以及不具原子性。讨论了内存屏障的作用、四大内存屏障和读写插入屏障,以及volatile在不同场景的应用,如标志位、单例模式中的使用。还介绍了Java内存模型和MESI协议在保证可见性中的作用。
摘要由CSDN通过智能技术生成

目录

一 volatile变量

1.1 volatile变量*

1.1.1 volatile变量的特性

1.1.2 使用场景*

1.2  四大屏障

1.2.1 内存屏障

1.2.2 内存屏障 的作用*

1.2.3 详解4大屏障(了解)

1.2.4 读写插入屏障(了解) 

二  volatile的三大特性

2.1 volatile的可见性

2.1.1 没有使用volatile

2.2.2 使用volatile

 2.3.3 java内存模型8种原子操作

​编辑

2.2 volatile的不具备原子性

2.2.1 原子性概念

2.2.2  原子性案例

2.2.2.1 上锁情况

2.2.2.2 使用volatile关键字的情况

2.2.2.3 案例原因分析*

2.3 禁止重排序

2.3.1 重排序

2.3.2 数据依赖性

 2.3.3 重排序案例

2.3.3.1 允许重排序情况

2.3.3.2 不允许重排序情况

 2.3.4 重排序原理

三  volatile的使用场景

3.1 场景1作为标志位

3.2 场景2 单一赋值

3.3 场景3 多读场景

3.4 场景4 单例场景DCL双端锁的发布

3.4.1 问题描述

3.4.2 解决办法

四  内存可见性

4.1  可见性

4.2 所遵守的协议

4.3 volatile保证可见性性原因


一 volatile变量

1.1 volatile变量*

1.1.1 volatile变量的特性

1.volatile保证变量在线程之间的可见性,具有可见性;

2.volatile禁止cpu进行指令重排序,保证有序性(禁止重排);

3.volatile不具有原子性。

4.对volatile变量的写直接刷到主内存,对volatile变量的读直接从主内存读取。

5.通过内存屏障禁重排序。通过这些内存屏障指令,volatile实现了java内存模型中的可见性和有序性(禁重排),但volatile无法保证原子性。

6.volatile的变量对复合操作不具有原子性。如k++;

7.详细说明:

当写一个volatile变量时,jmm会把该线程对应的本地内存中共享变量值立即刷回主内存中。

当读一个volatile变量时,jmm会把该线程本地的内存值设置无效,重新回到主内存中读取最新的值。

所以,对volatile变量的写直接刷到主内存,对volatile变量的读直接从主内存读取。

1.1.2 使用场景*

场景1: volatile的使用场景:通常volatile用作保存某个状态的boolean值或者int值。作为状态标志位,判断业务是否结束,或者初始化完成。

由于volatile变量只能保证可见性,在不符合以下两条规则的运算场景中,我们仍然要通过加锁(使用synchronized,juc下的锁或者原子类)来保证原子性。

1.运算结果并不依赖变量的当前值,或者能够确保只有单一的线程修改变量的值。

2.变量不需要与其他的状态变量共同参与不变约束。

场景2  不适合原子性

2. 单一赋值可以,volatile  int  k=10; volatile boolean flag=true;但是复合运算赋值不可以,如i++;

场景3: 和锁配合

3.当读多于写,结合内部的锁和volatile变量来减少同步的开销

4.应用单例模式下,使用volatile声明的变量可以强制屏蔽编译器和JIT的优化工作,能够防止双重检查锁的指令重排。

1.2  四大屏障

1.2.1 内存屏障

内存屏障:是一类同步屏障指令,是cpu或编译器在对内存随机访问的操作中的一个同步点,使得此点之前的所有读写操作都执行后才可以开始执行此点之后的操作。避免代码重排序。

内存屏障其实就是一种jvm指令,java内存模型的重排规划会要求java编译器在生成jvm指令时插入特定的内存屏障指令。

通过这些内存屏障指令,volatile实现了java内存模型中的可见性和有序性(禁重排),但volatile无法保证原子性。

内存屏障之前的所有写操作都要回写到主内存;内存屏障之后的所有读操作都能获得内存屏障之前的所有写操作的最新结果(实现可见性)

1.2.2 内存屏障 的作用*

内存屏障:阻止屏障两边的指令重排序。

作用:写数据时加入屏障,强制将线程私有工作内存的数据刷回主物理内存。

读数据时加入屏障,线程私有工作内存的数据失效,重新到主物理内存中获取最新数据。

volatile写之前的操作,都禁止重排序到volatile之后;

volatile读之后的操作,都禁止重排序到volatile之前

volatile写之后volatile读,禁止重排序。

1.2.3 详解4大屏障(了解)

总体就是:指令1和指令2不能重排序

1.2.4 读写插入屏障(了解) 

1.第1个为volatile读时,后面的任何操作都禁止重排序。保证volatile读之后的操作不会重排到volatile写之前。

2.第2个为volatile写时,不论第一个操作是什么,都不能重排序,这个操作保证了volatile写之前的操作不会重排到volatile写之后。

3.第1个操作位volatile写时,第2个为volatile位读时,禁止重排序。

案例;写

案例:读

二  volatile的三大特性

2.1 volatile的可见性

2.1.1 没有使用volatile

1没有使用volatile

package com.ljf.thread.volatiledemo;

/**
 * @ClassName: Kejian
 * @Description: TODO
 * @Author: admin
 * @Date: 2024/03/10 18:05:19 
 * @Version: V1.0
 **/
public class Kejian {
    static boolean  flag=true;
    public static void main(String[] args) throws InterruptedException {
        new Thread(new Runnable() {
            @Override
            public void run() {
                while(flag){
                   /// System.out.println("我是子线程:"+flag);
                }
                System.out.println("该我执行了...."+flag);
            }
        }, "t1").start();
        Thread.sleep(2000);
        flag=false;
        System.out.println("主线程执行完毕"+flag);
    }
}

执行结果:子线程获取不到flag的最新值,一直处于while 循环状态,不结束。

分析原因:

线程t1为何看不到主线程main修改flag的值为false?

1.主线程修改了falg的值之后没有将其最新值刷到主内存中,所以t1看不到。

2.主线程修改flag刷新到主内存,但是t1一直读取到的是自己私有工作内存中falg的值,没有去主内存中获取最新的flag的值。

2.2.2 使用volatile

解决办法:添加使用volatile修饰共享变量,就可以达到上面的效果,线程中读取的时候,每次读取都会去主内存中读取共享变量的最新值,然后将其复制到工作内存。.使用volatile修饰后,子线程读取到flag的最新值,结束while循环。

 2.3.3 java内存模型8种原子操作

java内存操作中定义8种,每个线程自己的工作内存与主物理内存之间的原子操作。

read:作用于【主内存】,将变量的值从主内存传输到工作内存,主内存到工作内存。

load:作用于【工作内存】,将read从主内存传输的变量值放入工作内存变量副本中,即数据加载。

use:作用于【工作内存】,将工作内存变量副本传递给执行引擎,每当jvm遇到需要该变量的字节码指令时会执行该操作。

assign:作用于工作内存,将从执行引擎接收到的值赋值给工作内存变量,每当jvm遇到一个给变量赋值字节码指令时会执行该操作。

store:作用于工作内存,将赋值完毕的工作变量的值写回主内存。

write:作用于【主内存】,将store传输过来的变量值赋值给工作内存中的变量。

lock:作用于【主内存】,将一个变量标记为一个线程独占的状态,锁住写变量的过程。

unlock:作用于【主内存】,把一个处于锁定状态的变量释放,然后才能被其他线程占用。

2.2 volatile的不具备原子性

2.2.1 原子性概念

原子性:指的是一个操作是不可中断的,即使是在多线程环境下,一个操作一旦开始就不会被其他线程影响。

2.2.2  原子性案例

2.2.2.1 上锁情况

a)上锁的情况

1.传统上锁机制

public class MyResources {
    int  k=0;
    public synchronized  int getNum(){
       // k++;
        return k++;
    }
}

线程调用

public class Yuanzi {
    public static void main(String[] args) throws InterruptedException {
      MyResources myResources=new MyResources();
      for(int k=0;k<10;k++){
          new Thread(new Runnable() {
              @Override
              public void run() {
                  for(int m=0;m<1000;m++){
                      myResources.getNum();
                  }
              }
          },"T1").start();
      }
      Thread.sleep(2000);
      System.out.println("最后值:"+myResources.getNum());
    }
}

结果:

2.2.2.2 使用volatile关键字的情况

b)不上锁,使用volatile关键字的情况

2.去掉锁,使用volatile关键字: 定义的变量添加volatile关键字

public class MyResources {
    volatile  int  k=0;
    public   int getNum(){
       // k++;
        return k++;
    }
}

线程调用

public class Yuanzi {
    public static void main(String[] args) throws InterruptedException {
      MyResources myResources=new MyResources();
      for(int k=0;k<10;k++){
          new Thread(new Runnable() {
              @Override
              public void run() {
                  for(int m=0;m<1000;m++){
                      myResources.getNum();
                  }
              }
          },"T1").start();
      }
      Thread.sleep(2000);
      System.out.println("最后值:"+myResources.getNum());
    }
}

 3.结果:结果不是10000;说明volatile变量,不加锁的情况下,出错。也就是volatile不具有原子性。

2.2.2.3 案例原因分析*

原因在于:对于volatile变量,jvm只是保证从主内存加载到线程工作内存的值是最新的,也只是数据加载时是最新的。如果第二线程在第一个线程读取旧值和写回新值期间读取域值,造成线程不安全,无法保证原子性,多线程修改主内存的共享变量的值,需要加同步锁synchronized,或lock

如下图所示:

2.3 禁止重排序

2.3.1 重排序

重排序是指编译器和处理器为了优化程序性能而对指令序列进行重新排序的一种手段,有时候改变程序语句的先后顺序。其中不存在数据依赖关系的可以重排序;存在数据依赖关系的,禁止重排序。但重排后的指令绝对不能改变原油的串行语义,重排序在并发设计中必须重点考虑。

2.3.2 数据依赖性

数据依赖性: 如果两个操作访问同一个变量,且这两个操作中又一个为写操作,此时两个操作就存在数据依赖性。如果数据之间存在依赖关系,禁止重排序。

 2.3.3 重排序案例

2.3.3.1 允许重排序情况

这种情况,重排序,没有问题

2.3.3.2 不允许重排序情况

以下3种情况,如果重排序,则会出现问题

 2.3.4 重排序原理

1总原理:

在每一个volatile写操作前面插入一个storestore屏障,可以保证在volatile写之前,其前面的所有普通写操作都已经刷新到主内存中。

在每一个volatile写操作后面插入一个storeload屏障,避免volatile写与后面可能有的volatile读/写重排序。

在每一个volatile读操作后面插入一个loadload屏障,禁止处理器把上面的volatile读与下面的普通读重排序。

在每一个volatile读操作后面插入一个loadstore屏障,禁止处理器把上面的volatile读与下面的普通写重排序。

2.volatile写之前的操作,都禁止重排序到volatile之后。如下图 volatile 变量前为storestore,后面为storeload

3.volatile读之后的操作,都禁止重排序到volatile之前,在volatile读后面设置loadload,loadstore屏障。如下图:

三  volatile的使用场景

3.1 场景1作为标志位

1.状态标志位,判断业务是否结束,或者初始化完成。

3.2 场景2 单一赋值

单一赋值可以,volatile  int  k=10; volatile boolean flag=true;但是复合运算赋值不可以,如i++;

3.3 场景3 多读场景

3.当读多于写,结合内部的锁和volatile变量来减少同步的开销

3.4 场景4 单例场景DCL双端锁的发布

3.4.1 问题描述

1不使用volatile的单例模式:

public class SingleTon {
    //1.私有化一个构造函数
    private  SingleTon(){ }
    //2.初始化一个静态对象
    private static SingleTon st=null;
    //3.提供一个获取实例对象的 公共 静态 加锁的方法
    public SingleTon  getInstance(){
        if(st==null){
            synchronized (SingleTon.class){
                if(st==null){
                    st=new SingleTon();
                }
            }
        }
        return st;
    }

}

2存在问题:instance = new LazySingleton();

这个创建对象的语句其实是个非原子操作,在极端的多线程环境下,会存在安全问题。对象的创建过程,在执行的时候分解成以下三条指令:

  1. 1.memory=allocate(); //1.分配对象的内存空间
    
    2.ctorInstance(memory); //2.执行构造方法来初始化对象
    
    3.instance=memory; //3.设置instance指向刚分配的内存地址
  2.  正常执行顺序应该是1->2->3,但可能指令会被重排序为1->3->2,也就是说,2、3步有可能发生指令重排导致重排序,因为synchronized只能保证有序性,但无法禁止指令重排。假设有两个线程A、B,在双重检查锁内,从cpu时间片上的执行顺序如下:

    A线程执行完3还没执行2,虽然分配了内存空间已,但是还没初始化对象,而此时B线程进来判断(instance == null),由于instance已经指向了内存空间,所以instance != null,于是直接返回了对象,但此时对象还未初始化。这样一来,线程B将得到一个还没有被初始化的对象

3.4.2 解决办法

   为了防止指令重排,需在声明instance对象时加上volatile关键词:

package com.ljf.thread.volatiledemo;

/**
 * @ClassName: SIngleTonV
 * @Description: TODO
 * @Author: admin
 * @Date: 2024/04/21 07:28:37 
 * @Version: V1.0
 **/
public class SIngleTonV {
    private SIngleTonV(){};
    private static volatile  SIngleTonV st=null;
    public static SIngleTonV  getInstance(){
        if(st==null){
            synchronized (SIngleTonV.class){
                if(st==null){
                    st=new SIngleTonV();
                }
            }
        }
        return st;
    }
}

解释参考: 

总结:使用volatile声明的变量可以强制屏蔽编译器和JIT的优化工作,能够防止双重检查锁的指令重排。

四  内存可见性

4.1  可见性

可见性是指一个线程对共享变量所作的修改能够被其他线程及时地看到。

4.2 所遵守的协议

MESI协议,即缓存一致性协议,它是一种用于维护多处理器系统中缓存一致性的协议。从上面我们知道,每个处理器都有自己的工作内存,这可能导致同一内存位置的多个副本同时存在于不同的缓存中。为了保证这些副本的一致性,引入 MESI 协议来保证一致性。

原理:

当 CPU 写数据时,如果发现操作的变量是共享变量,即在其他CPU中也存在该变量的副本,会发出信号通知其他CPU将该变量的缓存行置为无效状态,因此当其他CPU需要读取这个变量时,发现自己缓存中缓存该变量的缓存行是无效的,那么它就会从内存重新读取。

Java 面试宝典:什么是可见性?volatile 是如何保证可见性的?

4.3 volatile保证可见性性原因

volatile 通过在在每个读操作前都加上**Load屏障,强制从主内存读取最新的数据,在每个写操作后加上Store屏障,强制将数据刷新到主内存。**这样每次写都能将最新数据刷入到主内存,读都能从主内存读取最新数据,以此达到可见性

如上图所示,流程如下:

  • 线程 A 读取 i 时,遇到 Load 屏障,需要强制从主内存中读取得到 i = 0,加载到工作内存中。

  • 线程 A 执行 i++ 操作得到 i = 1,执行 assign指令进行赋值,遇到 Store 屏障,需要将 i = 1 强制刷新回主内存,此时主内存数据 i = 1

  • 然后线程 B 读取 i,也遇到Load 屏障,强制从主内存读取 i 的最新值, i = 1,执行 i++ 操作,得到 i = 2,同样在执行  assign 赋值后,遇到Store屏障立即将数据刷新回主内存,此时主内存数据 i = 2

这里可能有小伙们会认为,线程 A 和线程 B 同时执行,都从主内存读取 i = 0,然后执行 i++,最后主内存数据 i = 1,会不会存在这种情况?会,但是我们通过同步机制让他们不会,为什么?因为这个操作不是原子操作,在并发情况下会产生线程安全问题,我们是需要采用同步或者锁机制来保护的。

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值