JVM 运行时数据区(栈和堆)

2023-11-19

文章目录

JVM 是一种规范

什么是 JVM?为什么 JVM 是一种规范?

很多时候我们提到 JVM,都会默认的把 JVM 和 Java 虚拟机绑定起来,认为 JVM 就是虚拟机(毕竟 JVM 直译就是 Java Virtual Machine),但实际上 JVM 并不仅仅只是虚拟机,它是一种规范。

Java 程序的执行过程

说到 JVM 具有代表性的编程语言是 Java,Java 对比 C/C++ 而言主要的不同在于内存管理的自动化,编写 Java 语言不需要手动开辟释放内存,会有 GC 垃圾回收器帮助我们及时的释放内存。

一个 Java 程序从编译到机器码会经历以下几个步骤:

  • javac 将 Java 文件编译成 .class 文件,JVM 将其加载到方法区,执行引擎将会执行这些字节码

  • 执行时,会翻译成操作系统相关的函数

以上过程简单说明:Java 文件 -> 编译器编译为 .class 文件,将 .class 加载到 JVM 的方法区 -> .class 文件交给 JVM 翻译成对应操作系统的函数

这里的 JVM 是代指的 Java 虚拟机,它能识别 .class 后缀的文件,并且能够解析它的指令,最终调用操作系统上的函数,完成我们想要的操作。

在这里插入图片描述

JVM 与字节码文件

在这里插入图片描述

我们平时说的 Java 字节码,指的是 Java 语言编译成的字节码(通过 javac 编译 .java 后缀文件),准确的说任何能在 JVM 平台上执行的字节码格式都是一样的,所以应该统称为 JVM 字节码

不同的编译器,可以编译出相同的字节码文件,字节码文件也可以在不同的 JVM 上运行

Java 虚拟机与 Java 语言并没有直接联系,它只是与特定的二进制文件格式 .class 文件有所关联,.class 文件中包含 JVM 虚拟机指令集和符号表,还有一些其他辅助信息。

JVM 只识别符合格式的 .class 文件,具体是什么语言不需要关注,因为最终都会由编译器编译为 JVM 能识别的字节码文件。

正因为 JVM 具有这种特性,所以它能够做到跨平台,同时也是能做到跨语言。

栈指令集架构和寄存器指令集架构

Java 编译器指令流是基于栈指令集架构,而另一种指令集架构为基于寄存器指令集架构。

基于栈指令集架构特点:

  • 设计与实现简单,适用于资源受限系统

  • 避开寄存器的分配问题:使用0地址指令方式

  • 指令流中的指令操作过程基于栈,且位数小(最小执行单位一个字节 8位),编译器容易实现

  • 不需要硬件支持,可移植性好

基于寄存器指令集架构特点:

  • x86 二进制指令集,Android 中 Dalvik 使用的就是该架构

  • 依赖于硬件,可移植性差

  • 性能优秀和执行更加高效

  • 花费更少时间去执行一个操作

以上说明简单理解就是,栈指令集架构可以做到放在哪个平台上都能运行,而寄存器指令集架构是基于设备的,一次能读取多个指令(一次读取 2/3/4 个字节,栈指令集一次 1 个字节 8 位)

Hotspot 虚拟机及 Dalvik&ART 虚拟机

sun 公司基于 JVM 的标准开发了 Hotspot 虚拟机,目前我们常说的 JVM 虚拟机,默认都是代指的 Hotspot 虚拟机,它占据 Java 语言虚拟机市场的绝对地位。

而在 Android 并不是使用的 Hotspot 虚拟机,而是 Dalvik 虚拟机,当然在 Android 5.0 后被替换为 ART 虚拟机。Dalvik 是一款不是 JVM 的 JVM 虚拟机,本质上它没有遵循 JVM 规范,原因有如下几点:

  • 不直接运行 .class 文件,执行的是编译后的 dex 文件,执行效率较高

  • 它的结构基于寄存器指令集结构,而不是 JVM 的栈指令集结构

JVM 的组成部分及架构

JVM 既然是一种规范,那在组成部分和架构上也会有统一。JVM 是由三大组件构成:

  • 类加载器:将编译好的 .class 文件加载到 JVM 进程中(将 .class 文件读到运行时数据区内存里,因为 .class 文件是在硬盘存放)

  • 运行时数据区:存放系统执行过程中产生的数据

  • 执行引擎:用来执行汇编及当前进程内所要完成的一些具体内容(例如 GC)

三大组件具体的内容如下图:

在这里插入图片描述

该篇文章会主要讲解运行时数据区。

运行时数据区

在这里插入图片描述

Java 对于数据运行的角度而言,它分为了线程私有区和线程共享区,线程私有区就是我们常说的栈,线程共享区就是我们常说的堆,而直接内存你可以理解为就是物理内存。

堆栈在内存中的职责可以如下说明:

  • 栈是运行时的处理单位。用来解决程序运行问题,如程序如何运行,如何去处理数据,方法是怎么执行的

  • 堆是运行时的存储单位。用来解决数据存储问题,如数据放哪,怎么放

用一个生活中简单的例子说明堆栈的职责,堆就是存放炒菜时要用的材料和配料,栈就是要用来炒菜的锅,而人就是执行引擎。炒菜就是执行某个方法,所以要将堆里的材料和配料扔到栈这口锅里。

方法调用过程(栈)

虚拟机栈基本信息

虚拟机栈是承载方法调用的过程中产生的数据容器,随线程开辟,为线程私有(即一个线程对应一个虚拟机栈)

它主管 Java 方法运行过程中所产生的值变量、运算结果、方法的调用与返回等信息的管理。这涉及到虚拟机栈中的局部变量表、操作数栈、动态链接、方法返回地址、程序计数器等。

栈结构能产生一种快速有效的分配方案,它只需要出栈和入栈,访问速度仅次于程序计数器。

程序计数器/PC寄存器

因为 CPU 有时间片轮转机制,也就是一个 CPU 会分配时间片给每个要执行的线程,运行时是并发切换多个线程执行。

某个线程执行程序还没结束,因为分配给这个线程的时间片已经使用完,此时会切换到其他线程,等下一次该线程重新分配到时间片时,会继续在上次切换前的位置继续执行,这个位置就需要程序计数器(PC寄存器)记录代码执行的偏移量。如下图的指令序号就是代码执行的位置:

在这里插入图片描述

所以程序计数器主要的作用是在多线程情况下对需要执行的代码进行定位:

在这里插入图片描述

栈帧内部结构解析

在这里插入图片描述

上图中一个线程开辟了一个栈空间,在线程中执行方法时,每个方法对应一个栈帧,在方法被调用时入栈,方法执行结束就会出栈

上图中的例子是 method1() 调用 method()2,method2() 调用 method3(),method3() 调用 method4(),method4() 调用 method5(),所以对应的就是有 4 个栈帧,每个方法执行完成就出栈。

在单线程情况下,栈空间默认最多能存放 1MB 大小,在开发中遇到的抛出 StackOverflowError 栈溢出就是栈空间的栈帧数量超过了这个大小

而每个栈帧具体有五个部分组成:局部变量表、操作数栈、动态链接、方法返回地址、附加信息。在这里会主要说明局部变量表和操作数栈。

为了后续能够方便演示,在编写 demo 代码时可以在 IDEA 或 Android Studio 安装该插件,能够查看字节码的具体信息。

在这里插入图片描述

当我们需要查看具体字节码信息时,在 IDE 先 Build,完成后在 View -> Show Bytecode With Jclasslib,每次修改完代码后都要按上述操作执行才能获取到最新的字节码信息:

在这里插入图片描述

局部变量表

局部变量表也被称之为局部变量数组或者本地变量表。

局部变量表就是一个数组,主要用于存储方法参数和定义方法体内的局部变量。由于局部变量表是建立在线程的栈上,是线程的私有数据,因此不存在数据安全问题

方法嵌套调用的次数由栈的大小决定,局部变量表决定着栈帧的大小,局部变量表的大小在编译时就已经确定。

public void test() {
	// 局部变量 i、m、k
	int i = 10;
	int m = 20;
	int k = (i + m) * 10;
	
	// 对象引用 p
	Person p = new Person();
	
	double a = k * 10;
	int b = 5;
	p.setAge(a);
	
	method1();
}

上面的示例代码中,test() 在线程中就是一个栈帧,而上面我们讲到栈帧是由几个部分组成的,其中就有局部变量表,代码中的 int i、int m、int k、Person p 这些都在这个方法内定义的,它们都是局部变量,同时它们也定义在局部变量表里面。

我们在初学 Java 时知道 Java 的类型分别值类型(基本数据类型)和引用类型的内存地址,也讲过 在栈中会存放的值类型和对象的引用,其实更具体的说是存放在局部变量表里面,因为线程的栈空间是存放的栈帧。

将上面的 demo 代码使用 Jclasslib 查看具体信息:

在这里插入图片描述

其中,LineNumberTable 是字节码和 Java 代码行号对照表,LocalVariableTable 就是局部变量表。

在这里插入图片描述

名称 描述
起始PC 在汇编代码中的序号
长度 变量的作用域。该方法的字节码长度是 45,例如图中变量 i 起始 PC 是 3,长度是 42,起始 PC + 长度 = 45,其他变量也同理,说明变量的作用域在这个方法内
序号 变量的下标。与变量的类型占用的变量槽 slot 有关,变量类型是 32位占用 1 个 slot,类型是 64 位占用 2 个 slot

在非静态方法中,默认会在局部变量表的第一个位置置入一个 this 指针,这也是为什么使用方法时能用 this.xxx 的原因。而在 this 之后会放置方法参数。

在序号那一列,可以看到变量 a 是 double 类型所在序号为 6,变量 b 时序号变为了 8,其实这和局部变量表的变量槽 slot 有关。

slot 是局部变量表的基础单位,在局部变量表中,变量类型是 32 位占用 1 个 slot(如果变量类型小于 32 位例如 byte,同样也会是 32 位),类型是 64 位占用 2 个 slot,对象引用类型也是 32 位占用 1 个 slot

因为变量 a 是 double 类型 64 位占用了 2 个 slot,所以这里的序号跳了一位。

局部变量中 slot 是可以重用的,如果一个局部变量过了其他作用域,那么其作用域之后声明的新的局部变量有可能会复用这个 slot,以便于节省资源。

在这里插入图片描述
上图中定义了变量 b 在代码块中,但变量 b 的作用域在代码块中使用完就没用了,变量 b 的下标和变量 c 的序号下标相同,说明变量 b 的 slot 被复用了。

小结下局部变量表:

  • 默认在局部变量表第一位会置入一个 this 指针,参数在 this 指针之后

  • 局部变量表包含了声明的所有变量

  • 变量类型 32 位占用 1 个 slot,类型 64 位占用 2 个 slot,对象引用类型也是 32 位 1 个 slot

  • 局部变量表的 slot 存在复用

  • 局部变量表的大小在编译时已经确定

操作数栈

每一个独立的栈帧中,除了包含局部变量表之外,还包含一个后进先出的操作数栈。它的作用是在方法执行过程中,根据字节码指令往栈中写入数据或者提取数据,某些字节码指令将值压入操作数栈,其余的字节码指令将操作数取出栈,使用它们后再把结果压入栈。比如复制、交换、求和、求余等操作。

用一个例子介绍操作数栈是怎么工作的:

public void test1() {
	int i = 10;
	int j = 20;
	int k = (i + j) * 10;
}

在这里插入图片描述

在最开始时,局部变量表中第一位是 this 指针。此时执行字节码的 bipush 10,将 int 类型变量数值 10 压入操作数栈:

在这里插入图片描述

执行 istore_1,将 10 从操作数栈弹出放到局部变量表序号 1 的位置:

在这里插入图片描述

执行 bipush 20,istore_2 将 int 类型变量数值 20 压入操作数栈,然后放到局部变量表序号 2 的位置:

在这里插入图片描述

执行 iload_1、iload_2 从局部变量表将 10 和 20 压入操作数栈,iadd 弹出 10 和 20 并执行加法操作,将结果 10 + 20 = 30 压入操作数栈:

在这里插入图片描述

bipush 10 将 10 压入操作数栈,imul 弹出 10 和 30 并执行乘法操作,将结果 300 压入操作数栈,istore_3 将 300 放到局部变量表序号 3 的位置:

在这里插入图片描述

动态链接及方法区

每一个栈帧内部都包含一个执行 运行时常量池 中该栈帧所述方法的引用。包含这个引用的目的是为了支持当前方法的代码能够实现动态链接(invokeDynamic 指令)。

在 Java 源文件被编译到字节码文件中时,所有的变量和方法引用都作为符号引用保存在 .class 文件的常量池里。

在这里插入图片描述

类加载器将 .class 文件加载到方法区。

方法区存储的静态数据(即存储类加载器加载的类型信息、常量、静态变量即时编译器编译后的代码缓存等数据),堆区存储的是动态数据

相关信息如下:

public class com.example.demo.Main // 类全路径名称
	minor version: 0
	major version: 52
	flags: ACC_PUBLIC, ACC_SUPER
Constaint pool: // 常量池
	#1 = Methodref 			  #28.#77		// java/lang/Object."<init>":()V
	#2 = Class				  #78			// com/example/demo/Main
	#3 = Methodref			  #2.#77		// com/example/demo/Main."<init>":()V
	#4 = Methodref			  #2.#79		// com/example/demo/Main.test1():V
	#5 = Fieldref			  #80.#81		// java/lang/System.out:Ljava/io/PrintStream;
	#6 = String				  #82			// 挂起....
	#7 = Methodref			  #83.#84		// java/io/PrintStream.println:(Ljava/lang/String;)V
	....

public static void main(java.lang.String[]);
	descriptor: ([Ljava/lang/String;)V // 方法签名
	flags: ACC_PUBLIC, ACC_STATIC
	Code:
		stack=2, locals=3, args_size=1
		   0: new			#2				// class com/example/demo/Main
		   3: dup			
		   4: invokespecial #3				// Method "<init>":()V
		   7: astore_1
		   8: aload_1
		   9: invokevirtual #4				// Method test1:()V
		   ...

可以看到有一个 Constant Pool 就是常量池,里面定义了符号引用(#1、#2 等)信息,在方法调用时使用的 invoke 指令,根据符号引用从常量池找到对应的符号。

例如 main() 中有个方法调用是使用 invoke 指令 invokespecial,对应的符号引用是 #3,从常量池找到 #3,而 #3 又调用的 #2.#77,再继续去查找。

动态链接的本意是为了支持重写,将这些符号引用转换为调用方法的直接引用。例如子类继承父类重写了父类的方法,因为编译时是静态代码,通过动态链接可以找到是子类重写,是子类在具体调用。

方法返回地址

方法返回地址是存放方法调用的程序计数器/PC寄存器的值。

一个方法的结束有两种方式:

  • 正常执行完成

  • 出现未处理的异常,非正常退出

无论通过哪种方式退出,在方法退出后返回到该方法被调用的位置

方法正常退出是调用者的程序计数器/PC寄存器的值作为返回地址,即调用该方法的指令的下一条指令的地址

异常表

方法执行过程中异常退出,返回地址是需要通过异常表来确定,栈帧中一般不会保存这部分信息,通过异常完成的出口退出的不会给它的上层调用者产生任何的返回值,只要在本方法中没有搜索到匹配的异常处理器就会异常退出。

异常表如下所示:

在这里插入图片描述

上图中有一个 Exception table 就是异常表,from 是 0,to 是 4,target 的是 7,意思是异常出现在字节码位置 0(aload_0) 到 4(goto) 的位置,出现异常时将执行字节码的位置 7(astore_1),位置 7 其实就是 try-catch 代码。

栈帧内部结构总结

每个线程会单独开辟一个虚拟机栈,在方法调用时入栈出栈的都是一个个的栈帧,而每个栈帧内部主要由四个部分组成:局部变量表、操作数栈、动态链接和方法返回地址。具体图解如下图:

在这里插入图片描述

对象分配过程(堆)

堆概述

一个 JVM 进程存在一个堆内存,堆是 JVM 内存管理的核心区域。

Java 堆区在 JVM 启动时被创建,其空间大小也被确定,是 JVM 管理的最大的一块内存(堆内存大小可以调整)。堆默认的初始大小是 运行内存大小 / 64,最大内存大小是 运行内存大小 / 4。

本质上堆是一组在物理上不连续的内存空间,但是逻辑上是连续的空间。所有线程共享堆,但是堆内对于线程处理还是做了一个线程私有的部分。

也可以理解为:堆内存本质上就是连续的一串内存地址,然后堆分配内存时就是在这块内存地址范围内提供一个或多个内存地址分配给对象。例如堆内存范围是 0x0000001-0x0000090,给对象分配内存时就会从这个范围分配内存地址给对象。

堆的作用

在《Java 虚拟机规范》中对 Java 堆的描述是:所有的对象示例以及数组都应当在运行时分配在堆上。但是从实际使用角度看这并不是绝对的,存在某些特殊情况下的对象产生是不在堆上分配。也就是规范上是绝对的,实际上是相对的。

方法执行结束后,堆中的对象不会马上移除,需要通过 GC 执行垃圾回收后才会回收

堆的内存结构

在这里插入图片描述

上图是堆区的结构,young 是年青代,old 是老年代,它们的比例是 1:2(该比例是可以调整的)。young 年青代内部具体又可以分为 Eden 和 Survivor,Eden 是所有对象产生的地方(特殊情况除外,后面逃逸分析会讲到)

在 Java 7 之前内存逻辑的划分为:年青代+老年代+永久代,在 Java 8 之后内存逻辑划分为:年青代+老年代+元空间。

年青代就是生命周期短的临时对象,比如对象只在方法的作用域内创建,用完就可以被回收了;老年代就是生命周期长的对象

实际上你可以理解为只有年青代和老年代,不管永久代还是元空间,其实都只是将方法区中长期存在的常量对象进行保存。

或许你会有疑问:为什么需要分代?分代有什么好处?

经研究表明,不同对象的生命周期是不一致的,但是在 具体使用过程中 70%-90% 的对象是临时对象。分代唯一的理由是为了优化 GC 的性能。如果没有分代,那么所有对象在一块内存空间,GC 想要回收内存就必须先扫描所有对象;分代之后,长期持有的对象可以挑出,短期持有的对象可以固定在一个位置进行回收,省掉很大一部分空间利用。

年青代和老年代的区分条件

上面有提到,Eden 是所有对象产生的地方,年青代就是生命周期短的临时对象,老年代就是生命周期长的对象。那要怎么区分对象什么时候是年青代,什么时候是老年代?其实就是根据年龄。

这里的年龄指的是执行 GC 后对象没有被回收,年龄就 +1

老年代的入场条件在 Androi ART 虚拟机的阈值是 6,而 Hotspot 虚拟机的阈值是 15。

对象分配流程

接下来说明下对象在堆的分配过程。

在这里插入图片描述

在一开始 new 出来的对象都在 Eden 产生(大对象会直接进老年代),当 Eden 满了以后,会触发 Minor GC 扫描 Eden 进行对象回收。

在这里插入图片描述

Minor GC 后如果有对象仍然存活,对象会被复制到 Survivor 的 From 区,剩下的全部清除;后续继续还有对象在 Eden 产生,当 Eden 再一次满了,Minor GC 扫描 Eden 和 From 回收对象。

在这里插入图片描述

此时如果在 Eden 和 From 仍有存活的对象,就会将它们都复制到 Survivor 的 To 区,分代年龄会 +1.

From 区和 To 区是交替角色的,即如果 From 区要 GC 回收了,经过可达性分析存活的对象从 From 区复制到 To 区,然后 From 区再 GC 清出内存给其他对象;如果 To 区满了,就反过来相同的逻辑。

在这里插入图片描述

当 Survivor 区的对象年龄达到了老年代的阈值时就会推到 old 区。当 old 区满了会触发 Major GC 或 Full GC 回收老年代的对象。

对象分配流程可以用一个简单的示例理解:

  • 我是一个普通的 Java 对象,我出生在 Eden 区,在 Eden 区我还看到和我长得很像的小兄弟,我们在 Eden 区玩了挺长时间

  • 有一天 Eden 区中的人实在是太多了,我就被迫去了 Survivor 的 From 区,自从去了 Survivor,我就开始了漂泊的人生,有时候在 Survivor 的 From 区,有时候在 Survivor 的 To 区,居无定所

  • 直到我成年了(达到老年代年龄阈值),爸爸说我成人了,该去社会上闯闯,于是我就去了老年代那边,老年代里人很多并且年龄都挺大的,我在这里也认识了很多人。在老年代里,我生活了很多年(每次 GC 加一岁),然后被回收

在这里插入图片描述

还有一种特殊情况,申请的对象过大导致不能在 Eden 产生,此时就会去检查 old 区是否能存放对象,如果还不能,因为已经不够内存分配就会发生 OOM 内存溢出。

小结下上面的对象分配过程:

  • new 出来的对象进 Eden(大对象直接进老年代)

  • Eden 区放满了,再放就会开启一个 GC 线程(Minor GC)来回收垃圾

  • 把 Eden 区中非垃圾对象复制到 Survivor 的 From 区,剩下的直接全部清除

  • 继续 new 对象时,如果 Eden 区又满了,GC 来了就把 Eden 和 From 区存活的对象复制到 To 区,对象的分代年龄 +1

  • 每次 Eden 满的时候,就在 Eden+From 和 Eden+To 中来回复制

  • 对象年龄到老年代阈值,就进入老年代

  • 老年代放满了,就会发生 Full GC,对堆进行全面 GC

Visual GC 演示对象分配过程

上面分析了对象分配过程,但支持理论的支持,更好的方式是通过工具分析验证对象分配过程是否正确。

在 jdk 11 之前提供了 jvisualvm 工具,它存放在 jdk 的 bin 目录,例如 Mac 电脑是:

/Library/Java/JavaVirtualMachines/jdk1.8.0_181.jdk/Contents/Home/bin/jvisualvm

Visual GC 是一个插件,在刚打开 jvisualvm 时是没有这个插件需要自己安装,在 工具 -> 可用插件 -> Visual GC 下载插件:

在这里插入图片描述

安装完插件后重启 jvisualvm,先编写测试代码:

public class Main {

    public static void main(String[] args) {
        method();
    }

    private static void method() {
        for (;;) {
        	// 每次生成 300k 
            byte[] array = new byte[300 * 1024];
            try {
                Thread.sleep(10);
            } catch (InterruptedException e) {
                e.printStackTrace();
            }
        }
    }
}

在程序运行起来后,可以在 jvisualvm 查看到运行程序对应的进程,Visual GC 也可以查看具体的对象分配情况:

在这里插入图片描述

我们将上面的示例代码修改下,让它产生频繁的 GC:

public class TestGC {
	byte[] array = new byte[300 * 1024];
}

public class Main {

    public static void main(String[] args) {
        method();
    }

    private static void method() {
    	List<TestGC> list = new ArrayList<>();
        for (;;) {
        	TestGC testGC = new TestGC();
        	list.add(testGC);
            try {
                Thread.sleep(10);
            } catch (InterruptedException e) {
                e.printStackTrace();
            }
        }
    }
}

在这里插入图片描述

可以看到,Eden 满了之后触发了 GC,存活的对象从 Eden 推到 Survivor 的 From 区,所以 From 出现了一个增长;而 From 也满了推给了 To,To 满了最终将对象推到 old。

上面就是内存抖动的现象,内存抖动会频繁的触发 GC。

Minor GC、Major GC、Full GC

Minor GC、Major GC、Full GC 的区别

JVM 在进行 GC 时,并非每次都对上面三个内存区域(Eden、Survivor、Old)一起回收,大部分的只会针对 Eden 区进行。

在 JVM 标准中,它里面的 GC 按照回收区域划分为两种:一种是部分采集(Partial GC),一种是整堆采集(Full GC)。

GC 方式 GC 类型
部分采集 年轻代采集(Minor GC/Young GC):只采集 Eden+Survivor 区数据
老年代采集(Major GC/Old GC):只采集 Old 区数据,目前只有 CMS 会单独采集老年代
混合采集(Mixed GC):采集年青代和老年代部分数据,目前只有 G1 使用
整堆采集 收集整个堆与方法区的所有垃圾

GC 触发策略

我们常说的 GC 并不是只有一种,在不同的区域有不同的回收方式,在年青代 Eden+Survivor 区是 Minor GC,老年代 old 区是 Major GC 或 Full GC。不同的 GC 也有不同的触发时机。

Minor GC 触发机制:

  • 当 Eden 区空间不足时触发

  • 因为 Java 大部分对象都是具备朝生夕灭的特性,所以 Minor GC 非常频繁,一般回收速度也快

  • Minor GC 会触发 STW(Stop The World)行为,暂停其他用户的线程

Major GC 触发机制:

  • 出现 Major GC 经常会伴随至少一次 Minor GC(old 区空间不足时会尝试触发 Minor GC,如果空间还是不足则会触发 Major GC)

  • Major GC 比 Minor GC 速度慢 10 倍,如果 Major GC 后内存还是不足则会出现 OOM

Full GC 触发机制:

  • 调用 System.gc() 时

  • old 区空间不足时

  • 方法区空间不足时

  • Minor GC 后进入老年代的对象平均大小 > old 区可用内存

  • 在 Eden 区使用 Survivor 进行复制时,对象大小 > Survivor 的可用内存,则该对象转入老年代,且老年代的可用内存小于该对象

Full GC 是开发或者调优中尽量要避开的

所以 GC 触发时机可以简单理解为:

  • Eden 区满了,触发 Minor GC

  • old 区空间不足了,先触发 Minor GC,如果内存还不够则触发 Major GC

  • System.gc()、方法区空间不足、old 区空间不足、从年青代推到老年代的对象 > old 区可用内存,触发 Full GC

GC 日志查看

如果需要查看具体的 GC 日志信息,可以在 IDE 添加以下参数:

// -Xms9m 将堆初始大小和最大内存大小设置为9MB
-Xms9m -Xmx9m -XX:+PrintGCDetails

在这里插入图片描述

TLAB(Thread Local Allocation Buffer)

因为堆区是在线程共享区,任何线程都可以访问堆中的共享个数据,由于对象的创建很频繁,在并发环境下对重划分内存空间是线程不安全的,如果需要避免多个线程对于同一地址操作就需要加锁,而加锁会影响内存分配速度。

所以 JVM 默认在堆区的 Eden 中开辟了一块空间,专门服务于每一个线程,为每个线程分配了一个私有缓存区域,它就是 TLAB。TLAB 的作用是多线程同时分配内存时可以避免一系列的非线程安全问题
在这里插入图片描述

TLAB 会作为内存分配的首选,TLAB 总空间只会占用 Eden 空间的 1%。一旦对象在 TLAB 分配失败,JVM 会尝试使用加锁来保证数据操作的原子性,从而直接在 Eden 中分配。

对象逃逸

堆是分配对象存储的唯一选择吗?

在《深入理解 Java 虚拟机》一书中有一段这样的描述:随着 JIT 编译器的发展与逃逸分析技术逐渐成熟,栈上分配、标量替换优化技术将会导致一些微妙的变化,所有的对象分配到堆上也渐渐地变得不那么绝对了。

这里提到了几个要点:栈上分配、标量替换、逃逸分析技术。它们到底是什么?做了什么才让在堆分配对象变成不是唯一的选择?

标量替换

  • 标量:指一个无法再分解成更小数据的数据。Java 中的基本数据类型就是标量

  • 聚合量:Java 中的聚合量指的是类,封装的行为就是聚合。

标量替换指的是,在未发生逃逸的情况下,函数内部生成的聚合量在经过 JIT 优化后会将其拆解成标量。

简单理解就是,对象在方法内 new,对象也没有作为方法返回或作为全局变量,经过 JIT 编译优化后,这个对象会被拆解成基本数据类型(就是将对象内的数据提取出来),放到局部变量表处理

public class Point {
	public int x;
	public int y;
	public Object obj;
}

public void method() {
	Point p = new Point();
	...
}
经过 JIT 优化 Point 对象被拆解成基本数据类型
public void method() {
	int x = 0;
	int y = 0;
	Object obj = null;
	...
}

逃逸分析

对象逃逸可以用以下两个方式判定:

  • 一个对象的作用域仅限于方法区域内部在使用的情况下,此种状态叫做非逃逸(简单理解就是,对象在方法内 new,对象也没有作为方法返回或作为全局变量)
// 未发生逃逸
public void method() {
	Point p = new Point();
	...
	p = null;
}
  • 一个对象如果被外部其他类调用,或者是作用于属性中,则此种现象被称之为对象逃逸
Point p;

// 产生逃逸
public void method() {
	p = new Point();
}

// 产生逃逸
public static StringBuffer method(String s1, String s2) {
	StringBuffer sb = new StringBuffer();
	sb.append(s1);
	sb.append(s2);
	return sb; // 直接将对象通过方法返回,对象产生逃逸
}

// 未产生逃逸
public static String method(String s1, String s2) {
	StringBuffer sb = new StringBuffer();
	sb.append(s1);
	sb.append(s2);
	return sb.toString(); // 返回的是 String 对象,toString() 是 new String()
}

逃逸分析行为发生在字节码被编译后 JIT 对于代码的进一步优化:

  • 栈上分配:JIT 编译器在编译期间根据逃逸分析计算结果,如果发现当前对象没有发生逃逸现象,那么当前对象就可能被优化成栈上分配,会将对象直接分配在栈中

  • 标量替换:有的对象可能不需要作为一个连续的内存结构存在也能被访问到,那么对象部分可以不存储在内存,而是存储在 CPU 寄存器中

目前的虚拟机基本都会做标量替换,但逃逸分析的栈上分配技术却不一定会加上,逃逸分析技术至今都还未完全成熟,原因是对于会产生逃逸的对象做逃逸分析会进行一系列复杂的分析算法运算,逃逸分析是一个相对耗时的过程。

所以在平时编写的代码中做优化时,尽量写出不产生对象逃逸的代码,在加了逃逸分析栈上分配的虚拟机,就能在一定程度提高代码性能

对象创建过程和对象内存布局

对象创建过程

对象的创建有以下几种方式:

对象创建方式
直接创建 new
反射 Class.newInstance()
Constructor.newInstance(xx)
克隆 object.clone()
反序列化 从文件、网络中获取一个对象流

而当使用以上的方式创建对象时,对象在 JVM 中从分配内存到创建的步骤有如下步骤:

  • 判断对象对应类是否加载、链接、初始化:虚拟机遇到一条 new 指令,首先会去检查这个指令参数能否在 Metaspace 的常量池定位到一个类的符号引用,并且检查这个符号引用代表的类是否已经被加载、解析和初始化(即判断类的元信息是否存在,如果没有那么在双亲委派模式下,使用当前类加载器以 ClassLoader+包名+类名 为 key 查找对应的 class 文件)。如果没有抛出 ClassNotFoundException,找到则加载并生成 Class 类对象

在这里插入图片描述

  • 为对象分配内存:分配内存需要考虑两种情况,一种是 Eden 区还没执行 Minor GC 此时内存是规整的,那么就分配内存地址分配内存;如果执行过 Minor GC 内存不规整,会从空闲列表获取内存地址分配内存

在这里插入图片描述在这里插入图片描述

  • 处理并发安全问题:采用 CAS 失败重试,区域加锁保证更新的原子性,每个线程预先分配一块 TLAB

  • 初始化对象数值:所有数据设置默认值,保证示例字段在不赋值的情况下可以直接使用

  • 设置对象的对象头:将对象的所属类、hashcode、gc 信息、锁信息等数据存储在对象头

  • 执行 init 方法进行初始化(构造函数)

以上步骤可以简化为:检查类是否加载获取 Class 对象 -> 在堆分配内存 -> 为线程分配 TLAB -> 初始化对象默认值 -> 设置对象头 -> 调用构造

对象内存布局

在这里插入图片描述
在这里插入图片描述

本文内容由网友自发贡献,版权归原作者所有,本站不承担相应法律责任。如您发现有涉嫌抄袭侵权的内容,请联系:hwhale#tublm.com(使用前将#替换为@)

JVM 运行时数据区(栈和堆) 的相关文章

  • Java 中的 XPath 节点集

    我在 eclipse 中有这段代码 NodeSet nodes NodeSet xPath evaluate expression inputSource XPathConstants NODESET 它给我 NodeSet 上的编译时错误
  • 如何使用 FileChannel 将一个文件的内容附加到另一个文件的末尾?

    File a txt好像 ABC File d txt好像 DEF 我正在尝试将 DEF 附加到 ABC 所以a txt好像 ABC DEF 我尝试过的方法总是完全覆盖第一个条目 所以我总是最终得到 DEF 这是我尝试过的两种方法 File
  • 如何在 JFace 的 TableViewer 中创建复选框?

    我创建了一个包含两列的 tableViewer 我想将其中一列设为复选框 为此 我创建了一个 CheckBoxCellEditor 但我不知道为什么它不起作用 名为 tableName 的列显示其值正常 色谱柱规格如下 String COL
  • Android Studio 在编译时未检测到支持库

    由于 Android Studio 将成为 Android 开发的默认 IDE 因此我决定将现有项目迁移到 Android studio 中 项目结构似乎不同 我的项目中的文件夹层次结构如下 Complete Project gt idea
  • 解决错误:日志已在具有多个实例的atomikos中使用

    我仅在使用atomikos的实时服务器上遇到问题 在我的本地服务器上它工作得很好 我在服务器上面临的问题是 init 中出错 日志已在使用中 完整的异常堆栈跟踪 java lang RuntimeException Log already
  • 如何为 Gson 编写自定义 JSON 反序列化器?

    我有一个 Java 类 用户 public class User int id String name Timestamp updateDate 我收到一个包含来自 Web 服务的用户对象的 JSON 列表 id 1 name Jonas
  • 使用替换字符串中多个单词的最有效方法[重复]

    这个问题在这里已经有答案了 此刻我正在做 Example line replaceAll replaceAll cat dog replaceAll football rugby 我觉得那很丑 不确定有更好的方法吗 也许循环遍历哈希图 ED
  • OnClick 事件中的 finish() 如何工作?

    我有一个Activity一键退出Activity 通过layout xml我必须设置OnClick事件至cmd exit调用 this finish 效果很好 public void cmd exit View editLayout thi
  • Microsoft Graph 身份验证 - 委派权限

    我可以使用 Microsoft Graph 访问资源无需用户即可访问 https developer microsoft com en us graph docs concepts auth v2 service 但是 此方法不允许我访问需
  • 迁移到 java 17 后有关“每个进程的内存映射”和 JVM 崩溃的 GC 警告

    我们正在将 java 8 应用程序迁移到 java 17 并将 GC 从G1GC to ZGC 我们的应用程序作为容器运行 这两个基础映像之间的唯一区别是 java 的版本 例如对于 java 17 版本 FROM ubuntu 20 04
  • Spring Data 与 Spring Data JPA 与 JdbcTemplate

    我有信心Spring Data and Spring Data JPA指的是相同的 但后来我在 youtube 上观看了一个关于他正在使用JdbcTemplate在那篇教程中 所以我在那里感到困惑 我想澄清一下两者之间有什么区别Spring
  • 归并排序中的递归:两次递归调用

    private void mergesort int low int high line 1 if low lt high line 2 int middle low high 2 line 3 mergesort low middle l
  • 制作java包

    我的 Java 类组织变得有点混乱 所以我要回顾一下我在 Java 学习中跳过的东西 类路径 我无法安静地将心爱的类编译到我为它们创建的包中 这是我的文件夹层次结构 com david Greet java greeter SayHello
  • Java直接内存:在自定义类中使用sun.misc.Cleaner

    在 Java 中 NIO 直接缓冲区分配的内存通过以下方式释放 sun misc Cleaner实例 一些比对象终结更有效的特殊幻像引用 这种清洁器机制是否仅针对直接缓冲区子类硬编码在 JVM 中 或者是否也可以在自定义组件中使用清洁器 例
  • Windows 上的 Nifi 命令

    在我当前的项目中 我一直在Windows操作系统上使用apache nifi 我已经提取了nifi 0 7 0 bin zip文件输入C 现在 当我跑步时 bin run nifi bat as 管理员我在命令行上看到以下消息 但无法运行
  • Keycloak - 自定义 SPI 未出现在列表中

    我为我的 keycloak 服务器制作了一个自定义 SPI 现在我必须在管理控制台上配置它 我将 SPI 添加为模块 并手动安装 因此我将其放在 module package name main 中 并包含 module xml 我还将其放
  • Springs 元素“beans”不能具有字符 [children],因为该类型的内容类型是仅元素

    我在 stackoverflow 中搜索了一些页面来解决这个问题 确实遵循了一些正确的答案 但不起作用 我是春天的新人 对不起 这是我的调度程序 servlet
  • 将 JTextArea 内容写入文件

    我在 Java Swing 中有一个 JTextArea 和一个 提交 按钮 需要将textarea的内容写入一个带有换行符的文件中 我得到的输出是这样的 它被写为文件中的一个字符串 try BufferedWriter fileOut n
  • 如何修复“sessionFactory”或“hibernateTemplate”是必需的问题

    我正在使用 Spring Boot JPA WEB 和 MYSQL 创建我的 Web 应用程序 它总是说 sessionFactory or hibernateTemplate是必需的 我该如何修复它 我已经尝试过的东西 删除了本地 Mav
  • 中断连接套接字

    我有一个 GUI 其中包含要连接的服务器列表 如果用户单击服务器 则会连接到该服务器 如果用户单击第二个服务器 它将断开第一个服务器的连接并连接到第二个服务器 每个新连接都在一个新线程中运行 以便程序可以执行其他任务 但是 如果用户在第一个

随机推荐

  • Web网络安全-----Log4j高危漏洞原理及修复

    系列文章目录 Web网络安全 红蓝攻防之信息收集 文章目录 系列文章目录 什么是Log4j 一 Log4j漏洞 二 漏洞产生原因 1 什么是Lookups机制 2 怎么利用JNDI进行注入 JNDI简介 LADP RMI 三 Log4j漏洞
  • 2021/8/10补题A - Min Difference

    A Min Difference 题目大意 题解 1 暴力的方法 2 双指针 优化查询 3 所有元素打上标签扔进一个数组 和1异曲同工 题目大意 给定数组a和数组b 数组a长度为n 数组b长度为m 你可以从数组a和数组b中各选一个数 问这两
  • 解决Centos7 下 root账号 远程连接FTP,vsftpd 提示 530 Login incorrect 问题

    三步走 1 vim etc vsftpd user list 注释掉 root 2 vim etc vsftpd ftpusers 同样注释掉 root 3 重启服务 systemctl restart vsftpd service 最后测
  • 使用docker搭建gitlab服务器

    一 拉取gitalb镜像 1 使用docker search gitalb gitlab 搜索有哪些镜像 2 docker pull gitlab gitlab ce 拉取镜像 这里拉取社区版的 3 创建容器 先使用默认挂载目录 随机端口
  • 什么是矩阵的范数

    原文地址 在介绍主题之前 先来谈一个非常重要的数学思维方法 几何方法 在大学之前 我们学习过一次函数 二次函数 三角函数 指数函数 对数函数等 方程则是求函数的零点 到了大学 我们学微积分 复变函数 实变函数 泛函等 我们一直都在学习和研究
  • Springboot+Pagehelper+Vue 完成分页显示操作

    Springboot Pagehelper Vue 完成分页显示操作 在开发的过程最常用也是最常见的就是表格的分页查询了 在开发的时候碰到了这个需求 所以今天讲讲怎么把Pagehelper集成到SpringBoot并结合前端框架Vue 完成
  • 如何正确的关闭 MFC 线程

    前言 近日在网上看到很多人问及如何关闭一下线程 但是我看网上给出的并不详细 而且有些方法还是错误的 小弟在此拙作一篇 不谈别的 只谈及如何正确的关闭MFC的线程 至于Win32和C RunTime的线程暂不涉及 一 关于MFC的线程 MFC
  • JS中的发布-订阅

    发布订阅模式 什么是发布 订阅模式 发布 订阅模式的实现 发布 订阅实现思路 总结 优点 缺点 Vue 中的实现 观察者模式和发布订阅的区别 观察者模式 发布订阅模式 什么是发布 订阅模式 发布 订阅模式其实是一种对象间一对多的依赖关系 当
  • Kubernetes笔记(3) - 资源管理基础

    Kubernetes系统将一切事物都抽象为API资源 其遵循REST架构风格组织并管理这些资源及其对象 同时还支持通过标准的HTTP方法 POST PUT PATCH DELETE和GET 对资源进行增 删 改 查等管理操作 Kuberne
  • tcp三次握手和四次挥手的过程

    TCP是面向连接的 无论哪一方向另一方发送数据之前 都必须先在双方之间建立一条连接 在TCP IP协议中 TCP 协议提供可靠的连接服务 连接是通过三次握手进行初始化的 三次握手的目的是同步连接双方的序列号和确认号 并交换 TCP窗口大小信
  • 简述JAVA集合框架

    简述JAVA集合框架 对常用的数据结构和算法做了一些接口和具体实现接口的类 所有抽象出来的数据结构统称为Java集合框架 在具体应用时 不必考虑数据结构和算法实现细节 只需要用这些类创建出来一些对象 然后直接应用就可以了 这样就大大提高了编
  • Nginx中代理的上下文路径设置

    Nginx中代理的上下文路径设置 实际配置nginx的时候 在Location段中配置的路径 request uri 以及代理指令 proxy pass 中设置的上下文路径的组合不同 最后实现的结果就不一样 例子 加入请求nginx服务的U
  • php修改学生信息代码_简单学习PHP中的层次性能分析器

    在 PHP 中 我们需要进行调试的时候 一般都会使用 memory get usage 看下内存的使用情况 但如果想看当前的脚本 CPU 的占用情况就没有什么现成的函数了 不过 PHP 也为我们提供了一个扩展 XHProf 这是由 Face
  • 24.qint64转QString 以及获取文件属性

    qint64转QString 1 qint64 size info size 2 qint64 转QString 3 QString size2 tr 1 arg size 获取文件属性 1 include mainwindow h 2 i
  • 大数据与人工智能的关系

    大数据与人工智能有密切的关系 大数据可以为人工智能提供大量的训练数据 从而提高人工智能的准确性和效率 人工智能又可以帮助我们对大数据进行分析和挖掘 提取有用的信息
  • 计算机二级python经典真题

    计算机二级python经典考题 1 键盘输入正整数n 按要求把n输出到屏幕 格式要求 宽度为20个字符 减号字符 右填充 右对齐 带千位分隔符 如果输入正整数超过20位 则按照真实长度输出 例如 键盘输入正整数n为1234 屏幕输出 1 2
  • Android监听屏幕录制的过程

    Android监听屏幕录制的过程如下 在AndroidManifest xml文件中声明屏幕录制权限
  • Bert和T5的区别

    Bert 和 T5 之间的主要区别在于预测中使用的标记 单词 的大小 Bert 预测一个由单个词组成的目标 single token masking 另一方面 T5 可以预测多个词 如上图所示 它在学习模型结构方面为模型提供了灵活性 Tra
  • Vue中如何进行数据可视化大屏展示

    Vue中如何进行数据可视化大屏展示 在现代数据驱动的应用程序中 数据可视化大屏已经成为了非常重要的一环 通过对海量数据进行可视化展示 可以帮助用户更好地理解和分析数据 从而做出更加明智的决策 在Vue中进行数据可视化大屏展示也变得越来越流行
  • JVM 运行时数据区(栈和堆)

    文章目录 JVM 是一种规范 什么是 JVM 为什么 JVM 是一种规范 Java 程序的执行过程 JVM 与字节码文件 栈指令集架构和寄存器指令集架构 Hotspot 虚拟机及 Dalvik ART 虚拟机 JVM 的组成部分及架构 运行