概述:
Java Memory Model简称JMM, 是一系列的Java虚拟机平台对开发者提供的多线程环境下的内存可见性、是否可以重排序等问题的无关具体平台的统一的保证。我们常说的JVM内存模式指的是JVM的内存分区;而Java内存模式是一种虚拟机规范。JMM规范了Java虚拟机与计算机内存是如何协同工作的:规定了一个线程如何和何时可以看到由其他线程修改过后的共享变量的值,以及在必须时如何同步的访问共享变量。<br />**JMM 同步规定**
- 线程解锁前,必须把共享变量的值刷新回主内存
- 线程加锁前,必须读取主内存的最新值到自己的工作内存
- 加锁解锁是同一把锁
JMM是一个抽象的概念,并不是真实的存在,它涵盖了缓冲区,寄存器以及其他硬件和编译器优化。<br />Java内存模型抽象图如下:<br />![WX20191215-215640@2x.png](https://cdn.nlark.com/yuque/0/2019/png/416592/1576418210983-d9f7c96f-c6ec-446d-8dcb-5bc3ec2a8164.png#align=left&display=inline&height=1072&margin=%5Bobject%20Object%5D&name=WX20191215-215640%402x.png&originHeight=1072&originWidth=1240&size=396591&status=done&style=none&width=1240)<br />从上图可以看出每个线程都有一个本地内存,如果线程想要通信的话要执行一下步骤:
- A线程先把本地内存的值写入主内存
- B线程从主内存中去读取出A线程写的值
现代硬件内存模型与Java内存模型有一些不同,理解内存模型架构以及Java内存模型如何与它协同工作也是非常重要的。
现代计算机硬件架构的简单图示:
Java内存模型和硬件内存架构之间的桥接
Java内存模型与硬件内存架构之间存在差异。硬件内存架构没有区分线程栈和堆。对于硬件,所有的线程栈和堆都分布在主内存中。部分线程栈和堆可能有时候会出现在CPU缓存中和CPU内部的寄存器中。如下图所示:
从抽象的角度来看,JMM定义了线程和主内存之间的抽象关系:
**
- 线程之间的共享变量存储在主内存(Main Memory)中
- 每个线程都有一个私有的本地内存(Local Memory),本地内存是JMM的一个抽象概念,并不真实存在,它涵盖了缓存、写缓冲区、寄存器以及其他的硬件和编译器优化。本地内存中存储了该线程以读/写共享变量的拷贝副本。
- 从更低的层次来说,主内存就是硬件的内存,而为了获取更好的运行速度,虚拟机及硬件系统可能会让工作内存优先存储于寄存器和高速缓存中。
Java内存模型中的线程的工作内存(working memory)是cpu的寄存器和高速缓存的抽象描述。而JVM的静态内存储模型(JVM内存模型)只是一种对内存的物理划分而已,它只局限在内存,而且只局限在JVM的内存。
内存间交互操作
关于主内存与工作内存之间的具体交互协议,即一个变量如何从主内存拷贝到工作内存、如何从工作内存同步到主内存之间的实现细节,Java 内存模型定义了 8 个操作来完成主内存和工作内存的交互操作。
lock(锁定):作用于主内存的变量,把一个变量标识为一条线程独占状态。
- unlock(解锁):作用于主内存变量,把一个处于锁定状态的变量释放出来,释放后的变量才可以被其他线程锁定。
- read(读取):作用于主内存变量,把一个变量值从主内存传输到线程的工作内存中,以便随后的load动作使用
- load(载入):作用于工作内存的变量,它把read操作从主内存中得到的变量值放入工作内存的变量副本中。
- use(使用):作用于工作内存的变量,把工作内存中的一个变量值传递给执行引擎,每当虚拟机遇到一个需要使用变量的值的字节码指令时将会执行这个操作。
- assign(赋值):作用于工作内存的变量,它把一个从执行引擎接收到的值赋值给工作内存的变量,每当虚拟机遇到一个给变量赋值的字节码指令时执行这个操作。
- store(存储):作用于工作内存的变量,把工作内存中的一个变量的值传送到主内存中,以便随后的write的操作。
write(写入):作用于主内存的变量,它把store操作从工作内存中一个变量的值传送到主内存的变量中。
如果要把一个变量从主内存中复制到工作内存,就需要按顺寻地执行 read 和 load 操作,如果把变量从工作内存中同步回主内存中,就要按顺序地执行 store 和 write 操作。Java内存模型只要求上述操作必须按顺序执行,而没有保证必须是连续执行。也就是 read 和 load 之间,store 和 write 之间是可以插入其他指令的,如对主内存中的变量a、b进行访问时,可能的顺序是read a,read b,load b, load a。
Java内存模型还规定了在执行上述8种基本操作时,必须满足如下规则:不允许 read 和 load、store 和 write 操作之一单独出现
- 不允许一个线程丢弃它的最近 assign 的操作,即变量在工作内存中改变了之后必须同步到主内存中
- 不允许一个线程无原因地(没有发生过任何assign操作)把数据从工作内存同步回主内存中
- 一个新的变量只能在主内存中诞生,不允许在工作内存中直接使用一个未被初始化(load 或 assign)的变量。即就是对一个变量实施 use 和 store 操作之前,必须先执行过了 assign 和 load 操作。
- 一个变量在同一时刻只允许一条线程对其进行lock操作,lock 和 unlock必须成对出现
- 如果对一个变量执行 lock 操作,将会清空工作内存中此变量的值,在执行引擎使用这个变量前需要重新执行 load 或 assign 操作初始化变量的值
- 如果一个变量事先没有被 lock 操作锁定,则不允许对它执行 unlock 操作;也不允许去 unlock 一个被其他线程锁定的变量。
- 对一个变量执行 unlock 操作之前,必须先把此变量同步到主内存中(执行 store 和 write 操作)。
线程之间通信
线程之间通信的方式共有两种:一种就是共享内存,另一种是消息传递
。
在共享内存中的并发模型中线程是通过读取主内存的共享信息来进行隐性通信的。在消息传递通信中线程之间没有公共的状态,只能通过发送消息来进行显性通信。
线程间的同步:
同步就是在多线程的情况下有顺序的去执行。在共享内存中同步时显式进行的,在代码中我们必须要去指定方法需要同步执行比如说加同步锁等。在消息传递的并发模型中发送消息必须是在消接收之前,所以同步时隐式的。Java并发采用的是共享内存模型。
JMM三大特性
可见性
当有一个线程修改了值,会马上被另一个线程感知到,当前值作废,从新从主内存中获取值。对其他线程可见,这就叫可见性。
主要有有三种实现可见性的方式:
- volatile
- synchronized,对一个变量执行 unlock 操作之前,必须把变量值同步回主内存。
- final,被 final 关键字修饰的字段在构造器中一旦初始化完成,并且没有发生 this 逃逸(其它线程通过 this 引用访问到初始化了一半的对象),那么其它线程就能看见 final 字段的值。
原子性
如何实现原子性? 通过同步代码块 synchronized 或者 local 锁来确保原子性
Java 内存模型保证了 read、load、use、assign、store、write、lock 和 unlock 操作具有原子性,例如对一个 int 类型的变量执行 assign 赋值操作,这个操作就是原子性的。但是 Java 内存模型允许虚拟机将没有被 volatile 修饰的 64 位数据(long,double)的读写操作划分为两次 32 位的操作来进行,即 load、store、read 和 write 操作可以不具备原子性。
例子:
public class VolatileDemo {
public static void main(String[] args) {
// test01();
test02();
}
// 测试原子性
private static void test02() {
Data data = new Data();
for (int i = 0; i < 20; i++) {
new Thread(() -> {
for (int j = 0; j < 1000; j++) {
data.addOne();
}
}).start();
}
// 默认有 main 线程和 gc 线程
while (Thread.activeCount() > 2) {
Thread.yield();
}
System.out.println(data.a);
}
}
class Data {
volatile int a = 0;
void addOne() {
this.a += 1;
}
}
发现并不能输入 20000
除了使用原子类例如AtomicInteger之外,也可以使用 synchronized 互斥锁来保证操作的原子性。它对应的内存间交互操作为:lock 和 unlock,在虚拟机实现上对应的字节码指令为 monitorenter 和 monitorexit。
有序性
- 计算机在执行程序时,为了提高性能,编译器个处理器常常会对指令做重排,一般分为以下 3 种
- 编译器优化的重排
- 指令并行的重排
- 内存系统的重排
- 单线程环境里面确保程序最终执行的结果和代码执行的结果一致
- 处理器在进行重排序时必须考虑指令之间的数据依赖性
- 多线程环境中线程交替执行,由于编译器优化重排的存在,两个线程中使用的变量能否保证用的变量能否一致性是无法确定的,结果无法预测
在 Java 内存模型中,允许编译器和处理器对指令进行重排序,重排序过程不会影响到单线程程序的执行,却会影响到多线程并发执行的正确性。 volatile 关键字通过添加内存屏障的方式来禁止指令重排,即重排序时不能把后面的指令放到内存屏障之前。 也可以通过 synchronized 来保证有序性,它保证每个时刻只有一个线程执行同步代码,相当于是让线程顺序执行同步代码。
例子:
public class ReSortSeqDemo {
int a = 0;
boolean flag = false;
public void method01() {
a = 1; // flag = true;
// ----线程切换----
flag = true; // a = 1;
}
public void method02() {
if (flag) {
a = a + 3;
System.out.println("a = " + a);
}
}
}
如果两个线程同时执行,method01 和 method02 如果线程 1 执行 method01 重排序了,然后切换的线程 2 执行 method02 就会出现不一样的结果。
重排序
指令重排序
在执行程序时为了提高性能,编译器和处理器常常会对指令做重排序。
指令重排序包括:编译器重排序和处理器重排序
重排序分三种类型:
- 编译器优化的重排序。编译器在不改变单线程程序语义的前提下,可以重新安排语句的执行顺序。
- 指令级并行的重排序。现代处理器采用了指令级并行技术(Instruction-Level Parallelism, ILP)来将多条指令重叠执行。如果不存在数据依赖性,处理器可以改变语句对应机器指令的执行顺序。
- 内存系统的重排序。由于处理器使用缓存和读/写缓冲区,这使得加载和存储操作看上去可能是在乱序执行。
从 Java 源代码到最终实际执行的指令序列,会分别经历下面三种重排序:
源代码 | 1.编译器优化重排序 | 2.指令集并行重排序 | 3.内存系统重排序 | 最终执行的指令序列 |
---|---|---|---|---|
——————————————————> 运行顺序 |
上述的 1 属于编译器重排序,2 和 3 属于处理器重排序。这些重排序都可能会导致多线程程序出现内存可见性问题。对于编译器,JMM 的编译器重排序规则会禁止特定类型的编译器重排序(不是所有的编译器重排序都要禁止)。对于处理器重排序,JMM 的处理器重排序规则会要求 Java 编译器在生成指令序列时,插入特定类型的内存屏障(memory barriers,intel 称之为 memory fence)指令,通过内存屏障指令来禁止特定类型的处理器重排序(不是所有的处理器重排序都要禁止)。
JMM 属于语言级的内存模型,它确保在不同的编译器和不同的处理器平台之上,通过禁止特定类型的编译器重排序和处理器重排序,为程序员提供一致的内存可见性保证。
数据依赖性
如果两个操作访问同一个变量,且这两个操作中有一个为写操作,此时这两个操作之间就存在数据依赖性。数据依赖分下列三种类型:
名称 | 代码示例 | 说明 |
---|---|---|
写后读 | a = 1;b = a; | 写一个变量之后,再读这个位置。 |
写后写 | a = 1;a = 2; | 写一个变量之后,再写这个变量。 |
读后写 | a = b;b = 1; | 读一个变量之后,再写这个变量。 |
上面三种情况,只要重排序两个操作的执行顺序,程序的执行结果将会被改变。
前面提到过,编译器和处理器可能会对操作做重排序。编译器和处理器在重排序时,会遵守数据依赖性,编译器和处理器不会改变存在数据依赖关系的两个操作的执行顺序。
注意,这里所说的数据依赖性仅针对单个处理器中执行的指令序列和单个线程中执行的操作,不同处理器之间和不同线程之间的数据依赖性不被编译器和处理器考虑。
先行发生原则(happens-before)
从 JDK5 开始,java 使用新的 JSR -133 内存模型(本文除非特别说明,针对的都是 JSR- 133 内存模型)。JSR-133 使用 happens-before 的概念来阐述操作之间的内存可见性。在 JMM 中,如果一个操作执行的结果需要对另一个操作可见,那么这两个操作之间必须要存在happens-before 关系。这里提到的两个操作既可以是在一个线程之内,也可以是在不同线程之间。
与程序员密切相关的 happens-before 规则如下:
- 程序顺序规则:一个线程中的每个操作,happens- before 于该线程中的任意后续操作。
监视器锁规则:对一个监视器锁的解锁,happens- before 于随后对这个监视器锁的加锁。
volatile 变量规则:对一个 volatile 域的写,happens- before 于任意后续对这个 volatile 域的读。
传递性:如果 A happens- before B,且 B happens- before C,那么 A happens- before C。
注意,两个操作之间具有 happens-before 关系,并不意味着前一个操作必须要在后一个操作之前执行!happ ens-before 仅仅要求前一个操作(执行的结果)对后一个操作可见,且前一个操作按顺序排在第二个操作之 前(the first is visible to and ordered before the second)。happens- before 的定义很微妙,后文会具体 说明 happens-before 为什么要这么定义。
happens-before与JMM的关系如下图所示:
如上图所示,一个 happens-before 规则通常对应于多个编译器和处理器重排序规则。对于 java 程序员来
说,happens-before 规则简单易懂,它避免 java 程序员为了理解 JMM 提供的内存可见性保证而去学习复杂
的重排序规则以及这些规则的具体实现。
as-if-serial语义
as-if-serial 语义的意思指:不管怎么重排序(编译器和处理器为了提高并行度),(单线程)程序的执行结果不能被改变。编译器,runtime 和 处理器 都必须遵守 as-if-serial 语义。
为了遵守 as-if-serial 语义,编译器和处理器不会对存在数据依赖关系的操作做重排序,因为这种重排序会改变执行结果。但是,如果操作之间不存在数据依赖关系,这些操作可能被编译器和处理器重排序。
内存屏障
处理器都支持一定的内存屏障(memory barrier)或栅栏(fence)来控制重排序和数据在不同的处理器间的可见性。
- 保证特定操作的执行顺序。
- 影响某些数据(或则是某条指令的执行结果)的内存可见性。
编译器和CPU能够重排序指令,保证最终相同的结果,尝试优化性能。插入一条Memory Barrier会告诉编译器和CPU:不管什么指令都不能和这条Memory Barrier指令重排序。
Memory Barrier所做的另外一件事是强制刷出各种CPU cache,如一个Write-Barrier(写入屏障)将刷出所有在Barrier之前写入 cache 的数据,因此,任何CPU上的线程都能读取到这些数据的最新版本。
如果一个变量是volatile修饰的,JMM会在写入这个字段之后插进一个Write-Barrier指令,并在读这个字段之前插入一个Read-Barrier指令。
final关键字
final字段有两个额外的特殊规则
final字段的写入(在构造器中进行)以及final字段对象本身的引用的写入都不能和后续的(构造器外的)持有该final字段的对象的写入重排序。例如, 下面的语句是不能重排序的
x.finalField = v; ...; sharedRef = x;
final字段的第一次加载不能和持有这个final字段的对象的写入重排序,例如下面的语句是不允许重排序的
x = sharedRef; ...; i = x.finalField
对Java内存模型的理解,以及其在并发中的应用
volatile的语义,它修饰的变量一定线程安全吗
而volatile只能保证可见性和有序性
首先还是要说明下cpu和内存,cpu和内存直接是有高速缓存的,一般分为多级。cpu首先是要从内存中读取一个数据进缓存,然后从缓存中读取进行操作,将结果返回给缓存,再把缓存写回内存。
如果同一个变量i=0,有两个线程执行i++方法,线程1把i从内存中读取进缓存,而现在线程2也把i读取进缓存,两个线程执行完i++后,线程1写回内存,i = 1,线程2也写回内存i = 1,两次++结果最终值为1,这就是著名的缓存一致性问题。为了解决这个问题,前人给了两种方案
总线锁
缓存一致性协议
cpu为了和各个硬件打交道方便,设计师们把每个硬件都连接一个线到cpu,但是发现这样太麻烦了,所以改为所有硬件都挂在总线上,cpu通过总线和各个硬件打交道。如果使用总线锁,就阻塞了其他cpu和其他硬件交互(内存之类,磁盘,等等),i++这条语句就必须执行完了,其他cpu才能执行,否则只能一个cpu去和硬件交互。这也是一种解决办法,问题也明显,特别效率低下。
为了解决这个问题提出缓存一致性协议,具体协议就不讲,简单解释一下,如果我写入之后发现这是共享变量就使得其他cpu缓存了的值失效,让它再次去内存中读取。
下面这段话摘自《深入理解Java虚拟机》:
“观察加入volatile关键字和没有加入volatile关键字时所生成的汇编代码发现,加入volatile关键字时,会多出一个lock前缀指令” lock前缀指令实际上相当于一个内存屏障(也成内存栅栏),内存屏障会提供3个功能: 1)它确保指令重排序时不会把其后面的指令排到内存屏障之前的位置,也不会把前面的指令排到内存屏障的后面;即在执行到内存屏障这句指令时,在它前面的操作已经全部完成; 2)它会强制将对缓存的修改操作立即写入主存; 3)如果是写操作,它会导致其他CPU中对应的缓存行无效。
解释一下这段话的内容,重排序的指代码的真正执行过程可能不是代码书写的顺序,这是为了是cpu流水线作业提高cpu的利用率而优化的一门技术。
而lock前缀指令(内存屏障),一个屏障会把这个屏障前写入的数据刷新到内存,这样任何试图读取该数据的线程将得到最新值,而不用考虑到底是被哪个cpu核心或者哪颗CPU执行的。如果你的字段是volatile,Java内存模型将在写操作后插入一个写屏障指令,在读操作前插入一个读屏障指令。
这样咋一看貌似可以保证线程的安全性呀,为啥不能保证呢
这样如果有一个变量i = 0用volatile修饰,两个线程对其进行i++操作,如果线程1从内存中读取i=0进了缓存,然后把数据读入寄存器,之后时间片用完了,然后线程2也从内存中读取i进缓存,因为线程1还未执行写操作,内存屏障是插入在写操作之后的指令,意味着还未触发这个指令,所以缓存行是不会失效的。然后线程2执行完毕,内存中i=1,然后线程1又开始执行,然后将数据写回缓存再写回内存,结果还是1。