内存模型可以理解为在特定的操作协议下,对特定的内存或者高速缓存进行读写访问的过程抽象描述,不同架构下的物理机拥有不一样的内存模型,Java虚拟机是一个实现了跨平台的虚拟系统,因此它也有自己的内存模型,即Java内存模型(Java Memory Model, JMM)。
究竟什么是内存模型?
内存模型描述了程序中各个变量(实例域、静态域和数组元素)之间的关系,以及在实际计算机系统中将变量存储到内存和从内存中取出变量这样的底层细节
Java Memory Model(Java内存模型), 围绕着在并发过程中如何处理可见性、原子性、有序性这三个特性而建立的模型。
java内存模型
JMM规定了一系列内存间操作
注意点:
原子性
就是把一个或多个操作看成是一个原子操作,在CPU执行过程中,不会被中断,这样的特性就称为原子性。
1∶是对多线程而言的,对于单线程,原子性没有意义
2∶主要针对的、或者说要保护的是临界资源(共享变量),是对临界资源的操作,尤其是写操作
3∶原子操作是不可分割的
可见性
一个线程对临界资源(共享变量)的修改,另一个线程能够立即看到
要实现临界资源(共享变量)的可见性,至少要保证两点∶
1:线程修改了共享变量的值过后,要能够及时的从工作内存刷新回到主内存中
2:其它线程要能够及时的从主内存中,把最新的数据更新到自己的工作内存中
有序性
就是要保证程序执行的顺序,是按照代码的逻辑先后顺序来执行的
在Java程序中
1:一个线程内部,所有操作都可以视为是有序的(保证单线程内,串行语义执行的一致性)
2∶如果是多线程环境下,从一个线程去观察另外一个线程,所有操作都是无序的(指令重排现象,也有可能是工作内存和主内存同步延迟的现象)
重排序︰编译器或处理器为了优化程序的执行性能,对指令执行的顺序进行重新排列的一种手段
目的∶为了优化程序的执行性能·
分类︰
1:编译器优化的重排序·
2:指令级并行的重排序
3∶内存系统的重排序斜
编译器优化的重排序∶编译器在不改变程序在单线程环境下运行的语义前提下,可以重新安排语句的执行顺序·
目的︰尽可能减少寄存器的读取、存储次数,复用寄存器存储的数据
第一步A=某个计算的结果值
第二步B=某个计算结果的值
第三步C=使用A来计算一个新的数据
优化重排一下∶
第一步A=某个计算的结果值
第二步C=使用A来计算一个新的数据
第三步B=某个计算结果的值
指令级并行的重排序︰处理器将多条指令并行执行,如果不存在数据依赖,处理器可以改变语句对应的指令的执行顺序。“
lnt a = 5;
lnt b = 6;
内存系统的重排序︰处理器使用缓存和读写缓冲区,使得数据的加载、存储操作,看上去是乱序执行的。
并发编程里面的一个重要原则︰不要假设指令执行的顺序
不会重排
数据依赖︰如果两个操作访问同一个共享变量,而且,这两个操作里面有一个为写操作,那么这两个操作之间就存在数据依赖性。
数据依赖分类:
读后写:读一个变量过后,再写一个变量 a=b; b=1;
写后写∶写一个变量过后,再写一个变量 a=5 ; a=6;
写后读:写一个变量过后,再读这个变量 a=5; b=a;
具有数据依赖性的指令是不会被重排的
as-if-serial语义︰不管有没有重排序,也不关心如何进行的重排序,单线程环境不,程序的执行结果不会被改变。
编译器、JVM、处理器都必须要遵守这个语义。
为什么需要 happens-before 原则?
happens-before 原则的诞生是为了程序员和编译器、处理器之间的平衡。程序员追求的是易于理解和编程的强内存模型,遵守既定规则编码即可。编译器和处理器追求的是较少约束的弱内存模型,让它们尽己所能地去优化性能,让性能最大化。happens-before 原则的设计思想其实非常简单:
下面这张是 《Java 并发编程的艺术》这本书中的一张 JMM 设计思想的示意图,非常清晰。
了解了 happens-before 原则的设计思想,我们再来看看 JSR-133 对 happens-before 原则的定义:
int userNum = getUserNum(); // 1
int teacherNum = getTeacherNum(); // 2
int totalNum = userNum + teacherNum; // 3
虽然 1 happens-before 2,但对 1 和 2 进行重排序不会影响代码的执行结果,所以 JMM 是允许编译器和处理器执行这种重排序的。但 1 和 2 必须是在 3 执行之前,也就是说 1,2 happens-before 3 。
happens-before 原则表达的意义其实并不是一个操作发生在另外一个操作的前面,虽然这从程序员的角度上来说也并无大碍。更准确地来说,它更想表达的意义是前一个操作的结果对于后一个操作是可见的,无论这两个操作是否在同一个线程里。
举个例子:操作 1 happens-before 操作 2,即使操作 1 和操作 2 不在同一个线程内,JMM 也会保证操作 1 的结果对操作 2 是可见的。
happens-before规则
程序顺序规则:一个线程中的每个操作happens-before该线程中任意后续操作
int a = 5;
int b = 6;
int c = a + b;
}
监视器锁规则︰对一个锁的解锁操作happens-before随后对这个锁的加锁·
volatile 变量规则︰对一个volatile修饰的字段进行的写操作happens-before任意后续对这个volatile修饰的字段进行的读操作
private volatile int num = 0;
public void run(){
num = 8;
//
int a = num + 5;
}
start 规则︰如果在线程A里面去执行了线程B的start,那么在线程A里面的B.start()操作happens-before 线程B中的任意操作
join规则︰如果线程A执行了线程B.join(),那么线程B中的任意操作happens-before 线程A执行了线程Bjoin()之后的操作·
程序中断规则:对线程interrupted()方法的调用先行于被中断线程的代码检测到中断时间的发生。对象finalize规则:一个对象的初始化完成(构造函数执行结束)先行于发生它的finalize()方法的开始。
happens-before是JMM对程序员的承诺,有了它,我们在加锁,再线程同步的时候,就不用陷入有序性的底层细节中,他能天然保证有序。
happens-before 和 JMM 什么关系?
要学习 happens-before 这里首先介绍下JMM的设计意图。这个问题首先从实际出发:
好了,上面谈到的这两点明显就是冲突的,作为程序员我们希望JMM提供给我们一个强内存模型,而底层的编译器和处理器又需要一个弱内存模型来提高自己的性能。
因此JMM在设计时,定义了如下策略:
只要不改变程序的执行结果,编译器和处理器想怎么优化就怎么优化。
内存屏障︰是一种屏障指令,它使得CPU或编译器对屏障指令的前和后所发出的内存操作执行一个排序的约束。也叫内存栅栏或栅栏指令
内存屏障的能力∶
1∶阻止屏障两边的指令重排序·
2︰写数据的时候加了屏障的话,强制把写缓冲区的数据刷回到主内存中
3∶读数据的时候加了屏障的话,让工作内存/CPU高速缓存当中缓存的数据失效,重新到主内存中获取新的数据
基本分类
1:读屏障:Load Barrier :在读指令之前插入读屏障,让工作内存/CPU高速缓存当中缓存的数据失效,重新到主内存中获取新的数据
2:写屏障: Store Barrier ︰在写指令之后插入写屏障,强制把写缓冲区的数据刷回到主内存中
重排序和内存屏障
1︰重排序可能会给程序带来问题,因此,有些时候,我们希望告诉JVM,这里不需要排序
JVM本身为了保证可见性∶
2∶对于编译器的重排序,JMM会根据重排序的规则,禁止特定类型的编译器重排序
3∶对于处理器的重排序,Java编译器在生成指令序列的适当位置,插入内存屏障指令,来禁止特定类型的处理器排序
JVM的内存屏障
LoadL.oad Barriers :Load1; LoadLoad; Load2
禁止重排序∶访问Load2的读取操作一定不会重排到Load1之前保证Load2在读取的时候,自己缓存内到相应数据失效,Load2会去主内存中获取最新的数据
LoadStore Barriers :Load1;LoadStore; Store2
禁止重排序∶一定是Load1读取数据完成后,才能让Store2及其之后的写出操作的数据,被其它线程看到。
StoreStore, Barriers :Store1;StoreStore; Store2
禁止重排序︰一定是Store1的数据写出到主内存完成后,才能让Store2及其之后的写出操作的数据,被其它线程看到。保证 Store1指令写出去的数据,会强制被刷新回到主内存中
StoreLoad Barriers :Store1;StoreLoad; Load2e
禁止重排序∶一定是 Store1 的数据写出到主内存完成后,才能让Load2来读取数据
同时保证︰强制把写缓冲区的数据刷回到主内存中
让工作内存/CPU高速缓存当中缓存的数据失效,重新到主内存中获取新的数据
为什么说:StoreLoadBarriers.是最重的?
重∶就是跟内存交互次数多,交互延迟较大、消耗资源较多
扩展
这些屏障指令并不是处理器真实的执行指令,他们只是JMM定义出来的、跨平台的指令。
因为不同硬件实现内存屏障的方式并不相同,JMM为了屏蔽这种底层硬件平台的不同,抽象出了这些内存屏障指令,在运行的时候,由JVM来为不同的平台生成相应的机器码。
这些内存屏障指令,在不同的硬件平台上,可能会做一些优化,从而只支持部分的JMM 的内存屏障指令。
在x86机器上,就只有StoreLoadBarriers是有效的,其它的都不支持,被替换成nop,也就是空操作。
被volatile修饰的变量,具有如下特性∶
1:保证可见性︰
(1):对一个volatile修饰的变量进行读操作的话,总是能够读到这个变量的最新的值,也就是这个变量最后被修改的值
(2):一个线程修改了volatile修饰的变量的值的时候,那么这个变量的新的值,会立即刷新回到主内存中
(3):一个线程去读取volatile 修饰的变量的值的时候,该变量在工作内存中的数据无效,需要重新到主内存去读取最新的数据
2:禁止指令重排,也就是说要求维护happens-before的关系
(1):对volatile变量的写入,不能重排到写入之前的操作之前
(2)对volatile变量的读取,不能重排到读取操作的后续操作之后
volatile写的内存语义︰写一个volatile变量时,JMM会把该线程对应的工作内存中的共享变量的值刷新到主内存中
volatile读的内存语义︰读一个volatile变量时,JMM会把线程对应的工作内存中的共享变量数据设置为无效的,然后会从主内存中去读取共享变量最新的数据
Volatile内存语义实现
1:字节码层面
它影响的是Class内的Field 的flags : 添加了一个ACC_VOLATILE
JVM在把字节码生成为机器码的时候,发现操作是volatile 的变量的话,就会根据JMM要求,在相应的位置去插入内存屏障指令
2:JMM层面:插入内存屏障
volatile 写之前的操作,都禁止重排序到volatile之后
volatile读之后的操作,都禁止重排序到volatile之前
volatile 写之后volatile读,禁止重排序的
为了实现volatile内存语义,按如下方式来插入内存屏障∶
(1)在每个volatile 写操作的前面插入一个 StoreStore屏障
(2)在每个volatile 写操作的后面插入一个StoreLoad屏障
(3)在每个volatile读操作的后面插入一个LoadLoad屏障
(4)在每个volatile读操作的后面插入一个LoadStore屏障
需要注意的是: volatile写是在前面和后面分别插入内存屏障,而volatile读操作是在后面插入两个内存屏障
JMM采用保守的内存屏障插入策略,始终在每个volatile写后面,插入一个StoreLoad.屏障。
这样可以保证在任意的处理器平台上, volatile语义的正确性。
3:处理器层面
cpu执行机器码指令的时候,是使用lock前缀指令来实现volatile 的功能的。
Lock指令,相当于内存屏障,功能也类似内存屏障的功能∶
(1)首先对总线/缓存加锁,然后去执行后面的指令,最后,释放锁,同时把高速缓存的数据刷新回到主内存
(2)在lock锁住总线/缓存的时候,其它cou的读写请求就会被阻塞,直到锁释放。Lock过后的写操作,会让其它cpu.的高速缓存中相应的数据失效,这样后续这些cpu.在读取数据的时候,就会从主内存去加载最新的数据
加了Lock指令过后的具体表现,就跟JMM添加内存屏障后一样。
**
/**
* 3.懒汉式(双重检查,线程安全)
*/
public class Singleton3 {
//1.构造器私有化
private Singleton3() {
}
//2.本类内部创建对象实例
private static volatile Singleton3 singleton3;
//3.提供一个获取实例的方法,但是在第一次使用的时候再去实例化
public static Singleton3 getInstance() {
if (singleton3 == null) {
synchronized (Singleton3.class) {
if (singleton3 == null) {
singleton3 = new Singleton3();
}
}
}
return singleton3;
}
}
为什么要使用volatile关键字?
这是因为 singleton3 = new Singleton3(); 这一步并不是原子操作,别看这是一行代码,在底层它被分成了三步操作:
1.分配一部分空间用于创建对象
2.在分配好的空间创建对象
3.将创建好的对象指向变量,在这里变量是singleton3
cpu在操作时为了提高效率会出现指令重排的操作,也就是把这三步进行乱序。假如说步骤变成了 1、3、2。那么就会导致 当a线程执行到第2步(执行顺序是132)的时候失去了操作权,b线程开始执行(由于a线程没有执行第2步就把空间指向了变量),发现singleton3指向的空间不是null,但是实际上此空间并没有实例,那么b线程使用该对象时候就会报错。
使用了volatile关键字就会禁止指令重排。
优点:进行了懒加载
缺点:编写复杂,要考虑线程安全