[关闭]
@kiraSally 2019-09-03T02:39:46.000000Z 字数 10445 阅读 6326

并发番@Java内存模型&Volatile一文通(1.7版)

JAVA 并发 源码 1.7版


1.Java内存模型

1.1 Java内存模型(JMM)

  • Java线程间的通信由Java内存模型(JMM)控制,JMM决定一个线程对共享变量的写入何时以及如何变成对另一个线程可见
  • JMM是一个抽象的概念,并非真实存在,它涵盖了缓存、写缓冲区、寄存器以及其他的硬件和编译器的优化
  • JMM定义了线程和主内存之间的抽象关系:
    • 线程之间的共享变量存储在主内存中(从硬件角度来说就是内存条)
    • 每个线程都有一个私有的本地内存,本地内存中存储了该线程用来读/写共享变量的副本(从硬件角度来说就是CPU的缓存,比如寄存器、L1、L2、L3缓存等)
  • 同时JVM通过JMM来屏蔽各个硬件平台和操作系统的内存访问差异,以实现让Java程序在各种平台下都能达到一致的内存访问效果
  • 重要声明: JMM所描述的主内存、工作内存与Java内存区域的堆栈不是一回事,更准确是主内存就是内存条,为了提高性能,JVM可能会让工作内存优先存储在寄存器和高速缓存中,程序运行时主要访问读写的也是工作内存
  • 有兴趣的读者可以进一步研究SMP(对称多处理技术)

1.2 JMM的核心原则

  • JMM的关键技术点都是围绕多线程的原子性可见性有序性展开的
  • 多线程并发的法宝:外互斥内可见

1.2.1 原子性

  • 原子性是指一个操作是不可中断的,即多线程环境下,操作不能被其他线程干扰

1.2.2 可见性

  • 可见性是指当一个线程修改了某一个共享变量的值,其他线程是否能够立即知道该变更
  • Java中普通的共享变量不保证可见性,因为其的修改被写入内存的时机是不确定的,多线程并发下很可能出现"脏读"
  • 缓存优化或者硬件优化或指令重排以及编辑器的优化都可能导致一个线程修改不会立即被其他线程察觉
  • Java提供volatile保证可见性:写操作立即刷新到主内存,读操作直接从主内存读取
  • Java同时还可以通过加锁的同步性间接保证可见性:synchronized和Lock能保证同一时刻只有一个线程获取锁并执行同步代码,并在释放锁之后将变量的修改刷新到主内存中

1.2.3 有序性

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

1.3 JMM的抽象结构

JMM.jpg-343.4kB

1.4 JMM的影响范围

  • 在Java中,所有实例域、静态域和数组元素都存储在堆内存中,堆内存在线程间共享
  • 局部变量、方法定义参数和异常处理器参数不在线程间共享,即不会有可见性问题也不受JMM影响

1.5 JMM的内存可见性保证

  • 单线程程序: 不会出现内存可见性问题,不管重排序与否结果也会最终一致
  • 正确同步的多线程程序: 将顺序一致性,JMM通过限制重排序来为程序提供内存可见性保证
  • 未同步/未正确同步的多线程程序: JMM提供最小安全性保障-线程执行时读取到的值,要么是之前某个线程写入的值,要么是默认值

image_1bnni2uq41na83sb1r4q4701ganm.png-41.7kB

1.6 JMM的隐患

1.6.1 写缓冲区(工作内存)

  • 处理器使用写缓冲区临时保存向内存写入的数据
  • 写缓冲区可以保证指令流水线持续运行,避免由于处理器停顿等待向内存写入数据产生的延迟
  • 写缓冲区还可以通过批处理的方式刷新写缓冲区,以及合并写缓冲区中对同一内存地址的多次写,减少对内存总线的占用

1.6.2 写缓冲区的隐患

  • 虽然写缓冲区好处多多,但问题在于写缓冲区仅对其所在的处理器所见,即是处理器私有工作内存
  • 为了获得较好的执行性能,Java内存模型并没有限制执行引擎使用处理器的寄存器或者高速缓存来提升指令执行速度,也没有限制编译器对指令进行重排序
  • 因此,在JMM会存在缓存一致性问题指令重排序的问题

2.重排序

2.1 数据依赖性与串行语义

2.1.1 数据依赖性

  • 数据依赖性:若两个操作访问同一变量,且这两个操作中有一个为写操作,此时两操作间就存在数据依赖性
  • 编译器和处理器在重排序时,会遵守数据依赖性,不会改变存在依赖关系的两个操作的执行
  • 但不同处理器和不同线程之间的数据性不会被编译器和处理器考虑,其只会作用于单处理器和单线程环境
  • 下面三种情况,只要重排序两个操作的执行顺序,程序的执行结果就会被改变
    image_1bnngv4tf1hav1oi01rebcv6shv9.png-22.5kB

2.1.2 串行语义

  • 串行语义: 不管如何重排序,单线程的执行结果不能被改变,编译器和处理器必须遵守串行语义
  • 单线程下: 不存在依赖关系,可以重排序;存在依赖关系,禁止重排序

2.2 重排序

  • 重排序是指编译器和处理器为了优化程序性能而对指令序列进行重新排序的一种手段
  • 为了提高效率,指令序列执行遵循流水线模式,不同的指令交给不同的硬件处理,从而节省等待时间
  • 但流水线最害怕被中断(所有硬件设备会进入停顿期,再次满载又需要几个周期,性能损失很大),而重排序就是减少中断的一种重要手段
  • 但重排后的指令绝对不能改变原有的串行语义!这点在并发设计中必须要重点考虑!

2.3 重排序的分类

  • 编译器优化的重排序: 编译器在不改变单线程串行语义的前提下,可以重新调整指令的执行顺序
  • 指令级并行的重排序: 处理器使用指令级并行技术来讲多条指令重叠执行,若不存在数据依赖性,处理器可以改变语句对应机器指令的执行顺序
  • 内存系统的重排序: 由于处理器使用缓存和读/写缓冲区,这使得加载和存储操作看上去可能是乱序执行

2.4 重排序的执行流程

image_1bnl122f613nr9php3411dv50u5n.png-20.9kB

3.Happends-Before

3.1 Happends-Before

  • 从JDK5开始,JAVA使用心得JSR-133内存模型,其使用happens-before的概念来阐述操作之间的内存可见性
  • 在JMM中,若一个操作执行的结果需要对另一个操作可见,那么这两个操作至今必须存在happens-before关系
  • happens-before为指令重排制订了必须遵守的8项规则,在这些规则约束下,确保并非所有的指令都可以随意改变执行位置,进而保证操作结果的最终一致性(即不管重排与否,结果一致)
  • A Happens-Before B 前一个操作(A)的结果必须对后一个操作(B)可见,且前一个操作按顺序排在第二个顺序之前(但JMM并不要求A一定要在B之前执行)

3.2 Happends-Before规则

指令重排是有原则的,并非所有的指令都可以随便改变执行位置,如以下8种情况:

1.程序顺序原则(!!): 一个线程内,按照代码顺序,书写在前面的操作先行发生于书写在后面的操作

2.volatile规则(!!): volatile变量的写先发生于读,这保证了volatile变量的可见性

3.锁规则(!!): 解锁必然发生在随后的加锁之前

4.传递规则(!!): A先于B,B先于C,那么A必然先于C

5.线程启动规则: 线程的start()方法先于它的每一个动作

6.线程中断规则: 线程的中断(Thread.interrupt())先于被中断线程的代码

7.线程终止规则: 线程的所有操作先于线程的终止检测(Thread.join()方法结束,Thread.isAlive()检测)

8.对象终结规则: 对象的构造函数执行(初始化)结束先于finalize()方法

4.volatile

4.1 volatile

  • volatile是线程同步的轻量级实现,主要作用是使变量在多线程间可见
  • volatile本身并不处理数据的原子性,而是强制对数据的读写及时影响到主内存里
  • volatile会强制从公共堆栈中取得变量的值,而不是从线程私有数据栈中取的变量的值
  • volatile变量自身具有三个语义特性:
    • 可见性:保证了不同线程对这个变量进行操作时的可见性,即变量一旦变更所有线程立即可见
    • 有限原子性:对任意单个volatile变量的简单读写操作具有原子性,复合操作不具有原子性(如i++)
    • 重排序禁止:禁止进行指令重排序

4.2 volatile工作原理

  1. int a = 0;
  2. volatile boolean flag = false;
  3. public void write(){
  4. a = 1;
  5. flag = true;
  6. }
  7. public void read(){
  8. if (flag){
  9. int i = a;
  10. }
  11. }

4.2.1 volatile写操作的内存语义

  • 当写一个volatile变量时,JMM会把该线程对应的本地内存中的共享变量刷新到主内存

Valotile内存模型-写.jpg-268.8kB

4.2.2 volatile读操作的内存语义

  • 当读一个volatile变量时,JMM会把该线程对应的本地内存置为无效,线程会直接从主内存中读取共享变量
  • 此时实际上是线程间通过主内存完成了一次消息通信,即线程A向B发送消息

Valotile内存模型-读.jpg-297.9kB

4.2.3 volatile可见性实现原理

  • 为了实现volatile内存语义,JMM会分别限制编译器重排序和处理器重排序
  • 为了保证内存可见性,编译器会在生成指令序列的恰当位置插入内存屏障指令来禁止特定类型的处理器重排序
  • 实现原理是在指令序列执行过程中,通过在volatile写操作后面插入StoreLoad屏障(x86平台),仅对volatile写-读进行重排序(x86会忽略读-读、读-写、写-写的重排序)从而实现正确的内存语义
  • 从汇编角度来说,操作volatile变量会多出一个lock前缀指令,其相当于内存屏障
  • 执行该屏障开销昂贵,因为处理器通常会把写缓冲区的数据全部刷新到内存中
  • 为了保证各个CPU的缓存一致性,每个CPU通过嗅探在总线上传播的数据来检查自己的缓存的数据有效性,当发现自己缓存行对应的内存地址的数据被修改,就会将该缓存行设置为无效,当CPU读取该变量,发现所在的缓存行被设置为无效,就会重新从内存中读取数据到缓存中
  • 具体的实现原理已超出本文所限,读者有兴趣可参见 <<Java并发编程的艺术>>第三章-Java内存模型

QQ截图20170816151648.png-37.3kB

4.3 volatile非原子特性

4.3.1 VolatileDemo

  1. //注意本实例一定使用Debug运行,不能用Run运行,因为有Thread.yield()
  2. public class VolatileDemo {
  3. public volatile int count = 0;
  4. public void addCount(){
  5. count++;
  6. //需要注意的是 count++换成 count += 1 效果也是一样,因为都是复合操作,不单单只是的++这种操作
  7. }
  8. public static void main(String[] args) {
  9. VolatileDemo volatileDemo = new VolatileDemo();
  10. Thread[] threads = new Thread[10];
  11. for (int i = 0 ; i < threads.length ; i++){
  12. threads[i] = new Thread(new Runnable() {
  13. @Override
  14. public void run() {
  15. for (int j = 0;j<1000;j++){
  16. volatileDemo.addCount();
  17. }
  18. }
  19. });
  20. }
  21. //我们减少new的开销,让结果更准确一些
  22. for (int i = 0 ; i < threads.length ;i++){
  23. threads[i].start();
  24. }
  25. //线程需要都执行完毕
  26. while (Thread.activeCount() > 1){
  27. //让主线程主动释放资源,也就是等会在干活
  28. //用Thread.sleep(long)效果一样,但时间不好控制
  29. Thread.yield();
  30. }
  31. System.out.println("结果是:" + volatileDemo.count);
  32. }
  33. }
  34. ------------
  35. //笔者运行三次看看每次的结果,看看输出
  36. 第一次:结果是:9664
  37. 第二次:结果是:9551
  38. 第三次:结果是:9870
  39. //于我们的预期结果10000完全不相符,说明volatile根本就不能保证原子性

4.3.2 内存间交互

针对工作内存和主内存之间的原子性交互,JVM提供了如下原子性指令:

read: 作用于主内存,将变量的值从主内存传输到工作内存,即数据读取到本地内存

load: 作用于工作内存,将read从主内存传输的变量值放入工作内存变量副本中,即数据加载

use: 作用于工作内存,将工作内存变量副本的值传递给执行引擎,每当JVM遇到需要该变量的字节码指令时会执行该操作

assign: 作用于工作内存,将从执行引擎接收到的值赋值给工作内存变量,每当JVM遇到一个给变量赋值字节码指令时会执行该操作

store: 作用于工作内存,将赋值完毕的工作变量的值传输给主内存

write: 作用于主内存,将store传输过来的变量值赋值给主内存中的变量

由于上述只能保证单条指令的原子性,针对多条指令的组合性原子保证,JVM提供了另外两个原子指令:

lock: 作用于主内存,将一个变量标记为一个线程独占的状态

unlock: 作用于主内存,把一个处于锁定状态的变量释放,然后才能被其他线程占用

以上指令在使用时必须遵循如下规范:

1.read-load、store-write必须搭配使用

2.不允许线程丢弃最近一次assign操作,一旦在工作内存改变后必须同步回主内存

3.不允许线程没执行assign操作就执行主内存同步操作

4.变量必须在主内存生成,执行assign的前提必须是执行了read-load-use

5.一个变量的同一时刻只允许被一个线程执行lock操作,允许lock被同一线程多次执行,但必须执行相同次数的unlock操作

6.执行lock操作必须清空所有工作内存该变量值,在执行引擎使用该变量时必须重新load或assign初始化该值

7.使用unlock必须先执行lock,且执行unlock前必须将此变量同步到主内存

4.3.3 变量在内存中的工作过程

变量在内存中的工作过程.jpg-299.8kB

4.3.4 volatile非原子原因

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

4.4 volatile的应用场景

4.4.1 正确使用volatile条件

  • 条件一: 写入变量时并不依赖变量的当前值;或者能够确保只有单一线程能够修改变量的值
  • 条件二: 变量不需要与其他的状态变量共同参与不变约束
  • 条件三: 变量访问不需要额外加锁
  • 通俗点: 当一个变量依赖其他变量或变量的新值依赖旧值时,不能用volatile

4.4.2 volatile使用场景

  • 适用场合:多个线程读,一个线程写的场合
  • 使用场景:通常被 作为标识完成、中断、状态的标记,值变化应具有原子性
  • 充分利用其可见性:即volatile能够保证在读取的那个时刻读到的肯定是最新值
  • 重点声明: volatile主要使用的场合是在多线程中可以感知实例变量被变更了,并且可以获得最新的值使用,也就是用多线程读取共享变量时可以获得最新值使用,但不能保证你在使用最新值过程中最新值不发生变化!很可能在使用之后,最新值已经变更。原数据变成过期数据,这时候就会出现数据不一致(非同步)的问题

4.4.3 正确使用volatile

  1. //使用:作为一个布尔状态标志,用于指示发生了一个重要的一次性事件,例如完成初始化或任务结束
  2. //使用理由:状态标志并不依赖于程序内任何其他状态,且通常只有一种状态转换
  3. //例子:判断业务是否结束
  4. volatile boolean isOk = false;
  5. public void isOk() { isOk = true; }
  6. public void doWork() {
  7. //循环监听状态位变化
  8. while (!isOk) {
  9. // do work
  10. }
  11. }
  1. //使用:将 volatile变量用于多个独立观察结果的发布
  2. //特点:是"状态标志"的拓展,该值随时会发生变化,同时会被反复使用,前者一般就是用一次
  3. //使用理由:只是简单的赋值操作,不会做复合操作
  4. //例子:将新节点作为最后一个节点
  5. class CustomLinkedList{
  6. public volatile Node lastNode;
  7. .....
  8. public void add() {
  9. Node node = new Node();
  10. .....
  11. lastNode = node;//将新节点作为最后一个节点
  12. }
  13. }
  1. //使用:当读远多于写,结合使用内部锁和 volatile 变量来减少同步的开销
  2. //使用理由:利用volatile保证读取操作的可见性;利用synchronized保证复合操作的原子性
  3. @ThreadSafe
  4. public class Counter {
  5. private volatile int value;
  6. public int getValue() { return value; }//利用volatile保证读取操作的可见性
  7. public synchronized int increment() { //利用synchronized保证复合操作的原子性
  8. return value++;
  9. }
  10. }
  1. //双重检查锁定:实现线程安全的延迟初始化,同时降低同步开销
  2. // 1.多线程并发创建对象时,会通过加锁保证只有一个线程能创建对象
  3. // 2.对象创建完毕,执行get方法将不需要获取锁,直接返回创建对象
  4. //隐患:多线程环境下,由于重排序,该对象可能还完成初始化就被其他线程读取
  5. //
  6. public class DoubleSynchronizedSingleton {
  7. private static DoubleSynchronizedSingleton doubleSynchronizedSingleton;
  8. private DoubleSynchronizedSingleton(){
  9. }
  10. //双重锁设计
  11. public static DoubleSynchronizedSingleton getInstance(){
  12. if (doubleSynchronizedSingleton == null){
  13. //1.多线程并发创建对象时,会通过加锁保证只有一个线程能创建对象
  14. synchronized (DoubleSynchronizedSingleton.class){
  15. if (doubleSynchronizedSingleton == null){
  16. //隐患:多线程环境下,由于重排序,该对象可能还完成初始化就被其他线程读取
  17. doubleSynchronizedSingleton = new DoubleSynchronizedSingleton();//问题代码
  18. }
  19. }
  20. }
  21. //2.对象创建完毕,执行get方法将不需要获取锁,直接返回创建对象
  22. return doubleSynchronizedSingleton;
  23. }
  24. }
  1. //基于volatile的解决方案
  2. public class SafeDoubleCheckSingleton {
  3. //通过volatile声明,实现线程安全的延迟初始化
  4. private volatile static SafeDoubleCheckSingleton singleton;
  5. private SafeDoubleCheckSingleton(){
  6. }
  7. public static SafeDoubleCheckSingleton getInstance(){
  8. if (singleton == null){
  9. synchronized (SafeDoubleCheckSingleton.class){
  10. if (singleton == null){
  11. //原理利用volatile在于 禁止 "初始化对象"(2) 和 "设置singleton指向内存空间"(3) 的重排序
  12. singleton = new SafeDoubleCheckSingleton();
  13. }
  14. }
  15. }
  16. return singleton;
  17. }
  18. }

5. Synchronized vs Volatile

  • 作用: volatile解决的是变量在多线程间的可见性,而syn解决的是多线程间访问资源的同步性
  • 修饰域: volatile只能修饰变量;synchronized可以修饰方法和代码块
  • 阻塞: 多线程访问volatile不会发生阻塞;synchronized会出现阻塞
  • 原子性: volatile保证数据可见性,但不保证原子性;而syn可以保证原子性,也可以间接保证可见性,因为会将私有内存和公共内存中的数据做同步
  • 性能: volatile是线程同步的轻量级实现,性能优于synchronized,但synchronized的性能不断被优化提升,实际上表现不差

6. 原子操作的CPU硬件支持(进阶)

6.1 CPU自动保证基本内存操作的原子性

  • 自动原子性保证: CPU保证从内存中对一个字节的读写操作具有原子性,当一个CPU读写一个字节时即该CPU独占该字节,此时其他CPU不能访问该字节的内存地址,即不能同时读写该字节
  • 原子性机制: CPU提供总线锁定和缓存锁定两个机制来保证复杂内存操作的原子性

6.2 CPU原子性机制的实现

6.2.1 总线锁

  • 定义: 当一个CPU占用一个内存地址的数据时,通过在总线上使用LOCK前缀指令发送(LOCK # 信号)阻塞总线上其他线程从而实现对该共享内存的独占
  • 隐患: 由于总线锁会将CPU和内存之间的通信锁住,相当于把唯一通道给占用了,会导致在锁定期间其他CPU就不能操作其他内存地址的数据,因此总线锁定开销很大,但新的CPU支持使用缓存所代替总线锁进行优化

6.2.2 缓存锁

  • 定义:如果缓存在CPU缓存行中内存区域在LOCK操作期间被锁定,当它执行锁操作回写内存时,CPU不在总线上声言LOCK#信号,而是修改内部的内存地址,并允许它的缓存一致性机制来保证操作的原子性
  • 补充: 缓存一致性机制会阻止同时修改被两个以上处理器缓存的内存区域数据,当其他处理器回写已被锁定的缓存行的数据时会起缓存行无效
  • LOCK前缀指令:以上两个机制我们可以通过Inter处理器提供了很多LOCK前缀的指令来实现。比如位测试和修改指令BTS,BTR,BTC,交换指令XADD,CMPXCHG和其他一些操作数和逻辑指令,比如ADD(加),OR(或)等,被这些指令操作的内存区域就会加锁,导致其他处理器不能同时访问它
  • 不可用: 有两种情况下处理器不会使用缓存锁定:
    • 当操作的数据不能被缓存在处理器内部,或操作的数据跨多个缓存行(cache line),则处理器会调用总线锁定
    • 有些处理器不支持缓存锁定。对于Inter486和奔腾处理器,就算锁定的内存区域在处理器的缓存行中也会调用总线锁

6.3 Lock前缀

  • 确保对内存的读-改-写操作原子执行。在Pentium及Pentium之前的处理器中,带有lock前缀的指令在执行期间会锁住总线,使得其他 处理器暂时无法通过总线访问内存。很显然,这会带来昂贵的开销。从Pentium 4,Intel Xeon及P6处理器开始,intel在原有总线锁的基础上做了一个很有意义的优化:如果要访问的内存区域(area of memory)在lock前缀指令执行期间已经在处理器内部的缓存中被锁定(即包含该内存区域的缓存行当前处于独占或以修改状态),并且该内存区域被完全 包含在单个缓存行(cache line)中,那么处理器将直接执行该指令。由于在指令执行期间该缓存行会一直被锁定,其它处理器无法读/写该指令要访问的内存区域,因此能保证指令执行 的原子性。这个操作过程叫做缓存锁定(cache locking),缓存锁定将大大降低lock前缀指令的执行开销,但是当多处理器之间的竞争程度很高或者指令访问的内存地址未对齐时,仍然会锁住总线。
  • 禁止该指令与之前和之后的读和写指令重排序。
  • 把写缓冲区中的所有数据刷新到内存中。

并发番@Java内存模型&Volatile一文通(1.7版)黄志鹏kira 创作,采用 知识共享 署名-非商业性使用 4.0 国际 许可协议 进行许可。

本站文章除注明转载/出处外,均为本站原创或翻译,转载前请务必署名

添加新批注
在作者公开此批注前,只有你和作者可见。
回复批注