第二章 Java内存区域与内存溢出异常 | part 1

74 阅读7分钟

概述

对于 Java 程序员来说,在虚拟机自动内存管理机制的帮助下,不再需要为每一个 new 操作去写配对的 delete/free 代码,不容易出现内存泄漏和内存溢出的问题,但一旦出现这方面的问题,如果不了解虚拟机是怎样使用内存的,那排查错误将变得异常艰难。

运行时数据区域

Java 虚拟机在执行 Java 程序的过程中会把它所管理的内存划分为若干个不同的数据区域。这些区域都有各自的用途,以及创建和销毁的时间,有的区域随着虚拟机进程的启动而存在,有些区域则依赖用户线程的启动和结束而建立和销毁。运行时数据区如下图所示,其中橙色区域为所有线程共享的数据区,黄色区域为线程隔离数据区。

image.png

程序计数器

程序计数器 PC 是一块较小的内存空间,可以看作是当前线程所执行的字节码的“行号指示器”。字节码解释器工作时,就是通过改变 PC 的值来选取喜爱条需要执行的字节码指令。分支、循环、跳转、异常处理等基础功能都依赖 PC 完成。

由于 Java 虚拟机的多线程是通过线程轮流切换、分配处理器执行时间的方式来实现的,因此每个线程都需要有一个独立的 PC,各线程间互不影响,即“线程私有”。

如果线程正在执行的是一个 Java 方法,PC 记录的是正在执行的虚拟机字节码指令的地址;如果正在执行的是 Native 方法,PC 为空(Undefined)。此内存区域是唯一一个在 Java 虚拟机规范中没有规定任何 OutOfMemoryError 情况的区域。

Java 虚拟机栈

Java 虚拟机栈也是线程私有的,生命周期与线程相同。每个 Java 方法被执行的时候,Java 虚拟机都会同步创建一个栈帧,用于存储局部变量表、操作数栈、动态链接、方法出口等信息。方法被调用到执行完毕的过程,就是一个栈帧在虚拟机栈从入栈到出栈的过程。

局部变量表存放了编译期可知的各种基本数据类型、对象引用(指向对象起始地址的指针或指向代表对象的句柄)和 returnAddress 类型(指向了一条字节码指令的地址)。这些数据类型在局部变量表中的存储空间以局部变量槽 slot来表示。slot 是虚拟机为局部变量分配内存所使用的最小单位,一个 slot 的空间大小为四字节,因此 64 位的 long 和 double 占用两个槽,其余类型占用一个槽。局部变量表所需的内存空间在编译期间完全分配,当进入一个方法时,这个方法需要在栈帧中分配多大的局部变量空间(即变量槽的数量)是完全确定的。

对于 Java 虚拟机栈定义了两种异常状况:如果线程请求的栈深度大于虚拟机所允许的深度,将抛出 StackOverflowError 异常;如果虚拟机栈容量可以动态扩展,当扩展时无法申请到足够的内存会抛出 OutOfMemoryError 异常

Java程序从源文件创建到程序运行要经过两大步骤:

  • 编译期:也叫前期,即源文件由编译器编译成字节码(ByteCode)
  • 运行期:也叫后期,即字节码由java虚拟机解释运行。

本地方法栈

本地方法栈和虚拟机栈所发挥的作用是非常相似的,它们之间的区别只是虚拟机栈为虚拟机执行 Java 方法(也就是字节码)服务,而本地方法栈则为虚拟机使用到的 Native 方法服务。

有的 Java 虚拟机(如 HopSpot)直接把本地方法栈和虚拟机栈合二为一。与虚拟机栈一样,本地方法栈也是线程私有的,且会抛出 StackOverflowError 异常和 OutOfMemoryError 异常

Java 堆

Java 堆是虚拟机所管理的内存中最大的一块,被所有线程共享,在虚拟机启动时创建。

Java 堆是垃圾收集器管理的内存区域,Java 垃圾回收机制将在之后的章节详细解析。

当前主流的 Java 虚拟机都可以对堆区域进行拓展(通过参数 -Xmx-Xms 设定),如果在堆中没有内存完成实例分配,并且堆也无法再扩展时,将会抛出 OutOfMemoryError 异常

方法区

方法区与 Java 堆一样,是各个线程共享的内存区域,它用于存储已被虚拟机加载的类信息、常量、静态变量、即时编译器编译后的代码等数据。

在 JDK8 以前,由于 HotSpot 设计团队把收集器的分代设计拓展至方法区,因此很多人称呼方法区被称为“永久代”,但并非所有虚拟机都会这样实现。事实上,用永久代实现方法区不是一个好主意,因为这会更容易导致内存溢出问题。因此到了 JDK7 的 HotSpot,已经把原本放在永久代的字符串常量池、静态变量等溢出;而到了 JDK8,完全废弃了永久代的概念,改用本地内存中的“元空间”来代替

当方法区无法满足内存分配需求时,将抛出 OutOfMemoryError 异常

运行时常量池

运行时常量池是方法区的一部分。Class 文件中除了有类的版本、字段、方法、接口等描述信息外,还有一项信息是常量池,用于存放编译期生成的各种字面量和符号引用(如字段、方法的名称和描述符等),这部分内容将在类加载后进入方法区的运行时常量池中存放。每个类都有一个运行时常量池。

运行时常量池相对于 Class 文件常量池的另外一个重要特征是具备动态性,并非预置入 Class 文件中常量池的内容才能进入方法区运行时常量池,运行期间也可能将新的常量放入池中,这种特性被开发人员利用得比较多的便是 String 类的 intern() 方法。

既然运行时常量池是方法区的一部分,自然受到方法区内存的限制,当常量池无法再申请到内存时会抛出 OutOfMemoryError 异常

Java 虚拟机对于 Class 文件的每一部分格式有严格的要求,每一个字节用于存储哪种数据都必须符合规范上的要求才会被虚拟机认可、加载和执行。而对于运行时常量池,没有做任何细节要求。但一般来说,除了保存 Class文件中描述的符号引用外,还会把由符号引用翻译得到的直接引用也存储在运行时常量池中。

直接内存

直接内存不是虚拟机运行时数据区的一部分,但也被频繁使用,且可能导致 OOM 的出现。

在 BIO 模式下,如果程序需要读取硬盘或者远程数据,会经过3次复制的过程:磁盘->内核空间->堆外内存->堆空间,其中堆外内存和堆空间都属于用户态。在 JDK1.4 中引入了 NIO 类,开辟了直接内存的方式,它可以使用 Native 函数库直接分配堆外内存,然后通过一个存储在 Java 对里面的 DirectByteBuffer 对象作为这块内存的引用进行操作。这样能够避免在 Java 堆和 Native 堆中来回复制数据,显著提高性能。

为什么 BIO 需要将数据拷贝到堆外内存,再拷贝到堆内,而不直接拷贝到堆内?因为堆内存是受 GC 管理的,由于拷贝需要提供对应的目的地址,而拷贝中可能会出现堆内存中有垃圾被清理回收而导致的内存地址发生变化,可能造成目的地址也发生变化,所以需要先拷贝到堆外,再拷贝至堆内。

DirectByteBuffer 类中的 unsafe.allocateMemory(size) 是一个 Native 方法,使用 C 中的 malloc() 函数来分配系统本地内存,且分配完后会将堆外内存基地址赋给 address 属性。


到这里就完成了第二章的 part1 ——运行时数据区域部分啦,下一篇内容将是关于对象的创建和内存布局等,敬请期待!