JVM系列-深入理解JVM内存区域

前言

Java 的 .class 文件通过类加载器加载进虚拟机内存里面,由 JVM 虚拟机通过解析执行、或编译执行。JVM 为了方便管理被加载进来的 .class 内容,提出了 Java 虚拟机运行时数据区的概念。Java 虚拟机运行时数据区可以划分为线程私有、线程共享两大类型的数据区,其中线程私有包括程序计数器、虚拟机栈、本地方法栈;而线程共享包括 Java 堆、方法区。

在没有深入理解 JVM 之前,我们常常会把 Java 运行时数据区粗粒度地划分为 "堆"、"栈" 两大部分,"堆" 是用来存放对象地实例,而 "栈" 则是用来存放对象地引用。随着我们对 JVM 地深入学习,我们发现 JVM 对内存的划分远比我们在学习 Java 初级阶段所认知的运行时数据区要复杂。C/C++ 的程序员需要手动释放程序里面不需要再用到的内存空间,而在 Java 里面,虚拟机会自动地帮我们回收不需要用到的资源,这就需要我们深入理解 JVM 运行时的内存划分,有利于我们对程序有更加深刻的认识。

数据区详解

程序计数器

程序计数器(Program Counter Register)是一块比较小的数据区域。因为在 Java 中是支持多线程的,那就意味着每条线程内需要一块内存空间,记录当前线程切换的时候(现场销毁),当前字节码执行的行号数,以便在该线程重新获取到 CPU 执行时间的时候,可以接着上次执行的字节码行数号继续执行(现场恢复)。而程序计数器则是为了记录当前线程的字节码执行的行数号而提出的。

字节码的解析器是根据该计数器的值来选取下一条需要解析执行的字节码指令。Java 代码的循环、跳转、异常处理、线程恢复等都需要依靠该计数器来完成。

如果线程正在执行的是一个 Java 方法,这个计数器记录的是正在执行的虚拟机字节码指令的地址;如果正在执行的是 native 方法,这个计数器的值则为空(Undefined)。程序计数器是 JVM 唯一一个没有规定任何 OOM 情况的区域。

虚拟机栈

虚拟机栈(Java Virtual Machine Stacks)也是线程私有的,它的生命周期与线程相同。虚拟机栈描述的是 Java 方法执行的内存模型:在 Java 中每执行一个方法都会创建一个栈帧,然后系统会把这个栈帧压入虚拟机栈。栈帧还可以划分为局部变量表、操作数栈、动态链接、方法出口(方法返回地址)、以及额外的附加信息。Java 方法的执行就对应着栈帧在虚拟机中入栈和出栈的过程。

  • 局部变量表主要存放的是方法在执行过程中的局部变量。如在方法中定义的八种基本类型、局部的变量等。

  • 操作数栈存放的是操作数。操作数栈是一个栈结构的数据结构,栈元素可以为任意的 Java 数据类型。一个方法刚开始分配栈帧空间执行的时候,操作数栈是空的,当在执行方法内局部变量的运算的时候,操作数栈便会执行进栈/出栈操作。

  • 动态链接的作用主要是支持 Java 语言的多态性(需要类加载、运行时才能确定的方法)、动态性。

  • 方法返回地址返回的是方法结果的返回。如果是正常返回,则调用程序计数器中的地址作为返回;如果是异常返回,则是通过异常处理器表(非栈帧中的)来确定。

同时,Java 虚拟机规范中定义了虚拟机栈有两种异常:

异常 定义
StackOverFlowError 当虚拟机请求的栈深度超过当前栈帧所一开始定义的栈帧深度时抛出
OutOfMemoryError 如果虚拟机可以在运行时动态申请栈内存空间,当 Java 虚拟机无法申请到更多的栈内存的时候抛出

本地方法栈

本地方法栈(Native Method Stack)与虚拟机栈所发挥的作用时比较相似的,只不过虚拟机栈为 Java 的普通方法提供内存空间,而本地方法栈则为 Java 的本地方法提供内存空间。因此,有的虚拟机如 Sun HotSpot 虚拟机就直接地把虚拟机栈和本地方法栈合二为一。

同时,本地方法栈和虚拟机栈一样,也会抛出 StackOverFlowErrorOutOfMemoryError 两种异常。

Java 堆

Java 堆是虚拟机中最大地一块内存空间,同时该内存区域也是每条线程可以共享的。Java 堆是在虚拟机创建的时候创建的,其主要的目的使用来存放对象实例、数组数据。但随着 JIT编译器的发展、逃逸分析技术的发展,使得对象可以在栈中分配。

Java 堆是 GC 垃圾回收的主要区域。从内存回收的角度,因为 Java 堆主要采取分代收集算法,因而 Java 堆可以划分为新生代、老年代,而新生代又可以划分为 Eden空间、From Survivor和 To Survice空间。

从内存共享的角度,Java 堆可以被划分为多个线程共享的分配缓冲区(Thread Local Allocation Buffer,TLAB)。

根据虚拟机规范,Java 堆可以是物理内存上不连续,而在逻辑上连续的。同时,堆在无法完成实力的分配,并且虚拟机无法申请到更多的堆内存的时候,会抛出 OutOfMemoryError 异常。

在虚拟机中,对象实例主要是分配 Java 堆中,对象实例在内存的布局如下:

对象实例的内存布局分为 3 块区域:对象头(Header)、实例数据(Instance)、对齐填充(Padding)。

  • 对象头(Header)在 HotSpot 中包括两部分信息:用于存储对象自身的运行时数据、类型指针。对于第一部分用于存储自身运行时数据如上图;而对于第二部分类型指针,即对象指向它的元数据的指针,虚拟机通过这个指针来确定这个对象它属于哪一个类的实例。但并不是所有的虚拟机都需要在对象头数据上面保留类型指针,因为查找对象的元数据不一定需要通过对象的本身(如反射)。

  • 实例数据部分是对象真正存储的有效信息的,如在 Java 源码中所定义的各种类型的字段内容,包括从父类继承的数据内容。同时在实例数据部分的存储收到虚拟机的分配策略(FieldsAllocationStyle)和字段在 Java 源码中顺序的影响。

  • 对齐填充并不是必然存在对象实例布局中的,其没有特别的含义。因为虚拟机的自动内存管理系统要求对象的大小必须需要 8 字节的整数倍。对象头大小刚好是 8 字节的倍数(1 倍或 2 倍),当实例数据没有对齐的时,就需要对齐填充来补全。

在 Java 堆中存放对象的实例的目的是为了访问使用对象,Java 程序需要通过虚拟机栈上本地变量表上的 reference 数据来操作堆上的具体对象。目前主流的访问方式有使用句柄和直接指针两种。

同时,该 Java 堆可以抛出 OutOfMemoryError 异常。

方法区

方法区(Method Area)与 Java 堆一样,是各个线程共享的内存区域,它用于存储已被虚拟机加载的类信息、常量、静态变量、即时编译器编译的代码等数据,GC 在该区域出现的比较少。同时,该方法区还包含着运行时常量池。

运行时常量池用于存放编译期生成的各种字面量和符号引用,这部分内容将在类加载后进入方法区的运行时常量池中存放。

  • 各种字面量与 Java 语言层面概念相近,包含文本字符串、声明为 final 的常量等;

  • 符号引用包括:类和接口的全限定名称、字段的名称和描述符、方法的名称和描述符;

同时,方法区也可以抛出 OutOfMemoryError 异常。

小结

以上,包含了 JVM 运行时为 Java 的 .class 加载进 JVM 所划分的区域,分别为线程私有和线程共享的区域。当然,这只是深入理解 JVM 的过程的一小步,接下来还需要了解 JVM 对 Java 堆的垃圾标记、以及垃圾收集......

猜你喜欢

转载自juejin.im/post/5e761f74e51d45270f52e22d