java笔记_JMM(JAVA内存模型)

以下内容为从网上摘抄整理而来,仅用于本人知识积累

一、什么是JMM

JMM(Java Memory Model,Java内存模型)

因为在不同的硬件生产商和不同的操作系统下,内存的访问逻辑有一定的差异,结果就是当你的代码在某个系统环境下运行良好,并且线程安全,但是换了个系统就出现各种问题。Java内存模型,就是为了屏蔽系统和硬件的差异,让一套代码在不同平台下能到达相同的访问结果。JMM从java 5开始的JSR-133发布后,已经成熟和完善起来。

JMM并不是实际存在的,而是一套规范,这个规范描述了很多java程序中各种变量(线程共享变量)的访问规则,以及在JVM中将变量存储到内存和从内存中读取变量这样的底层细节,Java内存模型(JMM)是对共享数据的可见性、有序性、和原子性的规则和保障。

二、JMM介绍

首先,什么是线程安全?在《深入理解Java虚拟机》中有相关定义:

当多个线程访问同一个对象时,如果不用考虑这些线程在运行时环境下的调度和交替运行,也不需要进行额外的同步,或者在调用方进行任何其他的协调操作,调用这个对象的行为都可以获取正确的结果,那这个对象是线程安全的。

出现线程安全的问题一般是因为 主内存工作内存 数据不一致性和重排序导致的,而解决线程安全的问题最重要的就是理解这两种问题是怎么来的,那么,理解它们的核心在于理解Java内存模型(JMM)。

在多线程条件下,多个线程肯定会相互协作完成一件事情,一般来说就会涉及到多个线程间相互通信告知彼此的状态以及当前的执行结果等,另外,为了性能优化,还会涉及到 编译器指令重排序处理器指令重排序 。下面会一一来聊聊这些知识。

三、主内存、工作内存

首先来了解什么是主内存和工作内存。

JMM规定了内存主要划分为主内存和工作内存两种。此处的主内存和工作内存跟JVM内存划分(堆、栈、方法区)是在不同的层次上进行的,如果非要对应起来,主内存对应的是Java堆中的对象实例部分,工作内存对应的是栈中的部分区域,从更底层的来说,主内存对应的是硬件的物理内存,工作内存对应的是寄存器和高速缓存。

主内存、工作内存、线程之间的关系:

所有的变量都存储在主内存中,每个线程还有自己的工作内存,工作内存存储在高速缓存或者寄存器中,保存了该线程使用的变量的主内存副本拷贝。

线程只能直接操作工作内存中的变量,不同线程之间的变量值传递需要通过主内存来完成。

在这里插入图片描述

四、重排序

一个好的内存模型实际上会放松对处理器和编译器规则的束缚,也就是说软件技术和硬件技术都为同一个目标而进行奋斗:在不改变程序执行结果的前提下,尽可能提高并行度。JMM对底层尽量减少约束,使其能够发挥自身优势。因此,在执行程序时,为了提高性能,编译器和处理器常常会对指令进行重排序。一般重排序可以分为如下三种:

在这里插入图片描述

  1. 编译器优化的重排序。编译器在不改变单线程程序语义的前提下,可以重新安排语句的执行顺序;
  2. 指令级并行的重排序。现代处理器采用了指令级并行技术来将多条指令重叠执行。如果不存在数据依赖性,处理器可以改变语句对应机器指令的执行顺序;
  3. 内存系统的重排序。由于处理器使用缓存和读/写缓冲区,这使得加载和存储操作看上去可能是在乱序执行的。

如图,1属于编译器重排序,而2和3统称为处理器重排序。这些重排序会导致线程安全的问题,一个很经典的例子就是DCL问题(Double Check Lock 双重检查加锁)。针对编译器重排序,JMM的编译器重排序规则会禁止一些特定类型的编译器重排序;针对处理器重排序,编译器在生成指令序列的时候会通过插入内存屏障指令来禁止某些特殊的处理器重排序。

那么什么情况下,不能进行重排序了?下面就来说说数据依赖性。有如下代码:

double a = 3.0 //A

double b = 1.0 //B

double c = a * b * b //C

由于A,B之间没有任何关系,对最终结果也不会存在关系,它们之间执行顺序可以重排序。因此执行顺序可以是A->B->C或者B->A->C,最终结果都是3,即A和B之间没有数据依赖性。具体的定义为:如果两个操作访问同一个变量,且这两个操作有一个为写操作,此时这两个操作就存在数据依赖性这里就存在三种情况:1. 读后写;2.写后写;3. 写后读,者三种操作都是存在数据依赖性的,如果重排序会对最终执行结果会存在影响。编译器和处理器在重排序时,会遵守数据依赖性,编译器和处理器不会改变存在数据依赖性关系的两个操作的执行顺序。

as-if-serial

另外,还有一个比较有意思的就是as-if-serial语义。

as-if-serial语义的意思是:不管怎么重排序(编译器和处理器为了提供并行度),(单线程)程序的执行结果不能被改变。编译器,runtime和处理器都必须遵守as-if-serial语义。as-if-serial语义把单线程程序保护了起来,遵守as-if-serial语义的编译器,runtime和处理器共同为编写单线程程序的程序员创建了一个幻觉:单线程程序是按程序的顺序来执行的。比如上面计算面积的代码,在单线程中,会让人感觉代码是一行一行顺序执行上,实际上A,B两行不存在数据依赖性可能会进行重排序,即A,B不是顺序执行的。as-if-serial语义使程序员不必担心单线程中重排序的问题干扰他们,也无需担心内存可见性问题。

五、内存模型抽象结构

线程间协作通信可以类比人与人之间的协作的方式,在现实生活中,之前网上有个流行语“你妈喊你回家吃饭了”,就以这个生活场景为例,小明在外面玩耍,小明妈妈在家里做饭,做完饭后准备叫小明回家吃饭,那么就存在两种方式:

小明妈妈要去上班了十分紧急这个时候手机又没有电了,于是就在桌子上贴了一张纸条“饭做好了,放在…”小明回家后看到纸条如愿吃到妈妈做的饭菜,那么,如果将小明妈妈和小明作为两个线程,那么这张纸条就是这两个线程间通信的共享变量,通过读写共享变量实现两个线程间协作;

还有一种方式就是,妈妈的手机还有电,妈妈在赶去坐公交的路上给小明打了个电话,这种方式就是通知机制来完成协作。同样,可以引申到线程间通信机制。

通过上面这个例子,应该有些认识。在并发编程中主要需要解决两个问题:

  1. 线程之间如何通信;
  2. 线程之间如何完成同步(这里的线程指的是并发执行的活动实体)。

通信是指线程之间以何种机制来交换信息,主要有两种:共享内存消息传递 。这里,可以分别类比上面的两个举例。Java内存模型是 共享内存的并发模型,线程之间主要通过读-写共享变量来完成隐式通信。如果程序员不能理解Java的共享内存模型在编写并发程序时一定会遇到各种各样关于内存可见性的问题。

六、哪些是共享变量

在Java程序中所有 实例域,静态域和数组元素 都是放在堆内存中(所有线程均可访问到,是可以共享的),而局部变量,方法定义参数和异常处理器参数不会在线程间共享。共享数据会出现线程安全的问题,而非共享数据不会出现线程安全的问题。

数据存储类型以及操作方式
  • 方法中的基本类型本地变量将直接存储在工作内存的栈帧结构中;
  • 引用类型的本地变量:引用存储在工作内存,实际存储在主内存;
  • 成员变量、静态变量、类信息均会被存储在主内存中;
  • 主内存共享的方式是线程各拷贝一份数据到工作内存中,操作完成后就刷新到主内存中。

七、JMM抽象结构模型

我们知道CPU的处理速度和主存的读写速度不是一个量级的(CPU的处理速度快很多),为了平衡这种巨大的差距,每个CPU都会有缓存。因此,共享变量会先放在主存中,每个线程都有属于自己的工作内存,并且会把位于主存中的共享变量拷贝到自己的工作内存,之后的读写操作均使用位于工作内存的变量副本,并在某个时刻将工作内存的变量副本写回到主存中去。JMM就从抽象层次定义了这种方式,并且JMM决定了一个线程对共享变量的写入何时对其他线程是可见的。

在这里插入图片描述
如图为JMM抽象示意图,线程A和线程B之间要完成通信的话,要经历如下两步:

  1. 线程A从主内存中将共享变量读入线程A的工作内存后并进行操作,之后将数据重新写回到主内存中;
  2. 线程B从主存中读取最新的共享变量

从横向去看看,线程A和线程B就好像通过共享变量在进行隐式通信。这其中有个意思的问题,如果线程A更新后数据并没有及时写回到主内存,而此时线程B读到的是过期的数据,这就出现了 “脏读” 现象。可以通过 同步机制(控制不同线程间操作发生的相对顺序)来解决或者通过volatile关键字使得每次volatile变量都能够强制刷新到主存,从而对每个线程都是可见的。

八、内存间交互操作

Java 内存模型定义了 8 个操作来完成主内存和工作内存的交互操作。

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

在这里插入图片描述

JMM对这八种指令的使用,制定了如下规则:

  1. 不允许read和load、store和write操作之一单独出现。即使用了read必须load,使用了store必须write
  2. 不允许线程丢弃他最近的assign操作,即工作变量的数据改变了之后,必须告知主存
  3. 不允许一个线程将没有assign的数据从工作内存同步回主内存
  4. 一个新的变量必须在主内存中诞生,不允许工作内存直接使用一个未被初始化的变量。就是怼变量实施use、store操作之前,必须经过assign和load操作
  5. 一个变量同一时间只有一个线程能对其进行lock。多次lock后,必须执行相同次数的unlock才能解锁
  6. 如果对一个变量进行lock操作,会清空所有工作内存中此变量的值,在执行引擎使用这个变量前,必须重新load或assign操作初始化变量的值
  7. 如果一个变量没有被lock,就不能对其进行unlock操作。也不能unlock一个被其他线程锁住的变量
  8. 对一个变量进行unlock操作之前,必须把此变量同步回主内存

九、内存模型三大特性

1. 原子性

Java 内存模型保证了 read、load、use、assign、store、write、lock 和 unlock 操作具有原子性,例如对一个 int 类型的变量执行 assign 赋值操作,这个操作就是原子性的。但是 Java 内存模型允许虚拟机将没有被 volatile 修饰的 64 位数据(long,double)的读写操作划分为两次 32 位的操作来进行,即 load、store、read 和 write 操作可以不具备原子性。

有一个错误认识就是,int 等原子性的类型在多线程环境中不会出现线程安全问题。
为了方便讨论,将内存间的交互操作简化为 3 个:load、assign、store。

下图演示了两个线程同时对 cnt 进行操作,load、assign、store 这一系列操作整体上看不具备原子性,那么在 T1 修改 cnt 并且还没有将修改后的值写入主内存,T2 依然可以读入旧值。可以看出,这两个线程虽然执行了两次自增运算,但是主内存中 cnt 的值最后为 1 而不是 2。因此对 int 类型读写操作满足原子性只是说明 load、assign、store 这些单个操作具备原子性。

在这里插入图片描述
AtomicInteger 能保证多个线程修改的原子性。

在这里插入图片描述

synchronized关键字

除了使用原子类之外,也可以使用 synchronized 互斥锁来保证操作的原子性。它对应的内存间交互操作为:lock 和 unlock,在虚拟机实现上对应的字节码指令为 monitorenter 和 monitorexit。

volatile关键字

开启10个线程,每个线程都自加10000次,如果不出现线程安全的问题最终的结果应该就是:10*10000 = 100000;可是运行多次都是小于100000的结果,问题在于 volatile并不能保证原子性,counter++这并不是一个原子操作,包含了三个步骤:

  1. 读取变量counter的值;
  2. 对counter加一;
  3. 将新值赋值给变量counter。

如果线程A读取counter到工作内存后,其他线程对这个值已经做了自增操作后,那么线程A的这个值自然而然就是一个过期的值,因此,总结果必然会是小于100000的。

如果让volatile保证原子性,必须符合以下两条规则:

  1. 运算结果并不依赖于变量的当前值,或者能够确保只有一个线程修改变量的值;
  2. 变量不需要与其他的状态变量共同参与不变约束

2.可见性

可见性指当一个线程修改了共享变量的值,其它线程能够立即得知这个修改。Java 内存模型是通过在变量修改后将新值同步回主内存,在变量读取前从主内存刷新变量值来实现可见性的。

主要有三种实现可见性的方式:

  • volatile,通过在指令中添加lock指令,以实现内存可见性。
  • synchronized,当线程获取锁时会从主内存中获取共享变量的最新值,释放锁的时候会将共享变量同步到主内存中。
  • final,被 final 关键字修饰的字段在构造器中一旦初始化完成,并且没有发生 this 逃逸(其它线程通过 this 引用访问到初始化了一半的对象),那么其它线程就能看见 final 字段的值。

使用 volatile 修饰,不能解决线程不安全问题,因为 volatile 并不能保证操作的原子性。

3.有序性

有序性是指:在本线程内观察,所有操作都是有序的。在一个线程观察另一个线程,所有操作都是无序的,无序是因为发生了指令重排序。在 Java 内存模型中,允许编译器和处理器对指令进行重排序,重排序过程不会影响到单线程程序的执行,却会影响到多线程并发执行的正确性。

volatile 关键字通过添加内存屏障的方式来禁止指令重排,即重排序时不能把后面的指令放到内存屏障之前。

synchronized 关键字同样可以保证有序性,它保证每个时刻只有一个线程执行同步代码,相当于是让线程顺序执行同步代码。

总结

synchronized:具有原子性,有序性和可见性;

volatile:具有有序性和可见性;

final:具有可见性

十、内存屏障

我们都知道,为了性能优化,JMM在不改变正确语义的前提下,会允许编译器和处理器对指令序列进行重排序,那如果想阻止重排序要怎么办了?答案是可以添加内存屏障。

JMM内存屏障分为四类

屏障类型指令示例说明
LoadLoad BarriersLoad1;LoadLoad;Load2确保Load1的数据的装载先于Load2及所有后续装载指令的装载
StoreStoreBarriersStore1;StoreStore;Store2确保Store1数据对其他处理器可见(刷新到内存)先于Store2及所有后续存储指令的存储
LoadStore BarriersLoad1;LoadStore;Store2确保Load1的数据的装载先于Store2及所有后续存储指令的存储
StoreLoad BarriersStore1;StoreLoad;Load2确保Store1的数据对其他处理器可见(刷新到内存)先于Load2及所有后续的装载指令的装载

Java编译器会在生成指令系列时在适当的位置会插入内存屏障指令来禁止特定类型的处理器重排序。为了实现volatile的内存语义,JMM会限制特定类型的编译器和处理器重排序,JMM会针对编译器制定volatile重排序规则表,"NO"表示禁止重排序:

在这里插入图片描述

1.当第一个操作为普通的读或写时,如果第二个操作为volatile写,则编译器不能重排序这两个操作(1,3)

2.当第一个操作是volatile读时,不管第二个操作是什么,都不能重排序。这个规则确保volatile读之后的操作不会被编译器重排序到volatile读之前(第二行)

3.当第一个操作是volatile写,第二个操作是volatile读时,不能重排序(3,2)

4.当第二个操作是volatile写时,不管第一个操作是什么,都不能重排序(第三列)

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

  1. 在每个volatile写操作的 前面 插入一个StoreStore屏障;
  2. 在每个volatile写操作的 后面 插入一个StoreLoad屏障;
  3. 在每个volatile读操作的 后面 插入一个LoadLoad屏障;
  4. 在每个volatile读操作的 后面 插入一个LoadStore屏障。

需要注意的是:volatile写是在前面和后面分别插入内存屏障,而volatile读操作是在后面插入两个内存屏障

StoreStore屏障:禁止上面的普通写和下面的volatile写重排序;

StoreLoad屏障:防止上面的volatile写与下面可能有的volatile读/写重排序

LoadLoad屏障:禁止下面所有的普通读操作和上面的volatile读重排序

LoadStore屏障:禁止下面所有的普通写操作和上面的volatile读重排序

下面以两个示意图进行理解,图片摘自《Java并发编程的艺术》。

在这里插入图片描述
上图的StoreStore屏障可以保证在volatile写之前,其前面的所有普通写操作已经对任意处理器可见了

因为StoreStore屏障将保障上面所有的普通写在volatile写之前刷新到主内存

在这里插入图片描述

十一、happens-before

happens-before是JMM最核心的概念。用来阐述操作之间的内存可见性。

如一个操作的执行结果需要对另外一个操作可见,那么这两个操作之间必须存在happens-before关系。

这两个操作可以在一个线程内,也可以在不同线程之间。

happens-before是JMM最核心的概念,程序员基于它的内存可见性保证来编程。

JMM通过happens-before向程序员提供跨线程的内存可见性保证。(比如A线程的写操作a 和 B线程的读操作b 之间存在happens-before关系,那么JMM保证a操作将对b操作可见,即时在不同线程中)

在这里插入图片描述

1. happens-before的定义

  1. 如果一个操作发生在另外一个操作之前,第一个操作的结果将对第二个操作可见

我们也就是依据此保证来理解阅读源码的。

  1. 两个操作存在happens-before关系,编译器、处理器也不一定按照happens-Before指定的顺序执行。还是可能进行重排序的,只不过重排序执行的结果和 按照happens-Before关系来执行的结果一样就可以了。

上面1是JMM对程序员的承诺。从程序员的角度来说,可以这样理解happens-before关系:如果a happens-before b,那么JMM想程序员保证——A的操作结果将对B可见,且A的执行顺序排在B之前。注意,这只是JMM对程序员做出的保证。

上面2是JMM对编译器、处理器重排序的约束规则。只要不改变程序执行结果,编译器和处理器怎么优化都行。

happens-before和as-if-serial本质上是一回事:

  • as-if-serial保证单线程内程序的执行结果不被改变。happens-before保证正确同步的多线程的执行结果不被改变
  • as-if-serial给编写单线程程序的程序员创造了一个假象:单线程程序是按照程序顺序来执行的。happens-before给编写正确同步的多线程程序的程序员一个假象:正确同步的多线程程序是按照happens-before指定的顺序来执行的。
  • as-if-serial和happens-before这样做的目的,都是为了不改变程序执行结果的前提下,尽可能的提高程序执行的并行度。

happens-before不要求前一个操作和后一个操作的发生顺序, 仅仅要求前一个操作的执行完成并刷新回内存发生在后一个操作读取结果之前.

2. happens-before规则

JMM为程序员在上层提供了几条规则,这样我们就可以根据规则去推论跨线程的内存可见性问题,而不用再去理解底层重排序的规则。

(1) 单一线程原则 Single Thread rule

在一个线程内,在程序前面的操作先行发生于后面的操作。

在这里插入图片描述

(2)监视器锁规则 Monitor Lock Rule

对一个锁的解锁,happens-before于随后对这个锁的加锁。

在这里插入图片描述

(3)volatile 变量规则 Volatile Variable Rule

对一个 volatile 变量的写操作先行发生于后面对这个变量的读操作。

在这里插入图片描述

(4)线程启动规则 Thread Start Rule

  • 如果线程A中执行ThreadB.start()启动线程B,那么线程A的ThreadB.start() happens-before于 线程B中任意操作

  • 假如线程A修改了共享变量,然后执行ThreadB.start(),那么通过start()规则,线程B可以看到共享变量被线程A修改后的值

在这里插入图片描述

(5) 线程加入规则 Thread Join Rule

  • 如果线程A执行ThreadB.join()并且成功返回,那么线程B中任意操作 happens-before 线程A从ThreadB.join()成功返回。
  • 假如线程A执行ThreadB.join(),线程B修改了共享变量,然后ThreadB.join()成功返回,那么线程A可以看到线程B对共享变量的修改。

在这里插入图片描述

(6) 线程中断规则 Thread Interruption Rule

对线程 interrupt() 方法的调用先行发生于被中断线程的代码检测到中断事件的发生,可以通过 interrupted() 方法检测到是否有中断发生。

(7) 对象终结规则 Finalizer Rule

一个对象的初始化完成(构造函数执行结束)先行发生于它的 finalize() 方法的开始。

(8) 传递性 Transitivity

如果操作 A 先行发生于操作 B,操作 B 先行发生于操作 C,那么操作 A 先行发生于操作 C。


下面以一个具体的例子来讲下如何使用这些规则进行推论:

依旧以上面计算面积进行描述。利用程序顺序规则(规则1)存在三个happens-before关系:

  1. A happens-before B;
  2. B happens-before C;
  3. A happens-before C。

这里的第三个关系是利用传递性进行推论的。A happens-before B,定义1要求A执行结果对B可见,并且A操作的执行顺序在B操作之前,但与此同时利用定义中的第二条,A,B操作彼此不存在数据依赖性,两个操作的执行顺序对最终结果都不会产生影响,在不改变最终结果的前提下,允许A,B两个操作重排序,即happens-before关系并不代表了最终的执行顺序。

3. happents-before 与 JMM 之间的关系

在这里插入图片描述

一个happens-before规则其背后的实现依赖于多个编译器和处理器的重排序规则,我们不需要去掌握这些复杂的重排序规则及他们的实现,我们只需根据happens-before的规则来编程。

想想自己在阅读JUC下源码时是怎么理解那些正确同步的代码的:

  • 我们看到synchronized会想到互斥,锁的释放还会引起共享变量的刷新,一个线程的对锁的释放与随后获取的线程实质上是在通信;
  • 看到volatile会想到它的读/写是原子的,且与锁的获取/释放具有相同的内存语义;
  • 看到循环CAS想到原子操作,且它具有volatile读/写的内存语义;
  • 对于代码的执行顺序我们都默认是按顺序的,我们认为程序是按代码顺序来执行的,可编译器与处理器是会重排序的。

那是谁给了你这种保障,让你有这种按顺序执行的幻觉?是JMM,你只要按照happens-before规则来编程,编写的程序是正确同步的,你就可以按顺序来理解它,编译器和处理器的重排序不会影响到你,因为JMM对他们的限制,禁止了那些会改变执行结果的重排序。

十二、JVM 和 JMM 之间的关系

jmm中的主内存、工作内存与jvm中的Java堆、栈、方法区等并不是同一个层次的内存划分,这两者基本上是没有关系的,如果两者一定要勉强对应起来,那从变量、主内存、工作内存的定义来看,主内存主要对应于Java堆中的对象实例数据部分,而工作内存则对应于虚拟机栈中的部分区域。

从更低层次上说,主内存就直接对应于物理硬件的内存,而为了获取更好的运行速度,虚拟机(甚至是硬件系统本身的优化措施)可能会让工作内存优先存储于寄存器和高速缓存中,因为程序运行时主要访问读写的是工作内存。

评论 1
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值