本文主要是对深入理解java虚拟机等书籍和各路大神的博客的总结并谈谈自己的理解
参考:https://www.jianshu.com/p/895950290179
https://blog.csdn.net/javazejian/article/details/72772461
https://www.jianshu.com/p/f5883ca0348f
深入理解java虚拟机
理解java内存模型之前我们需要先了解以下物理计算机中的并发问题,物理机的并发处理方案对JVM的实现有很大的参考意义。
物理机内存架构
通过一张图我们看一下物理机硬件内存架构
由上图我们可以看到在CPU内部有一组CPU寄存器,寄存器是cpu直接访问和处理的数据,是一个临时放数据的空间。一般CPU都会从内存取数据到寄存器,然后进行处理。
学过操作系统的我们知道cpu和内存的运算速度有几个数量级的差距,所以现代计算机在内存和cpu之间加入一层读写速度尽可能接近处理器运算速度的高速缓存来作为内存和处理器之间的缓冲。
原理是:当一个CPU需要访问主存时,会先读取一部分主存数据到CPU缓存(当然如果CPU缓存中存在需要的数据就会直接从缓存获取),进而在读取CPU缓存到寄存器,当CPU需要写数据到主存时,同样会先刷新寄存器中的数据到CPU缓存,然后再把数据刷新到主内存中。
缓存的容量远远小于主存,因此出现缓存不命中的情况在所难免,既然缓存不能包含CPU所需要的所有数据,那么Cache的存在真的有意义吗?
CPU缓存存在的意义分两点(局部性原理):
- 时间局部性:如果某个数据被访问,那么在不久的将来它很可能被再次访问
- 空间局限性:如果某个数据被访问,那么与它相邻的数据很快也可能被访问
所以说其实cpu的缓存命中率其实可以达到90%
基于高速缓存的存储很好的解决了处理器和内存之间的矛盾,但是在多处理器系统上,每个处理器上都有自己的高速缓存,而他们又共享同一主内存,当多个处理器的运算任务都涉及到同一块主内存区域时,将可能导致各个处理器的缓存数据不一致。
多核CPU硬件架构厂商,设计之初就预测到多线程操作数据不一致的问题,因此出现了——缓存一致性协议。不同的CPU硬件生产厂商,具体的实现不一样。Intel的MESI协议最出名。
参考文章 https://blog.csdn.net/reliveit/article/details/50450136
这就是可见性问题
除了增加高速缓存外,为了使处理器内部的运算单元能尽量被充分利用。处理器可能会对输入代码进行乱序执行,处理器会在计算后将乱序执行的结果重组,保存该结果与顺序执行的结果是一致的
在只有一个处理器的时候 乱序执行优化 不会对程序的结果产生影响,但是在多处理器情况下程序的运行结果就会不如人意。
这叫做有序性问题
java内存模型
经过上面对物理机内存结构的分析 我们已经了解到 CPU缓存和乱序执行优化 导致的可见性和有序性,在多核多并发下,需要额外做很多的事情,才能保证程序的执行。
内存模型规范了如何提供按需禁用缓存和程序优化的 方法
其实有很多的处理器内存模型。java为什么还要自己用JMM来解决呢?
处理器内存模型是硬件级的内存模型,JMM是语言级的内存模型
深入理解java虚拟机书中原文的一段话:
java虚拟机试图定义一种java内存模型来屏蔽掉各种硬件和操作系统中的内存访问差异,以实现java程序在各种平台下都能达到一致的内存访问效果。在此之前,主流程序语言如c和c++直接使用物理硬件和操作系统的内存模型,因此会由于不同平台上内存模型的差异,有可能导致程序在一套平台上并发完全正常,而在另外一个平台上并发访问却经常出错,因此在某些场景就必须针对不同的平台来编写程序。
接下来正式介绍java内存模型(JMM)
Java内存模型(即Java Memory Model,简称JMM)本身是一种抽象的概念,并不真实存在
它描述的是一组规则或规范,通过这组规范定义了程序中各个变量(包括实例字段,静态字段和构成数组对象的元素)的访问方式。它规定了一个线程如何和何时能看到别的线程修改过的变量,以及在必须时如何同步访问共享变量。 并且是围绕着在并发过程中如何处理原子性、可见性和有序性这3个特征来建立的。
在JMM中所有的变量都存储在主内存中,每条线程都有自己的工作内存,工作内存里面保存了该线程使用到的变量的主内存的副本拷贝,线程对变量的所有操作必须在工作内存中进行,而不能直接读写主内存中的变量,不同线程之间也无法直接访问对方工作内存中的变量,线程间变量的传递都必须通过主内存来完成。
下面是线程、主内存、工作内存三者的交互关系
线程A如果要和线程B通信的话 至少要通过2个步骤
-
线程A把本地内存A中更新过的共享变量刷新到主内存中
-
线程B从主内存中读取线程A之前更新过的共享变量
很多人把java内存模型和java运行时区域搞混了
运行时区域指的是栈、堆、方法区、程序计数器、本地方法栈这些。他们2个不是一个层次的内存划分 两者之间基本没有关系,如果非要扯上关系。
那么可以说 主内存对应线程共享的堆中对象实例数据部分,而工作内存则对应线程私有虚拟机栈中的部分区域。
根据虚拟机规范,对于一个实例对象中的成员方法而言
- 如果方法中包含本地变量是基本数据类型,将直接存储在工作内存(栈)中;
- 但如果本地变量是引用类型,那么该变量的引用会存储在工作内存的栈中,而对象的实例将存储在主内存(堆)中。但对于实例对象的成员变量,不管它是基本数据类型或者包装类型(Integer、Double等)还是引用类型,都会被存储到堆区。
- 至于static变量以及类自身相关信息将会存储在主内存中。
需要注意的是,在主内存中的实例对象可以被多线程共享,如果两个线程同时调用了同一个对象的同一个方法,那么两个线程会将要操作的数据拷贝一份到自己的工作内存中,执行完成后才刷新到主内存
如下图所示:
Java内存模型 与 硬件内存架构的关系
对于硬件架构来说,只有寄存器、缓存、主内存的概念,并没有工作内存和主内存之分,也就是说Java内存模型对内存的划分对硬件内存并没有任何影响,因为JMM是一种抽象的概念,是一组规则,并不实际存在,不管是工作内存的数据还是主内存的数据,对于计算机硬件来说都会存储在计算机主内存中,当然也有可能存储到CPU缓存或者寄存器中,因此总体上,Java内存模型和硬件内存架构师一个相互交叉的关系,是一种抽象概念划分与真实物理硬件的交叉。(注意对于Java内存区域划分也是同样的道理)
接下来我们来谈谈在jvm层面上产生的一套原子性 有序性 可见性问题
原子性
原子性问题产生的根本原因就是因为线程切换
cpu通过给每个线程分配cpu时间片来实现多线程,当某个线程时间片用完后cpu会保存该任务状态
以便下次切换到这个任务时,可以再加载这个任务的状态 这就产生了著名的上下文切换问题这里暂且不谈上下文切换 我们先来谈原子性
原子性指的是一个操作是不可中断的,即使是在多线程环境下,一个操作一旦开始就不会被其他线程影响。比如对于一个静态变量int x,两条线程同时对他赋值,线程A赋值为1,而线程B赋值为2,不管线程如何运行,最终x的值要么是1,要么是2,线程A和线程B间的操作是没有干扰的,这就是原子性操作,不可被中断的特点。
int count = 0;
比如count++这个操作 分为3步
1:首先,需要把变量 count 从主内存加载到 当前线程的工作内存;
2:之后,在工作中执行 +1 操作;
3:最后,将结果写入主内存。
当线程A执行第1步后 发生了线程切换 线程B执行了这3步之后count的值变为了1 然后切换回线程A
执行 此时线程A工作内存的count值依然为0
那么经过这2个线程的操作之后 count的值只加了1
我们把一个或者多个操作在 CPU 执行的过程中不被中断的特性称为原子性
可见性
由于线程对共享变量的操作都是线程拷贝到各自的工作内存进行操作后才写回到主内存中的
这就可能存在2种情况
- 线程A修改了共享变量x的值,把它写回主内存后,而对线程B来说并不可见,线程B读的一直是自己工作内存的值,
- 假设要对共享变量count++,线程A修改了count的值,但它并没有写回主内存,这时线程B也从主内存拿到count的值对他++,这时2个线程的操作只执行了一次count++;
这种工作内存与主内存同步延迟现象就造成了可见性问题,另外指令重排以及编译器优化也可能导致可见性问题,通过前面的分析,我们知道无论是编译器优化还是处理器优化的重排现象,在多线程环境下,确实会导致程序轮序执行的问题,从而也就导致可见性问题。
有序性
我们都知道单例模式中的一种实现叫做双重检验锁
public class Singleton {
// 私有构造函数
private Singleton() {
}
private static Singleton instance = null;
// 静态的工厂方法
public static Singleton getInstance() {
if (instance == null) { // 双重检测机制 // B
synchronized (Singleton.class) { // 同步锁
if (instance == null) {
instance = new Singleton(); // A - 3
}
}
}
return instance;
}
}
很多人都是像上面这样实现的 然而这样做其实还是不安全的
因为instance = new Singleton()
其实分为了3步
- 先在堆上分配Singleton对象的内存空间
- 进行对象的初始化
- 将栈中instance变量指向堆中的对象的内存地址
但是由于编译器在不改变单线程程序语义的前提下,可以重新安排语句的执行顺序。在经过指令重排序后的执行路径却是这样的
- 先在堆上分配Singleton对象的内存空间
- 将栈中instance变量指向堆中的对象的内存地址
- 进行对象的初始化
现在假设线程A获得同步锁后判断instance==null 然后进行第一步和第二步分配完内存空间并将instance指向这个地址后。此时线程B调用getInstance方法 发现instance!=null 直接就获得了instance
但是因为此时这个对象并没有进行初始化 所以会发生空指针异常
理解指令重排
计算机在执行程序时,为了提高性能和并行度,编译器和处理器的常常会对指令做重排,一般分以下3种
-
编译器优化的重排
编译器在不改变单线程程序语义的前提下,可以重新安排语句的执行顺序。
-
指令并行的重排
现代处理器采用了指令级并行技术来将多条指令重叠执行。如果不存在数据依赖性(即后一个执行的语句无需依赖前面执行的语句的结果),处理器可以改变语句对应的机器指令的执行顺序
-
内存系统的重排
由于处理器使用缓存和读写缓存冲区,这使得加载(load)和存储(store)操作看上去可能是在乱序执行,因为三级缓存的存在,导致内存与缓存的数据同步存在时间差。
其中编译器优化的重排属于编译期重排,指令并行的重排和内存系统的重排属于处理器重排,在多线程环境中,这些重排优化可能会导致程序出现内存可见性问题
as-if-serial语义
该语义指的是:不管怎么重排序,单线程的程序执行结果不能被改变,编译器、runtime和处理器都遵守as-if-serial语义。
为了遵守语义,编译器和处理器不会对存在数据依赖关系的操作做重排序,如果数据之间不存在依赖关系,就会被重排序。这个语义把单线程程序保护了起来,但是在多线程程序中数据的依赖性不被编译器和处理器考虑
那么jvm是怎么解决原子性 有序性 可见性问题的呢?
- 如原子性问题,除了JVM自身提供的对基本数据类型读写操作的原子性外,对于方法级别或者代码块级别的原子性操作,可以使用synchronized关键字或者重入锁(ReentrantLock)保证程序执行的原子性
- 而工作内存与主内存同步延迟现象导致的可见性问题,可以使用synchronized关键字或者volatile关键字解决,它们都可以使一个线程修改后的变量立即对其他线程可见。
- 对于指令重排导致的可见性问题和有序性问题,则可以利用volatile关键字解决,因为volatile的另外一个作用就是禁止重排序优化。
Happens-Before 规则
如果仅仅靠sychronized和volatile关键字来保证原子性、可见性以及有序性,那么有一些操作会变得十分繁琐,但是我们在编写程序并没有感觉到这一点这是因为,JMM内部还定义一套happens-before 原则来保证多线程环境下两个操作间的原子性、可见性以及有序性,happens-Before 约束 了编译器的优化行为,虽允许编译器优化,但是要求编译器优化后一定遵守 happens-Before 规则。
深入理解java虚拟机一书中Happens-Before的意思是先行发生
先行发生:指的是java内存模型中定义的两项操作之间的偏序关系,如果操作A先行发生于操作B,其实就是说在发生操作B之前,操作A产生的操作能被操作B观察到。
在这里我们可以理解为前一个操作的结果对后续操作是可见的。
比如如果A happens-before B 那么可以说A的操作产生的结果对于B来说是可以知道的。
happens-before规则一共有8条
-
程序的顺序性规则
在一个线程内,按照程序代码顺序,书写在前面的操作happens-before于书写在后面的操作,这里需要考虑分支,循环结构。 -
管程锁定规则
对一个锁的解锁 happens-before 于后续对这个锁的加锁int wmh = 0; synchronized (this) { if(wmh <= 0) { wmh = 818; } } // 代码块执行完自动解锁
比如这段代码,当线程A进入同步代码块 对wmh这个变量进行了修改并退出代码块后,线程B进入代码块可以知道线程A对变量的修改操作。
-
volatile变量规则
对一个volatile变量的写操作happends-before于后面对这个volatile变量的读操作 -
线程启动规则
Thread对象的start方法happends-before于此线程的每一个操作
比如在主线程中开启了另一个线程B 那么在线程B中可以看到主线程在调用B.start()之前的操作
代码如下Thread B = new Thread(()->{ // 主线程调用 B.start() 之前所有对共享变量的修改,此处皆可见 // 在这里可以看wmh = 8; }); // 此处对共享变量 wmh 修改 wmh = 8; // 主线程启动子线程 B.start();
-
线程终止规则
如果在线程A中,调用线程B的 join() 并成功返回,那么线程B中的任 意操作 Happens- Before于该join()操作的返回。
Thread B = new Thread(()->{
// 此处对共享变量 wmh修改
wmh = 66;
});
// 主线程启动子线程
B.start();
B.join()
// 子线程所有对共享变量的修改
// 在主线程调用 B.join() 之后皆可见
// 这里看见wmh = 8;
- 线程中断规则
对线程interrupt()方法的调用happends-before于被中断线程的代码检测到中断事件的发生,可以通过Thread.interrupted()方法检测到是否有中断发生。 - 对象终结规则
一个对象的初始化完成(构造函数执行结束) happends-before于它的finalize()方法的开始 - 传递性规则
如果 A Happens-Before B,且 B Happens-Before C,那么 A HappensBefore C。
下面举个例子
// 线程A执行
1. a = 1;
// 线程B执行
2. b = a;
// 线程c执行
3. a = 2;
上面这段代码中如果忽略第3条,因为第一条在第2条代码之前
所以根据程序顺序性规则 线程B的操作执行后 b一定等于1
但是因为还有一个线程c b和c之间不满足happens-before规则
所以线程B的操作执行后 b =1 或者2 都有可能
很多人认为一个操作时间上的先发生 就代表 前面的操作happens-brefore后面的操作
这个观点是错误的!
比如下面这段代码
private int value = 0;
public int getValue() {
return value;
}
public void setValue(int value) {
this.value = value;
}
如果有线程A在时间上先调用setValue(1),然后线程B调用了同一个对象的getValue()。
仔细观察这段代码我们就会发现 happens-before的8项规则 这里一项都不符合
因为这里有2个线程 程序顺序性规则坑定不符合 没有同步快,没有volatile,其余规则也和这里没有关系。
因此我们就可以判定这里的操作不是线程安全的,即使线程A 在时间上优先线程B发生。
那么反过来呢!如果A happens-before B 是不是可以说 A在时间上一定先发生于B 这个观点也是错的
比如下面这段代码
// 同一个线程执行
int a = 1;
int b = 2;
由于指令重排序 int b = 2可能会先被处理器执行
但是这并不影响happens-before规则 因为 int a = 1 在 int b = 2 前面。
结论:时间先后顺序于happens-before规则基本没有太大关系,衡量并发安全问题不要受到时间顺序干扰,一切必须以happens-before原则为准