【JUC】Volatile关键字+CPU/JVM底层原理

Volatile关键字

volatile内存语义

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

2.当读一个volatile变量时,JMM会把该线程对应的本地内存设置为无效,直接从主内存中读取共享变量

所以volatile的写内存语义是直接刷新到主内存中,读的内存语义是直接从主内存中读取。

volatile两大特点

可见性:是指当一个线程修改了某一个共享变量的值,其他线程是能够立即知道该变更 ,JMM规定了所有的变量都存储在主内存中。

Java中普通的共享变量不保证可见性,因为数据修改被写入内存的时机是不确定的,多线程并发下很可能出现"脏读",所以每个线程都有自己的工作内存,线程自己的工作内存中保存了该线程使用到的变量的主内存副本拷贝,线程对变量的所有操作(读取,赋值等 )都必需在线程自己的工作内存中进行,而不能够直接读写主内存中的变量。不同线程之间也无法直接访问对方工作内存中的变量,线程间变量值的传递均需要通过主内存来完成

有序性:对于一个线程的执行代码而言,我们总是习惯性认为代码的执行总是从上到下,有序执行。但为了提供性能,编译器和处理器通常会对指令序列进行重新排序。指令重排可以保证串行语义一致,但没有义务保证多线程间的语义也一致,即可能产生"脏读"

简单说,两行以上不相干的代码在执行的时候有可能先执行的不是第一条,不见得是从上到下顺序执行,执行顺序会被优化。

注意:volatile修饰的变量复合操作不具有原子性

volatile底层原理:内存屏障

什么是内存屏障

内存屏障(Memory Barriers / Memory Fences)(也称内存栅栏,内存栅障,屏障指令等,是一类同步屏障指令,是CPU或编译器在对内存随机访问的操作中的一个同步点,使得此点之前的所有读写操作都执行后才可以开始执行此点之后的操作),避免代码重排序。内存屏障其实就是一种JVM指令,Java内存模型的重排规则会要求Java编译器在生成JVM指令时插入特定的内存屏障指令,通过这些内存屏障指令,volatile实现了Java内存模型中的可见性和有序性,但volatile无法保证原子性。

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

内存屏障基于计算机指令实现

内存屏障的作用

1.阻止屏障两边的指令重排序

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

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

JVM中四类内存屏障指令

屏障类型 指令示例 说明
LoadLoad Load1; LoadLoad ; Load2 保证Load1的读取操作在Load2以及后续操作之前
StoreStore Strore1; StoreStore; Store2 在Store2及其后续写操作执行前,保证Store1的写操作结果刷新到主内存
LoadStore Load1; LoadStore; Store1 在Store1及其后的写操作执行前,保证Load1的读操作已经结束
StoreLoad Store1; StoreLoad; Load1 保证Store1的写操作结果已刷新到主内存之后,Load1及其后的读操作才开始

happen-before 之volatile变量规则

第一个操作 第二个操作:普通读写 第二个操作:volatile读 第二个操作:volatile写
普通读写 ×
volatile读 × × ×
volatile写 × ×

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

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

当第一个操作为volatile写时,第二个操作为volatile读时,不能重排。

JMM内存屏障插入策略

写 : 1. 在每个 volatile 写操作的前⾯插⼊⼀个 StoreStore 屏障

2.在每个在每个 volatile 写操作的后⾯插⼊⼀个 StoreLoad 屏障

store -> (store写)->laod

读: 1. 在每个 volatile 读操作的后⾯插⼊⼀个 LoadLoad 屏障

2.在每个 volatile 读操作的后⾯插⼊⼀个 LoadStore 屏障

load->(load读)->store

volitile关键字会在字节码添加flags :ACC_VOLATILE

JVM再把字节码转化为机器码的时候,会根据JMM规范为其相应位置插入内存屏障指令

CPU底层volatile

cpu执行机器码指令的时候,是使用lock前缀命令来实现volatile的功能的

lock指令相当于内存屏障,功能也类似于内存屏障:

(1)首先对总线/缓存加锁,然后去执行后面的命令,最后释放锁,同时把高速缓存的数据刷新到主内存

(2)在lock锁住总线/缓存的时候,其他cpu的读写请求就会被阻塞,直到锁释放。lock过后的写操作会让其他cpu中高速缓存的相应的数据失效,这样后续这些cpu在读取数据的时候就会从主存中加载最新的数据

volitile使用场景

1.volatile修饰的变量单一赋值可以,但是复合运算赋值不可以(i++), 因为i++字节码中被拆分为三个指令:getfield :执行拿到原始i iadd:加一操作 putfield:累加后的值写回

2.状态标志,判断业务是否结束

java 复制代码
public class Demo
{
   //  * 使用:作为一个布尔状态标志,用于指示发生了一个重要的一次性事件,例如完成初始化或任务结束
 //* 理由:状态标志并不依赖于程序内任何其他状态,且通常只有一种状态转换
 //* 例子:判断业务是否结束

    private volatile static boolean flag = true;

    public static void main(String[] args)
    {
        new Thread(() -> {
            while(flag) {
                //do something......
            }
        },"t1").start();

        //暂停几秒钟线程
        try { TimeUnit.SECONDS.sleep(2L); } catch (InterruptedException e) { e.printStackTrace(); }

        new Thread(() -> {
            flag = false;
        },"t2").start();
    }
}
 

3.开销较低的读,写锁策略

java 复制代码
public class Demo
{
    /**
     * 使用:当读远多于写,结合使用内部锁和 volatile 变量来减少同步的开销
     * 理由:利用volatile保证读取操作的可见性;利用synchronized保证复合操作的原子性
     */
    public class Counter
    {
        private volatile int value;

        public int getValue()
        {
            return value;   //利用volatile保证读取操作的可见性
              }
        public synchronized int increment()
        {
            return value++; //利用synchronized保证复合操作的原子性
               }
    }
}

4.dcl双重检查锁

java 复制代码
public class SafeDoubleCheckSingleton
{
    private static SafeDoubleCheckSingleton singleton;
    //私有化构造方法
    private SafeDoubleCheckSingleton(){
    }
    //双重锁设计
    public static SafeDoubleCheckSingleton getInstance(){
        if (singleton == null){
            //1.多线程并发创建对象时,会通过加锁保证只有一个线程能创建对象
            synchronized (SafeDoubleCheckSingleton.class){
                if (singleton == null){
                    //隐患:多线程环境下,由于重排序,该对象可能还未完成初始化就被其他线程读取
                    singleton = new SafeDoubleCheckSingleton();
                }
            }
        }
        //2.对象创建完毕,执行getInstance()将不需要获取锁,直接返回创建对象
        return singleton;
    }
}
 

单线程环境下 singleton = new SafeDoubleCheckSingleton();回进行如下操作;

但是多线程环境下,在"问题代码处",会执行如下操作,由于重排序导致2,3乱序,后果就是其他线程得到的是null而不是完成初始化的对象

解决方法:

1.volatile修饰

java 复制代码
public class SafeDoubleCheckSingleton
{
    //通过volatile声明,实现线程安全的延迟初始化。
    private volatile static SafeDoubleCheckSingleton singleton;
    //私有化构造方法
    private SafeDoubleCheckSingleton(){
    }
    //双重锁设计
    public static SafeDoubleCheckSingleton getInstance(){
        if (singleton == null){
            //1.多线程并发创建对象时,会通过加锁保证只有一个线程能创建对象
            synchronized (SafeDoubleCheckSingleton.class){
                if (singleton == null){
                    //隐患:多线程环境下,由于重排序,该对象可能还未完成初始化就被其他线程读取
                                      //原理:利用volatile,禁止 "初始化对象"(2) 和 "设置singleton指向内存空间"(3) 的重排序
                    singleton = new SafeDoubleCheckSingleton();
                }
            }
        }
        //2.对象创建完毕,执行getInstance()将不需要获取锁,直接返回创建对象
        return singleton;
    }
}

2.静态内部类

java 复制代码
public class SingletonDemo
{
    private SingletonDemo() { }

    private static class SingletonDemoHandler
    {
        private static SingletonDemo instance = new SingletonDemo();
    }

    public static SingletonDemo getInstance()
    {
        return SingletonDemoHandler.instance;
    }
}

既然一修改就是可见,为什么还不能保证原子性?

volatile主要对其中的部分指令做了处理

要use(使用)一个变量的时候必需load(载入),要载入的时候必需从主内存read(读取)这样就解决了读的可见性。

写操作是把assign和store做了关联(在assign(赋值)后必需store(存储))。store(存储)后write(写入)。

也就是做到了给一个变量赋值的时候一串关联指令直接把变量值写到主内存。

就这样通过用的时候直接从主内存取,在赋值到直接写回主内存做到了内存可见性。注意蓝色框框的间隙

多线程并发导致的问题有三个:

  1. CPU缓存引起的可见性问题
  2. CPU指令重排引起的有序性问题
  3. CPU轮换线程中断导致的原子性问题

volatile修饰词前两个可以杜绝,对于3无法:

S:线程1取i,进行1取出值,结果存入某个寄存器,W:线程切换到2执行1,2,3,内存刷新,H:线程再次切换到线程1,线程1执行3

这里线程1的缓存按理说应该是失效的,因为W操作以后i的值已经更新了,事实确实缓存已经失效了,但是寄存器里面已经存入值了,所以就直接使用了寄存器里面的值进行在AL寄存器+1操作,然后写入i的地址。相反如果寄存器里面没有值,这时cpu缓存也失效了,就必须先从主内存里面获取i的值,然后再导入寄存器。

CPU/JVM底层原理

volatile关键字的作用是:修饰的对象在进行写操作的完成时候会立即将变量的值从工作的线程空间刷新回主内存;在执行读操作前会从主内存中获取最新的值。这些功能是由JMM规定的内存屏障插入策略实现的。

CPU多核处理器之间缓存不一致现象是通过MESI协议实现的,但是MESI协议下cpu执行对变量执行操作后缓存行状态通信需要发送信息给其他缓存了该数据的CPU,并且要等到他们确认回执,这段时间CPU是阻塞状态的,因此CPU引入了store buffers,cpu直接将共享数据写入store bufferes同时发送消息,然后去处理其他指令.其他cpu发送反馈消息后再将store bufferes中的缓存存储到缓存行,最后同步到主内存,这种异步优化导致了CPU的对内存的乱序访问带来的可见性问题,因此CPU层面引入了内存屏障让软件层面决定禁止指令重排序,因此votalie底层是通过CPU的MESI协议和访存排序来保证可见性和有序性的,而可见性又是在有序性基础上保证的。

对于加强访存排序x86平台主要有以下几种手段:

ifence,sfence,mfence(序列化指令),io指令,加锁指令,序列化指令,lock前缀(指的是lock开头的一系列指令)等进行强排序。

jVM底层是使用了lock前缀实现的。

lock前缀会对CPU总线和缓存进行加锁,然后执行后面的命令,执行完命令后将脏数据从缓存立即刷新到主内存而不需要刷新到store bufferes,同时加锁期间其他cpu核心对总线和缓存的访问会被阻塞,释放锁后其他CPU核心相应的cache line会失效然后从主内存重新加载,这个是由MESI协议实现的,同时访存排序模型也规定了:读写操作都不能跨越加锁指令和序列化指令,因此保证了有序性和可见性。因此lock前缀同时达到了MESI和强指令排序的效果。

其中loadload和storeload,loadstore屏障在x86处理器上不需要指令,而是插入了一段特定的空的内联汇编块防止编译器重排序,CPU层面的防止重排序是由x86平台默认的访存排序实现的,而stroeload是在基础上加入了lock前缀来加强了访存排序实现了全内存屏障。

c++ 复制代码
static inline void compiler_barrier() {
  __asm__ volatile ("" : : : "memory");
}

inline void OrderAccess::loadload()   { compiler_barrier(); }
inline void OrderAccess::storestore() { compiler_barrier(); }
inline void OrderAccess::loadstore()  { compiler_barrier(); }
inline void OrderAccess::storeload()  { fence();            }

inline void OrderAccess::acquire()    { compiler_barrier(); }
inline void OrderAccess::release()    { compiler_barrier(); }

inline void OrderAccess::fence() {
   // always use locked addl since mfence is sometimes expensive
#ifdef AMD64
  __asm__ volatile ("lock; addl $0,0(%%rsp)" : : : "cc", "memory");
#else
  __asm__ volatile ("lock; addl $0,0(%%esp)" : : : "cc", "memory");
#endif
  compiler_barrier();
}
  • compiler_barrier():这是一个编译器屏障,它使用了一个空的内联汇编块来阻止编译器进行指令重排序。这个函数没有任何运行时开销,但可以阻止编译器在优化过程中改变指令的顺序。
  • loadload(), storestore(), loadstore(), acquire(), release():这些函数都调用了compiler_barrier(),因此它们的效果与compiler_barrier()相同。
  • storeload():这个函数调用了fence(),它提供了一个全内存屏障。这意味着在fence()之前的所有内存访问(读取和写入)在fence()执行之前完成,而在fence()之后的所有内存访问在fence()执行之后开始。
  • fence():这个函数提供了一个全内存屏障。它使用了一个带有lock前缀的addl指令来阻止处理器进行指令重排序。lock前缀会锁定总线,确保指令的原子性。这个函数在执行完lock addl指令后又调用了compiler_barrier(),以阻止编译器进行指令重排序。

对于Linux_AMD_x86: storesload是lock;addl 0,(sp)指令,

always use locked addl since mfence is sometimes expensive

c++ 复制代码
#ifdef AMD64
  __asm__ volatile ("lock; addl $0,0(%%rsp)" : : : "cc", "memory");
#else
  __asm__ volatile ("lock; addl $0,0(%%esp)" : : : "cc", "memory");
#endif
  compiler_barrier();
ceylon 复制代码
StoreLoad屏障:
    使用addl 0,(sp)指令来实现这是一种加法指令,它的作用是堆栈指针(sp)所指向的内存地址进行加0的操作,并且在执行这个操作的过程中,使用lock指令来修饰。这个指令的作用是保证在它之前的所有写操作都对其他处理器可见,然后才执行它之后的所有操作。这样就可以保证这个内存地址的值对所有的处理器是一致的,也就是实现了StoreLoad屏障的功能。

JVM的内存屏障是由lock addl 0,(sp)实现的,

volitile关键字会在字节码添加flags :ACC_VOLATILE

JVM再把字节码转化为机器码的时候,会根据JMM规范为其相应位置插入内存屏障指令

读操作前插入loadload,后插入loadstore

写操作前插入storestore,写操作后插入storeload

多线程并发导致的问题有三个:

  1. CPU缓存引起的可见性问题
  2. CPU指令重排引起的无序性问题
  3. CPU轮换线程中断导致的原子性问题

通过上面对volatile底层操作,volatile可以解决可见性和无序性问题,但是无法保证原子性问题。

JDK12源码:/src/hotpot/share/runtime/orderAccess.hpp

/src/hotpot/os_cpu/linux_x86/orderAcces_linux_x86.hpp

window_x64

c++ 复制代码
#ifdef AMD64
  StubRoutines_fence();
#else
  __asm {
    lock add dword ptr [esp], 0;
  }
#endif // AMD64
  compiler_barrier();
}
相关推荐
唐古乌梁海3 小时前
【Java】JVM 内存区域划分
java·开发语言·jvm
众俗4 小时前
JVM整理
jvm
echoyu.4 小时前
java源代码、字节码、jvm、jit、aot的关系
java·开发语言·jvm·八股
代码栈上的思考18 小时前
JVM中内存管理的策略
java·jvm
thginWalker21 小时前
深入浅出 Java 虚拟机之进阶部分
jvm
沐浴露z1 天前
【JVM】详解 线程与协程
java·jvm
thginWalker1 天前
深入浅出 Java 虚拟机之实战部分
jvm
程序员卷卷狗2 天前
JVM 调优实战:从线上问题复盘到精细化内存治理
java·开发语言·jvm
Sincerelyplz3 天前
【JDK新特性】分代ZGC到底做了哪些优化?
java·jvm·后端
初学小白...4 天前
线程同步机制及三大不安全案例
java·开发语言·jvm