[JVM][2][Java内存区域与内存溢出异常]

第 2 章 Java内存区域与内存溢出异常

2.1 概述

对于CC++程序员来说,他们既拥有每个对象的“所有权”,有担负着每个对象生命从开始到终结的维护责任。然而对于Java程序员来说,在虚拟机自动内存管理机制的帮助下,不再需要为每个new操作去写配对的delete/free代码,不容易出现内存泄漏和内存溢出问题

2.2 运行时数据区域

Java虚拟机在执行Java程序的过程中会把它所管理的内存划分为若干个不同的数据区域,这些区域有各自的用途,以及创建和销毁的时间,有的区域随着虚拟机进程的启动而一直存在,有的区域则是依赖用户线程的启动和结束而建立和销毁

2.2.1 程序计数器

程序计数器(Program Counter Register)是一块较小的内存空间,它可以看作是当前线程所执行的字节码的行号指示器。同时它可以控制程序流,分支、循环、跳转、异常处理、线程恢复等基础功能都需要依赖这个计数器来完成

在任何一个确定的时刻,一个处理器都只会执行一条线程中的指令。为了线程切换后能恢复到正确的执行位置,每条线程都需要有一个独立的程序计数器,各条线程计数器互不影响,独立存储

如果一个线程正在执行的是一个Java方法,这个计数器记录的是正在执行的虚拟机字节码指令的地址;如果正在执行的是本地方法,这个计数器值则应为空

2.2.2 Java虚拟机栈

Java虚拟机栈(Java Virtual Machine Stack)是线程私有的,其生命周期与线程相同

虚拟机栈描述的是Java方法执行的线程内存模型:每个方法被执行的时候,Java虚拟机都会同步创建一个栈帧(Stack Frame)用来存储局部变量表、操作数栈、动态连接、方法出口等信息。每个方法被调用直至执行完毕的过程,就对应着一个栈帧在虚拟机栈中从入栈到出栈的过程

局部变量表存放了编译期可知的各种Java虚拟机基本数据类型、对象引用和returnAddress类型(指向了一条字节码指令的地址)。这些数据类型在局部变量表中的存储空间以局部变量槽(Slot)来表示,其中64位长度的longdouble类型的数据会占用两个变量槽,其余的数据类型只占用一个。局部变量表所需的内存空间在编译期间完成分配,在方法运行期间不会改变局部变量表的大小。

2.2.3 本地方法栈

与虚拟机栈类似,本地方法栈主要是为虚拟机使用到的本地(Native)方法服务

《Java虚拟机规范》对本地方法栈中方法使用的语言、使用方式与数据结构并没有任何强制规定,因此具体的虚拟机可以根据需要自由实现它,甚至有的Java虚拟机直接就把本地方法栈和虚拟机栈合二为一

2.2.4 Java堆

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

Java堆是垃圾收集器管理的内存区域,因此有的资料将其称为GC堆。现代垃圾收集器大部分都是基于分代理论设计的,因此Java堆中经常会出现“新生代”、“老年代”、“永久代”等名词

Java堆可以处于物理上不连续的内存空间,但在逻辑上它应该被视为连续的

Java堆既可以被实现为固定大小的、也可以是可扩展的,不过当前主流的Java虚拟机都是按照可扩展来实现的。如果在Java堆中没有内存完成实例分配,并且堆也无法再扩展时,Java虚拟机将抛出OutOfMemoryError异常

2.2.5 方法区

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

很多人更愿意把方法区称呼为永久代,本质上两者并不是等价的,仅仅是当时的HotSpot虚拟机设计团队选择使用永久代来实现方法区而已,这样使得HotSpot的垃圾收集器能够像管理Java堆一样管理这部分内存,省去专门为方法区编写内存管理代码的工作。但是这样的设计导致了Java应用更容易遇到内存溢出的问题。所以到了JDK8完全放弃了永久代的概念,改用与JRockitJ9一样在本地内存中实现的元空间来代替。

一般而言,垃圾回收行为很少在这个区域发生,这区域的内存回收目标主要是针对常量池的回收和对类型的卸载

2.2.6 运行时常量池

运行时常量池是方法区的一部分,主要用来存放编译期生成的各种字面量和符号引用

2.2.7 直接内存

直接内存(direct memory)并不是虚拟机运行时数据区的一部分。在JDK1.4中,引入了一种基于通道(channel)和缓冲区(buffer)的I/O方式,它可以使用Native函数直接分配堆外内存,避免了在Java堆和Native堆中来回复制数据,这样可以显著提升某些场景下的性能。

2.3 HotSpot虚拟机对象探秘

2.3.1 对象的创建

Java虚拟机遇到一条字节码new指令时,首先会检查这个指令的参数是否能在常量池中定位一个类的符号引用,并且检查这个符号引用代表的类是否已被加载、解析和初始化过。如果没有,那必须先执行相应的类加载过程

接着虚拟机将为新生对象分配内存。对象所需内存的大小在类加载完成后便可完全确定,因此要把一块确定大小的内存块从Java堆中划分出来作为对象所需内存。常见的分配方式有:指针碰撞(Bump The Pointer)和空闲列表(Free List)

此外我们还要考虑并发问题,因为Java堆是线程共享的。有两种解决方式:一种是堆分配内存空间的动作进行同步处理;另一种是把内存分配的动作按照线程划分在不同的区域之中进行,即每个线程在Java堆中预先分配一小块内存,称为本地线程分配缓冲(Thread Local Allocation Buffer, TLAB);只有当这个线程的本地缓冲区用完了,分配新的缓存区时才需要同步锁定

内存分配完成后,虚拟机必须将分配到的内存空间都初始化为零值

再接下来,Java虚拟机要对对象进行必要的设置,例如这个对象是哪个类的实例、如何才能找到类的元数据信息、对象的哈希码、对象的GC分代年龄等信息。这些信息存放在对象的对象头(Object Header)中

通过前面的步骤,new指令已经执行完毕,但是构造函数,即Class文件中的<init>()方法还没执行,接着执行这个方法,按照程序员的意愿对对象进行初始化,一个真正可用的对象才算完全被构造出来

2.3.2 对象的内存布局

对象在堆内存中的存储布局可以划分为三个部分:对象头(Header)、实例数据(InstanceData)和对齐填充(Padding)

对象头部分包括两类信息

  1. 第一类是用于存储对象自身的运行时数据,如哈希码(HashCode)、GC分代年龄、锁状态标识、线程持有的锁、偏向线程ID、偏向时间戳等,这一部分被称为Mark Word
  2. 第二类是类型指针,即对象指向它的类型元数据的指针,Java虚拟机通过这个指针来确定该对象是哪个类的实例。此外,如果对象是一个Java数组,那在对象头中还必须有一块用于记录数组长度的数据

实例数据是对象真正存储的有效信息,即我们在程序代码中所定义的各种类型的字段内容。这部分的存储数据收到虚拟机分配策略的影响。HotSpot中默认的分配次序是longs/doublesintsshortscharsbytes/booleansoops,相同宽度的字段总是被分配到一起存放。此外,在父类中定义的变量会出现在子类之前

对齐填充,仅仅起到占位符的作用。如果对象实例数据部分没有8字节的整数倍,就需要通过对齐填充来补全

2.3.3 对象的访问定位

Java程序会通过栈上的reference数据来操作堆上的具体对象。虚拟机中的对象访问方式主要有使用句柄和直接指针两种

  • 如果采用句柄访问的话,Java堆中将可能划分出一块内存来作为句柄池,reference中存储的是对象的句柄地址,而句柄中包含了对象实例数据与类型数据各自具体的地址信息。这种方式的优点是reference中存储的是稳定句柄地址,在对象被移动时只会改变句柄中的实例数据指针
  • 如果使用直接指针访问,可以直接访问到对象的实例数据,但是若要访问对象类型数据,需要通过实例数据中的间接引用,这样在直接访问对象时减少了一次开销,这也是HotSpot的对象访问方式