同事分享_1_java 内存模型

 

文龙同事分享:

导语

本文是针对java内存模型JMM的入门级简介,通过学习JMM可以让我们加深对java代码执行的过程,及java并发编程中常见的问题的了解。例如 volatilesynchronized等在JMM中的具体体现。

概念

Java内存模型(JMM)

Java虚拟机规范中定义了Java内存模型(Java Memory Model,JMM),用于屏蔽掉各种硬件和操作系统的内存访问差异,以实现让Java程序在各种平台下都能达到一致的并发效果,JMM规范了Java虚拟机与计算机内存是如何协同工作的:规定了一个线程如何和何时可以看到由其他线程修改过后的共享变量的值,以及在必须时如何同步的访问共享变量

同事分享_1_java 内存模型_第1张图片

同事分享_1_java 内存模型_第2张图片

同事分享_1_java 内存模型_第3张图片

  • 一个本地变量可能是原始类型,在这种情况下,它总是待在线程栈上。
  • 一个本地变量也可能是指向一个对象的一个引用。在这种情况下,引用(这个本地变量)存放在线程栈上,但是对象本身存放在堆上。
  • 一个对象可能包含方法,这些方法可能包含本地变量。这些本地变量仍然存放在线程栈上,即使这些方法所属的对象存放在堆上。
  • 一个对象的成员变量可能随着这个对象自身存放在堆上。不管这个成员变量是原始类型还是引用类型。
  • 静态成员变量跟随着类定义一起也存放在堆上。
  • 存放在堆上的对象可以被所有持有对这个对象引用的线程访问。当一个线程可以访问一个对象时,它也可以访问这个对象的成员变量。如果两个线程同时调用同一个对象上的同一个方法,它们将会都访问这个对象的成员变量,但是每一个线程都拥有这个成员变量的私有拷贝。

同事分享_1_java 内存模型_第4张图片

同事分享_1_java 内存模型_第5张图片

关于主内存与工作内存之间的具体交互协议,即一个变量如何从主内存拷贝到工作内存、如何从工作内存同步到主内存之间的实现细节,Java内存模型定义了以下八种操作来完成:

  • lock(锁定):作用于主内存的变量,把一个变量标识为一条线程独占状态。
  • unlock(解锁):作用于主内存变量,把一个处于锁定状态的变量释放出来,释放后的变量才可以被其他线程锁定。
  • read(读取):作用于主内存变量,把一个变量值从主内存传输到线程的工作内存中,以便随后的load动作使用
  • load(载入):作用于工作内存的变量,它把read操作从主内存中得到的变量值放入工作内存的变量副本中。
  • use(使用):作用于工作内存的变量,把工作内存中的一个变量值传递给执行引擎,每当虚拟机遇到一个需要使用变量的值的字节码指令时将会执行这个操作。
  • assign(赋值):作用于工作内存的变量,它把一个从执行引擎接收到的值赋值给工作内存的变量,每当虚拟机遇到一个给变量赋值的字节码指令时执行这个操作。
  • store(存储):作用于工作内存的变量,把工作内存中的一个变量的值传送到主内存中,以便随后的write的操作。
  • write(写入):作用于主内存的变量,它把store操作从工作内存中一个变量的值传送到主内存的变量中。

Java内存模型还规定了在执行上述八种基本操作时,必须满足如下规则:

  • 如果要把一个变量从主内存中复制到工作内存,就需要按顺寻地执行read和load操作, 如果把变量从工作内存中同步回主内存中,就要按顺序地执行store和write操作。但Java内存模型只要求上述操作必须按顺序执行,而没有保证必须是连续执行。
  • 不允许read和load、store和write操作之一单独出现
  • 不允许一个线程丢弃它的最近assign的操作,即变量在工作内存中改变了之后必须同步到主内存中。
  • 不允许一个线程无原因地(没有发生过任何assign操作)把数据从工作内存同步回主内存中。
  • 一个新的变量只能在主内存中诞生,不允许在工作内存中直接使用一个未被初始化(load或assign)的变量。即就是对一个变量实施use和store操作之前,必须先执行过了assign和load操作。
  • 一个变量在同一时刻只允许一条线程对其进行lock操作,但lock操作可以被同一条线程重复执行多次,多次执行lock后,只有执行相同次数的unlock操作,变量才会被解锁。lock和unlock必须成对出现
  • 如果对一个变量执行lock操作,将会清空工作内存中此变量的值,在执行引擎使用这个变量前需要重新执行load或assign操作初始化变量的值
  • 如果一个变量事先没有被lock操作锁定,则不允许对它执行unlock操作;也不允许去unlock一个被其他线程锁定的变量。
  • 对一个变量执行unlock操作之前,必须先把此变量同步到主内存中(执行store和write操作)

CPU硬件架构及缓存一致性协议MESI

同事分享_1_java 内存模型_第6张图片

同事分享_1_java 内存模型_第7张图片

同事分享_1_java 内存模型_第8张图片

同事分享_1_java 内存模型_第9张图片

为什么CPU要引入缓存系统:CPU在摩尔定律的指导下以每18个月翻一番的速度在发展,然而内存和硬盘的发展速度远远不及CPU。这就造成了高性能能的内存和硬盘价格及其昂贵。然而CPU的高度运算需要高速的数据。为了解决这个问题,CPU厂商在CPU中内置了少量的高速缓存以解决I\O速度和CPU运算速度之间的不匹配问题。

缓存一致性问题:在多处理器系统中,每个处理器都有自己的高速缓存,而它们又共享同一主内存(MainMemory)。基于高速缓存的存储交互很好地解决了处理器与内存的速度矛盾,但是也引入了新的问题:缓存一致性(CacheCoherence)。当多个处理器的运算任务都涉及同一块主内存区域时,将可能导致各自的缓存数据不一致的情况,如果真的发生这种情况,那同步回到主内存时以谁的缓存数据为准呢?为了解决一致性的问题,需要各个处理器访问缓存时都遵循一些协议,在读写时要根据协议来进行操作,这类协议有MSI、MESI(IllinoisProtocol)、MOSI、Synapse、Firefly及DragonProtocol,等等:

什么是MESI


Java内存模型和硬件内存架构之间的桥接

同事分享_1_java 内存模型_第10张图片

从抽象的角度来看,JMM定义了线程和主内存之间的抽象关系:

  • 线程之间的共享变量存储在主内存(Main Memory)中
  • 每个线程都有一个私有的本地内存(Local Memory),本地内存是JMM的一个抽象概念,并不真实存在,它涵盖了缓存、写缓冲区、寄存器以及其他的硬件和编译器优化。本地内存中存储了该线程以读/写共享变量的拷贝副本。
  • 从更低的层次来说,主内存就是硬件的内存,而为了获取更好的运行速度,虚拟机及硬件系统可能会让工作内存优先存储于寄存器和高速缓存中。
  • Java内存模型中的线程的工作内存(working memory)是cpu的寄存器和高速缓存的抽象描述。而JVM的静态内存储模型(JVM内存模型)只是一种对内存的物理划分而已,它只局限在内存,而且只局限在JVM的内存。

引入

本章节通过三个并发编程中常见的问题引入JMM为我们日常开发提供的帮助。

内存访问重排序(可见性问题):

 
  1.  
  2. package jmm.demo;
  3.  
  4. public class VisibilityTest {
  5.  
  6. private static boolean flag = true;
  7.  
  8. public static void main(String[] args) throws InterruptedException {
  9. Thread t1 = new Thread(new Worker1());
  10. Thread t2 = new Thread(new Worker2());
  11. t1.start();
  12. Thread.sleep(1000);
  13. t2.start();
  14. t1.join();
  15. t2.join();
  16. System.out.println("main down");
  17. }
  18.  
  19. static class Worker1 implements Runnable {
  20.  
  21. @Override
  22. public void run() {
  23. while (flag) {
  24.  
  25. }
  26. System.out.println("worker1 down");
  27. }
  28. }
  29.  
  30. static class Worker2 implements Runnable {
  31.  
  32. @Override
  33. public void run() {
  34. flag = false;
  35. }
  36. }
  37. }
  38.  

原子性问题

 
  1.  
  2. package jmm.demo;
  3.  
  4. import java.util.ArrayList;
  5. import java.util.List;
  6.  
  7. /***
  8. * Description: 原子性问题
  9. */
  10. public class AtomicityProblem {
  11.  
  12. /***共享变量*/
  13. private static int num = 0;
  14.  
  15. public static void main(String[] args) throws InterruptedException {
  16.  
  17. Runnable increment = () -> {
  18. for (int i = 0; i < 1000; i++) {
  19. num++;
  20. }
  21. };
  22.  
  23. List threads = new ArrayList<>();
  24.  
  25. //10个线程各执行1000次num++
  26. for (int i = 0; i < 10; i++) {
  27. Thread t = new Thread(increment);
  28. t.start();
  29. threads.add(t);
  30. }
  31.  
  32. //确保10个线程都走完
  33. for (Thread thread : threads) {
  34. thread.join();
  35. }
  36.  
  37. System.out.println("10个线程执行后的结果为:" + num);
  38. }
  39. }

有序性问题(求r1,r2可能出现的结果集)

 
  1.  
  2. int a=0, b=0;
  3.  
  4. public void method1() {
  5. int r2 = a;
  6. b = 1;
  7. }
  8.  
  9. public void method2() {
  10. int r1 = b;
  11. a = 2;
  12. }

单线程情况下

 
  1. Thread1
  2. r2=a=0
  3. b=1
  4. r1=b=1
  5. a=2

(1,0)

 
  1. Thread1
  2. r1=b=0
  3. a=2
  4. r2=a=2
  5. b=1

(0,2)

多线程情况下

 
  1. Thread1 Thread2
  2. r1=b=0 |
  3. | r2=a=0
  4. | b=1
  5. a=2 |

(0,0)

 
  1. Thread1 Thread2
  2. r1=b=0 |
  3. a=2 |
  4. | r2=a=2
  5. | b=1

(0,2)

 
  1. Thread1 Thread2
  2. r1=b=0 |
  3. | r2=a=0
  4. a=2 |
  5. | b=1

(0,0)

 
  1. Thread1 Thread2
  2. r1=b=0 |
  3. | r2=a=0
  4. a=2 |
  5. | b=1

(0,0)

 
  1. Thread1 Thread2
  2. | r2=a=0
  3. r1=b=0 |
  4. a=2 |
  5. | b=1

(0,0)

 
  1. Thread1 Thread2
  2. | r2=a=0
  3. | b=1
  4. r1=b=1 |
  5. a=2 |

(1,0)

那么会不会出现 (1,2)的情况?


 
  1. Thread1 Thread2
  2. | b=1 #重排序或乱序执行
  3. r1=b=1 |
  4. a=2 |
  5. | r2=a=2

线程2的执行结果出现了重排序:

重排序是针对程序顺序而言的,如果指令执行顺序与程序顺序不同,就说明这段指令被重排序了。

目前重排序的方式有三种

  • 处理器的乱序执行
  • 即时编译器的重排序(JVM)
  • 内存访问重排序(可见性问题,伪·重排序)

为什么要重排序,而上述的现象属于哪种情况?

同事分享_1_java 内存模型_第11张图片

 

CPU乱序执行

大多数现代微处理器都会采用将指令乱序执行(out-of-order execution,简称OoOE或OOE)的方法,在条件允许的情况下,直接运行当前有能力立即执行的后续指令,避开获取下一条指令所需数据时造成的等待。通过乱序执行的技术,处理器可以大大提高执行效率。

JIT编译器重排序

JIT编译器:将java字节码指令翻译成本地机器码。在翻译的过程当中会对字节码的执行顺序进行优化,甚至删除一部分指令。

 
  1. int a = 3;
  2. int b = 2;
  3. a = a + 1;
  4. b++;
  5.  
  6. 0: iconst_3 #将int型3推送至栈顶
  7. 1: istore_1 #将栈顶int型数值存入第二个本地变量
  8. 2: iconst_2 #将int型2推送至栈顶
  9. 3: istore_2 #将栈顶int型数值存入第三个本地变量
  10. 4: iload_1 #将第2个int型本地变量推送至栈顶
  11. 5: iconst_1 #将int型1推送至栈顶
  12. 6: iadd #将栈顶两int型数值相加并将结果压入栈顶
  13. 7: istore_1 #将栈顶int型数值存入第二个本地变量
  14. 8: iinc 2, 1 #将指定int型变量增加指定值

同事分享_1_java 内存模型_第12张图片

数据依赖性

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

as-if-serial语义

不管怎么重排序(编译器和处理器为了提高并行度),(单线程)程序的执行结果不能被改变。(编译器、runtime和处理器都必须遵守as-if-serial语义)

Java内存模型与 happens-before规则

Java的目标是成为一门平台无关性的语言,即Write once, run anywhere。但是不同硬件环境下指令重排序的规则不尽相同。例如,x86下运行正常的Java程序在IA64下就可能得到非预期的运行结果。为此,JSR-1337制定了Java内存模型(Java Memory Model, JMM),旨在提供一个统一的可参考的规范,屏蔽平台差异性。从Java 5开始,Java内存模型成为Java语言规范的一部分。
根据Java内存模型中的规定,可以总结出以下几条happens-before规则。Happens-before的前后两个操作不会被重排序且后者对前者的内存可见

  • 程序次序法则:线程中的每个动作A都happens-before于该线程中的每一个动作B,其中,在程序中,所有的动作B都能出现在A之后。
  • 监视器锁法则:对一个监视器锁的解锁 happens-before于每一个后续对同一监视器锁的加锁。
  • volatile变量法则:对volatile域的写入操作happens-before于每一个后续对同一个域的读写操作。
  • 线程启动法则:在一个线程里,对Thread.start的调用会happens-before于每个启动线程的动作。
  • 线程终结法则:线程中的任何动作都happens-before于其他线程检测到这个线程已经终结、或者从Thread.join调用中成功返回,或Thread.isAlive返回false。
  • 中断法则:一个线程调用另一个线程的interrupt happens-before于被中断的线程发现中断。
  • 终结法则:一个对象的构造函数的结束happens-before于这个对象finalizer的开始。
  • 传递性:如果A happens-before于B,且B happens-before于C,则A happens-before于C。

翻译一下:

  • 解锁操作 happens-before 之后(这里指时钟顺序先后)对同一把锁的加锁操作。
  • volatile 字段的写操作 happens-before 之后(这里指时钟顺序先后)对同一字段的读操作。
  • 线程的启动操作(即 Thread.starts()) happens-before 该线程的第一个操作。
  • 线程的最后一个操作 happens-before 它的终止事件(即其他线程通过 Thread.isAlive() 或 Thread.join() 判断该线程是否中止)。
  • 线程对其他线程的中断操作 happens-before 被中断线程所收到的中断事件(即被中断线程的 InterruptedException 异常,或者第三个线程针对被中断线程的 Thread.interrupted 或者 Thread.isInterrupted 调用)。
  • 构造器中的最后一个操作 happens-before 析构器的第一个操作。

同事分享_1_java 内存模型_第13张图片

那么如何解决(1,2)这个问题?答案是,将 a 或者 b 设置为 volatile 字段。

 
  1.  
  2. int a=0;
  3. volatile int b=0;
  4.  
  5. public void method1() {
  6. int r2 = a;
  7. b = 1;
  8. }
  9.  
  10. public void method2() {
  11. int r1 = b;
  12. a = 2;
  13. }

比如说将 b 设置为 volatile 字段。假设 r1 能够观测到 b 的赋值结果 1。显然,这需要 b 的赋值操作在时钟顺序上先于 r1 的赋值操作。根据 volatile 字段的 happens-before 关系,我们知道 b 的赋值操作 happens-before r1 的赋值操作。
根据同一个线程中,字节码顺序所暗含的 happens-before 关系,以及 happens-before 关系的传递性,我们可以轻易得出 r2 的赋值操作 happens-before a 的赋值操作。

这也就意味着,当对 a 进行赋值时,对 r2 的赋值操作已经完成了。因此,在 b 为 volatile 字段的情况下,程序不可能出现(r1,r2)为(1,2)的情况。

由此可以看出,解决这种数据竞争问题的关键在于构造一个跨线程的 happens-before 关系 :操作 X happens-before 操作 Y,使得操作 X 之前的字节码的结果对操作 Y 之后的字节码可见。


Java 内存模型的底层实现

在理解了 Java 内存模型的概念之后,我们现在来看看它的底层实现。Java 内存模型是通过内存屏障(memory barrier)来禁止重排序的。

内存屏障(Memory barrier)

为什么会有内存屏障
每个CPU都会有自己的缓存(有的甚至L1,L2,L3),缓存的目的就是为了提高性能,避免每次都要向内存取。但是这样的弊端也很明显:不能实时的和内存发生信息交换,分在不同CPU执行的不同线程对同一个变量的缓存值不同。
用volatile关键字修饰变量可以解决上述问题,那么volatile是如何做到这一点的呢?那就是内存屏障,内存屏障是硬件层的概念,不同的硬件平台实现内存屏障的手段并不是一样,java通过屏蔽这些差异,统一由jvm来生成内存屏障的指令。

内存屏障是什么

硬件层的内存屏障分为两种:Load Barrier 和 Store Barrier即读屏障和写屏障。

内存屏障有两个作用:

  • 阻止屏障两侧的指令重排序;
  • 强制把写缓冲区/高速缓存中的脏数据等写回主内存,让缓存中相应的数据失效。
  • 对于Load Barrier来说,在指令前插入Load Barrier,可以让高速缓存中的数据失效,强制从新从主内存加载数据;
  • 对于Store Barrier来说,在指令后插入Store Barrier,能让写入缓存中的最新数据更新写入主内存,让其他线程可见。

java内存屏障

java的内存屏障通常所谓的四种即LoadLoad,StoreStore,LoadStore,StoreLoad实际上也是上述两种的组合,完成一系列的屏障和数据同步功能。

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

volatile语义中的内存屏障

volatile的内存屏障策略非常严格保守,非常悲观且毫无安全感的心态:
- 在每个volatile写操作前插入StoreStore屏障,在写操作后插入StoreLoad屏障;
- 在每个volatile读操作前插入LoadLoad屏障,在读操作后插入LoadStore屏障;

由于内存屏障的作用,避免了volatile变量和其它指令重排序、线程之间实现了通信,使得volatile表现出了锁的特性。
final语义中的内存屏障

对于final域,编译器和CPU会遵循两个排序规则:

新建对象过程中,构造体中对final域的初始化写入和这个对象赋值给其他引用变量,这两个操作不能重排序;

初次读包含final域的对象引用和读取这个final域,这两个操作不能重排序;(晦涩,意思就是先赋值引用,再调用final值)
总之上面规则的意思可以这样理解,必需保证一个对象的所有final域被写入完毕后才能引用和读取。这也是内存屏障的起的作用:

  • 写final域:在编译器写final域完毕,构造体结束之前,会插入一个StoreStore屏障,保证前面的对final写入对其他线程/CPU可见,并阻止重排序。
  • 读final域:在上述规则2中,两步操作不能重排序的机理就是在读final域前插入了LoadLoad屏障。

X86处理器中,由于CPU不会对写-写操作进行重排序,所以StoreStore屏障会被省略;而X86也不会对逻辑上有先后依赖关系的操作进行重排序,所以LoadLoad也会变省略。

参考资料:
一文解决内存屏障
为何内存重排序
JAVA内存模型
JVM并发来看CPU内存指令重排序

+​​​​

你可能感兴趣的:(他山之石,java&JVM)