Java内存模型
1. 基本概念:
程序:代码,完成某一件任务,代码序列(静态的概念)
进程:程序在某些数据上的一次运行(动态的概念)
线程:一个进程可能包含一个或多个线程(占有资源的独立单元)
2. JVM与线程 (线程在JVM中)
JVM什么时候启动?在运行程序的时候启动 JVM线程 --> 其他的线程(main)
JVM 什么时候关闭? 当该程序关闭退出,这个虚拟机实例也就随之消亡
3. JVM内存区域
方法区:
Method Area是各个线程共享内存区域,用于存储已被虚拟机加载的类信息,常量,静态变量,即时编译器编译后的代码等数据。这个区域的内存回收目标主要是针对常量池的回收和对类型的卸载。
堆:
Java Heap是Java虚拟机所管理的内存中最大的一块。Java堆是被所有线程共享的一块内存区域,在虚拟机启动时创建。此区域的唯一目的就是存放对象实例(Java虚拟机规范中的描述时:所有的对象实例以及数组都要在堆上分配)。
Java堆是GC的主要区域,因此很多时候也被称为GC堆。 (OOM异常)
Java虚拟机栈:
Java Virtual Machine Stacks,也是线程私有的,它的生命周期与线程相同。
虚拟机栈描述的是Java方法执行的内存模型(非native方法)。
每个方法在执行的同时都会创建一个栈帧用于存储局部变量表,操作数栈,动态链接,方法出口等信息。
每一个方法从调用直至执行完成的过程,就对应着一个栈帧在虚拟机栈中入栈到出栈的过程,当方法被调用则入栈,一旦完成调用则出栈。所有的栈帧都出栈后,线程就结束了。 (OOM)
PC(程序计数器):
程序计数器(Program Counter Register)是一块较小的内存空间,可以看做当前线程所执行的字节码的行号指示器。字节码解释器工作时就是通过改变这个计数器的值来选取下一条需要执行的字节码指令,分支、循环、跳转、异常处理、线程恢复等基础功能都需要依赖这个计数器来完成。
本地方法栈:
Native Method Stack与虚拟机栈的作用非常相似,区别是:虚拟机栈为虚拟机执行Java方法(也就是字节码)服务,而本地方法栈则为虚拟机使用到的Native方法。
4. Java内存模型 Java memory model JMM(规范,抽象的模型)
- 主内存:共享的信息
- 工作内存:私有信息,基本数据类型,直接分配到工作内存,引用的地址存放在工作内存,引用的对象存放在堆中
- 工作方式:
A 线程修改私有数据,直接在工作空间修改
B 线程修改共享数据,把数据复制到工作空间中去,在工作空间中修改,修改完成以后,刷新内存中的数据
5. 硬件内存架构与java内存模型
1) 硬件架构
a) CPU缓存的一致性问题:并发处理的不同步
b) 解决方案:
i. 总线加锁 降低CPU的吞吐量
ii. 缓存上的一致性协议(MESI)
当CPU写数据时,如果发现操作的变量是共享变量,即在其他CPU中也存在该变量的副本,会发出信号通知其他CPU将该变量的缓存行置为无效状态,因此当其他CPU需要读取这个变量时,发现自己缓存中缓存该变量的缓存行是无效的,那么它就会从内存重新读取。
2) Java线程与硬件处理器
- Java内存模型与硬件内存架构的关系
对于硬件架构来说,只有寄存器、缓存、主内存的概念,并没有工作内存(线程私有数据区)和主内存(堆内存)之分,也就是说Java内存模型对内存的划分对硬件内存并没有任何影响,因为JMM是一种抽象的概念,是一组规则,并不实际存在,不管是工作内存的数据还是主内存的数据,对于计算机硬件来说都会存储在计算机主内存中,当然也有可能存储到CPU缓存或者寄存器中,因此总体上,Java内存模型和硬件内存架构是一个相互交叉的关系,是一种抽象概念划分与真实物理硬件的交叉。
4) Java内存模型的必要性
Java内存模型的作用:规范内存数据和工作空间数据的交互
6、并发编程的三个重要特性
原子性:不可分割
可见性:线程只能操作自己工作空间中的数据
有序性:程序中的顺序不一定就是执行的顺序
编译重排序
指令重排序
提高效率
as-if-seria:单线程, 多线程中重排后不影响执行的结果。
happens-before
7、JMM对三个特征的保证
1)JMM与原子性
A) X=10 写 原子性 如果X是私有数据具有原子性,如果是共享数据则没原子性
B) Y=x 没有原子性
a) 把数据X读到工作空间(原子性)
b) 把X的值写到Y(原子性)
C) I++ 没有原子性
a) 读i到工作空间
b) +1;
c) 刷新结果到内存
D) Z=z+1 没有原子性
a) 读z到工作空间
b) +1;
c) 刷新结果到内存
多个原子性的操作合并到一起没有原子性
保证方式:
Synchronized
JUC Lock的lock
2)JMM与可见性
Volatile:在JMM模型上实现MESI协议
Synchronized:加锁
JUC JUC Lock的lock
3)JMM与有序性
Volatile:
Synchronized:
Happens-before原则:
1)程序次序原则
2)锁定原则 :后一次加锁必须等前一次解锁
3)Volatile原则:霸道原则
4)传递原则:A—B —C A–C
总结:
JVM内存区域和JMM的关系
JMM和硬件的关系
JMM和并发编程三个重要特征(有序性 as-if-seria happens-before )
为何要指令重排?
现在的CPU一般采用流水线来执行指令。一个指令的执行被分成:取指、译码、访存、执行、写回、等若干个阶段。然后,多条指令可以同时存在于流水线中,同时被执行。
指令流水线并不是串行的,并不会因为一个耗时很长的指令在“执行”阶段呆很长时间,而导致后续的指令都卡在“执行”之前的阶段上。
相反,流水线是并行的,多个指令可以同时处于同一个阶段,只要CPU内部相应的处理部件未被占满即可。比如说CPU有一个加法器和一个除法器,那么一条加法指令和一条除法指令就可能同时处于“执行”阶段, 而两条加法指令在“执行”阶段就只能串行工作。
相比于串行+阻塞的方式,流水线像这样并行的工作,效率是非常高的。
然而,这样一来,乱序可能就产生了。比如一条加法指令原本出现在一条除法指令的后面,但是由于除法的执行时间很长,在它执行完之前,加法可能先执行完了。再比如两条访存指令,可能由于第二条指令命中了cache而导致它先于第一条指令完成。
一般情况下,指令乱序并不是CPU在执行指令之前刻意去调整顺序。CPU总是顺序的去内存里面取指令,然后将其顺序的放入指令流水线。但是指令执行时的各种条件,指令与指令之间的相互影响,可能导致顺序放入流水线的指令,最终乱序执行完成。这就是所谓的“顺序流入,乱序流出”。
指令流水线除了在资源不足的情况下会卡住之外(如前所述的一个加法器应付两条加法指令的情况),指令之间的相关性也是导致流水线阻塞的重要原因。
CPU的乱序执行并不是任意的乱序,而是以保证程序上下文因果关系为前提的。有了这个前提,CPU执行的正确性才有保证。比如:
a++; b=f(a); c–;
由于b=f(a)这条指令依赖于前一条指令a++的执行结果,所以b=f(a)将在“执行”阶段之前被阻塞,直到a++的执行结果被生成出来;而c–跟前面没有依赖,它可能在b=f(a)之前就能执行完。(注意,这里的f(a)并不代表一个以a为参数的函数调用,而是代表以a为操作数的指令。C语言的函数调用是需要若干条指令才能实现的,情况要更复杂些。)
像这样有依赖关系的指令如果挨得很近,后一条指令必定会因为等待前一条执行的结果,而在流水线中阻塞很久,占用流水线的资源。而编译器的乱序,作为编译优化的一种手段,则试图通过指令重排将这样的两条指令拉开距离, 以至于后一条指令进入CPU的时候,前一条指令结果已经得到了,那么也就不再需要阻塞等待了。比如将指令重排为:
a++; c–; b=f(a);
相比于CPU的乱序,编译器的乱序才是真正对指令顺序做了调整。但是编译器的乱序也必须保证程序上下文的因果关系不发生改变。
指令重排可能产生的问题:
1.无法识别带有隐式因果关系的指令
有些程序逻辑,单纯从上下文是看不出它们的因果关系的。比如:
*addr=5; val=*data;
从表面上看,addr和data是没有什么联系的,完全可以放心的去乱序执行。但是如果这是在某某设备驱动程序中,这两个变量却可能对应到设备的地址端口和数据端口。并且,这个设备规定了,当你需要读写设备上的某个寄存器时,先将寄存器编号设置到地址端口,然后就可以通过对数据端口的读写而操作到对应的寄存器。那么这么一来,对前面那两条指令的乱序执行就可能造成错误。
2.多线程带来的问题
考虑下面的代码:
线程A:
c=10;
b=c;
flag=true;
线程B:
while(flag){
System.out.println(b);
}
如果编译器将线程A的第三条指令重排到第一行,那线程B拿到的b的数据就有可能出错了。