JVM探秘:Java内存区域

本系列笔记目前主要基于《深入理解Java虚拟机:JVM高级特性与最佳实践 第2版》,后续还会加入《实战Java虚拟机:JVM故障诊断与性能优化》、《Java性能权威指南》、《Java性能优化权威指南》的阅读笔记。

概述

Java 虚拟机为程序员分担了很多内存管理的工作,不再像 C/C++ 那样容易出现内存泄漏和内存溢出问题了,也正是这样,导致一旦出现了内存泄漏和溢出方面的问题,就难以排查。因此一个优秀的 Java 程序员应该对 Java 虚拟机有充足的了解,JVM 是你的必修课。

运行时数据区域

根据《Java虚拟机规范(Java SE 7版)》,JVM 所管理的内存区域的划分如下:
image
每个内存区域都有各自的用途,以及创建和销毁时间,有的区域会随着虚拟机的启动而存在,有的区域依赖用户线程而建立和销毁,接下来一次介绍这些内存区域。

程序计数器

程序计数器(Program Counter Register)是线程私有的内存区域,是当前线程所执行的字节码的行号指示器,是一块较小的内存空间。

字节码解释器(java源码编译成字节码,运行时解释成机器码执行)工作时,就是通过改变程序计数器的值,来选取下一条要执行的字节码。分支、循环、跳转等都依赖程序计数器执行。

当线程执行 Java 方法时,程序计数器记录的是正在执行的虚拟机字节码指令地址;当线程执行 Native 方法时,程序计数器的值为空(Undefined)。程序计数器是唯一一个在 Java 虚拟机规范中没有规定 OutOfMemoryError 的内存区域。

Java虚拟机栈

Java 虚拟机栈(Java Stack)也是线程私有的内存区域,它的生命周期与线程相同。虚拟机栈描述的是 Java 方法执行的内存模型:每个方法在执行时都会创建一个栈帧(Stack Frame)用以存储局部变量表、操作数栈、动态链接、方法出口等信息。每一个方法从开始调用到执行完成的过程,就对应一个栈帧在虚拟机栈中从入栈到出栈的过程。

局部变量表存放了编译期可知的各种基本数据类型(boolean byte char short int long double float)、对象引用,以及 returnAddress 类型(指向一条字节码指令的地址)。局部变量表所需内存空间在编译期间完成分配,当进入一个方法时,需要在栈帧中分配多大的局部变量空间是完全确定的,在方法运行期间不会改变局部变量表大小。

在 Java 虚拟机规范中,Java 虚拟机栈有可能会出现两种异常:StackOverflowError 和 OutOfMemoryError。如果线程请求的栈深度大于虚拟机栈的深度,则会 StackOverflowError。如果虚拟机栈动态扩展时申请不到足够的内存,则会 OutOfMemoryError。

本地方法栈

本地方法栈(Native Method Stack)与 Java 虚拟机栈的作用一样,是线程私有的,区别就是 Java 虚拟机栈为执行 Java 方法服务,本地方法栈为 Native 方法服务,虚拟机规范并没有对本地方法栈做强制规定,在 HotSpot 虚拟机中把本地方法栈和虚拟机栈合二为一了。此内存区域也会抛出 StackOverflowError 和 OutOfMemoryError。

Java堆

Java 堆(Java Heap)是一块被所有线程共享的内存区域,同时也是 Java 虚拟机所管理的内存中最大的一块,在虚拟机启动时创建。此内存区域的唯一目的就是存放对象实例,几乎所有的对象实例都在这里分配内存。

Java 堆是垃圾收集器管理堆主要区域,也被称做“GC堆”。现在垃圾收集器基本都采用分代收集算法,所以从内存回收角度看,Java 堆还可以细分为:新生代和老年代;新生代可以再细分为 Eden 空间、From Survivor 空间、To Survivor空间。
如下图所示:
image

从内存分配角度看,线程共享的 Java 堆中可能划分出多个线程私有的分配缓冲区(Thread Local Allocation Buffer,TLAB)。虚拟机为新生对象分配内存时,为每个线程在 Java 堆 中预先分配一小块内存,称做本地线程分配缓冲(TLAB)。哪个线程要分配内存,就在哪个线程的 TLAB 上分配,只有 TLAB 用完并分配新的 TLAB 时,才会同步锁定(为了并发情况下的线程安全)。

Java 堆可以处于物理上不连续的内存空间中,只要逻辑上是连续的即可。如果堆中没有足够内存完成实例分配,并且也无法扩展时,会抛出 OutOfMemoryError。

方法区

方法区(Method Aera)与 Java 堆一样,也是线程共享的内存区域,它用于存储已被虚拟机加载的类信息、常量、静态变量、即时编译器编译后的代码等数据。在虚拟机规范中,它被描述为堆的一个逻辑部分,但实际它应该要与 Java 堆区分开来。

从分代收集的角度看,方法区也被称做永久代(Permanent Generation),实际上两者并不等价,只是因为 HotSpot 虚拟机使用永久代实现了方法区,对于其他虚拟机(JRockit、IBM J9)是不存在永久代概念的。

使用永久代实现方法区,更容易出现内存溢出问题(永久代有 -XX:MaxPermSize 的上限),所以在 JDK1.8 中,HotSpot 就取消了永久代(JEP122),取而代之的是元空间(MetaSpace),元空间是方法区新的实现,而且使用的是本地内存不是虚拟机内存。原先永久代中类的元信息会放入元空间,类的静态变量和常量会放入 Java 堆。

永久代也并不是指真的“永久”存在,只是说这部分内存回收(常量池回收和对类型的卸载)的成绩难以令人满意,条件也非常苛刻。

方法区会有 OutOfMemoryError 异常。

运行时常量池

运行时常量池(Runtime Constant Pool)是方法区的一部分。Class文件中除了有类的版本信息、字段、方法、接口等描述信息外,还有一项信息就是常量池,用于存放编译期间生成的各种字面量和符号引用,这部分内容将在类加载后进入方法区的运行时常量池中,另外翻译出来的直接引用也会存储在这个区域中。

这个区域另外一个特点就是动态性,Java并不要求常量就一定要在编译期间才能产生,运行期间也可以在这个区域放入新的内容,String.intern()方法就是这个特性的应用。此区域有 OutOfMemoryError 异常。

JDK1.6及之前,常量池是位于方法区中的,但在JDK1.7的时候常量池挪到了堆内存,也就是常量池和对象共享 Java 堆,所以在 Java7 以后,常量池就不在方法区分配了,而是在 Java 堆 中分配。

直接内存

直接内存(Direct Memory)并不是虚拟机运行时数据区的一部分,也不是 Java 虚拟机规范中定义的内存区域。但这部分区域被频繁的使用,也可能导致 OutOfMemory 异常出现。

JDK1.4 中加入了 NIO,这是一种基于通道(Channel)和缓冲区的 I/O 方式,它可以使用 Native 函数库直接分配堆外内存,然后通过一个存储在 Java 堆中的 DirectByteBuffer 对象作为这块内存的引用进行操作。这样显著提高了性能,因为避免了在 Java 堆和 Native 堆中来回复制数据。显然,本机直接内存的分配不会受到 Java 堆大小的限制,但是,既然是内存,肯定还是会受到本机总内存(包括RAM、SWAP区)大小以及处理器寻址空间的限制。在配置虚拟机参数时,会根据实际内存配置 -Xmx 等,但经常忽略直接内存,使得各个内存区域总和大于了物理内存,从而导致动态扩展时出现 OutOfMemoryError。