前言:

上一篇我们对 JVM 有了一个简单的认知,直到了 JVM 存在哪里有什么作用,本篇我们来深入了解 JVM 虚拟机的组成部分。

JVM 系列文章传送门

初识 JVM(Java 虚拟机)

JVM内存结构

深入了解 JVM 就需要了解 JVM 的内存结构,先贴上一张图,从感官上对 JVM 的内存结构有个基本的认知,如下:

在这里插入图片描述

  • Java 栈:线程私有,Java 线程执行方法的内存模型,一个线程对应一个栈,每个方法在执行的时候都会创建一个栈帧,用来存储局部变量表,操作数栈,动态链接,方法出口等,Java 栈不会存在内存垃圾回收问题,只要线程结束该栈就释放,生命周期和线程一致。
  • 本地方法栈:线程私有,加载 Native 方法的区域。
  • 程序计数器:线程私有,就是一个指针,指向方法区中的方法字节码,存储的是指向下一条指令的地址,也就是即将要执行的代码,由指向引擎来读取下一条指令,是一个非常小的内存空间,几乎可以忽略不计。
  • 方法区:线程共享,类的所有字段和方法字节码,以及构造函数、接口方法都定义在此,简单来说所有的方法定义信息都在该区域,静态变量、常量、构造方法、接口方法、运行时常量池都在方法区,每加载一个类,方法区就会分配一定的内存空间,用于储存该类的相关信息,虽然 Java 虚拟机把方法区描述为堆的一个逻辑部分,但它却又另外一个名字叫做非堆 Non-Heap,目的是和 Java 堆进行区分。
  • 堆:所有线程共享,虚拟机启动的时候创建,存放的是对象实例,几乎又多的对象包含常量池都在堆上分配内存,当对象无法在该空间分配到内存的时候将会抛出 OutOfMemoryError 异常,同时该区域也是 Java 垃圾回收的主要区域,可以通过 -Xmx 和 -Xms 来分别制定最大堆内存和最小堆内存。

Java 栈常见的两种异常

JVM 对 Java 栈规范定义了两种异常,如下:

  • 线程请求的栈深度大于虚拟机允许的栈深度,将会抛出 StackOverError 异常。
  • 若虚拟机栈可以动态扩展,无法申请到足够的内存空间将抛出 OutOfMemoryError 异常,可以通过 JVM 参数 -Xss 指定栈空间,栈空间的大小直接决定调用函数的深度。

Java 栈的组成部分

我们知道 Java 栈是 Java 线程执行方法的内存模型,用来存储方法执行时候的局部变量表、操作数栈、动态链接、方法出口,下面我们分别来对局部变量表、操作数栈、动态链接、方法出口进行详细说明。

对局部变量表

  • 一组变量值的存储空间,用于存放方法参数和方法内部定义的局部变量。
  • Class 文件中的方法表的 Code 属性和 max_locals 数据项定义了该表的容量最大值。
  • 该表的容量计算单位为 Slot,一个 Slot 可以存放一个 32 位以内的数据,因此可以存放 boolean、int、char、byte 等,long 和 double 是 64 位占用两个 Slot,引用类型可能是32位也可能是64位。
  • 虚拟机通过索引的方式使用局部变量表,索引值从0开始,方法开始执行时,索引为 0 的Slot默认用于传递方法所属对象的引用,方法中可以使用 this 这个关键字来访问这个隐含的参数。
  • 为了节省空间,表中 Slot 是可以重用的,这可能会影响到垃圾回收的行为。

操作数栈

  • 操作数栈也被称为操作栈,是一个后入先出的栈。
  • 操作数栈的最大深度在编译时候已经写入方法的 Code 属性和 max_stacks 数据项中。
  • 操作数的每一个元素可以是任意的 Java 数据类型,包括 long 和 double 类型,32为数据占用的栈空间为1,64为数据占用的栈空间为2。
  • 方法刚开始执行时候,操作数栈是空的,方法执行过程中,会有各种字节码指令汪操作数栈中存取数据。
  • 操作数栈中元素的数据类型必须和字节码指令的序列严格匹配,例如使用 iadd 指令用于执行整数加法,就一定不能用于操作一个 double 类型的数据。

动态链接

  • 每个栈帧都指向一个运行时常量池中该栈帧所属方法的引用,持有这个引用是为了支持方法调用过程中的动态链接。
  • Class 文件的常量池中存有大量的符号引用,字节码中的方法调用指令就以常量池中指向方法的符号引用作为参数,这些符号引用一部分会在类加载阶段或者第一个使用的时候转换为直接引用,转化为静态解析,另一部分会在每一次运行期间转化为直接引用,这部分成为动态链接。

方法出口

  • 当一个方法开始执行之后,只有两种方式可以退出方法,一个是正常退出一个是异常退出。
  • 正常退出,执行引擎遇到任意一个方法返回的字节码指令,这时候可能会有返回值返回给上层方法的调用者,是否有返回值以及返回值的类型将根据遇到的方法的字节码指令来决定,这种退出方式是正常退出。
  • 异常退出,方法在执行过程中出现了异常,并且这个异常没有在方法体内得到处理,不论是 JVM 产生的异常还是 throw 关键字产生的异常,只要在方法的异常处理处理中没有搜索到匹配的异常处理器,就会导致方法退出,这种异常退出的情况是不会给上层调用者返回任何值。
  • 无论采用何种返回方式,在方法退出后,都需要返回的到方法被调用的位置继续执行,方法返回时候可能需要在栈帧中保存一些信息,用来帮助恢复上层方法的执行状态。
  • 方法退出时可能执行的操作有:恢复上层方法的局部变量表和操作数栈、把返回值压入调用者栈帧中的操作数栈中、调用 PC 计数器的值指向方法调用指令的后面一条指令地址。

JVM 堆内存结构

JVM 堆内存简图:
在这里插入图片描述

从简图中可以看出 JVM 的堆内存分为新生代和老年代,新生代和老年代的内存占比默认是 1:2,JVM 的代指的是不同生命周期的对象,不同生命周期对象又存在不同的区域中,不同区域的内存被定义为“代”,这样做的目的是提升垃圾回收的效率,因为有了“代”的概念就可以针对不同的“代”执行不同的垃圾回收策略。

  • 新生代:新生代又分为 Eden(伊甸区)和 Survivor(幸存者区),Eden 和 Survivor 区域的内存占比默认是 8:2,而所有的类都在 Eden 取产生的,而 Survivor 区域又由 Survivor 1 区和 Survivor 2 区组成,我们 new 出来的对象都存在 Eden 区域,如果 Eden 区域没有内存了,我们还需要 new 对象,这时候就 JVM 就会进行 MinorGC,将 Eden 区域还存活的对象移动到 Survivor 区域,当 Survivor 区域也没有内存空间后,就会将对象转移到老年代。
  • 老年代:新生代经过多次 GC 后还存活的对象移动到老年代,如果老年代内存不足了,会触发 MajorGC 也就是 FullGC,如果 FullGC 后内存还是不足就会触发 OutOfMemoryError,因为老年代的对象都是存活了很久的对象,因此进行 GC 回收的时间也会比较长。

JVM 堆、栈、方法区的交互关系

我们知道到 HotSpot 虚拟机使用指针来访问对象的,Java 堆中会存放访问类元数据的地址,reference存储的就直接是对象的地址,具体关联如下简图:

在这里插入图片描述

Java 堆和栈的区别?

堆和栈是 Java 程序运行过程中的主要存储区域,经常会被拿来对比,堆和栈的主要区别如下:

  • 存储变量不同,堆中存储的都是对象,而栈中存储的是本地变量。
  • 堆是线程共享的,栈是线程私有的。
  • 存储位置不同,上面的图我已经看到了,堆和栈占用不同的存储空间。
  • 堆是垃圾回收的主要区域,内存的回收依赖 JVM 的 GC 机制,而栈内存的使用是先进后出的机制,栈内存会在方法执行完毕后自动释放。
  • 堆空间相对于栈空间大很对,栈占用的空间很少。
  • 堆内存不足会发生 OutOfMemoryError,栈内存不足会发生 StackOverflowError。

什么是元数据区?

元数据区取代了以前的永久代,本质和永久代类似,都是对 JVM 规范中方法区的实现,区别在于元数据区不在虚拟机中,而是使用的本地物理内存,而之前的永久代是在虚拟机中,永久代逻辑结构上属于堆,但在屋里上不属于堆,元数据区域也会发生 OutOfMemoryError。

JDK 1.6 及以前:有永久代,常量池在方法区。
JDK 1.7:有永久代,常量池在堆中。
JDK 1.8及以后:没有永久代,常量池在元空间中。

元空间是动态扩展的,默认的元空间大小是 21MB,可以通过 -XX:MetaspaceSize 配置,元空间一旦触发 FullGC 将会卸载没有用的类(类对应的加载器不在存活,这种场景较少),卸载的类少释放的元空间较小,则元空间会进行内存扩展,当然如果释放的空间过多,元空间占用的内存会下降(一般不会发生)。

JVM 执行引擎

JVM 执行引擎是 Java 虚拟机的核心组成部分,JVM 的主要任务负责装载字节码文件到其内部,但是字节码文件并不能直接在操作系统上执行,因为字节码文件并不等价于本地机器指令,它内部包含的仅仅只是一些能够被 JVM 识别字节码指令,想要这些指令能够运行起来,就需要用到执行引擎,执行引擎的任务就是将字节码指令解释编译为对应系统本地的机器指令。

JVM 编译模式了解吗?

JVM 编译模式一种有三种,解释执行模式、编译执行模式、混合执行模式。

  • 解释执行模式:通过解释器解释执行,启动快无需编译,但是执行慢。
  • 编译执行模式:JIT 启动慢,编译过程慢,因为预先编译好了,所以执行快。
  • 混合编译模式:采用解释执行模式和编译执行模式,热点代码执行编译执行模式。

怎么判断是热点代码?

  • 基于采样的热点探测:虚拟机会周期性的检查各个线程的栈顶,如果某个方法经常出现在栈顶,那就判断其为热点方法。
  • 方法调用计数器:采用计数器的方式来统计方法的热点,统计一段时间一个方法被调用的次数,当一个方法被调用的时候,检查这个方法是否有 JIT 编译,如果存在就使用 JIT 编译的本地代码来执行,如果不存在 JIT 编译,就将计数器加 1,然后判断该方法的调用计数器和回边计数器值的和是否大于阀值,如果大于阀值,则会提交一个 JIT 编译请求,如果在一个时间周期该方法的调用次数不足以让它提交 JIT 编译请求,那么整个方法的计数器就会减少一半,整个过程叫做方法调用计数器的热度减半,也叫半衰周期。

回边计数器:统计一个方法的循环次数,在字节码文件中遇到控制流向后流转的指令叫做“回边”。

C1、C2各自怎么进行字节码编译的?有什么优缺点?

  • C1:Client Complier 客户端编译器,对字节码进行简单可靠的优化,耗时短,以达到更快的编译速度,适合桌面程序。
  • C2:Server Complier 服务端编译器,进行耗时较长的优化,以及激进的优化,但优化的代码执行效率更高。

C1优化方式:

  • 方法内联:将引用的函数代码编译到引用点处,这样可以减少栈帧的生成,减少参数传递以及跳转过程。
  • 去虚拟化:对唯一的实现类进行内联。
  • 冗余消除:在运行期间把一些不会执行的代码折叠掉。

C2优化方式:

  • 标量替换:用标量值代替聚合对象的属性值。
  • 栈上分配:对于未逃逸的对象分配对象在栈而不是堆。
  • 同步消除:清除同步操作,通常指 synchronized。

总结:本篇分享了 JVM 的组成结构和 JVM 堆、栈、方法区、本地方法栈、程序计数器的相关概念,希望可以帮助到有需要的伙伴。

如有不正确的地方欢迎各位指出纠正。

Logo

GitCode 天启AI是一款由 GitCode 团队打造的智能助手,基于先进的LLM(大语言模型)与多智能体 Agent 技术构建,致力于为用户提供高效、智能、多模态的创作与开发支持。它不仅支持自然语言对话,还具备处理文件、生成 PPT、撰写分析报告、开发 Web 应用等多项能力,真正做到“一句话,让 Al帮你完成复杂任务”。

更多推荐