valotile与内存屏障

valotile与内存屏障

开心 247 2021-12-20

1. volatile

1.1 volatile 的内存语义

  • 当写一个 volatile 变量时,JMM 会把该线程对应的本地内存中的共享变量值立即刷新回主内存中
  • 当读一个 volatile 变量时,JMM 会把该线程对应的本地内存设置为无效,直接从主内存中读取共享变量

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

1.2 volatile 特性

1.2.1 保证可见性

保证不同线程对这个变量进行操作时的可见性,即变量一旦改变所有线程立即可见

public class VolatileSeeDemo {

    static boolean flag = true;       // 不加 volatile,没有可见性
//    static volatile boolean flag = true;       // 加了 volatile,保证可见性

    public static void main(String[] args) {
        new Thread(() -> {
            System.out.println(Thread.currentThread().getName() + ":come in");
            while (flag) {
            }
            System.out.println(Thread.currentThread().getName() + ":flag 被修改为 false,退出.....");
        }, "t1").start();

        try {
            TimeUnit.SECONDS.sleep(2);
        } catch (InterruptedException e) {
            e.printStackTrace();
        }

        flag = false;
    }
}

线程 t1 中为何看不到被主线程修改为 false 的 flag 的值

  • 主线程修改了 flag 之后没有将其刷新到主内存,所以 t1 线程看不到
  • 主线程将 flag 刷新到了主内存,但是 t1 一直读取的是自己工作内存中 flag 的值,没有去主内存中更新获取 flag 最新的值
1.2.2 没有原子性

volatile 变量的复合操作(如 i++)不具有原子性

public class VolatileNoAtomicDemo {

    volatile int number = 0;

    public void addPlusPlus() {
        number++;
    }

    public static void main(String[] args) throws InterruptedException {
        VolatileNoAtomicDemo volatileNoAtomicDemo = new VolatileNoAtomicDemo();

        for (int i = 1; i <= 10; i++) {
            new Thread(() -> {
                for (int j = 1; j <= 1000; j++) {
                    volatileNoAtomicDemo.addPlusPlus();
                }
            }, String.valueOf(i)).start();
        }

        try {
            TimeUnit.SECONDS.sleep(3);
        } catch (InterruptedException e) {
            e.printStackTrace();
        }

        System.out.println(Thread.currentThread().getName() + "\t" + volatileNoAtomicDemo.number);
    }
}

Snipaste_20211220_233521.png

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

如果第二个线程在第一个线程读取旧值和写回新值期间读取 number 的值,那么第二个线程就会与第一个线程一起看到同一个值,并执行相同值的加 1 操作,这也就造成了线程安全失败,因此对于 addPlusPlus 方法必须使用 synchronized 修饰,以便保证线程安全

Snipaste_20211222_233612.png

多线程环境下,“数据计算”和“数据赋值”操作可能多次出现,即操作非原子,若数据在加载之后,主内存 count 变量发生修改,由于线程工作内存中的值在此前已经加载,从而不会对变更操作做出相应变化,即私有内存和公共内存中变量不同步,进而导致数据不一致
对于 volatile 变量,JVM 只是保证从主内存加载到线程工作内存的值是最新的,也就是数据加载时是最新的
由此可见 volatile 解决的是变量读时的可见性问题,但无法保证原子性,对于多线程修改共享变量的场景必须使用加锁同步

既然一修改就是可见,为什么还不能保证原子性,volatile 主要是对其中部分指令做了处理:

  • 要 use 一个变量的时候必须 load,要载入的时候必须从主内存 read 这样就解决了读的可见性
  • 写操作是把 assign 和 store 做了关联(在 assign 后必须 store,store 后 write),也就是做到了给一个变量赋值的时候一串关联指令直接把变量值写到主内存

就这样通过用的时候直接从主内存取,在赋值到直接写回主内存做到了内存可见性

1.2.3 指令禁重排

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

编译器和处理器在重排序时,会遵守数据依赖性,不会改变存在依赖关系的两个操作的执行,但不同处理器和不同线程之间的数据性不会被编译器和处理器考虑,其只会作用于单处理器和单线程环境,下面三种情况,只要重排序两个操作的执行顺序,程序的执行结果就会被改变

名称示例说明
写后读a=1;
b=a;
写一个变量之后,再读这个位置
写后写a=1;
a=2;
写一个变量之后,再写这个变量
读后写a=b;
b=1;
读一个变量之后,再写这个变量

有关禁止指令重排的行为

第一个操作第二个操作:普通读写第二个操作:volatitle读第二个操作:volatile写
普通读写可以重排可以重排不可以重排
volatile读不可以重排不可以重排不可以重排
volatile写可以重排不可以重排不可以重排

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

1.3 volatile 读写过程

lock(锁定)→read(读取)→load(加载)→use(使用)→assign(赋值)→store(存储)→write(写入)→unlock(解锁)

image.png

  • read: 作用于主内存,将变量的值从主内存传输到工作内存,主内存到工作内存
  • load: 作用于工作内存,将 read 从主内存传输的变量值放入工作内存变量副本中,即数据加载
  • use: 作用于工作内存,将工作内存变量副本的值传递给执行引擎,每当 JVM 遇到需要该变量的字节码指令时会执行该操作
  • assign: 作用于工作内存,将从执行引擎接收到的值赋值给工作内存变量,每当 JVM 遇到一个给变量赋值字节码指令时会执行该操作
  • store: 作用于工作内存,将赋值完毕的工作变量的值写回给主内存
  • write: 作用于主内存,将 store 传输过来的变量值赋值给主内存中的变量,由于上述只能保证单条指令的原子性,针对多条指令的组合性原子保证,没有大面积加锁,所以 JVM 提供了另外两个原子指令:
  • lock: 作用于主内存,将一个变量标记为一个线程独占的状态,只是写时候加锁,就只是锁了写变量的过程
  • unlock: 作用于主内存,把一个处于锁定状态的变量释放,然后才能被其他线程占用

2. 内存屏障

是一种屏障指令,它使得 CPU 或编译器对屏障指令的前后所发出的内存操作执行一个排序的约束,也叫内存栅栏或栅栏指令

2.1 内存屏障的作用

  • 阻止屏障两边的指令重排序
  • 写数据时加入屏障,强制将线程私有工作内存的数据刷回主物理内存
  • 读数据时加入屏障,线程私有工作内存的数据失效,重新到主物理内存中获取最新数据

2.2 内存屏障四大指令

  • 在每一个 volatile 写操作前面插入一个 StoreStore 屏障
    • StoreStore 屏障可以保证在 volatile 写之前,其前面的所有普通写操作都已经刷新到主内存中
  • 在每一个 volatile 写操作后面插入一个 StoreLoad 屏障
    • StoreLoad 屏障的作用是避免 volatile 写与后面可能有的 volatile 读/写操作重排序
  • 在每一个 volatile 读操作后面插入一个 LoadLoad 屏障
    • LoadLoad 屏障用来禁止处理器把上面的 volatile 读与下面的普通读重排序
  • 在每一个 volatile 读操作后面插入一个 LoadStore 屏障
    • LoadStore 屏障用来禁止处理器把上面的 volatile 读与下面的普通写重排序
屏障类型指令示例说明
LoadLoadLoad1;LoadLoad;Load2保证 Load1 的读取操作在 Load2 及后续读取操作之前执行
StoreStoreStore1;StoreStore;Store2在 Store2 及其后的写操作执行前,保证 Store1 的写操作已刷新到主内存
LoadStoreLoad1;LoadStore;Store2在 Stroe2 及其后的写操作执行前,保证 Load1 的读操作已读取结束
StoreLoadStore1;StoreLoad;Load2保证 Store1 的写操作已刷新到主内存之后,Load2 及其后的读操作才能执行

2.3 与 volatile 的关系

对字段使用 volatile 修饰后,会在字节码文件中添加一个 Field(flags: ACC_VOLATILE),JVM 在把字节码生成为机器码的时候,发现操作是 volatile 的变量的话,就会根据 JMM 要求,在相应的位置去插入内存屏障指令


# java