Java内存区域与内存溢出异常

659 阅读10分钟

一、概述

Java开发者在开发过程中基本不用关心内存的管理,因为虚拟机自动内存管理机制已帮你打理好一切。开发者只需要将重点放在代码实现业务逻辑上,基本可以将内存的管理交给虚拟机去完成。当然,也正是虚拟机的管理,如果我们不了解Java虚拟机如何管理、使用内存,一旦出现内存方面的问题,那排查错误也就成为一项艰难的工作。真的是让人欢喜让人忧啊!

二、运行时数据区域(Run-Time Data Areas

Java虚拟机定义了在程序执行期间使用的各种运行时数据区域。其中一些数据区域是在Java虚拟机启动时创建的,仅在Java虚拟机退出时销毁;其他数据区域是线程数据区域,线程数据区域随着线程的启动而创建,随着线程的结束而销毁。
根据最新的Java ®虚拟机规范(Java SE 12 Edition内容看,Java虚拟机运行时数据区域分为以下几个区域:

程序计数器(The Program Counter Register)

Java虚拟机可以同时支持许多线程的执行。每个Java虚拟机线程都有自己的 pc寄存器(程序计数器)。在任何时候,每个Java虚拟机线程都在执行单个方法的代码,即该线程的当前方法。如果不是native方法 ,则pc寄存器记录当前正在执行的Java虚拟机指令的地址。如果线程当前正在执行native方法,则Java虚拟机pc 寄存器的值未定义。Java虚拟机程序计数器功能足够强大,可以在特定平台上保存返回地址或指向本机指针。
在虚拟机的概念模型里(不同的虚拟机实现不同),字节码解释器就是通过改变计数器的值来选取下一条需要执行的字节码指令,分支、循环、跳转、异常处理、线程恢复(多线程线程轮流切换)等都是由程序计数器来完成。

Java虚拟机栈(Java Virtual Machine Stacks)

每个Java虚拟机线程都有一个私有Java虚拟机栈,与线程同时创建。Java虚拟机栈类似于传统语言的堆栈,例如C:它保存局部变量和部分结果,并在方法调用和返回中起作用。由于除了推送和弹出帧之外,永远不会直接操作Java虚拟机栈帧,因此可以对堆进行堆分配。Java虚拟机栈的内存不需要是连续的。

帧(Frame)用于存储数据和部分结果,以及执行动态链接,对方法和调度异常返回值。 虚拟机栈是描述的是Java方法执行的内存模型。每次调用方法时都会创建一个新帧。当方法调用完成时,帧将被销毁,无论该完成是正常还是突然中断(它会抛出未捕获的异常)。帧是从创建帧的线程的Java虚拟机栈中分配的。每个帧都有自己的局部变量数组,它自己的操作数堆栈,以及对当前方法类的运行时常量池的引用。

以下异常条件与Java虚拟机栈相关联:

  • 如果线程中的计算需要比允许的更大的Java虚拟机堆栈,则Java虚拟机会抛出一个StackOverflowError
  • 如果可以动态扩展Java虚拟机堆栈,并且尝试进行扩展但可以使内存不足以实现扩展,或者可以使内存不足以为新线程创建初始Java虚拟机堆栈,则Java Virtual机器抛出一个OutOfMemoryError

堆(Heap)

Java虚拟机具有在所有Java虚拟机线程之间共享的堆。堆是运行时数据区,从中分配所有类实例和数组的内存。 堆是在虚拟机启动时创建的。对象的堆存储由自动存储管理系统(称为垃圾收集器)回收 ; 对象永远不会被显式释放。Java虚拟机假设没有特定类型的自动存储管理系统,可以根据实现者的系统要求选择存储管理技术。堆可以具有固定大小,或者可以根据计算的需要进行扩展,并且如果不需要更大的堆,则可以收缩。堆的内存不需要是连续的。
Java虚拟机实现可以为程序员或用户提供对堆的初始大小的控制,以及如果可以动态扩展或收缩堆,则控制最大和最小堆大小。
以下异常情况与堆相关联:

  • 如果计算需要的堆量超过自动存储管理系统可用的堆,则Java虚拟机会抛出一个 OutOfMemoryError

方法区(Method Area)

Java虚拟机具有在所有Java虚拟机线程之间共享的方法区域。方法区域类似于传统语言的编译代码的存储区域或类似于操作系统进程中的“文本”段。它存储虚拟机加载类结构信息,例如运行时常量池,字段和方法数据,以及方法和构造函数的代码,包括类和接口初始化以及实例初始化中使用的特殊方法。
方法区域是在虚拟机启动时创建的。虽然方法区域在逻辑上是的一部分,但是简单的实现可能选择不垃圾收集或压缩它。Java ®虚拟机规范(Java SE 12 Edition)未规定方法区域的位置或用于管理编译代码的策略。方法区域可以是固定大小的,或者可以根据计算的需要进行扩展,并且如果不需要更大的方法区域,则可以缩小方法区域。方法区域的内存不需要是连续的。
Java虚拟机实现可以提供程序员或用户对方法区域的初始大小的控制,以及在变大小方法区域的情况下,控制最大和最小方法区域大小。
以下异常条件与方法区域相关联:

  • 如果方法区域中的内存无法满足分配请求,则Java虚拟机会抛出一个OutOfMemoryError

运行时常量池(Run-Time Constant Pool)

运行时常量池是constant pool table在类或接口的Class文件中的 表示。它包含几种常量,从编译时已知的数字文字到必须在运行时解析的方法和字段引用。运行时常量池提供类似于传统编程语言的符号表的功能,尽管它包含比典型符号表更宽范围的数据。
每个运行时常量池都是从Java虚拟机的方法区域中分配的。当Java虚拟机创建类或接口时,将构造类或接口的运行时常量池。

以下异常条件与类或接口的运行时常量池的构造相关联:

  • 在创建类或接口时,如果运行时常量池的构造需要的内存比Java虚拟机的方法区域中可用的内存多,则Java虚拟机会抛出一个OutOfMemoryError

本地方法栈(Native Method Stacks)

Java虚拟机的实现可以使用常规堆栈(俗称"C堆栈")来支持native方法(用Java编程语言以外的语言编写的方法)。本机方法堆栈也可以通过以诸如C语言的Java虚拟机的指令集的解释器的实现来使用。无法加载native方法并且本身不依赖于传统堆栈的Java虚拟机实现不需要提供本机方法堆栈。如果提供,则通常在创建每个线程时为每个线程分配本机方法堆栈。
Java ®虚拟机规范(Java SE 12 Edition允许本机方法堆栈具有固定大小或根据计算的需要动态扩展和收缩。如果本机方法堆栈具有固定大小,则可以在创建该堆栈时独立地选择每个本机方法堆栈的大小。 Java虚拟机实现可以为程序员或用户提供对本机方法堆栈的初始大小的控制,以及在不同大小的本机方法堆栈的情况下,控制最大和最小方法堆栈大小。

以下异常条件与本机方法堆栈相关联:

  • 如果线程中的计算需要比允许的更大的本机方法堆栈,则Java虚拟机会抛出一个StackOverflowError
  • 如果可以动态扩展本机方法堆栈并尝试进行本机方法堆栈扩展,但可以使内存不足,或者如果没有足够的内存可用于为新线程创建初始本机方法堆栈,则Java虚拟机会抛出OutOfMemoryError

三、HotSpot虚拟机对象探秘

对象在虚拟机中如何创建?如何存储?如何访问适用对象?

对象的创建

对象的创建过程基本流程如上图。
当然具体的细节比较复杂,这里没有展开。因为很小的一个点深入的研究会发现更多特别的东西。

对象的内存布局

对象在内存中的存储布局可以分为3块区域:

对象头

对象头包括以下两部分:
第一部分:用于存储对象自身运行时数据;哈希码、GC分代年龄、锁状态标志、线程持有的锁、偏向线程ID、偏向时间戳等。 这部分的数据的长度在32位虚拟机和64为虚拟机中分别为32bit64bit请记住这里的长度】,官方称为"Mark Word"Mark Word根据对象的状态不同,复用自己的存储空间存储不同的需要信息。
第二部分:类型指针;即对象指向它的类元数据的指针,虚拟机通过该指针确认时那个类的实例对象。
如果是数组,对象头中还需包括记录数组长度的数据。

实例数据

实例数据部分是存储的可用对象的有效信息,程序中所定义的各种类型的字段内容。这部分存储的顺序受虚拟机分配策略参数和Java源码中字段定义顺序的影响。

对齐填充

对齐填充并不是必然会存在的。仅仅是占位符的作用。因为HostSpot虚拟机的自动内存管理系统要求对象起始地址必须是8字节的整数倍,对象头的长度正好是8的整数倍(1或者2倍)。当对象实例数据部分没有对齐时,就需要对齐填充的补全对齐。

对象的访问定位

Java程序需要通过栈上的reference数据(Java虚拟机栈中创建的栈帧中的局部变量数组中保存reference数据)操作堆上的具体对象。

主流的访问对象的方式:

使用句柄访问

使用句柄访问则局部变量数组中存储的是稳定的句柄地址,在对象被移动(垃圾回收时移动对象)时只会改变句柄中的实例数据指针。

直接指针访问

使用直接指针访问方式则访问对象的速度更快,节省了指针定位到对象实例数据的时间,由于Java程序中的类实例非常多,而且对象访问十分频繁,所以节省这部分时间是十分有必要的。Sun HotSpot采用的就是此种方式。
以上内容来自《深入理解Java虚拟机》第2版 周志明(著)学习笔记。由于本人理解水平有限,如有不妥之处,请不吝赐教!