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

4 篇文章 0 订阅

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 -> 初始化对象默认值 -> 设置对象头 -> 调用构造

对象内存布局

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

  • 2
    点赞
  • 5
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值