JVM内存区域面试详解
JVM 组成
JVM(Java Virtual Machine,Java 虚拟机)是 Java 程序的运行环境,它负责将字节码转换为机器码并执行。JVM 由以下三部分组成:
Java虚拟机(Java Virtual Machine,JVM)包含以下三个主要组成部分:
-
类加载器(ClassLoader)子系统:负责将class文件加载到内存中,并生成对应的 Class 对象。
-
运行时数据区(Runtime Data Area):Java虚拟机运行时用于存放数据的区域,包括:
-
方法区(Method Area):用于存放类的元数据,如类名、访问修饰符、常量池等信息。
-
堆内存(Heap):用于存放类的实例对象和数组对象实例。
-
栈(Stack):用于存放线程的方法调用栈。
-
本地方法栈(Native Method Stack):用于存放Java虚拟机调用本地方法(Native Method)时的参数和返回值。
-
程序计数器(Program Counter Register):用于保存当前线程执行的字节码指令的地址。
- 执行引擎(Execution Engine):负责将字节码转换为对应的机器码指令并在处理器上执行。
当程序运行时,JVM 将编译好的 Java 代码转换为字节码文件,然后由类加载器负责将字节码文件加载到内存中,并为其分配内存,最终由执行引擎负责执行字节码文件中的指令,执行程序。
JVM 提供了跨平台的支持,使得同一个 Java 程序可以在不同的操作系统平台上运行,这也是 Java 广泛应用于互联网开发的一个重要因素。
运行时数据区域
Java 虚拟机在执行 Java 程序的过程中会把它管理的内存划分成若干个不同的数据区域。JDK 1.8 和之前的版本会有一些差异,下面会详细介绍。
JDK 1.8 之前:
JDK 1.8 之后:
线程私有的:
- 程序计数器
- 虚拟机栈
- 本地方法栈
线程共享的:
- 堆
- 方法区
- 直接内存 (非运行时数据区的一部分)
程序计数器
程序计数器是一块较小的内存空间,用于保存当前线程执行的字节码指令的地址,可以看作是当前线程所执行的字节码的行号指示器。字节码解释器工作时通过改变这个计数器的值来选取下一条需要执行的字节码指令,分支、循环、跳转、异常处理、线程恢复等功能都需要依赖这个计数器来完成。
另外,为了线程切换后能恢复到正确的执行位置,每条线程都需要有一个独立的程序计数器,各线程之间计数器互不影响,独立存储,我们称这类内存区域为“线程私有”的内存。
值得一提的是程序计数器是唯一一个不会出现OutOfMemoryError的内存区域,它的生命周期随着线程的创建而创建,随着线程的结束而死亡。
程序计数器主要作用:
- 程序计数器可以看作当前线程所执行的字节码的行号指示器,字节码解释器通过改变程序计数器的值来依次读取指令,从而实现对代码的流程控制,如顺序执行、选择、循环、异常处理等。
- 在多线程场景下,程序计数器还用于记录当前线程执行的位置,从而当线程切换回来时能知道线程上一次运行到哪里了。
Java虚拟机栈
Java虚拟机栈是线程私有的,每个方法执行的时候都会创建一个栈帧,用于存储局部变量表、操作数栈、动态链接和方法返回地址等信息。当线程请求的栈深度超过了虚拟机允许的最大深度时,就会抛出StackOverFlowError。它的生命周期和线程的相同,随着线程的创建而创建,随着线程的结束而死亡。
Java虚拟机栈是 JVM 运行时数据区域的一个核心,除了一些 Native 方法调用是通过本地方法栈实现的(后面会提到),其他所有的 Java 方法调用都是通过虚拟机栈来实现的(也需要和其他运行时数据区域比如程序计数器配合)。
方法调用的数据需要通过栈进行传递,每一次方法调用都会有一个对应的栈帧被压入栈中,每一个方法调用结束后,都会有一个栈帧被弹出。Java 方法有两种返回方式,一种是 return 语句正常返回,一种是抛出异常。不管哪种返回方式,都会导致栈帧被弹出。也就是说, 栈帧随着方法调用而创建,随着方法结束而销毁。无论方法正常完成还是异常完成都算作方法结束。
Java虚拟机栈由一个个栈帧组成,而每个栈帧中都拥有:局部变量表、操作数栈、动态链接、方法返回地址。和数据结构上的栈类似,两者都是后进先出的数据结构,只支持出栈和入栈两种操作。
Java程序运行过程中栈可能会出现两种错误:
- 如果栈的内存大小不允许动态扩展,那么当线程请求栈的深度超过当前 Java 虚拟机栈允许的最大深度的时候,就抛出 StackOverFlowError 错误。
- 如果栈的内存大小允许动态扩展, 如果虚拟机在动态扩展栈时无法申请到足够的内存空间,则抛出OutOfMemoryError错误。
局部变量表
用于存储方法执行过程中的局部变量,包括基本数据类型和对象引用等。
操作数栈
主要作为方法调用的中转站使用,用于存放方法执行过程中产生的中间计算结果和计算过程中产生的临时变量。
动态链接
主要服务于一个方法需要调用其他方法的场景。在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虚拟机运行时数据区域。
为什么要将永久代 (PermGen) 替换为元空间 (MetaSpace) 呢?
- 整个永久代有一个 JVM 本身设置的固定大小上限,无法进行调整,容易出现OOM错误;而元空间使用的是直接内存,受本机可用内存的限制,可以根据应用程序的实际情况动态调整容量,虽然元空间仍旧可能溢出,但是比原来出现的几率会更小。当元空间溢出时会得到如下错误: java.lang.OutOfMemoryError: MetaSpace
我们可以使用 -XX:MaxMetaspaceSize 标志设置最大元空间大小,默认值为 unlimited,这意味着如果没有主动设置该参数,元空间大小只受系统内存的限制。 - 元空间里面存放的是类的元数据( Class metadata),如类名、方法名、字段名、注解等,,这样加载多少类的元数据就不由 MaxPermSize 控制了, 而由系统的实际可用空间来控制,这样能加载的类就更多了。
- 在 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 堆的限制,但是,既然是内存就会受到本机总内存大小以及处理器寻址空间的限制。