← 深入理解 Java 虚拟机读书笔记

[2] Java 内存结构


1. 运行时数据区域

Java 虚拟机(Java Virtual Machine,JVM)进程在启动时会向操作系统申请一大块堆内存空间,作为自己运行时的数据存储区域。JVM 在运行 Java 程序的过程中又会把它所管理的内存空间划分为若干个不同的数据区域(在称呼这些数据区域时常常有人以 Java 开头,用于区分操作系统中的同名内存区域区,如 Java 栈、Java 堆等)。
JVM 内存布局规定了这些数据区域的申请、分配和管理的策略,保证 JVM 稳定的、高效的运行。不同的 JVM 实现对于内存的划分和管理机制也存在部分差异(对于 HotSpot VM 中的内存划分差异,主要是指方法区)。
Java虚拟机运行时数据区
这些区域都有各自的生命周期和用途,有的随着 JVM 进程的启动而一直存在,有的则是依赖 Java 程序中用户线程的启动和结束而建立和销毁:
  • 每个线程:拥有各自独立的程序计数器、虚拟机栈和本地方法栈。
  • 线程间共享:堆、堆外内存(方法区、永久代或元空间、代码缓存)。
JVM 内存与操作系统内存之间的关系:
  • 操作系统划分堆和栈,栈由操作系统管理,会由操作系统自动回收,堆由用户程序进行分配使用。
  • JVM 内存使用的是操作系统的堆,以防 JVM 分配的内存被操作系统回收。
  • JVM 本地方法栈指的是操作系统的栈

1.1 程序计数器

程序计数器(Program Counter Register,PC Register,PC 寄存器,PC 计数器)虽然名称源于 CPU 中的 PC 寄存器,在功能和作用上也相似,但它们并不是同一个东西。操作系统的 PC 寄存器,是与内存条一样的、位于计算机上的存储硬件,只不过 PC 寄存器位于 CPU 内,而内存是外挂在 CPU 的数据总线上。JVM 中的 PC 寄存器位于由 JVM 向操作系统申请的堆中,是对物理 PC 寄存器的一种抽象模拟。实际上在 JVM 概念模型中,还抽象了许多计算机系统的底层思想,例如 JVM 也有自己的堆、自己的栈、自己的 PC 寄存器等等。
💡
JVM 概念模型:是指根据《Java虚拟机规范》实现的所有 JVM 的统一「外观」,但各款具体的 JVM 实现并不一定要完全按照规范中的概念模型来设计,可能部分实现会通过更高效率的等价方式去代替它。
《Java虚拟机规范》中有说明,每个线程都有着它自己的程序计数器,并且是线程私有的,每个线程中的程序计数器是互不影响、独立存储的。显然程序计数器的生命周期与当前线程的生命周期保持一致:每个线程在启动时,就有一个与之对应的程序计数器被 JVM 建立,并在线程结束时销毁。
程序计数器相较于其他的运行时数据存储区域只是一块很小的内存空间,小到几乎可以忽略不计的程度,并且也是运行速度最快的存储区域。它是用于记录正在执行的虚拟机字节码指令的地址(可以看作是正在执行的指令代码),执行引擎就能读取程序计数器的值来决定执行何种指令。
前提是该线程正在执行的是 Java 方法。如果正在执行的是本地(Native)方法,则这个程序计数器的值为空(Undefined)。因为程序计数器只记录字节码指令地址,本地方法一般由 C/C++ 或其他语言实现,不存在字节码指令。
程序计数器是唯一一个在《Java虚拟机规范》中没有规定任何 OutOfMemoryError 情况的区域。
来源:https://www.cnblogs.com/bwangblog/p/13653927.html
多线程宏观上是同时发生的并行事件,但实际在某个确切的时间点上其实只有一条线程在工作,通过快速交替执行各个线程来达到并行的效果,程序计数器的一大作用就是用来恢复当前线程的「工作现场」,告诉执行引擎要接着从哪里执行。 
如此看来程序计数器就像当前线程所执行的字节码的「行号指示器」,在 JVM 概念模型里,字节码解释器工作时就是通过改变这个程序计数器中的值来选取下一条要执行的字节码指令,它就像是程序控制流的指示器,分支、循环、跳转、异常处理、线程恢复等基础功能都需要依赖程序计数器完成。

1.2 Java 虚拟机栈

Java 虚拟机栈(Java Virtual Machine Stack,虚拟机栈,Java 栈)与 JVM 堆的核心区别在于:栈是运行时单位,堆是存储单位。栈用于解决程序的运行时的数据处理,堆解决的是数据的存储问题。一般来说,对象主要都是存储在堆中的,堆空间是运行时数据区域中比较大的一块空间。虚拟机栈主要用于存放基本数据类型的局部变量及引用数据类型的对象引用等。
虚拟机栈与程序计数器、本地方法栈一样也是线程私有的,它的生命周期也与当前线程的生命周期相同。虚拟机栈描述的是 Java 方法执行的线程内存模型:每个方法被执行时,JVM 都会同步创建一个栈帧(Stack Frame),用于存储与该方法相关的一些信息,栈帧是虚拟机栈中的基本单位,对应一次次的 Java 方法调用。每一个方法被调用直到执行结束的过程,就对应着一个栈帧在虚拟机栈中入栈和出栈的过程。
栈帧用于存储:
  • 局部变量表
  • 操作数栈
  • 动态链接
  • 方法出口信息
  • ……
JVM 运行时栈帧结构
虚拟机栈中可能出现的异常
《Java虚拟机规范》允许虚拟机栈的大小是动态的或者是固定不变的。如果在进行递归调用等操作,当递归层次逐渐加深,超过了虚拟机栈的最大内存后,就会发生栈溢出的情况。
  • 如果采用固定大小的 Java 虚拟机栈,那每一个线程的虚拟机栈容量可以在线程创建的时候独立选定。如果线程请求分配的栈容量超过虚拟机栈允许的最大容量,JVM 将会抛出一个 StackOverFlowError 异常。
  • 如果 Java 虚拟机栈容量可以动态扩展,并且在尝试扩展的时候无法申请到足够的内存,或者在创建新的线程时没有足够的内存去创建对应的虚拟机栈,那 JVM 将会抛出一个 OutOfMemoryError 异常。
书中注解:HotSpot 虚拟机的栈容量是不可以动态扩展的,以前的 Classic 虚拟机倒是可以。 所以在 HotSpot 虚拟机上是不会由于虚拟机栈无法扩展而导致 Dutofmemoryerror 异常——只要线程申请栈空间成功了就不会有 OOM,但是如果申请时就失败,仍 然是会出现 OOM 异常的,后面的实战中笔者也演示了这种情况。本书第 2 版时这里的描述是有误的,请阅读过第 2 版的读者特别注意。

设置虚拟机栈的内存大小
可以使用参数 -Xss 选项来设置线程的最大栈空间,栈的大小直接决定了函数调用的最大可达度。 分配的栈内存也并不是越大越好,会挤占其他线程的空间。
IDEA 设置方法:Run → EditConfigurations-VM → options,填入指定栈的大小 -Xss256k

1.3 本地方法栈

本地方法栈(Native Method Stacks)和虚拟机栈十分相似,其区别只是虚拟机栈为虚拟机执行Java 方法(也就是字节码)服务,而本地方法栈则是为虚拟机使用到的本地方法服务。
《Java虚拟机规范》里对本地方法栈中方法使用的程序语言、使用方式和数据结构均没有任何强制的规定,因此虚拟机可以根据需要自由实现它。所以并不是所有的虚拟机都支持本地方法,如果虚拟机产品不打算支持本地方法,也可以无需实现本地方法栈,甚至在 HotSpot VM 的实现中,直接将本地方法栈与虚拟机栈合二为一。
本地方法栈的抛出异常机制也与虚拟机栈一致。

1.4 Java 堆

Java 堆(Java Heap)是虚拟机所管理的内存中最大的、被所有线程共享的一块内存区域。Java 堆是在 JVM 启动时创建的。此区域的唯一目的就是存放对象实例,Java 世界里「几乎」所有的对象实例都在这里分配内存。
Java 对象永远不会显示释放,而是由垃圾收集器(Garbage Collector)统一收集,所以 Java 堆是垃圾收集器管理的内存区域,因此一些资料中它也被称作 GC 堆(Garbage Collected Heap)。现代垃圾收集器大部分都是基于经典分代收集理论新生代(Young Generation)老年代(Old Generation)永久代(Permanent Generation)设计的,但《Java虚拟机规范》中并没有规定特定类型的垃圾收集器,譬如 HotSpot VM 中也有不采用分代设计的垃圾收集器(如 G1 垃圾收集器)。
经典分代设计,来源:https://www.cnblogs.com/cuijj/p/10499621.html
JVM垃圾回收并不会涉及到程序计数器、本地方法栈和虚拟机栈
《Java虚拟机规范》中规定 Java 堆可以处于物理上不连续的内存空间中,但在逻辑上它应该被视为连续的。Java 堆既可以被实现成固定大小的,也可以是可扩展的,不过当前主流的 JVM 都是按照可扩展来实现的(通过参数 -Xmx-Xms 设定)。
当 Java 堆中没有足够的内存可以完成实例的分配,并且堆也无法再扩展时,JVM 将会抛出 OutOfMemoryError 异常。

1.5 方法区

方法区(Method Area)是 Java 堆的一个逻辑部分,也是线程共享的区域,用于存放以下信息:
  • 已经被虚拟机加载的类信息
  • 常量
  • 静态变量
  • 即时编译器编译后的代码

永久代
基于经典分代设计的 HotSpot VM 在 JDK 8 之前采用永久代来实现方法区,目的是将收集器的分代设计扩展至方法区,使得 GC 能像管理 Java 堆一样管理方法区的内存空间,省去专门为方法区编写内存管理代码的工作。
但 HotSpot VM 在 JDK 8 之后就废弃了永久代,原因:
  • 永久代默认有内存上限:在达到 -XX:MaxPermSize 永久代的内存上限后会抛出 java.lang.OutOfMemoryError: PermGen space 异常,而字符串常量池和静态变量存储在永久代中又容易出现内存溢出和性能问题。
  • 类及方法的信息大小难以确定:对于永久代的大小指定比较困难,太小容易出现永久代溢出,太大则容易导致老年代溢出。
  • 永久代回收率偏低且额外增加了 GC 的复杂度:《Java虚拟机规范》中允许在方法区内不实现垃圾收集。GC 行为在这个区域的确是比较少出现的,但并非数据进入了方法区就如永久代的名字一样「永久」存在了。这区域的内存回收目标主要是针对常量池的回收和对类型的卸载,一般来说这个区域的回收效果比较难令人满意,尤其是类型的卸载,条件相当苛刻,但是这部分区域的回收有时又确实是必要的。
  • 为了 HotSpot VM 更好的明天:Oracle 收购 BEA 获得了 JRockit VM 的所有权后,准备把 JRockit VM 中的优秀功能移植到 HotSpot VM。

元空间
HotSpot VM 在 JDK 7 时已经把原本放在永久代中的字符串常量池、静态变量等移出,到了 JDK 8 后也终于完全废弃了永久代,取而代之的是与 JRockit VM、J9 VM 等虚拟机一样并不在 JVM 内存中,而是在本地内存(Native Memory)中实现的元空间(Meta-space)。
HotSpot VM JDK 8
元空间使用的是本地内存(Native Memory),它的大小可以大到处理器寻址空间的上限(例如 32 位系统中的 4 GB 限制),可以通过 -XX:MetaspaceSize 来指定元空间的初始大小,达到该值就会触发 GC 进行类型卸载,同时 GC 会对该值进行调整:如果释放了大量的空间,就适当降低该值;如果释放了很少的空间,那么在不超过 -XX:MaxMetaspaceSize 时,适当提高该值。还可以设置在 GC 之后,最小的:-XX:MinMetaspaceFreeRatio 或最大的:-XX:MaxMetaspaceFreeRatio 元空间剩余容量的百分比,减少为分配或释放空间所导致的 GC。

如果方法区无法满足新的内存分配需求时,将抛出 OutOfMemoryError 异常。

1.6 运行时常量池

方法区中存放:类信息、常量、静态变量、即时编译器编译后的代码。常量就存放在运行时常量池中。
当类被 JVM 加载后,.class 文件中的常量就存放在方法区的运行时常量池中。而且在运行期间,可以向常量池中添加新的常量。如 String 类的 intern() 方法就能在运行期间向常量池中添加字符串常量。
当常量池无法再申请到内存时会抛出 OutOfMemoryError 异常。

1.7 直接内存

直接内存是除 JVM 之外的内存,也不是《Java虚拟机规范》中定义的内存区域,但也可能被 Java 使用。譬如在 JDK 1.4 中新加入的 NIO(New Input/Output)类中引入了一种基于通道和缓冲的 I/O 方式。它可以通过调用本地方法直接分配 JVM 之外的内存,然后通过一个存储在堆中的 DirectByteBuffer 对象直接操作该内存,而无须先将外部内存中的数据复制到堆中再进行操作,从而提高了数据操作的效率。

直接内存与堆内存比较
  • 直接内存申请空间耗费更高的性能
  • 直接内存读取 I/O 的性能要优于普通的堆内存。
  • 直接内存作用链:本地 I/O → 直接内存 → 本地 I/O
  • 堆内存作用链:本地 I/O → 直接内存 → 非直接内存 → 直接内存 → 本地 I/O

本机直接内存的大小不受 JVM 控制,分配回收成本较高。但既然是内存,还是会受到本机总内存(包括物理内存、 SWAP 分区或者分页文件)大小以及处理器寻址空间的限制,一般服务器管理员配置虚拟机参数时,会根据实际内存去设置 -Xmx 等参数信息,但经常忽略掉直接内存,使得各个内存区域总和大于物理内存限制(包括物理的和操作系统级的限制),从而导致动态扩展时出现 OutOfMemoryError 异常。

Nobelium is built with ♥ and ⚛ Next.js. Proudly deployed on ▲Vercel.

© Ashinch 2021 桂ICP备18011166号-1