【JVM】Java内存模型
概述
多任务和高并发是衡量一台计算机处理器的能力重要指标之一。一般衡量一个服务器性能的高低好坏,使用每秒事务处理数(Transactions Per Second,TPS
)这个指标比较能说明问题,它代表着一秒内服务器平均能响应的请求数,而TPS
值与程序的并发能力有着非常密切的关系。在讨论Java
内存模型和线程之前,先简单介绍一下硬件的效率与一致性。
硬件的效率与一致性
由于计算机的存储设备与处理器的运算能力之间有几个数量级的差距,所以现代计算机系统都不得不加入一层读写速度尽可能接近处理器运算速度的高速缓存(cache
)来作为内存与处理器之间的缓冲:将运算需要使用到的数据复制到缓存中,让运算能快速进行,当运算结束后再从缓存同步回内存之中没这样处理器就无需等待缓慢的内存读写了。
基于高速缓存的存储交互很好地解决了处理器与内存的速度矛盾,但是引入了一个新的问题:缓存一致性(Cache Coherence
)。在多处理器系统中,每个处理器都有自己的高速缓存,而他们又共享同一主存,如下图所示:多个处理器运算任务都涉及同一块主存,需要一种协议可以保障数据的一致性,这类协议有MSI
、MESI、MOSI
及Dragon Protocol
等。Java
虚拟机内存模型中定义的内存访问操作与硬件的缓存访问操作是具有可比性的,后续将介绍Java
内存模型。
除此之外,为了使得处理器内部的运算单元能竟可能被充分利用,处理器可能会对输入代码进行乱起执行(Out-Of-Order Execution
)优化,处理器会在计算之后将对乱序执行的代码进行结果重组,保证结果准确性。与处理器的乱序执行优化类似,Java
虚拟机的即时编译器中也有类似的指令重排序(Instruction Recorder
)优化。
Java内存模型(JMM)
定义Java
内存模型并不是一件容易的事情,这个模型必须定义得足够严谨,才能让Java
的并发操作不会产生歧义。但是,也必须得足够宽松,使得虚拟机的实现能有足够的自由空间去利用硬件的各种特性(寄存器、高速缓存等)来获取更好的执行速度。经过长时间的验证和修补,在JDK1.5
发布后,Java
内存模型就已经成熟和完善起来了。JMM
给我们一种规范,它描述了多线程程序如何与内存交互。
JMM大致描述:
JMM
描述了线程如何与内存进行交互。Java
虚拟机规范视图定义一种Java
内存模型,来屏蔽掉各种操作系统内存访问的差异,以实现Java
程序在各种平台下都能达到一致的访问效果。
JMM
描述了JVM
如何与计算机的内存进行交互。
JMM
都是围绕着原子性,有序性和可见性进行展开的。
JMM
的主要目标是定义程序中各个变量的访问规则,虚拟机将变量存储到内存和从内存取出变量这样的底层细节。此处的变量指在堆中存储的元素。
多线程的时候为什么容易出错?
Java
内存模型规定所有的共享变量都存储在主内存中,而每条线程有自己的工作内存(本地内存),工作内存保存了共享变量的副本,而不同内存又无法访问对方的工作内存,所以如果线程在工作内存中修改了变量副本,其它线程是无从得知的。
线程的传值均需要通过主内存来完成
主内存与工作内存如何交互?
Java
内存模型定义了8种操作来完成主内存与工作内存的交互细节,虚拟机必须保证这8种操作的每一个操作都是原子的,不可再分的。
lock
: 作用于主内存的变量,把变量标识为线程独占的状态。
unlock
: 与lock
对应,把主内存中处于锁定状态的变量释放出来,释放后的变量才可以被其他线程锁定。
read
: 作用于主内存的变量,把一个变量的值从主内存传输到线程的工作内存,便于随后的load
使用。
load
:作用于工作内存的变量,把read
读取到的变量放入工作内存副本。
use
:作用于工作内存,把工作内存的变量值传递给执行引擎,每当虚拟机遇到一个需要使用到变量的值的字节码指令时将会执行这个操作。
assign
: 作用于工作内存,把执行引擎收到的值赋给工作内存的变量,虚拟机遇到赋值字节码时候执行这个操作。
store
:作用于工作内存,把变量的值传输到住内存中,以便随后的write
使用。
write
:作用于主内存,把store
操作从工作内存得到的值放入主内存的变量中。
Java内存模型还规定了在执行上述八种基本操作时,必须满足如下规则:
- 不允许
read
和load
,store
和write
操作之一单独出现。- 不允许一个线程丢弃它最近的
assign
操作。即变量在工作内存中改变了账号必须把变化同步回主内存。- 不允许一个线程无原因地(没有发生过任何
assign
操作)把数据从工作内存同步回主内存中。- 一个新的变量只允许在主内存中诞生,不允许工作内存直接使用未初始化的变量。
- 一个变量同一时刻只允许一条线程进行
lock
操作,但同一线程可以lock
多次,lock
多次之后必须执行同样次数的unlock
操作。- 如果对一个变量执行
lock
操作,将会清空工作内存中此变量的值,在执行引擎使用这个变量前需要重新执行load
或assign
操作初始化变量的值。- 如果一个变量事先没有被
lock
操作锁定,则不允许对它执行unlock
操作;也不允许去unlock
一个被其他线程锁定的变量。- 对一个变量执行
unlock
操作之前,必须先把此变量同步到主内存中(执行store
和write
操作)。
这8种操作定义相当严谨,实践起来又比较麻烦,但是可以有助于我们理解多线程的工作原理。有一个与此8种操作相等的Happen-before
原则。
Happen-before原则
这个是Java
内存模型下无需任何同步器协助就已经存在,可以直接在编码中使用。如果两个操作之间的关系不在此列,并且无法从下列规则推导出来的话,它们的顺序就没有保障,虚拟机可以对他们进行任意的重排。
天然的happens-before:
程序次序规则:一个线程内,按照代码顺序,书写在前面的操作先行发生于书写在后面的操作;
锁定规则:一个unlock
操作先行发生于后面对同一个锁的lock
操作;
volatile变量规则:对一个变量的写操作先行发生于后面对这个变量的读操作;
传递规则:如果操作A
先行发生于操作B
,而操作B
又先行发生于操作C
,则可以得出操作A
先行发生于操作C
;
线程启动规则:Thread
对象的start()
方法先行发生于此线程的每一个动作;
线程中断规则:对线程interrupt()
方法的调用先行发生于被中断线程的代码检测到中断事件的发生;
线程终结规则:线程中所有的操作都先行发生于线程的终止检测,我们可以通过Thread.join()
方法结束、Thread.isAlive()
的返回值手段检测到线程已经终止执行;
对象终结规则:一个对象的初始化完成先行发生于他的finalize()
方法的开始。
这8条原则摘自《深入理解Java虚拟机》。
这8条规则中,前4条规则是比较重要的,后4条规则都是显而易见的。
下面我们来解释一下前4条规则:
对于程序次序规则来说,我的理解就是一段程序代码的执行在单个线程中看起来是有序的。注意,虽然这条规则中提到“书写在前面的操作先行发生于书写在后面的操作”,这个应该是程序看起来执行的顺序是按照代码顺序执行的,因为虚拟机可能会对程序代码进行指令重排序。虽然进行重排序,但是最终执行的结果是与程序顺序执行的结果一致的,它只会对不存在数据依赖性的指令进行重排序。因此,在单个线程中,程序执行看起来是有序执行的,这一点要注意理解。事实上,这个规则是用来保证程序在单线程中执行结果的正确性,但无法保证程序在多线程中执行的正确性。
第二条规则也比较容易理解,也就是说无论在单线程中还是多线程中,同一个锁如果出于被锁定的状态,那么必须先对锁进行了释放操作,后面才能继续进行lock操作。
第三条规则是一条比较重要的规则。直观地解释就是,如果一个线程先去写一个变量,然后一个线程去进行读取,那么写入操作肯定会先行发生于读操作。
第四条规则实际上就是体现happens-before原则具备传递性。
Java运行时数据区
JVM
定义了一些程序运行时会使用到的运行时数据区,其中一些会随着虚拟机启动而创建,随着虚拟机退出而销毁。另外一些是与现场一一对应的,这些线程对应的数据区会随着线程的开始和结束而创建和销毁。
这部分参考JVM规范
1. PC寄存器
可以支持多条线程同时允许,每一条Java虚拟机线程都有自己的PC
寄存器。任意时刻,一条JVM
线程之后执行一个方法的代码,这个方法被称为当前方法(current method
)。
如果这个方法不是Native
的,那么PC
寄存器就保存JVM
正在执行的字节码指令地址。
如果是Native
的,那么PC
寄存器的值为undefined
。
PC
寄存器的容量至少能保证一个returnAddress
类型的数据或者一个平台无关的本地指针的值。
2. JVM Stack(虚拟机栈)
每一个JVM
线程都有自己的私有虚拟机栈,这个栈与线程同时创建,用于存储栈帧(Frame
)。
栈用来存储局部变量与一些过程结果的地方。在方法调用和返回中也扮演了很重要的角色。
栈可以试固定分配的也可以动态调整。
如果请求线程分配的容量超过JVM栈允许的最大容量,抛出StackOverflowError
异常。
如果JVM
栈可以动态扩展,扩展的动作也已经尝试过,但是没有申请到足够的内存,则抛出OutOfMemoryError
异常。
3. Heap(堆)
堆是可以可供各个线程共享的运行时存储区域,也是供所有类的实例和数组对象分配内存的区域。堆在JVM
启动的时候创建。
堆所存储的就是被GC
所管理的各种对象。
堆也是可以固定大小和动态调整的。
实际所需的堆超过的GC
所提供的最大容量,那么JVM
抛出OutOfMemoryError
异常。
4. Method Area(方法区)
也是各个线程共享的运行时内存区,它存储每一个类的实例信息,运行时常量池,字段和方法数据,构造函数和普通方法的字节码等内容。还有一些特殊方法。
方法区是堆的逻辑组成部分,也在JVM
启动时创建,简单的JVM可以不实现这个区域的垃圾收集。
方法区也可固定大小和动态分配与堆一样,内存空间不够,那么JVM
抛出OutOfMemoryError
异常。
5. Run-Time Constant Pool(运行时常量池)
在方法区中分配,在加载类和接口到虚拟机之后,就创建对应的运行时常量池。
它是class
文件中每一个类或接口的常量池表的运行时表现形式。
存储区域不够用时候抛出OutOfMemoryError
异常。
6. Native Method Stacks(原生方法栈或本地方法栈)
JDK
中Native
的方法,System
类和Thread
类中有很多。使用C
语言编写的方法,这个也通常叫做C stack
。
可以不支持本地方法栈,但是如果支持的时候,这个栈一般会在线程创建的时候按线程分配。
与栈的错误一样,StackOverFlowError
和OutOfMemeoryError
。
注意,具体 JVM 的内存结构,其实取决于其实现,不同厂商的JVM,或者同一厂商发布的不同版本,都有可能存在一定差异。
画了一个简单的内存结构图,里面展示了前面提到的堆、线程栈等区域,并从数量上说明了什么是线程私有,例如,程序计数器、Java 栈等,以及什么是 Java 进程唯一。另外,还额外划分出了直接内存等区域。
里简要介绍两点区别:
直接内存(Direct Memory)区域,它就是 Direct Buffer 所直接分配的内存,也是个容易出现问题的地方。尽管,在 JVM 工程师的眼中,并不认为它是JVM 内部内存的一部分,也并未体现 JVM 内存模型中。
JVM 本身是个本地程序,还需要其他的内存去完成各种基本任务,比如,JIT Compiler 在运行时对热点方法进行编译,就会将编译后的方法储存在 Code Cache 里面;GC 等功能需要运行在本地线程之中,类似部分都需要占用内存空间。这些是实现 JVM JIT 等功能的需要,但规范中并不涉及。
如果深入到 JVM 的实现细节,你会发现一些结论似乎有些模棱两可。比如:
Java 对象是不是都创建在堆上的呢?
我注意到有一些观点,认为通过逃逸分析,JVM 会在栈上分配那些不会逃逸的对象,这在理论上是可行的,但是取决于 JVM 设计者的选择。据我所知,Oracle Hotspot JVM 中并未这么做,这一点在逃逸分析相关的文档里已经说明,所以可以明确所有的对象实例都是创建在堆上。
目前很多书籍还是基于 JDK 7 以前的版本,JDK 已经发生了很大变化,Intern 字符串的缓存和静态变量曾经都被分配在永久代上,而永久代已经被元数据区取代。但是,Intern 字符串缓存和静态变量并不是被转移到元数据区,而是直接在堆上分配,所以这一点同样符合前面一点的结论:对象实例都是分配在堆上。
接下来,我们来看看什么是 OOM 问题,它可能在哪些内存区域发生?
首先,OOM 如果通俗点儿说,就是 JVM 内存不够用了,javadoc 中对OutOfMemoryError的解释是,没有空闲内存,并且垃圾收集器也无法提供更多内存。
这里面隐含着一层意思是,在抛出 OutOfMemoryError 之前,通常垃圾收集器会被触发,尽其所能去清理出空间,例如:
JVM 会去尝试回收软引用指向的对象等。
在java.nio.BIts.reserveMemory() 方法中,我们能清楚的看到,System.gc() 会被调用,以清理空间,这也是为什么在大量使用 NIO 的 Direct Buffer 之类时,通常建议不要加下面的
参数,毕竟是个最后的尝试,有可能避免一定的内存不足问题。
-XX:+DisableExplictGC
当然,也不是在任何情况下垃圾收集器都会被触发的,比如,我们去分配一个超大对象,类似一个超大数组超过堆的最大值,JVM 可以判断出垃圾收集并不能解决这个问题,所以直接抛出OutOfMemoryError。
从前面分析的数据区的角度,除了程序计数器,其他区域都有可能会因为可能的空间不足发生OutOfMemoryError,简单总结如下:
-
堆内存不足是最常见的 OOM 原因之一,抛出的错误信息是“java.lang.OutOfMemoryError:Java heap space”,原因可能千奇百怪,例如,可能存在内存泄漏问题;也很有可能就是堆的大小不合理,比如我们要处理比较可观的数据量,但是没有显式指定 JVM 堆大小或者指定数值偏小;或者出现 JVM 处理引用不及时,导致堆积起来,内存无法释放等。
-
而对于 Java 虚拟机栈和本地方法栈,这里要稍微复杂一点。如果我们写一段程序不断的进行递归调用,而且没有退出条件,就会导致不断地进行压栈。类似这种情况,JVM 实际会抛出StackOverFlowError;当然,如果 JVM 试图去扩展栈空间的的时候失败,则会抛出OutOfMemoryError。
-
对于老版本的 Oracle JDK,因为永久代的大小是有限的,并且 JVM 对永久代垃圾回收(如,常量池回收、卸载不再需要的类型)非常不积极,所以当我们不断添加新类型的时候,永久代出现 OutOfMemoryError 也非常多见,尤其是在运行时存在大量动态类型生成的场合;类似 Intern 字符串缓存占用太多空间,也会导致 OOM 问题。对应的异常信息,会标记出来和永久代相关:“java.lang.OutOfMemoryError: PermGen space”。
-
随着元数据区的引入,方法区内存已经不再那么窘迫,所以相应的 OOM 有所改观,出现OOM,异常信息则变成了:“java.lang.OutOfMemoryError: Metaspace”。
-
直接内存不足,也会导致 OOM。
指令重排序
在执行程序时为了提高性能,编译器和处理器经常会对指令进行重排序。重排序分成三种类型:
1、编译器优化的重排序。编译器在不改变单线程程序语义放入前提下,可以重新安排语句的执行顺序。
2、指令级并行的重排序。现代处理器采用了指令级并行技术来将多条指令重叠执行。如果不存在数据依赖性,处理器可以改变语句对应机器指令的执行顺序。
3、内存系统的重排序。由于处理器使用缓存和读写缓冲区,这使得加载和存储操作看上去可能是在乱序执行。
从Java
源代码到最终实际执行的指令序列,会经过下面三种重排序:
为了保证内存的可见性,Java
编译器在生成指令序列的适当位置会插入内存屏障指令来禁止特定类型的处理器重排序。Java
内存模型把内存屏障分为LoadLoad、LoadStore、StoreLoad
和StoreStore
四种:
一个案例
- 一个本地变量可能是原始类型,在这种情况下,它总是“呆在”线程栈上。
- 一个本地变量也可能是指向一个对象的一个引用。在这种情况下,引用(这个本地变量)存放在线程栈上,但是对象本身存放在堆上。
- 一个对象可能包含方法,这些方法可能包含本地变量。这些本地变量任然存放在线程栈上,即使这些方法所属的对象存放在堆上。
- 一个对象的成员变量可能随着这个对象自身存放在堆上。不管这个成员变量是原始类型还是引用类型。
- 静态成员变量跟随着类定义一起也存放在堆上。
- 存放在堆上的对象可以被所有持有对这个对象引用的线程访问。当一个线程可以访问一个对象时,它也可以访问这个对象的成员变量。如果两个线程同时调用同一个对象上的同一个方法,它们将会都访问这个对象的成员变量,但是每一个线程都拥有这个本地变量的私有拷贝。