JVM面试题详解系列——JVM内存区域详解

206 阅读9分钟

重点感谢

从这篇文章开始,我会一直更新这个系列,核心就是面试常用的Java八股系列,我最近在准备面试,阅读了很多资料,这个总结系列要感谢很多人,我觉得非常有必要在这个系列最开始,先感谢这些技术前辈给我带来的帮助。包括JavaGuideJava-Interview和程序员囧辉的文章面试必问的 JVM 运行时数据区,你懂了吗?。 当然还有很多其他的文章,就不一一列举了,总之感谢这些技术前辈对我的帮助。 当然算法系列我也会更下去,我也一直在学习算法,但是最近太忙了,可能会更的晚一些。我希望我还是能够坚持把Java八股系列和算法系列更完,当然,这有可能是我的幻想。

运行时数据区域

Java 虚拟机在执行 Java 程序的过程中会把它管理的内存划分成若干个不同的数据区域。JDK 1.8 和之前的版本会有一些差异,下面会详细介绍。

JDK 1.8 之前:

截图20221022094216.png JDK 1.8 之后:

截图20221021090726.png

线程私有的:

  • 程序计数器
  • 虚拟机栈
  • 本地方法栈

线程共享的:

  • 方法区
  • 直接内存 (非运行时数据区的一部分)

程序计数器

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

另外,为了线程切换后能恢复到正确的执行位置,每条线程都需要有一个独立的程序计数器,各线程之间计数器互不影响,独立存储,我们称这类内存区域为“线程私有”的内存。

值得一提的是程序计数器是唯一一个不会出现OutOfMemoryError的内存区域,它的生命周期随着线程的创建而创建,随着线程的结束而死亡。

程序计数器主要作用:

  • 程序计数器可以看作当前线程所执行的字节码的行号指示器,字节码解释器通过改变程序计数器的值来依次读取指令,从而实现对代码的流程控制,如顺序执行、选择、循环、异常处理等。
  • 在多线程场景下,程序计数器还用于记录当前线程执行的位置,从而当线程切换回来时能知道线程上一次运行到哪里了。

Java虚拟机栈

Java虚拟机栈是线程私有的,每个方法执行的时候都会创建一个栈帧,用于存储局部变量表、操作数栈、动态链接和方法返回地址等信息。当线程请求的栈深度超过了虚拟机允许的最大深度时,就会抛出StackOverFlowError。它的生命周期和线程的相同,随着线程的创建而创建,随着线程的结束而死亡。

Java虚拟机栈是 JVM 运行时数据区域的一个核心,除了一些 Native 方法调用是通过本地方法栈实现的(后面会提到),其他所有的 Java 方法调用都是通过虚拟机栈来实现的(也需要和其他运行时数据区域比如程序计数器配合)。

方法调用的数据需要通过栈进行传递,每一次方法调用都会有一个对应的栈帧被压入栈中,每一个方法调用结束后,都会有一个栈帧被弹出。Java 方法有两种返回方式,一种是 return 语句正常返回,一种是抛出异常。不管哪种返回方式,都会导致栈帧被弹出。也就是说, 栈帧随着方法调用而创建,随着方法结束而销毁。无论方法正常完成还是异常完成都算作方法结束。

Java虚拟机栈由一个个栈帧组成,而每个栈帧中都拥有:局部变量表、操作数栈、动态链接、方法返回地址。和数据结构上的栈类似,两者都是后进先出的数据结构,只支持出栈和入栈两种操作。

Java程序运行过程中栈可能会出现两种错误:

  • 如果栈的内存大小不允许动态扩展,那么当线程请求栈的深度超过当前 Java 虚拟机栈允许的最大深度的时候,就抛出 StackOverFlowError 错误。
  • 如果栈的内存大小允许动态扩展, 如果虚拟机在动态扩展栈时无法申请到足够的内存空间,则抛出OutOfMemoryError错误。

局部变量表

主要存放了编译期可知的各种数据类型(boolean、char、byte、short、int、long、float、double)、对象引用(reference 类型,它不同于对象本身,可能是一个指向对象起始地址的引用指针,也可能是指向一个代表对象的句柄或其他与此对象相关的位置)。

操作数栈

主要作为方法调用的中转站使用,用于存放方法执行过程中产生的中间计算结果和计算过程中产生的临时变量。

动态链接

主要服务于一个方法需要调用其他方法的场景。在Java源文件被编译成字节码文件时,所有的变量和方法引用都作为符号引用(Symbilic Reference)保存在class文件常量池中,当一个方法需要调用其他方法时,需要将常量池中指向方法的符号引用转换为其在内存地址中的直接引用,动态链接的作用就是为了将符号引用转换为调用方法的直接引用。

本地方法栈

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

本地方法被执行的时候,在本地方法栈也会创建一个栈帧,用于存放该本地方法的局部变量表、操作数栈、动态链接、出口信息。

方法执行完毕后相应的栈帧也会出栈并释放内存空间,也会出现 StackOverFlowError 和 OutOfMemoryError 两种错误。

Java 虚拟机所管理的内存中最大的一块,Java 堆是所有线程共享的一块内存区域,在虚拟机启动时创建,几乎所有的对象实例以及数组都在这里分配内存。 Java 堆是垃圾收集器管理的主要区域,因此也被称作 GC 堆(Garbage Collected Heap)。 从垃圾回收的角度,由于现在收集器基本都采用分代垃圾收集算法,所以 Java 堆还可以细分为:新生代和老年代;新生代还可以分为Eden区和Survivor区(S0和S1)。具体的细节我会在JVM垃圾回收篇详细介绍。

方法区

方法区属于是 JVM 运行时数据区域的一块逻辑区域,是各个线程共享的内存区域。

《Java 虚拟机规范》只是规定了有方法区这么个概念和它的作用,方法区到底要如何实现那就是虚拟机自己要考虑的事情了。也就是说,在不同的虚拟机实现上,方法区的实现是不同的。

当虚拟机要使用一个类时,它需要读取并解析 Class 文件获取相关信息,再将信息存入到方法区。方法区会存储已被虚拟机加载的类信息、字段信息、方法信息、常量、静态变量、即时编译器编译后的代码缓存等数据。

方法去是Java虚拟机的一个模型规范,它的实现主要是永久代元空间。在JDK1.7及之前,方法区的实现是永久代,JDK1.8及之后,方法区的实现是元空间,而且元空间使用的直接内存,而不是Java虚拟机运行时数据区域。

截图20221022083356.png

为什么要将永久代 (PermGen) 替换为元空间 (MetaSpace) 呢?

  1. 整个永久代有一个 JVM 本身设置的固定大小上限,无法进行调整,而元空间使用的是直接内存,受本机可用内存的限制,虽然元空间仍旧可能溢出,但是比原来出现的几率会更小。当元空间溢出时会得到如下错误: java.lang.OutOfMemoryError: MetaSpace 我们可以使用 -XX:MaxMetaspaceSize 标志设置最大元空间大小,默认值为 unlimited,这意味着如果没有主动设置该参数,元空间大小只受系统内存的限制。
  2. 元空间里面存放的是类的元数据( Class metadata),这样加载多少类的元数据就不由 MaxPermSize 控制了, 而由系统的实际可用空间来控制,这样能加载的类就更多了。
  3. 在 JDK8,合并 HotSpot 和 JRockit 的代码时, JRockit 从来没有一个叫永久代的东西, 合并之后就没有必要额外的设置这么一个永久代的地方了。

方法区常用参数

-XX:PermSize=N //方法区 (永久代) 初始大小
-XX:MaxPermSize=N //方法区 (永久代) 最大大小,超过这个值将会抛出 OutOfMemoryError 异常:java.lang.OutOfMemoryError: PermGen

-XX:MetaspaceSize=N //设置 Metaspace 的初始(和最小大小)
-XX:MaxMetaspaceSize=N //设置 Metaspace 的最大大小

直接内存

直接内存并不是Java虚拟机运行时数据区的一部分,也不是虚拟机规范中定义的内存区域,但是这部分内存也被频繁地使用,而且也可能导致 OutOfMemoryError 错误出现。 本机直接内存的分配不会受到 Java 堆的限制,但是,既然是内存就会受到本机总内存大小以及处理器寻址空间的限制。