volatile

作用原理

volatile是Java虚拟机提供的轻量级的同步机制。

两个作用

  • 可见性:保证被volatile修饰的共享变量对所有线程总数可见的,也就是当一个线程修改了一个被volatile修饰共享变量的值,新值总是可以被其他线程立即得知。
  • 有序性:禁止指令重排序优化

但无法保证原子性

 

作用实现

可见性

package com.gx.demo.bingfa;

public class VolatileTest {
    
    private volatile boolean changeFlag = false;

    public void save() {
        this.changeFlag = true;
        System.out.println("线程:" + Thread.currentThread().getName() + " 修改了主存中的共享变量changeFlag");
    }

    public void load() {
        while (!changeFlag) {
        }
        System.out.println("线程:" + Thread.currentThread().getName() + " 感知到了changeFlag变量的修改");
    }

    public static void main(String[] args) {
        VolatileTest sample = new VolatileTest();
        Thread threadA = new Thread(() -> {
            sample.save();
        },"threadA");
        Thread threadB = new Thread(()-> {
                sample.load();
            },"threadB");
        threadB.start();
        try {
            Thread.sleep(2000);
        } catch (InterruptedException e) {
            e.printStackTrace();
        }
        threadA.start();
    }
}
结果:
线程:threadA:修改共享变量changeFlag
线程:threadB 感知到了changeFlag变量的修改

 

有序性(禁止指令重排)

禁止指令重排优化指的是:避免多线程环境下程序出现乱序执行的现象。

 

内存屏障

概念

什么是内存屏障(Memory Barrier)?
内存屏障(memory barrier)是一个CPU指令。

它的作用有两个:

a) 确保一些特定操作执行的顺序

b) 影响一些数据的可见性(可能是某些指令执行后的结果,保证在内存中可见)。

编译器和CPU可以在保证输出结果一样的情况下对指令重排序,使性能得到优化。插入一个内存屏障,相当于告诉CPU和编译器先于这个命令的必须先执行,后于这个命令的必须后执行。内存屏障另一个作用是强制更新一次不同CPU的缓存。

例如,一个写屏障会把这个屏障前写入的数据刷新到缓存,这样任何试图读取该数据的线程将得到最新值,而不用考虑到底是被哪个cpu核心或者哪颗CPU执行的。

 

硬件层的内存屏障

Intel硬件提供了一系列的内存屏障,主要有:

  1. lfence,是一种Load Barrier 读屏障
  2. sfence, 是一种Store Barrier 写屏障
  1. mfence, 是一种全能型的屏障,具备ifence和sfence的能力
  2. Lock前缀,Lock不是一种内存屏障,但是它能完成类似内存屏障的功能。Lock会对CPU总线和高速缓存加锁,可以理解为CPU指令级的一种锁。它后面可以跟ADD, ADC, AND, BTC, BTR, BTS, CMPXCHG, CMPXCH8B, DEC, INC, NEG, NOT, OR, SBB, SUB, XOR, XADD, and XCHG等指令。

 

JVM提供的四类内存屏障

Java内存屏障主要有Load和Store两类。
对Load Barrier()来说,在读指令前插入读屏障,可以让高速缓存中的数据失效,重新从主内存加载数据
对Store Barrier()来说,在写指令之后插入写屏障,能让写入缓存的最新数据写回到主内存

 

对于Load和Store,在实际使用中,又分为以下四种:

屏障类型

指令例子

说明用途

LoadLoad

Load1,Loadload,Load2

确保Load1所要读入的数据能够在被Load2和后续的load指令访问前读入

StoreStore

Store1,StoreStore,Store2

确保Store1的数据在Store2以及后续Store指令操作相关数据之前对其它处理器可见(例如向主存刷新数据)

LoadStore

Load1; LoadStore; Store2

确保Load1的数据在Store2和后续Store指令被刷新之前读取。在等待Store指令可以越过loads指令的乱序处理器上需要使用LoadStore屏障。

StoreLoad

Store1; StoreLoad; Load2

确保Store1的数据在被Load2和后续的Load指令读取之前对其他处理器可见

volatile的有序性实现

JMM针对编译器制定的volatile重排序规则表。

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

普通读写 可以重排 可以重排 不可以重排

volatile读 不可以重排 不可以重排 不可以重排

volatile写 可以重排 不可以重排 不可以重排

 

为了实现volatile的内存语义,编译器在生成字节码时,会在指令序列中插入内存屏障来禁止特定类型的处理器重排序。对于编译器来说,发现一个最优布置来最小化插入屏障的总数几乎不可能。为此,JMM采取保守策略。下面是基于保守策略的JMM内存屏障插入策略。

∙在每个volatile写操作的前面插入一个StoreStore屏障。

∙在每个volatile写操作的后面插入一个StoreLoad屏障。

∙在每个volatile读操作的后面插入一个LoadLoad屏障。

∙在每个volatile读操作的后面插入一个LoadStore屏障。

上述内存屏障插入策略非常保守,但它可以保证在任意处理器平台,任意的程序中都能得到

正确的volatile内存语义。

 

有序性示例:

单例模式中的DCL(double check lock)。

private static MyTest instance;
public static MyTest getInstance(){
    if(instance == null){
        synchronized (MyTest.class){
            if(instance == null){
                instance = new MyTest();
            }
        }
    }
    return instance;
}
instance = new MyTest();

 

在内存中具体分为几个步骤实现

1.给对象分配内存空间

2.初始化对象 init()

3.给变量分配内存地址

步骤2、3可能会发生重排序,所以当一条线程访问instance不为null时,由于instance实例未必已初始化完成,也就造成了线程安全问题。

解决办法:

private volatile static MyTest instance;//禁止指令的重排
 

原子性

volatile无法保证原子性。

static volatile int i = 0;
public static void caculate(){
    i++;
}

 

i++分为:先去读取i的值,然后再+1写入一个新的值,两个步骤完成,本身不具备原子性。

假如在第一步完成之后,第二步执行之前时,有线程在此时读取了i在内存中的值,那么这个线程会和开始那个线程相当于要对i执行一样的操作,i结果都是1。也就造成线程安全失败了。

解决办法:对执行方法添加synchronized,但是synchronized一样具备了可见性,可以不用volatile修饰了。

posted @ 2021-08-01 23:25  Flyinglion  阅读(64)  评论(0编辑  收藏  举报