本篇记录的内容多来源于《深入理解 Java 虚拟机(第三版)》与个人总结。
在介绍内存模型之前首先先看一个例子,假设在一段 Java 方法中使用了 Math 类并且创建了 User 对象,在下图中使用不同颜色来分别代表:线程共享区域和私有区域。
JVM内存模型
基本概念
JVM 是可运行 Java 代码的假想计算机 ,包括一套字节码指令集、一组寄存器、一个栈、一个垃圾回收,堆 和 一个存储方法域。JVM 是运行在操作系统之上的,它与硬件没有直接的交互。
运行过程
Java 源文件通过编译器,能够产生相应的.class
文件,即字节码文件。而字节码文件又通过 Java 虚拟机中的解释器,编译成特定机器上的机器码。
- Java 源文件->编译器->字节码文件
- 字节码文件->JVM->机器码
因为每一种平台解释器不同,但是实现的虚拟机是相同的,这就是 Java 能够跨平台的原因。
当一个程序从开始运行,这时虚拟机就开始实例化了,多个程序启动就会存在多个虚拟机实例。程序退出或者关闭,则虚拟机实例消亡,多个虚拟机实例之间数据不能共享。
线程
这里所说的线程指执行过程中的一个线程实体。JVM 允许一个应用并发执行多个线程。Hotspot JVM 中的 Java 线程与原生操作系统线程有直接的映射关系。当线程本地存储、缓冲区分配、同步对象、栈、程序计数器等准备好以后,就会创建一个操作系统原生线程。Java 线程结束,原生线程随之被回收。操作系统负责调度所有线程,并把它们分配到任何可用的 CPU 上。当原生线程初始化完毕,就会调用 Java 线程的run()
方法。当线程结束时,会释放原生线程和 Java 线程的所有资源。
Hotspot JVM 后台运行的系统线程主要有下面几个:
虚拟机线程(VM thread) | 这个线程等待 JVM 到达安全点操作出现。这些操作必须要在独立的线程里执行,因为当堆修改无法进行时,线程都需要 JVM 位于安全点。这些操作的类型有:stop-the-world 垃圾回收、线程栈dump 、线程暂停、线程偏向锁(Biased locking)解除 |
---|---|
周期性任务线程 | 这些线程负责定时器事件(中断),用来调度周期性操作的执行 |
GC 线程 | 这些线程支持 JVM 中不同的垃圾回收活动 |
编译器线程 | 这些线程在运行时将字节码动态编译成本地平台相关的机器码 |
信号分发线程 | 这个线程接收发送到 JVM 的信号并调用适当的 JVM 方法处理 |
运行时数据区
JVM 内存区域主要分为:线程私有区域【程序计数器,虚拟机栈、本地方法栈】、线程共享区【Java 堆,方法区(元空间)】、直接内存。
- 线程私有数据区域生命周期与线程相同,依赖用户线程的启动/结束,而创建/销毁(在 Hotspot VM 内),每个线程都与操作系统的本地线程直接映射,因此这部分内存区域的存与否随着本地线程的生与死对应。
- 线程共享区域随虚拟机的启动/关闭而创建/销毁。
- 直接内存并不是 JVM 运行时数据区的一部分,但也会被频繁的使用:在 JDK1.4引入的
NIO
提供了基于 Channel 与Buffer 的 IO 方式,它可以使用Native
函数库直接分配堆外内存,然后使用DirectByteBuffer
对象作为这块内存的引用进行操作,这样就避免了在 Java 堆和 Native 堆中来回复制数据,因此在一些场景中可以显著提高性能。程序计数器(私有)
一块较小的内存空间,是当前线程所执行的字节码的行号指示器,每条线程都要有一个独立的程序计数器,这类内存也成为了“线程私有”的内存。
正在执行 Java 方法的话,计数器记录的是虚拟机字节码指令的地址(当前指定的地址)。如果还是Native
方法方法,则为空(Undefined)。
这个内存区域是唯一一个在虚拟机中没有规定任何 OOM(OutOfMemoryError) 情况的区域。虚拟机栈(私有)
是描述 Java 方法执行的内存模型,每个方法在执行的同时都会创建一个栈帧(Stack Frame)用于存储局部变量表、操作数栈、动态链接、方法出口等信息。每一个方法从调用直至执行完成的过程,就对应着一个栈帧在虚拟机栈中入栈到出栈的过程。
栈帧(Frame)是用来储存数据和部分过程结果的数据结构,同时也被用来处理动态链接(Dynamic Linking)、方法返回值和异常分派(Dispatch Exception)。栈帧随着方法调用而创建,随方法结束而销毁——无论方法是正常完成还是异常完成(抛出了在方法内未被捕获的异常)都算作方法结束。
“栈”通常就是指这里讲的虚拟机栈,或者更多情况下只是指虚拟机栈中局部变量表部分。
局部变量表存放了编译期可知的各种 Java 虚拟机基本数据类型(8)、对象引用和 returnAddress 类型(指向一条字节码指定地址)。这些数据类型在局部变量表中的存储空间以局部变量槽(Slot)来表示,其中64位 long 和 double 占用两个槽,其余占用一个。局部变量表空间在编译期间完成分配,大小(槽)是完全确定的且运行期间不变,虚拟机真正使用的空间由具体虚拟机来实现。
在《Java 虚拟机规范》中,对这个内存区域规定了两类异常情况:如果线程请求的栈深度大于虚拟机所允许的深度,将抛出 StackOverflowError 异常;如果 Java 虚拟机栈容量可以动态扩展(HotSpot 不行),当栈扩展时无法申请到足够的内存会抛出 OutOfMemoryError 异常。本地方法栈(私有)
本地方法区和虚拟机栈作用类似,区别是虚拟机栈为执行 Java 方法服务,而本地方法栈则为 Native 方法服务,如果一个 VM 实现使用 C-linkage 模型来支持 Native 调用,那么该栈将会是一个 C 栈,但Hotspot VM 直接就把本地方法栈和虚拟机栈合二为一。
与虚拟机栈一样,满足某些条件时,可能会抛出两种异常。堆(Heap 共享)
Java Heap 是虚拟机所管理的内存中最大的一块,也是被所有线程共享的一块内存区域,在虚拟机启动时创建。
Java 中“几乎”所有的对象实例都在堆上分配。在《Java 虚拟机规范》中对 Java 堆的描述是:“所有的对象实例以及数组0都应在堆上分配”。之所以用到了“几乎”是指从实现角度看,随着 Java 语言的发展,尤其是逃逸分析技术的日渐强度大,栈上分配、标量替换优化手段已经导致了一些微妙的变化,也就是说完全堆上分配也不那么绝对了。
Java 堆是垃圾收集器管理的内存区域。现代垃圾收集器大部分都是基于”分代收集理论“设计的,所以 Java 堆中经常会出现“新生代”,“老年代”,“永久代”,“Eden空间”,“From Survivor”,“To Survivor”空间等名词。这些区域划分仅仅是一部分垃圾收集器的共同特征或说设计风格而已,而非某个 Java 虚拟机具体实现的固有布局,因为在 G1收集器之后出现的垃圾收集器,都是基于“分区”来设计。
Java 堆既可以被实现成固定大小也可以是可扩展的,当前主流的 Java 虚拟机都是按照可扩展来实现的(通过参数-Xms
和-Xmx
设定)。如果在 Java 堆中没有内存能完成实例分配,并且堆也无法再扩展时,Java 虚拟机将会抛出 OutOfMemoryError 异常。方法区(共享)
方法区(Method Area)与 Java 堆一样,是各个线程共享的内存区域,它用于存储已经被虚拟机加载的类型信息、常量、静态变量、即时编译器编译后的代码缓存等数据。
在 JDK8 以前叫做“永久代”,当时的 HopSpot 虚拟机设计团队选择使用永久代来实现方法区,这样使得 HopSpot 的垃圾收集器能够像管理 Java 堆一样管理这部分内存,节省专门为方法区编写内存管理代码的工作。但现在看来这并不是一个好主意,因为这种设计导致了 Java 应用更容易遇到内存溢出的问题(永久代有-XX:MaxPermSize
的上限,即使不设置也有默认大小),而且有极少数方法(例如String::intern()
)会因永久代的原因导致不同虚拟机下有不同的表现。在 JDK8完全废弃了“永久代”的概念,改用在本地内存中实现的元空间(Metaspace)来代替。
根据《Java 虚拟机规范》的规定,如果方法区无法满足新的内存分配需求时将抛出 OutOfMemoryError 异常。运行时常量池
运行时常量池(Runtime Constant Pool)是方法区的一部分。Class 文件中除了有类的版本、字段、方法接口等描述信息以外,还有一项是常量池表(Constant Pool Table),用于存放编译期生成的各种字面量与符号引用,这部分内容在类加载后存放到方法区的运行时常量池中。
Java 虚拟机对 Class 文件的每一部分(7自然也包括常量池)的格式都有严格的规定,每一个字节用于存储哪种数据都必须符合规范上的要求,这样才会被虚拟机认可、加载和执行。
一般来说除了保存 Class 文件中描述的符号引用外,还会把由符号引用翻译出来的直接引用也存储在运行时常量池中。相比于 Class 文件常量池的另外一个重要特征是具备动态性,Java 语言并不要求常量一定只有编译期才能产生,也就是说在运行期间也可以将新的常量放入池中,这种特性被开发人员利用得比较多的便是 String 类的intern()
方法。直接内存
直接内存(Direct Memory)并不是虚拟机运行时数据区的一部分,也不是《Java 虚拟机规范》中定义的内存区域。但是这部分内存也被频繁地使用,而且也可能导致 OutOfMemoryError 异常出现。
在 JDK 1.4 中新增了 NIO(New Input/Output)类,引入了一种基于通道(Channel)与缓冲器(Buffer)的 I/O 方式,它可以使用 Native 函数库直接分配堆外内存,然后通过一个存储在 Java 堆里面的 DirectByteBuffer 对象作为这块内存的引用进行操作。这样能在一些场景中显著提高性能,因为避免了在 Java 堆和 Native 堆中来回复制数据。
显然,本机直接内存分配不会受到 Java 堆大小的限制,但是会收到本机总内存(包括物理内存、SWAP 分区或者分页文件)大小以及处理器寻址空间的限制,从而在动态扩展时可能出现 OutOfMemoryError 异常。
JVM运行时内存
Java堆从GC的角度还可以细分为:新生代(Eden区、From Survivor区和To Survivor区)和老年代。
新生代
Eden区
Java新对象的出生地(如果新创建的对象占用内存很大,则直接分配到老年代)。当Eden区内存不足时会出发MinorGC,对新生代区进行一次垃圾回收。
ServivorFrom(s0)
ServivorTo(s1)
MinorGC过程(复制->清空->替换)
MinorGC采用复制算法。
- Eden、s0 复制到 s1,分代年龄+1
首先,把 Eden 和ServivorFrom区域中存活的对象复制到ServivorTo区域(如果有对象的年龄达到了老年的标准,则复制到老年代),同时把这些对象的年龄+1(如果ServivorTo中放不下就放到老年区)
- 清空Eden,ServivorFrom中的对象
- 互换 s0 和 s1 互换,原ServivorTo成为下一次GC时的ServivorFrom区
老年代
主要存放应用程序中生命周期长的内存对象。
老年代的对象比较稳定,所以MajorGC不会频繁执行。在进行MajorGC前一般都先进行了一次MinorGC,使得由新生代的对象进入老年代,导致空间不够用时才触发。当无法找到足够大的连续空间分配给新创建的较大的对象时也会提前触发一次MajorGC进行垃圾回收腾出空间。
MajorGC采用标记清除算法:首先扫描一次所有老年代,标记出存活的对象,然后回收没有标记的对象。MajorGC的耗时比较长,因为要扫描再回收。MajorGC会产生内存碎片,为了减少内存损耗,我们一般需要进行合并或者标记出来下次直接分配。当老年代内存不足时,会抛出OOM(Out of Memory)异常。永久代与元数据区
指内存的永久保存区域,主要存放Class和Meta(元数据)的信息,Class在被加载的时候被放入永久区域,它和存放实例的区域不同,GC不会在主程序运行期间对永久区域进行清理。所以这也导致了永久代的区域会随着加载的Class增多而胀满,最终抛出OOM异常。
元数据区(1.8)
在java8中,永久代已经被移除,被一个称为“元数据区”(元空间)的区域所取代。元空间的本质和永久代类似,元空间与永久代的最大区别在于:元空间并不在虚拟机中,而是使用本地内存。因此,默认情况下,元空间的大小仅受本地内存限制。类的元数据放入Native Memory,字符串池和类的静态变量放入java堆中,这样可以加载多少类的元数据就不再由MaxPermSize
控制,而是由系统的实际可用空间来控制。