JMM & JVM 内存模型
JMM
JMM 定义了 Java 虚拟机(JVM)在计算机内存(RAM)中的工作方式。JVM是整个计算机虚拟模型,所以JMM是隶属于JVM的。从抽象的角度来看,JMM 定义了线程和主内存之间的抽象关系:线程之间的共享变量存储在主内存(Main Memory)中,每个线程都有一个私有的本地内存(Local Memory),本地内存中存储了该线程以读/写共享变量的副本。本地内存是JMM的一个抽象概念,并不真实存在。它涵盖了缓存、写缓冲区、寄存器以及其他的硬件和编译器优化。
主存和缓存
大家都知道,计算机在执行程序时,每条指令都是在CPU中执行的,而执行指令过程中,势必涉及到数据的读取和写入。由于程序运行过程中的临时数据是存放在主存(物理内存)当中的,这时就存在一个问题,由于CPU执行速度很快,而从内存读取和写入数据的过程跟CPU执行指令的速度比起来要慢的多,因此如果任何时候对数据的操作都要通过和内存的交互来进行,会大大降低指令执行的速度,因此在CPU里面就有了高速缓存。
通常高速缓存分为三类,读写速度和制造成本由高到低依次为一级缓存、二级缓存和三级缓存。
int i;
i = i + 1;
当线程执行这个语句时,会先从主存当中读取i的值,然后复制一份到高速缓存当中,然后CPU执行指令对i进行加1操作,然后将数据写入高速缓存,最后将高速缓存中i最新的值刷新到主存当中。
Java 内存模型规定了所有的变量都存储在主内存中,每条线程有自己的工作内存。这是具体是什么意思呢,以上面的操作为例,在多线程模型下,当同时有多个线程在对主存中的变量 i
进行操作,会从主存中读取 i 的值复制到线程自己工作内存对应的高速缓存中,然后 CPU 从高速缓存中取到值并执行指令运算,最后将结果写到高速缓存中,最后由高速缓存将 i 的计算结果刷新到主存中。
这样就会可能带来我们所喜闻乐见的并发问题,由于计算是在线程自己的工作内存中进行的,其他线程无法获取,而计算是个耗时操作,极大可能发生多个线程操作同一个临时缓存变量的情况,这样再将操作后的变量刷新到主存中就会发生数据错乱。
并发三个概念
原子性
对应关键字:Atomic、synchronized
一个操作或者多个操作 要么全部执行并且执行的过程不会被任何因素打断,要么就都不执行。
可见性
对应关键字:volatile
当多个线程访问同一个变量时,一个线程修改了这个变量的值,其他线程能够立即看得到修改的值。
lock前缀指令和MESI协议综合使用
对于volatile修饰的变量,执行写操作的话,JVM会发送一条lock前缀指令给CPU,CPU在计算完之后会立即将这个值写回主内存,同时因为有MESI缓存一致性协议,所以各个CPU都会对总线进行嗅探自己本地缓存中的数据是否被修改了。如果发现某个缓存的值被修改了,那么CPU就会将自己本地缓存的数据过期掉,然后这个CPU上执行的线程在读取那个变量的时候,就会从主内存重新加载最新的数据了。
有序性
程序执行的顺序按照代码的先后顺序执行。举个简单的例子,看下面这段代码:
class TestSingleton{
private TestSingleton instance;
public TestSingleton getInstance() {
if (instance == null) {
synchronized (this) {
if (instance == null) {
instance = new TestSingleton();
}
}
}
return instance;
}
}
上面代码常用于双检锁单例模式,当对instance进行实例化时,编译器执行了三个步骤:
- 在主存中为
instance
分配内存空间 - 初始化
instance
对象 - 将
instance
对象指向内存空间
下面解释一下什么是指令重排序,一般来说,处理器为了提高程序运行效率,可能会对输入代码进行优化,它不保证程序中各个语句的执行先后顺序同代码中的顺序一致,但是它会保证程序最终执行结果和代码顺序执行的结果是一致的。
例如这里可能会发生指令重排序(Instruction Reorder)
- 在主存中为
instance
分配内存空间 - 将
instance
对象指向内存空间 - 初始化
instance
对象
而重排后的指令就会存在安全隐患,当A、B两个线程同时执行上述代码时,假如A执行完指令重排后的第二步时,B开始执行,这时因为已经将分配好的内存空间指向了instance
,所以判断不为null,这样B就会拿到一个未初始化完成的null对象。
那遇到这种情况该如何解决呢,简单来说我们可以通过添加volatile
关键字来保证 instance
的有序性。
class TestSingleton{
private volotile TestSingleton instance;
public TestSingleton getInstance() {
if (instance == null) {
synchronized (this) {
if (instance == null) {
instance = new TestSingleton();
}
}
}
return instance;
}
}
JVM 内存模型
主要分为五大内存区域
程序计数器
程序计数器是一块很小的内存空间,它是线程私有的,可以认作为当前线程的行号指示器。
Java栈(虚拟机栈)
线程私有,就是我们平时所说的栈,栈描述的是Java方法执行的内存模型。
每个方法被执行的时候都会创建一个栈帧用于存储**局部变量表,操作栈,动态链接,方法出口等信息。**每一个方法被调用的过程就对应一个栈帧在虚拟机栈中从入栈到出栈的过程。
平时说的栈一般指局部变量表:
一片连续的内存空间,用来存放方法参数,以及方法内定义的局部变量,存放着编译期间已知的数据类型(八大基本类型和对象引用(reference类型),returnAddress类型。它的最小的局部变量表空间单位为Slot,虚拟机没有指明Slot的大小,但在jvm中,long和double类型数据明确规定为64位,这两个类型占2个Slot,其它基本类型固定占用1个Slot。
reference类型:与基本类型不同的是它不等同本身,即使是String,内部也是char数组组成,它可能是指向一个对象起始位置指针,也可能指向一个代表对象的句柄或其他与该对象有关的位置。
需要注意的是,局部变量表所需要的内存空间在编译期完成分配,当进入一个方法时,这个方法在栈中需要分配多大的局部变量空间是完全确定的,在方法运行期间不会改变局部变量表大小。
Java虚拟机栈可能出现两种类型的异常:
- 线程请求的栈深度大于虚拟机允许的栈深度,将抛出StackOverflowError。
- 虚拟机栈空间可以动态扩展,当动态扩展是无法申请到足够的空间时,抛出OutOfMemory异常。
本地方法栈
本地方法栈是与虚拟机栈发挥的作用十分相似,区别是虚拟机栈执行的是Java方法(也就是字节码)服务,而本地方法栈则为虚拟机使用到的native方法服务,可能底层调用的c或者c++,我们打开jdk安装目录可以看到也有很多用c编写的文件,可能就是native方法所调用的c代码。
堆
对于大多数应用来说,堆是java虚拟机管理内存最大的一块内存区域,因为堆存放的对象是线程共享的,所以多线程的时候也需要同步机制。因此需要重点了解下。
java虚拟机规范对这块的描述是:所有对象实例及数组都要在堆上分配内存,但随着JIT编译器的发展和逃逸分析技术的成熟,这个说法也不是那么绝对,但是大多数情况都是这样的。
注意:它是所有线程共享的,它的目的是存放对象实例。同时它也是GC所管理的主要区域,因此常被称为GC堆,又由于现在收集器常使用分代算法,Java堆中还可以细分为新生代和老年代,再细致点还有Eden(伊甸园)空间之类的不做深究。
根据虚拟机规范,Java堆可以存在物理上不连续的内存空间,就像磁盘空间只要逻辑是连续的即可。它的内存大小可以设为固定大小,也可以扩展。
方法区
方法区同堆一样,是所有线程共享的内存区域,为了区分堆,又被称为非堆。
用于存储已被虚拟机加载的类信息、常量、静态变量,如static修饰的变量加载类的时候就被加载到方法区中。