Java内存模型及happens-before原则

1. JMM数据原子操作

  • read(读取): 从主内存读取数据
  • load(载入): 将主内存读取到的数据写入工作内存
  • use(使用): 从工作内存读取数据用于计算
  • assign(赋值): 将计算好的值重新赋值到工作内存中
  • store(存储): 将工作内存数据写入主内存
  • write(写入): 将store过去的变量值赋值给主内存中的变量
  • lock(锁定): 将主内存变量加锁,标识为线程独占状态
  • unlock(解锁): 将主内存变量解锁,解锁后其他线程可以锁定该变量

2. 保证可见性原理

对于volatile变量,当对volatile变量进行写操作的时候,JVM会向处理器发送一条lock前缀的指令,将这个缓存中的变量回写到系统主存中。
但是就算写回到内存,如果其他处理器缓存的值还是旧的,再执行计算操作就会有问题,所以在多处理器下,为了保证各个处理器的缓存是一致的,就会实现缓存一致性协议
缓存一致性协议:每个处理器通过嗅探在总线上传播的数据来检查自己缓存的值是不是过期了,当处理器发现自己缓存行对应的内存地址被修改,就会将当前处理器的缓存行设置成无效状态,当处理器要对这个数据进行修改操作的时候,会强制重新从系统内存里把数据读到处理器缓存里。
所以,如果一个变量被volatile所修饰的话,在每次数据变化之后,其值都会被强制刷入主存。而其他处理器的缓存由于遵守了缓存一致性协议,也会把这个变量的值从主存加载到自己的缓存中。这就保证了一个volatile在并发编程中,其值在多个缓存中是可见的。

缓存一致性-总线加锁

Volatile 原理 - 图1

  1. 线程2 从主存中read值之后 开始加锁,直到store 线程执行完之后 unlock,其他线程才可以执行。

缺点:

  1. 存在严重的性能问题

缓存一致性-MESI 缓存一致性协议

Volatile 原理 - 图2

  • 对应代码:
  1. public class VolatileVisiblityTest {
  2. private static volatile boolean initFlag = false;
  3. public static void main(String[] args) throws InterruptedException {
  4. new Thread(() -> {
  5. System.out.println("waiting data...");
  6. while (!initFlag) {
  7. }
  8. System.out.println("---------------success");
  9. }).start();
  10. Thread.sleep(2000);
  11. new Thread(() -> prepareData()).start();
  12. }
  13. public static void prepareData() {
  14. System.out.println("准备数据中....");
  15. initFlag = true;
  16. System.out.println("数据准备完毕");
  17. }
  18. }
  1. MESI是保持一致性的协议。它的方法是在CPU缓存中保存一个标记位,这个标记位有四种状态:
  • MESI

    • M: Modify,修改缓存,当前CPU的缓存已经被修改了,即与内存中数据已经不一致了;
    • E: Exclusive,独占缓存,当前CPU的缓存和内存中数据保持一致,而且其他处理器并没有可使用的缓存数据;
    • S: Share,共享缓存,和内存保持一致的一份拷贝,多组缓存可以同时拥有针对同一内存地址的共享缓存段;
    • I: Invalid,失效缓存,这个说明CPU中的缓存已经不能使用
  • CPU的读取遵循下面几点:

    • 如果缓存状态是I,那么就从内存中读取,否则就从缓存中直接读取。
    • 如果缓存处于M或E的CPU读取到其他CPU有读操作,就把自己的缓存写入到内存中,并将自己的状态设置为S。
    • 只有缓存状态是M或E的时候,CPU才可以修改缓存中的数据,修改后,缓存状态变为M。
  • 汇编#Lock前缀指令
    Volatile 原理 - 图3

  1. volatile关键字修饰的变量 在线程对其赋值操作的时候,会在其汇编语言中加上#Lock前缀,如上图。有2个功能:
  2. [1] 将当前处理器缓存行中的数据立刻刷新到主内存中
  3. [2] 这个写回内存的操作会触发 CPU总线嗅探机制 ,会引起在其他CPU核里缓存了该内存地址的数据无效(MESI缓存一致性协议)。从而 从表面上实现了内存可见性。
  • VM参数配置
  1. -server -Xcomp -XX:+UnlockDiagnosticVMOptions -XX:+PrintAssembly -XX:CompileCommand=compileonly,*VolatileVisiblityTest.prepareData

volatile无法保证线程操作的原子性

Volatile 原理 - 图4

  • 代码:
  1. public class VolatileAtomicTest {
  2. static int num = 0;
  3. static void increase() {
  4. num++;
  5. }
  6. public static void main(String[] args) throws InterruptedException {
  7. Thread[] threads = new Thread[10];
  8. for (int i = 0; i < threads.length; i++) {
  9. threads[i] = new Thread(new Runnable() {
  10. @Override
  11. public void run() {
  12. for (int i = 0; i < 1000; i++) {
  13. increase();
  14. }
  15. }
  16. });
  17. threads[i].start();
  18. }
  19. for (Thread thread : threads) {
  20. thread.join();
  21. }
  22. System.out.println(num);
  23. }
  24. }
  1. 结果:num<=10000
  • 分析:
  1. 线程1计算完之后 通过主线向主存更新num值,此时MESI缓存一致性及CPU嗅探,使得线程2已经计算完的num丢失。原本两个线程计算完之后应该是2,此时只能是1

3.禁止指令重排序

内存屏障

1.分类

1、内存屏障分为两种:Load Barrier 和 Store Barrier 即读屏障和写屏障。【volatile写 是在前面和后面分别插入内存屏障,而 volatile读 操作是在后面插入两个内存屏障。】
1)、Load Barrier:在指令前插入 Load Barrier,可以让高速缓存中的数据失效,强制重新从主内存加载数据;
2)、Store Barrier:在指令后插入 Store Barrier,能让写入缓存中的最新数据更新写入主内存,让其他线程可见。

2. 内存屏障作用:

1)、阻止屏障两侧的指令重排序;
2)、强制把写缓冲区/高速缓存中的脏数据等写回主内存,让缓存中相应的数据失效。

3. 内存屏障通常是两两组合,共通完成一系列的屏障和数据同步问题。

1)、LoadLoad屏障:对于这样的语句Load1; LoadLoad; Load2,在Load2及后续读取操作要读取的数据被访问前,保证Load1要读取的数据被读取完毕。
2)、StoreStore屏障:对于这样的语句Store1; StoreStore; Store2,在Store2及后续写入操作执行前,保证Store1的写入操作对其它处理器可见。
3)、LoadStore屏障:对于这样的语句Load1; LoadStore; Store2,在Store2及后续写入操作被刷出前,保证Load1要读取的数据被读取完毕。
4)、StoreLoad屏障:对于这样的语句Store1; StoreLoad; Load2,在Load2及后续所有读取操作执行前,保证Store1的写入对所有处理器可见。它的开销是四种屏障中最大的。在大多数处理器的实现中,这个屏障是个【万能屏障】,兼具其它三种内存屏障的功能

4. volatile防止指令重排序具体步骤

1)、在每个【volatile写操作】前插入StoreStore屏障,在【volatile写操作】后插入StoreLoad屏障;
2)、在每个【volatile读操作】前插入LoadLoad屏障,在【volatile读操作】后插入LoadStore屏障;

引用

https://www.hollischuang.com/archives/2673
[

](https://blog.csdn.net/jiyiqinlovexx/article/details/50989328)