JVM学习笔记

发布于:2025-07-01 ⋅ 阅读:(21) ⋅ 点赞:(0)

内容参考来源

  • 《深入理解Java虚拟机:JVM高级特性与最佳实践(第3版)周志明》

Java

Java优点

  • 除了它拥有一门结构严谨、面向对象的编程语言之外
  • 它摆脱了硬件平台的束缚,实现了“一次编写,到处运行”的理想;
  • 它提供了一种相对 安全的内存管理和访问机制,避免了绝大部分内存泄漏和指针越界问题;
  • 它实现了热点代码检测和运 行时编译及优化,这使得Java应用能随着运行时间的增长而获得更高的性能;
  • 它有一套完善的应用程序接口,还有无数来自商业机构和开源社区的第三方类库来帮助用户实现各种各样的功能……

Java体系

image-20220614222732972

Java各版本变化

JDK 5发布,工程代号为Tiger(老虎)。Sun公司从这个版本开始放弃了谦逊
的“JDK 1.x”的命名方式,将产品版本号修改成了“JDK x”。JDK 5在Java语法易用性上做出了非常大的改进。如:自动装箱、泛型、动态注解、枚 举、可变长参数、遍历循环(foreach循环)等语法特性都是在JDK 5中加入的。在虚拟机和API层面上,这个版本改进了Java的内存模型(Java Memory Model,JMM)、提供了java.util.concurrent并发包 等。另外,JDK 5是官方声明可以支持Windows 9x操作系统的最后一个JDK版本。

JDK 6发布,工程代号为Mustang(野马)。在这个版本中,Sun公司终结了从
JDK 1.2开始已经有八年历史的J2EE、J2SE、J2ME的产品线命名方式,启用Java EE 6、Java SE 6、Java ME 6的新命名来代替。JDK 6的改进包括:提供初步的动态语言支持(通过内置Mozilla JavaScript Rhino引擎实现)、提供编译期注解处理器和微型HTTP服务器API,等等。同时,这个版本对Java虚拟 机内部做了大量改进,包括锁与同步、垃圾收集、类加载等方面的实现都有相当多的改动。

Hotspot虚拟机

HotSpot虚拟机的热点代码探测能力可以通过执行计数器 找出最具有编译价值的代码,然后通知即时编译器以方法为单位进行编译。如果一个方法被频繁调 用,或方法中有效循环次数很多,将会分别触发标准即时编译和栈上替换编译(On-Stack Replacement,OSR)行为[1]。通过编译器与解释器恰当地协同工作,可以在最优化的程序响应时间与 最佳执行性能中取得平衡,而且无须等待本地代码输出才能执行程序,即时编译的时间压力也相对减 小,这样有助于引入更复杂的代码优化技术,输出质量更高的本地代码。

到了2014年的JDK 8时期,HotSpot在这个过程 里移除掉永久代,吸收了JRockit的Java Mission Control监控工具等功能。

模块化方面原本是HotSpot的弱项,监控、执行、编译、内存管理等多个子系统的代码相互纠缠。

即时编译器

参考

对需要长时间运行的应用来说,由于经过充分预热,热点代码会被HotSpot的探测机制准确定位捕获,并将其编译为物理硬件可直接执行的机器码,在这类应用中Java的运行效率很大程度上取决于即 时编译器所输出的代码质量。

HotSpot虚拟机中含有两个即时编译器,分别是编译耗时短但输出代码优化程度较低的客户端编译器(简称为C1)以及编译耗时长但输出代码优化质量也更高的服务端编译器(简称为C2),通常它们 会在分层编译机制下与解释器互相配合来共同构成HotSpot虚拟机的执行子系统

自JDK 10起,HotSpot中又加入了一个全新的即时编译器:Graal编译器,看名字就可以联想到它是来自于前一节提到的Graal VM。Graal编译器是以C2编译器替代者的身份登场的。Graal编译器本身就是由Java语言写 成,实现时又刻意与C2采用了同一种名为“Sea-of-Nodes”的高级中间表示(High IR)形式,使其能够 更容易借鉴C2的优点。Graal的编译效果短短几年间迅速追平了C2,甚至某些 测试项中开始逐渐反超C2编译器。Graal能够做比C2更加复杂的优化,如“部分逃逸分析”(Partial Escape Analysis),也拥有比C2更容易使用激进预测性优化(Aggressive Speculative Optimization)的 策略,支持自定义的预测性假设等。

提前编译

提前编译AOT是相对于即时编译的概念,提前编译能带来的最大好处是Java虚拟机加载这些已经预编译成二进制库之后就能够直接调用,而无须再等待即时编译器在运行时将其编译成二进制机器码。理 论上,提前编译可以减少即时编译带来的预热时间,减少Java应用长期给人带来的“第一次运行慢”的 不良体验,可以放心地进行很多全程序的分析行为

JVM参数

JVM选项

  • 布尔型参数选项:-XX:+ 打开,-XX:- 关闭。(比如-XX:+PrintGCDetails)
  • 数字型参数选项:通过-XX:=设定。数字可以是m/M(兆字节),k/K(千字节),g/G(G字节)。比如:32K表示32768字节。
  • 字符行参数选项**:**通过-XX:=设定。通常用来指定一个文件、路径,或者一个命令列表。(比如-XX:HeapDumpPath=./java_pid.hprof)

参数分类

根据jvm参数开头可以区分参数类型,共三类:“-”、“-X”、“-XX”,

标准参数

  • 标准参数(-):所有的JVM实现都必须实现这些参数的功能,而且向后兼容;
参数 含义
-verbose:gc 输出每次GC的一些信息
-verbose:class 输出JVM载入类的相关信息
-verbose:jni 输出native方法调用的相关情况

非标准参数

  • 非标准参数(-X):默认jvm实现这些参数的功能,但是并不保证所有jvm实现都满足,且不保证向后兼容;
参数 含义 备注
-Xms 初始堆内存大小 此值可以设置与-Xmx相同,以避免每次垃圾回收完成后JVM重新分配内存。
-Xmn 新生代内存大小 此处的大小是(eden+ 2 survivor space);与jmap -heap中显示的New gen是(eden+1 survivor space)不同的。
-Xmx 堆内存最大值
-Xss 每个线程的堆栈大小
-Xloggc:file 将每次GC事件的相关情况记录到一个文件中 与-verbose:gc功能类似,文件的位置最好在本地,以避免网络的潜在问题。若与verbose命令同时出现在命令行中,则以-Xloggc为准。
-Xprof 跟踪正运行的程序,并将跟踪数据在标准输出输出 适合于开发环境调试
-Xrunhprof J2SE自带的一个内存使用分析工具profiler agent 一个动态链接库文件,监控CPU的使用率,Heap分配情况等。将这些信息输出到文件或到socket。从而找到占用内存较大的对象。这对应经常出现内存泄漏(OOM)的JAVA系统进行调优很有帮助。
-Xdebug JVM调试参数,用于远程调试。

非Stable参数

  • 非Stable参数(-XX):此类参数各个jvm实现会有所不同,将来可能会随时取消,需要慎重使用;
参数 含义 备注
-XX:+printGC 每次GC时打印相关信息
-XX:+PrintGCDetails 每次GC时打印详细信息
-XX:+PrintGCTimeStamps 打印每次GC的时间戳
-XX:-DisableExplicitGC 禁止调用System.gc(),但JVM的GC仍然有效。
-XX:SurvivorRatio 设置Eden和其中一个Survivor的比值 默认比例为8(Eden):1(一个survivor)
-XX:PretenureSizeThreshold 新对象直接在老年代分配的阈值 只对Serial和ParNew两款新生代收集器有效
-XX:MaxTenuringThreshold 对象晋升老年代的年龄阈值
-XX:NewSize=1024m 设置年轻代初始值为1024M
-XX:MaxNewSize=1024m 设置年轻代最大值为1024M。
-XX:PermSize=256m 设置持久代初始值为256M。
-XX:MaxPermSize=256m 设置持久代最大值为256M。
-XX:NewRatio=4 设置年轻代(包括1个Eden和2个Survivor区)与年老代的比值。表示年轻代比年老代为1:4。
-XX:SurvivorRatio=4 设置年轻代中Eden区与Survivor区的比值。表示2个Survivor区(JVM堆内存年轻代中默认有2个大小相等的Survivor区)与1个Eden区的比值为2:4,即1个Survivor区占整个年轻代大小的1/6。
-XX:MaxTenuringThreshold=7 表示一个对象如果在Survivor区(救助空间)移动了7次还没有被垃圾回收就进入年老代。如果设置为0的话,则年轻代对象不经过Survivor区,直接进入年老代,对于需要大量常驻内存的应用,这样做可以提高效率。如果将此值设置为一个较大值,则年轻代对象会在Survivor区进行多次复制,这样可以增加对象在年轻代存活时间,增加对象在年轻代被垃圾回收的概率,减少Full GC的频率,这样做可以在某种程度上提高服务稳定性。

自动内存管理

简介

Java技术体系的自动内存管理,最根本的目标是自动化地解决两个问题:自动给对象分配内存以及自动回收分配给对象的内存。

jvm运行时数据区

image-20220615211543658

Java 运行时数据区域(JDK1.8 之后)

Java 运行时数据区域(JDK1.8 之后)

程序计数器

生命周期与线程相同。

程序计数器(Program Counter Register)是一块较小的内存空间,它可以看作是当前线程所执行的字节码的行号指示器。在Java虚拟机的概念模型里[1],字节码解释器工作时就是通过改变这个计数器 的值来选取下一条需要执行的字节码指令,它是程序控制流的指示器,分支、循环、跳转、异常处 理、线程恢复等基础功能都需要依赖这个计数器来完成。

由于Java虚拟机的多线程是通过线程轮流切换、分配处理器执行时间的方式来实现的,在任何一个确定的时刻,一个处理器(对于多核处理器来说是一个内核)都只会执行一条线程中的指令。因此,为了线程切换后能恢复到正确的执行位置,每条线程都需要有一个独立的程序计数器,各条线程之间计数器互不影响,独立存储,我们称这类内存区域为“线程私有”的内存。

如果线程正在执行的是一个Java方法,这个计数器记录的是正在执行的虚拟机字节码指令的地址;如果正在执行的是本地(Native)方法,这个计数器值则应为空(Undefined)。此内存区域是唯 一一个在《Java虚拟机规范》中没有规定任何OutOfMemoryError情况的区域

虚拟机栈

生命周期与线程相同。

虚拟机栈为虚拟机执行Java方法(也就是字节码)服务。

虚拟机栈描述的是Java方法执行的线程内存模型:每个方法被执行的时候,Java虚拟机都 会同步创建一个栈帧[1](Stack Frame)用于存储局部变量表、操作数栈、动态连接、方法出口等信 息。

每一个方法被调用直至执行完毕的过程,就对应着一个栈帧在虚拟机栈中从入栈到出栈的过程。

人们常说的堆内存(Heap)和栈内存(Stack),这种划分方式直接继承自传统的C、C++程序的内存布局结构,在Java中结构更复杂。栈”通常就是指这里讲的虚拟机栈,或 者更多的情况下只是指虚拟机栈中局部变量表部分。

局部变量表存放了编译期可知的各种Java虚拟机基本数据类型(boolean、byte、char、short、int、float、long、double)、对象引用(reference类型,它并不等同于对象本身,可能是一个指向对象起始 地址的引用指针,也可能是指向一个代表对象的句柄或者其他与此对象相关的位置)和returnAddress 类型(指向了一条字节码指令的地址)。这些数据类型在局部变量表中的存储空间以局部变量槽(Slot)来表示,其中64位长度的long和double类型的数据会占用两个变量槽,其余的数据类型只占用一个。

局部变量表所需的内存空间在编 译期间完成分配,当进入一个方法时,这个方法需要在栈帧中分配多大的局部变量空间是完全确定 的,在方法运行期间不会改变局部变量表的大小。“大小”是指变量槽的数量, 虚拟机真正使用多大的内存空间(譬如按照1个变量槽占用32个比特、64个比特,或者更多)来实现一 个变量槽,这是完全由具体的虚拟机实现自行决定的事情。

在《Java虚拟机规范》中,对这个虚拟机栈规定了两类异常状况:

  • 如果线程请求的栈深度大于虚拟机所允许的深度,将抛出StackOverflowError异常;
  • 如果Java虚拟机栈容量可以动态扩展[2],当栈扩 展时无法申请到足够的内存会抛出OutOfMemoryError异常。

HotSpot虚拟机的栈容量是不可以动态扩展的,以前的Classic虚拟机倒是可以。所以在HotSpot虚拟 机上是不会由于虚拟机栈无法扩展而导致OutOfMemoryError异常——只要线程申请栈空间成功了就不 会有OOM,但是如果申请时就失败,仍然是会出现OOM异常的,后面的实战中笔者也演示了这种情 况。

本地方法栈

本地方法栈则是为虚拟机使用到的本地(Native) 方法服务。

Java虚拟机规范》对本地方法栈中方法使用的语言、使用方式与数据结构并没有任何强制规定,因此具体的虚拟机可以根据需要自由实现它,甚至有的Java虚拟机(譬如Hot-Spot虚拟机)直接 就把本地方法栈和虚拟机栈合二为一。

与虚拟机栈一样,本地方法栈也会在栈深度溢出或者栈扩展失 败时分别抛出StackOverflowError和OutOfMemoryError异常。

简介

Java堆是被所有线程共享的一块内存区域,在虚拟机启动时创建。此内存区域的唯一目的就是存放对象实例,Java 世界里“几乎”所有的对象实例都在这里分配内存,但是随着技术发展,Java对象实例都分配在堆上也渐渐变得不是绝对的。

在《Java虚拟机规范》中对Java堆的描述是:“所有 的对象实例以及数组都应当在堆上分配[1]”

Java堆是垃圾收集器管理的内存区域,因此一些资料中它也被称作“GC堆”(Garbage Collected
Heap)。

从回收内存的角度看,由于现代垃圾收集器大部分都是基于分 代收集理论设计的,所以Java堆中经常会出现“新生代”“老年代”“永久代”“Eden空间”“From Survivor空 间”“To Survivor空间”等名词,这些区域划分仅仅是一部分垃圾收集器的共同特性或者说设计风格而已,而非某个Java虚拟机具体 实现的固有内存布局,更不是《Java虚拟机规范》里对Java堆的进一步细致划分。在十年 之前(以G1收集器的出现为分界),作为业界绝对主流的HotSpot虚拟机,它内部的垃圾收集器全部 都基于“经典分代”[3]来设计,需要新生代、老年代收集器搭配才能工作。但是到了今天,HotSpot里面也出 现了不采用分代设计的新垃圾收集器。

经典分代:指新生代(其中又包含一个Eden和两个Survivor)、老年代这种划分

如果从分配内存的角度看,所有线程共享的Java堆中可以划分出多个线程私有的分配缓冲区(Thread Local Allocation Buffer,TLAB),以提升对象分配时的效率。不过无论从什么角度,无论如 何划分,都不会改变Java堆中存储内容的共性,无论是哪个区域,存储的都只能是对象的实例,将Java 堆细分的目的只是为了更好地回收内存,或者更快地分配内存。

根据《Java虚拟机规范》的规定,Java堆可以处于物理上不连续的内存空间中,但在逻辑上它应该被视为连续的,这点就像我们用磁盘空间去存储文件一样,并不要求每个文件都连续存放。但对于大 对象(典型的如数组对象),多数虚拟机实现出于实现简单、存储高效的考虑,很可能会要求连续的 内存空间。

Java堆既可以被实现成固定大小的,也可以是可扩展的,不过当前主流的Java虚拟机都是按照可扩展来实现的(通过参数-Xmx和-Xms设定)。如果在Java堆中没有内存完成实例分配,并且堆也无法再 扩展时,Java虚拟机将会抛出OutOfMemoryError异常。

老年代大小 =(-Xmx)减(-Xmn) 
整个堆大小 = 年轻代大小 + 老年代大小 + 持久代大小 

持久代大小:一般固定大小为64M,所以增大年轻代(-Xmn)后,将会减小年老代大小。此值对系统性能影响较大,Sun官方推荐配置为 整个堆的3/8。

结构

新生代默认占1/3,老年代默认占2/3,新生代包含Eden区、From Survivor0区和 To Survivor1区,默认 比例是8:1:1,老年代就一个Old Memory区。

方法区

方法区是各个线程共享的内存区域,它用于存储已被虚拟机加载的类型信息、常量、静态变量、即时编译器编译后的代码缓存等数据。

jdk8之前,许多程序员把方法区和永久代混为一谈,但实际上不是这样的。

当时的HotSpot虚拟机设 计团队选择把收集器的分代设计扩展至方法区,或者说使用永久代来实现方法区,这样使得 HotSpot的垃圾收集器能够像管理Java堆一样管理这部分内存,省去专门为方法区编写内存管理代码的 工作。但是对于其他虚拟机实现,譬如BEA JRockit、IBM J9等来说,是不存在永久代的概念的。

永久代缺点:

  • 永久代使用的是设定好的虚拟机内存,无法动态扩展内存空间,当加载的类过多就可能发生OOM,并且 永久代的内存大小设置也是难以确定的,所以对永久代调优也是比较困难的。(永久代有-XX:MaxPermSize的上限,即使不设置也有默认大小,而J9和JRockit只要 没有触碰到进程可用内存的上限,例如32位系统中的4GB限制,就不会出问题)
  • 有极少数方法 (例如String::intern())会因永久代的原因而导致不同虚拟机下有不同的表现。

JDK 8,完全废弃了永久代的概念,改用与JRockit、J9一样在本地内存中实现的元空间

《Java虚拟机规范》对方法区的约束是非常宽松的

  • 和Java堆一样不需要连续的内存和可以选择固定大小或者可扩展
  • 甚至还可以选择不实现垃圾收集

相对而言,垃圾收集行为在这个区域的 确是比较少出现的,但并非数据进入了方法区就如永久代的名字一样“永久”存在了。这区域的内存回 收目标主要是针对常量池的回收和对类型的卸载,一般来说这个区域的回收效果比较难令人满意,尤 其是类型的卸载,条件相当苛刻,但是这部分区域的回收有时又确实是必要的。

根据《Java虚拟机规范》的规定,如果方法区无法满足新的内存分配需求时,将抛出
OutOfMemoryError异常。

直接内存

直接内存(Direct Memory)并不是虚拟机运行时数据区的一部分,也不是《Java虚拟机规范》中定义的内存区域。但是这部分内存也被频繁地使用,而且也可能导致OutOfMemoryError异常出现。

在JDK 1.4中新加入了NIO(New Input/Output)类,引入了一种基于通道(Channel)与缓冲区
(Buffer)的I/O方式,它可以使用Native函数库直接分配堆外内存,然后通过一个存储在Java堆里面的 DirectByteBuffer对象作为这块内存的引用进行操作。这样能在一些场景中显著提高性能,因为避免了 在Java堆和Native堆中来回复制数据。

本机直接内存的分配不会受到Java堆大小的限制,但还是会受到本机总内存(包括物理内存、SWAP分区或者分页文件)大小以及处理器寻址空间的限制,一般服务 器管理员配置虚拟机参数时,会根据实际内存去设置-Xmx等参数信息,但经常忽略掉直接内存,使得 各个内存区域总和大于物理内存限制(包括物理的和操作系统级的限制),从而导致动态扩展时出现 OutOfMemoryError异常。

常量池

参考

  • https://blog.csdn.net/qq_26719997/article/details/110187881

class常量池

Class文件中除了有类的版本、字段、方法、接口等描述信息外,还有一项信息是常量池表(Constant Pool Table),用于存放编译期生成的各种字面量与符号引用,这部分内容将在类加载后存放到方法区的运行时常量池中。

运行时常量池

运行时常量池(Runtime Constant Pool)是方法区的一部分。

Java虚拟机对于Class文件每一部分(自然也包括常量池)的格式都有严格规定,如每一个字节用于存储哪种数据都必须符合规范上的要求才会被虚拟机认可、加载和执行,但对于运行时常量池, 《Java虚拟机规范》并没有做任何细节的要求,不同提供商实现的虚拟机可以按照自己的需要来实现 这个内存区域,不过一般来说,除了保存Class文件中描述的符号引用外,还会把由符号引用翻译出来 的直接引用也存储在运行时常量池中[1]。

运行时常量池相对于Class文件常量池的另外一个重要特征是具备动态性,Java语言并不要求常量一定只有编译期才能产生,也就是说,并非预置入Class文件常量池的内容才能进入方法区运行时常 量池,运行期间也可以将新的常量放入池中,这种特性被开发人员利用得比较多的便是String类的 intern()方法。

既然运行时常量池是方法区的一部分,自然受到方法区内存的限制,当常量池无法再申请到内存
时会抛出OutOfMemoryError异常。

运行时常量池是每个类都有一个。

字符串常量池

简介

字符串常量池逻辑上属于运行时常量池的一部分,但是它和运行时常量池的区别在于,字符串常量池是全局唯一的,而运行时常量池是每个类一个。

结构

为了提高匹配速度,即更快的查找某个字符串是否存在于常量池,Java在设计字符串常量池的时候,还搞了一张stringtable, stringtable 有点类似于我们的hashtable,里面保存了字符串的引用。

在这里插入图片描述

在jdk6中 StringTable 的长度是固定的,就是1009,因此如果放入String Pool中的String非常多,就会造成hash冲突,导致链表过长。此时当调用 String.intern() 时会需要到链表上一个一个找,从而导致性能大幅度下降;
在jdk7及以上版本, StringTable 的长度可以通过一个参数指定:-XX:StringTableSize=99991

字符串常量池查找字符串的方式:

  • 根据字符串的 hashcode 找到对应entry。如果没冲突,它可能只是一个entry,如果有冲突,它可能是一个entry链表,然后Java再遍历entry链表,匹配引用对应的字符串。
  • 如果找得到字符串,返回引用。如果找不到字符串,会把字符串放到常量池,并把引用保存到stringtable里。
intern()

String::intern()是一个本地方法,它的作用是如果字符串常量池中已经包含一个等于此String对象的字符串,则返回代表池中这个字符串的String对象的引用;否则,会将此String对象包含的字符串添加到常量池中,并且返回此String对象的引用。

它遵循以下规则:对于任意两个字符串 s 和 t,当且仅当 s.equals(t) 为 true 时,s.intern() == t.intern() 才为 true。

关于字符串的创建:

  • 采用new创建的字符串对象不进入字符串池
  • 字符串相加的时候,都是静态字符串的结果会添加到字符串池
  • 如果其中含有变量,则不会进入字符串池中
// String.intern()返回引用的测试
public class RuntimeConstantPoolOOM {
    public static void main(String[] args) {
        String str1 = new StringBuilder("计算机").append("软件").toString();
        System.out.println(str1.intern() == str1); // 1
        String str2 = new StringBuilder("ja").append("va").toString();
        System.out.println(str2.intern() == str2); // 2
        System.out.println(str2.intern() == "java"); // 3
    }
}

这段代码在JDK 6中运行,123会得到false,而在JDK 7之后中运行,13会得到一个true,2一个false。

  • 在JDK 6中,intern()方法会把首次遇到的字符串实例的字面量复制到永久代的字符串常量池中存储,返回的也是字符串常量池里面这个字面量的引用,返回字面量的引用。首次遇到的字符串常量的字面量复制到永久代的字符串常量池中存储。而由StringBuilder创建的字符串对象实例在 Java堆上,所以必然不可能是同一个引用,结果将返回false。
  • 而JDK 7(以及部分其他虚拟机,例如JRockit)的intern()方法,把首次遇到的字符串实例的引用存储在字符串常量池,返回常量池中的实例引用。如果是首次遇到的字符串常量,则存储在常量池中,返回常量池中常量。
    • str1是一个字符串实例,首次出现,str1.intern()返回的是常量池存储的str1的引用,和由StringBuilder创建的那个字符串实例就是同一个。
    • str2是一个字符串实例,但是“java”这个字符串常量在执行String-Builder.toString()之前(它是在加载sun.misc.Version这个类的时候进入常量池的)就已经出现过了,字符串常量池中已经有它的引用,str2.inern()返回的是字符串常量池中的"java"。

各种常量池的位置

  • 在JDK1.7之前 运行时常量池逻辑包含字符串常量池存放在方法区, 此时hotspot虚拟机对方法区的实现为永久代。静态常量池在Class文件中。
  • 在JDK1.7 字符串常量池被从方法区拿到了堆(方法区是堆的一个逻辑分区)中, 这里没有提到运行时常量池,也就是说字符串常量池被单独拿到堆,运行时常量池剩下的东西还在方法区, 也就是hotspot中的永久代。静态常量池在Class文件中。
  • 在JDK1.8及之后 hotspot移除了永久代用元空间(Metaspace)取而代之, 这时候字符串常量池还在堆, 运行时常量池还在方法区, 只不过方法区的实现从永久代变成了元空间(Metaspace)。静态常量池在Class文件中。

对象

对象创建

image-20221025205951426

当Java虚拟机遇到一条字节码new指令时,首先将去检查这个指令的参数是否能在常量池中定位到一个类的符号引用,并且检查这个符号引用代表的类是否已被加载、解析和初始化过。如果没有,那必须先执行相应的类加载过程

在类加载检查通过后,接下来虚拟机将为新生对象分配内存。对象所需内存的大小在类加载完成后便可完全确定,为对象分配空间的任务实际上便等同于把一块确定大小的内存块从Java堆中划分出来。内存分配方式根据Java堆中内存是否完整主要分为指针碰撞和空闲列表两种:

  • 指针碰撞:假设Java堆中内存是绝对规整的,所有被使用过的内存都被放在一 边,空闲的内存被放在另一边,中间放着一个指针作为分界点的指示器,那所分配内存就仅仅是把那 个指针向空闲空间方向挪动一段与对象大小相等的距离,这种分配方式称为“指针碰撞”(Bump The Pointer)。
  • 空闲列表:但如果Java堆中的内存并不是规整的,已被使用的内存和空闲的内存相互交错在一起,那 就没有办法简单地进行指针碰撞了,虚拟机就必须维护一个列表,记录上哪些内存块是可用的,在分 配的时候从列表中找到一块足够大的空间划分给对象实例,并更新列表上的记录,这种分配方式称 为“空闲列表”(Free List)。

选择哪种分配方式由Java堆是否规整决定,而Java堆是否规整又由所采用的垃圾收集器是否带有空间压缩整理(Compact)的能力决定。因此,当使用Serial、ParNew等带压缩 整理过程的收集器时,系统采用的分配算法是指针碰撞,既简单又高效;而当使用CMS这种基于清除 (Sweep)算法的收集器时,理论上就只能采用较为复杂的空闲列表来分配内存,实际上在CMS的实现里面,为了能在多数情况下分配得更快,设计了一个叫作Linear Allocation Buffer的分配缓冲区,通过空闲列表拿到一大块分配缓冲区之后,在它里面仍然可以使用指 针碰撞方式来分配。

因为堆是线程共享的,所以对象创建存在线程安全问题,解决方法:

  • 一种是对分配内存空间的动作进行同步处理——实际上虚拟机是采用CAS配上失败重试的方式保证更新操作的原子性;
  • 另外一种是把内存分配的动作按照线程划分在不同的空间之中进行,即每个线程在Java堆中预先分配一小块内存,称为本地线程分配缓冲(Thread Local Allocation Buffer,TLAB),哪个线程要分配内存,就在哪个线程的本地缓冲区中分配,只有本地缓冲区用完 了,分配新的缓存区时才需要CAS同步锁定。虚拟机是否使用TLAB,可以通过-XX:+/-UseTLAB参数来 设定

内存分配完成之后,虚拟机必须将分配到的内存空间(但不包括对象头)都初始化为零值,如果使用了TLAB的话,这一项工作也可以提前至TLAB分配时顺便进行。这步操作保证了对象的实例字段 在Java代码中可以不赋初始值就直接使用,使程序能访问到这些字段的数据类型所对应的零值。

接下来设置对象头。虚拟机需要对对象进行必要的设置,例如这个对象是哪个类的实例、 如何才能找到类的元数据信息、对象的哈希码、对象的GC分代年龄等信息都是存放在对象的对象 头中。根据虚拟 机当前运行状态的不同,如是否启用偏向锁等,对象头会有不同的设置方式。

执行到这一步,从虚拟机的视角来看,一个新的对象已经产生了。但是从Java程序的视
角看来,对象创建才刚刚开始——构造函数,即Class文件中的()方法还没有执行,所有的字段都 为默认的零值,对象需要的其他资源和状态信息也还没有按照预定的意图构造好。一般来说(由字节 码流中new指令后面是否跟随invokespecial指令所决定,Java编译器会在遇到new关键字的地方同时生成 这两条字节码指令,但如果直接通过其他方式产生的则不一定如此),new指令之后会接着执行 ()方法,按照程序员的意愿对对象进行初始化,这样一个真正可用的对象才算完全被构造出来。

img

内存布局

  • 对象头(Header):用于存储对象自身运行时状态的相关信息、指向该对象所属类的元数据的指针,如果对象是数组还会额外存储数组的长度。包括哈希值(hashcode)、类型、GC分代年龄、锁状态标志、线程持有的锁、偏向线程ID、偏向时间戳等。

  • 实际数据(Instance Data):用于存放类的数据信息,父类的信息,对象字段属性信息。字段的存储顺序会受到数据类型长度、以及虚拟机的分配策略的影响

  • 对齐填充(Padding):64位的jvm中对象的大小被要求向8字节对齐,因此当对象的长度不足8字节的整数倍时,需要在对象中进行填充操作。但是填充字节并不是固定存在的部分。

image-20220513220615330

openjdk官网提供了查看对象内存布局的工具jol (java object layout)

<dependency>
    <groupId>org.openjdk.jol</groupId>
    <artifactId>jol-core</artifactId>
    <version>0.14</version>
</dependency>

在代码中使用jol提供的方法查看jvm信息:

System.out.println(VM.current().details());
image-20220509150742069

使用jol查看对象头的内存布局:

public class User {
}

public static void main(String[] args) {
    User user=new User();
    //查看对象的内存布局
    System.out.println(ClassLayout.parseInstance(user).toPrintable());
}

执行代码,查看打印信息:

image-20220509151018806
  • OFFSET:偏移地址,单位为字节
  • SIZE:占用内存大小,单位为字节
  • TYPEClass中定义的类型
  • DESCRIPTION:类型描述,Obejct header 表示对象头,alignment表示对齐填充
  • VALUE:对应内存中存储的值

一个不含属性的对象在内存中的组成如下所示

8B (mark word) + 4B (klass pointer) + 0B (instance data) + 4B (padding)

对齐填充字节

  • 前置补位:对象头长度不足8n字节时,用字段(长度小于8字节)紧接着对象头后面填充称为前置补位。需要设置JVM参数来开启,否则不会进行前置补位。比如,在开启指针压缩的情况下,如果类中定义了long或者double类型变量,在对象头和实例数据之间会存在空隙。
  • 后置补位:实例数据中有父类的字段,但是对象头+父类字段的长度不足8n字节时,用子类的字段紧接着父类的字段后面填充称为后置补位。
  • 对齐填充:用0填充空位使得对象长度满足8n字节,0只起到占位符作用,无特殊含义。

对象头

简介

32位虚拟机中,一个字宽是32位。64位虚拟机中,一个字宽是64位。

非数组类型对象的对象头用2字宽存储,数组类型对象的对象头用3字宽存储。

image-20220507200111364

长度 内容 说明
32/64bit Mark Word 存储对象的hashCode或锁信息等
32/64bit Class Metadata Address 存储到对象类型数据的指针
32/64bit Array length 数组的长度(如果是数组)

以64位为例,在对象头中mark word 占8字节,默认开启指针压缩的情况下klass pointer 占4字节,数组对象的数组长度占4字节。

Mark Word

用于存储对象自身的运行时数据

32位

img

64位

img

hashcode

hashcode占31位,无锁状态的hashcode采用了延迟加载技术。

在第一次调用hashcode()方法的时候,如果是没有被重写的Object.hashCode()方法或System.identityHashCode(Object)方法才会写入mark word,执行用户自定义的hashCode()方法不会被写入。

当对象被加锁后,mark word中就没有足够空间来保存hashCode了,这时hashcode会被移动到重量级锁的Object Monitor中。

public class User {
}

@Test
public void hashcodeTest() {
    User user=new User();
    //打印内存布局
    System.out.println(ClassLayout.parseInstance(user).toPrintable());
    //计算hashCode
    System.out.println(user.hashCode());
    //再次打印内存布局
    System.out.println(ClassLayout.parseInstance(user).toPrintable());
}

image-20220510190959198

根据大小端,实际填入的hashcode值为1100110 00000100 10001011 11111101,对应十进制的值为1711574013。

epoch

偏向锁的时间戳

age

分代年龄。

在JVM的垃圾回收过程中,每当对象经过一次 young gc ,年龄都会加一。age占4位,所以分代年龄最大值为15,当对象的年龄超过15之后会被移动到老年代。

在JVM启动的时候可以修改参数调整分代年龄阈值,但是不能超过15。

Klass Pointer

Klass Pointer ,类型指针,是一个指向方法区中 Class 信息的指针,JVM通过该指针来确定对象属于哪个类的实例。在64位的JVM中,支持指针压缩功能,从jdk 6 开始默认开启。并不是所有的虚拟机实现都必须在对象数据上保留类型指针,换句话 说,查找对象的元数据信息并不一定要经过对象本身。

  • 未开指针压缩功能,类型指针占8B(64位)
  • 开启指针压缩功能,类型指针占4B(32位)
#开启指针压缩:
-XX:+UseCompressedOops
#关闭指针压缩:
-XX:-UseCompressedOops

以空对象的User类为例

关闭指针压缩后查看对象的内存布局:

image-20220510193607709

开启指针压缩后查看对象的内存布局:

image-20220510193715672

指针压缩的作用

  • 利用对齐填充的特性,通过映射方式达到了内存地址扩展的效果。在不开启指针压缩的情况下,一个对象的内存地址使用64位表示,在开启指针压缩后,一个对象的内存地址使用32位表示,可以表示2^32=4GB 个内存地址,由于java中对象默认使用了8字节对齐,一个对象占用空间是8字节的整数倍,所以2^32 个内存地址实际可以映射到2^32*8=32GB个内存地址。
  • 节省内存空间,提高程序寻址效率
  • 堆内存设置时最好不要超过32GB,这时指针压缩将会失效,造成空间的浪费
  • 指针压缩不仅可以作用于对象头的类型指针,还可以作用于引用类型的字段指针,以及引用类型数组指针
Length Field

数组长度。如果对象是一个数组对象,在对象头有一个保存数组长度的空间,占4字节。

public class User {
}

@Test
public void test1() {
    //查看对象的内存布局
    User[] users = new User[3];
    System.out.println(ClassLayout.parseInstance(users).toPrintable());
}

image-20220510201923692

数组对象内存结构示例

  • 8字节mark word
  • 4字节klass pointer
  • 4字节数组长度,值为3,表示数组中有3个元素
  • 开启指针压缩后每个引用类型占4字节,数组中3个元素共占12字节
  • 4字节padding用于对象的字节填充

实例数据

实例数据保存对象真正存储的有效信息,保存了对象中的各种数据类型的字段,如果对象继承了父类,实例数据还会包括从父类继承的字段。

数据类型 具体类型 字节数
基本数据类型 byte,boolean 1
基本数据类型 char,short 2
基本数据类型 int,float 4
基本数据类型 long,double 8
引用数据类型 引用数据类型 开启指针压缩4,关闭指针压缩8

以User2为例查看对象内存结构

class User2 {
    int id,age,weight;
    byte sex;
    String firstName;
    String lastName;
    long phone;
    char local;
}

image-20220510205049115

属性的排列顺序与在类中定义的顺序不同,这是因为jvm会采用字段重排序技术,对原始类型进行重新排序,以达到内存对齐的目的。具体规则遵循如下:

  • 字段按照长度大小从大到小排列
  • 相同长度的字段分配在相邻位置
  • 字段长度为L字节,那么该字段的偏移量需要对齐到nL(n为整数)。比如long占8字节,偏移量一定的8n,由于对象头长度至少是12字节,所以long类型字段的最小偏移量16,偏移量12到16之间的4字节通过长度小于等于4字节的字段(前置补位)或者0(对齐填充padding)来填充,以达到8字节对齐。
  • 默认情况下,基本数据类型的变量排在引用数据类型前,可通过修改JVM参数调整。

如果对象继承了父类

  • 父类的字段在子类字段之前
  • 如果父类字段需要后置补位,可能会发生子类字段的重排序,子类长度小于等于4字节的字段会用于父类字段的后置补位。
  • 父类的前置对齐填充会被子类继承,即使子类存在可以用于前置补位的字段,但是会违反父类的字段在子类字段之前的原则。

对象访问

由于reference类型在《Java虚拟机规范》里面只规定了它是一个指向对象的引用,并没有定义 这个引用应该通过什么方式去定位、访问到堆中对象的具体位置,所以对象访问方式也是由虚拟机实 现而定的,主流的访问方式主要有使用句柄直接指针两种

句柄

使用句柄访问的话,Java堆中将可能会划分出一块内存来作为句柄池,reference中存储的就是对象的句柄地址,而句柄中包含了对象实例数据与类型数据各自具体的地址信息。使用句柄来访问的最大好处就是reference中存储的是稳定句柄地址,在对象被移动(垃圾收集时移动对象是非常普遍的行为)时只会改变句柄中的实例数据指针,而 reference本身不需要被修改

image-20220617215634111

直接指针

如果使用直接指针访问的话,Java堆中对象的内存布局就必须考虑如何放置访问类型数据的相关
信息,reference中存储的直接就是对象地址,如果只是访问对象本身的话,就不需要多一次间接访问 的开销。使用直接指针来访问最大的好处就是速度更快,它节省了一次指针定位的时间开销,由于对象访问在Java中非常频繁,因此这类开销积少成多也是一项极为可观的执行成本。

image-20220617215647640

对象内存分配

  1. 一般,对象在新生代Eden区分配,当Eden区没有足够空间分配时,虚拟机发起一次Minor GC。如果经过第一次 Minor GC 后仍然存活,并且能被Survivor容纳的话,该对象会被移动到From Survivor空间中,并且将其对象年龄设为1岁。
  2. 当再次发生Minor GC,会将Eden区和From Survivor区一起清理,存活的对象会被移动到To Survivor区(年龄加1)。
  3. 这时From Survivor区会和To Survivor区进行交换,然后重复第一步,不过这次第一步中的From Survivor区其实是上一轮中的To Survivor区。

对象在Survivor区中每熬过一次Minor GC,年龄就增加1岁,当它的年龄增加到一定程度,就会被晋升到老年代中。

大对象直接在老年代分配。

Eden区和Survior区的比例是8:1。

空间分配担保机制:在发生Minor GC之前,虚拟机必须先检查老年代最大可用的连续空间是否大于新生代所有对象总空间,如果这个条件成立,那这一次Minor GC可以确保是安全的。如果不成立,则虚拟机会先查看XX:HandlePromotionFailure参数的设置值是否允许担保失败(Handle Promotion Failure);如果允许,那会继续检查老年代最大可用的连续空间是否大于历次晋升到老年代对象的平均大小,如果大 于,将尝试进行一次Minor GC,尽管这次Minor GC是有风险的;如果小于,或者-XX: HandlePromotionFailure设置不允许冒险,那这时就要改为进行一次Full GC。

为了可以更好地适应不同程度的内存状况,虚拟机并不是必须要求对象的年龄达到 MaxTenuringThreshold才进入老年代,如果在Survivor空间中相同年龄所有对象大小的总和大于 Survivor空间的一半,年龄大于或等于该年龄的对象可以直接进入老年代,无需等到 MaxTenuringThreshold。

对象年龄

默认来说,对象年龄达到15(对象晋升老年代的年龄阈值可以通过参 数 -XX: MaxTenuringThreshold设置)就会进入老年代。如果在Survivor空间中相同年龄所有对象大小的总和大于 Survivor空间的一半,年龄大于或等于该年龄的对象就可以直接进入老年代

内存泄露

定义

指程序中动态分配给内存一些临时对象,并且这些对象始终没有被回收,一直占用着内存, 简单来说就是申请内存使用完了不进行释放

原因

  • 静态集合类引起内存泄漏,因为静态集合的生命周期和JVM一致,所以静态集合引用的对象不能被 释放
  • 单例模式导致内存泄漏,因为单例模式的静态特性,它的生命周期和JVM的生命周期一致,如果单 例对象持有外部对象的引用,这个对象也不会被回收
  • 内部类的对象被长期持有,那么内部类对象所属的外部类对象也不能被收回
  • 数据库连接、网络连接等各种连接没有显示释放导致内存泄漏,例如在数据库连接后不再使用时, 必须调用close方法释放与数据库的连接,否则会造成大量对象无法被回收进而造成内存泄漏
  • 改变哈希值,例如在一个对象存储到HashSet后,改变了对象中参与计算哈希值的字段,那么会导 致对象的哈希值发生变化,和之前存入HashSet的哈希值不同,也就无法通过当前对象的引用在 HashSet中找到这个对象,无法从HashSet中删除对象,造成内存泄漏,这也是为什么通常利用 String类型的变量当作HashMap的key,因为String类型是不可变的

解决方法

编写代码时注意避免

内存溢出

定义

指程序运行过程中无法申请到足够的内存导致的错误

原因

  • 内存加载的数据量太大,内存不够用了
  • 代码中存在死循环或循环产生大量对象
  • 启动参数内存值设置过小
  • 长期的内存泄漏也会导致内存溢出

解决方案

  • 修改JVM启动参数,增加内存
  • 使用内存查看工具动态查看内存使用情况.
  • 对代码进行排查,重点排查有没有上述提到的造成常见内存溢出情景的代码

OOM异常

简介

除了程序计数器外,虚拟机内存的其他几个运行时区域都有发生OutOfMemoryError(下文称OOM)异常的可能

堆溢出

Java堆用于储存对象实例,我们只要不断地创建对象,并且保证GC Roots到对象之间有可达路径来避免垃圾回收机制清除这些对象,那么随着对象数量的增加,总容量触及最大堆的容量限制后就会 产生内存溢出异常。

  • -XX:+HeapDumpOnOutOf-MemoryError 可以让虚拟机在出现内存溢出异常的时候Dump出当前的内存堆转储快照以便进行事后分析
  • -Xms 堆的最小值
  • -Xmx 堆的最大值

要解决这个内存区域的异常,常规的处理方法是首先通过内存映像分析工具(如Eclipse Memory
Analyzer)对Dump出来的堆转储快照进行分析。

  • 首先应确认内存中导致OOM的对象是否是必 要的,也就是要先分清楚到底是出现了内存泄漏(Memory Leak)还是内存溢出(Memory Overflow)
  • 如果是内存泄漏,可进一步通过工具查看泄漏对象到GC Roots的引用链,找到泄漏对象是通过怎样的引用路径、与哪些GC Roots相关联,才导致垃圾收集器无法回收它们,根据泄漏对象的类型信息 以及它到GC Roots引用链的信息,一般可以比较准确地定位到这些对象创建的位置,进而找出产生内 存泄漏的代码的具体位置。
  • 如果不是内存泄漏,换句话说就是内存中的对象确实都是必须存活的,那就应当检查Java虚拟机的堆参数(-Xmx与-Xms)设置,与机器的内存对比,看看是否还有向上调整的空间。再从代码上检查 是否存在某些对象生命周期过长、持有状态时间过长、存储结构设计不合理等情况,尽量减少程序运 行期的内存消耗。
虚拟机栈和本地方法栈溢出

在《Java虚拟机规范》中描述了两种异常:

  • 如果线程请求的栈深度大于虚拟机所允许的最大深度,将抛出StackOverflowError异常。
  • 如果虚拟机的栈内存允许动态扩展,当扩展栈容量无法申请到足够的内存时,将抛出
    OutOfMemoryError异常。

《Java虚拟机规范》明确允许Java虚拟机实现自行选择是否支持栈的动态扩展,而HotSpot虚拟机的选择是不支持扩展,所以除非在创建线程申请内存时就因无法获得足够内存而出现 OutOfMemoryError异常,否则在线程运行时是不会因为扩展而导致内存溢出的,只会因为栈容量无法 容纳新的栈帧而导致StackOverflowError异常。

HotSpot虚拟机中并不区分虚拟机栈和本地方法栈

  • -Xoss参数(设置本地方法栈大小) 在HotSpot虚拟机实际上是没有任何效果的
  • -Xss参数 栈容量

操作系统分配给每个进程的内存是有限制的,譬如32位Windows的单个进程 最大内存限制为2GB。HotSpot虚拟机提供了参数可以控制Java堆和方法区这两部分的内存的最大值,那剩余的内存即为2GB(操作系统限制)减去最大堆容量,再减去最大方法区容量,由于程序计数器 消耗内存很小,可以忽略掉,如果把直接内存和虚拟机进程本身耗费的内存也去掉的话,剩下的内存 就由虚拟机栈和本地方法栈来分配了。因此为每个线程分配到的栈内存越大,可以建立的线程数量自 然就越少,建立线程时就越容易把剩下的内存耗尽。

如果使用HotSpot虚拟机默认参数,栈深度在大多数情况下(因为每个方法压入栈的帧大小并不是 一样的,所以只能说大多数情况下)到达1000~2000是完全没有问题,对于正常的方法调用(包括不能做尾递归优化的递归调用),这个深度应该完全够用了。

如果是建立过多线程导致的内存溢出,解决方法:

  • 能减少线程数量
  • 更换64位虚拟机
  • 减少最大堆和减少栈容量来换取更多的线程
方法区和运行时常量池溢出

一个类如果要被垃圾收集器回收,要达成的条件是比较苛刻的。在经常运行时生成大量动态类的应用场景里,就应该特别关注这些类的回收状况。

这类场 景除了之前提到的程序使用了CGLib字节码增强和动态语言外,常见的还有:大量JSP或动态产生JSP 文件的应用(JSP第一次运行时需要编译为Java类)、基于OSGi的应用(即使是同一个类文件,被不同的加载器加载也会视为不同的类)等。

本机直接内存溢出

-XX:MaxDirectMemorySize 指定直接内存大小,不指定则默认与Java堆最大值(由-Xmx指定)一致

由直接内存导致的内存溢出,一个明显的特征是在Heap Dump文件中不会看见有什么明显的异常情况,如果读者发现内存溢出之后产生的Dump文件很小,而程序中又直接或间接使用了 DirectMemory(典型的间接使用就是NIO),那就可以考虑重点检查一下直接内存方面的原因了。

垃圾回收

思考

思考垃圾收集需要完成的三件事情:

  • 哪些内存需要回收?
  • 什么时候回收?
  • 如何回收?

程序计数器、虚拟机栈、本地方法栈3个区域随线程而生,随线程而灭,栈中的栈帧随着方法的进入退出而入栈出栈,每一个栈帧中分配多少内存基本上是在类结构确定下来时就已知的,因此这几个区域的内存分配和回收都具备确定性。

而Java堆和方法区这两个区域则有着很显著的不确定性,只有处于运行期间,我们才能知道程序究竟会创建哪些对象,创建多少个对象,这部分内存的分配和回收是动态的。垃圾收集器所关注的正是这部分内存该如何管理。

在Java中垃圾回收的目的是回收释放不再被引用的实例对象,这样做可以减少内存泄漏、内存溢出问题 的出现

Java堆的分区域垃圾回收

简介

在Java堆划分出不同的区域之后,垃圾收集器才可以每次只回收其中某一个或者某些部分的区域——因而才有了“Minor GC”“Major GC”“Full GC”这样的回收类型的划分。

部分收集(Partial GC):指目标不是完整收集整个Java堆的垃圾收集,其中又分为:

  • 新生代收集(Minor GC/Young GC):指目标只是新生代的垃圾收集。
  • 老年代收集(Major GC/Old GC):指目标只是老年代的垃圾收集。目前只有CMS收集器会有单独收集老年代的行为。另外请注意“Major GC”这个说法现在有点混淆,在不同资料上常有不同所指, 读者需按上下文区分到底是指老年代的收集还是整堆收集。
  • 混合收集(Mixed GC):指目标是收集整个新生代以及部分老年代的垃圾收集。目前只有G1收集器会有这种行为。

整堆收集(Full GC):收集整个Java堆和方法区的垃圾收集。

触发时机

Minor GC:

  • 在新生代的Eedn区满了会触发

Full GC:

  • System.gc() 方法的调用,此方法会建议JVM进行Full GC,但JVM可能不接受这个建议,所以不 一定会执行。
  • 老年代空间不足,创建的大对象的内存大于老年代空间,导致老年代空间不足,则会发生Full GC
  • JDK1.7及以前的永久代空间满了,在JDK1.7以前,HotSpot虚拟机的方法区是永久代实现都得,在 永久代中会存放一些Class的信息、常量、静态变量等数据,在永久代满了,并且没有配置CMS GC 的情况下就会触发Full GC,在JDK1.8开始移除永久代也是为了减少Full GC的频率
  • 空间分配担保失败,通过Minor GC后进入老年代的平均大小大于老年代的可用空间,会触发Full GC

判断对象死亡

引用计数算法

思路

对象中添加一个引用计数器,每当有一个地方引用它时,计数器值就加一;当引用失效时,计数器值就减一;任何时刻计数器为零的对象就是不可能再被使用的。

优点

原理简单,判定效率也很高,在大多数情况下它都是一个不错的算法

缺点

在Java 领域,至少主流的Java虚拟机里面都没有选用引用计数算法来管理内存,主要原因是,这个看似简单 的算法有很多例外情况要考虑,必须要配合大量额外处理才能保证正确地工作,譬如单纯的引用计数 就很难解决对象之间相互循环引用的问题。

可达性分析算法

思路

通过一系列称为“GC Roots”的根对象作为起始节点集,从这些节点开始,根据引用关系向下搜索,搜索过程所走过的路径称为“引用链”(Reference Chain),如果某个对象到GC Roots间没有任何引用链相连, 或者用图论的话来说就是从GC Roots到这个对象不可达时,则证明此对象是不可能再被使用的。

image-20220619162415599

Java中固定可作为GC Roots的对象:

  • 在虚拟机栈(栈帧中的本地变量表)中引用的对象,譬如各个线程被调用的方法堆栈中使用到的参数、局部变量、临时变量等。
  • 在方法区中类静态属性引用的对象,譬如Java类的引用类型静态变量。
  • 在方法区中常量引用的对象,譬如字符串常量池(String Table)里的引用。
  • 在本地方法栈中JNI(即通常所说的Native方法)引用的对象。
  • Java虚拟机内部的引用,如基本数据类型对应的Class对象,一些常驻的异常对象(比如
    NullPointExcepiton、OutOfMemoryError)等,还有系统类加载器。
  • 所有被同步锁(synchronized关键字)持有的对象。
  • 反映Java虚拟机内部情况的JMXBean、JVMTI中注册的回调、本地代码缓存等。

除了这些固定的GC Roots集合以外,根据用户所选用的垃圾收集器以及当前回收的内存区域不同,还可以有其他对象“临时性”地加入,共同构成完整GC Roots集合。

并发的可达性分析

并发问题

包含“标记”阶段是所有追踪式垃圾收集算法的共同特征,根节点枚举这一步必须全程冻结用户线程的运行,必须在一个能保障一致性的快照上才能进行对象图的遍历。停顿时间与Java堆容量成正比例关系,堆越大,存储的对象越多,对象图结构越复杂,要标记更多对象而产生的停顿时间自然就更长。其影响就会波及几乎所有的垃圾收集器,同理可知,如果能够削减这部分停顿时间的话,那收益也将会是系统性的。

必须在一个能保障一致性的快照上才能进行对象图的遍历的原因:

  • 如果用户线程此时是冻结的,只有收集器线程在工作,那不会有任何问题。
  • 如果用户线程与收集器是并发工作,收集器在对象图上标记颜色,同时用户线程在修改引用关系,即修改对象图的结构,这样可能把原本消亡的对象错误标记为存活,或者把原本存活的对象错误标记为已消亡。
三色标记

对象标记的过程中需要给对象打上三色标记,这些标记本质上就只和对象的引用有关,而与对象本身无关——某个对象只有它的引用关系能决定它存活与否,对象上其他所有的属性都不能够 影响它的存活判定结果。

HotSpot虚拟机的几种收集器的标记实现方案

  • 标记直接记录在对象头:Serial收集器
  • 标记记录在与对象相互独立的数据结构上:G1、Shenandoah用了一种相当于堆内存的1/64大小的,称为BitMap的结构来记录标记信息
  • 把标记信息记在引用对象的指针上:ZGC

三色标记

  • 白色:表示对象尚未被垃圾收集器访问过。显然在可达性分析刚刚开始的阶段,所有的对象都是白色的,若在分析结束的阶段,仍然是白色的对象,即代表不可达。
  • 黑色:表示对象已经被垃圾收集器访问过,且这个对象的所有引用都已经扫描过。黑色的对象代表已经扫描过,它是安全存活的,如果有其他对象引用指向了黑色对象,无须重新扫描一遍。黑色对 象不可能直接(不经过灰色对象)指向某个白色对象。
  • 灰色:表示对象已经被垃圾收集器访问过,但这个对象上至少存在一个引用还没有被扫描过

image-20220623165328024

当且仅当以下两个条件同时满足时,会产生“对象消失”的问题,即原本应该是黑色的对象被误标为白色:

  • 赋值器插入了一条或多条从黑色对象到白色对象的新引用;
  • 赋值器删除了全部从灰色对象到该白色对象的直接或间接引用。
解决方法

解决并发扫描时的对象消失问题,只需破坏这两个条件的任意一个即可。由此分别产生了两种解决方案:增量更新(Incremental Update)和原始快照(Snapshot At The Beginning, SATB)。

  • 增量更新要破坏的是第一个条件,当黑色对象插入新的指向白色对象的引用关系时,就将这个新插入的引用记录下来,等并发扫描结束之后,再将这些记录过的引用关系中的黑色对象为根,重新扫 描一次。这可以简化理解为,黑色对象一旦新插入了指向白色对象的引用之后,它就变回灰色对象 了。
  • 原始快照要破坏的是第二个条件,当灰色对象要删除指向白色对象的引用关系时,就将这个要删除的引用记录下来,在并发扫描结束之后,再将这些记录过的引用关系中的灰色对象为根,重新扫描 一次。这也可以简化理解为,无论引用关系删除与否,都会按照刚刚开始扫描那一刻的对象图快照来进行搜索。

以上无论是对引用关系记录的插入还是删除,虚拟机的记录操作都是通过写屏障实现的。

在HotSpot虚拟机中,增量更新和原始快照这两种解决方案都有实际应用,譬如,CMS是基于增量更新 来做并发标记的,G1、Shenandoah则是用原始快照来实现。

对象标记过程

即使在可达性分析算法中判定为不可达的对象,也不是“非死不可”的,这时候它们暂时还处于“缓刑”阶段,要真正宣告一个对象死亡,至少要经历两次标记过程:

  1. 如果对象在进行可达性分析后发现没 有与GC Roots相连接的引用链,那它将会被第一次标记,随后进行一次筛选,筛选的条件是此对象是 否有必要执行finalize()方法。
    • 假如对象没有覆盖finalize()方法,或者finalize()方法已经被虚拟机调用 过,那么虚拟机将这两种情况都视为“没有必要执行”。
    • 如果这个对象被判定为确有必要执行finalize()方法,那么该对象将会被放置在一个名为F-Queue的队列之中,并在稍后由一条由虚拟机自动建立的、低调度优先级的Finalizer线程去执行它们的finalize() 方法。(这里所说的“执行”是指虚拟机会触发这个方法开始运行,但并不承诺一定会等待它运行结束。 这样做的原因是,如果某个对象的finalize()方法执行缓慢,或者更极端地发生了死循环,将很可能导 致F-Queue队列中的其他对象永久处于等待,甚至导致整个内存回收子系统的崩溃)
  2. 稍后收集器将对F-Queue中的对象进行第二次小规模的标记,如果对象要在finalize()中成功拯救自己——只要重新与引用链上的任何一个对象建立关联即可,譬如把自己 (this关键字)赋值给某个类变量或者对象的成员变量,那在第二次标记时它将被移出“即将回收”的集 合;如果对象这时候还没有逃脱,那基本上它就真的要被回收了

finalize()方法是对 象逃脱死亡命运的最后一次机会。任何一个对象的finalize()方法都只会被系统自动调用一次,如果对象在第一次finalize()自救成功,在下一次回收,它的finalize()方法不会被再次执行。

finalize()能做的所有工作,使用try-finally或者其他方式都可以做得更好、 更及时,所以笔者建议大家完全可以忘掉Java语言里面的这个方法。

回收方法区

简介

《Java虚拟机规范》中提到过可以不要求虚拟机在方法区中实现垃圾收集,事实上也确实有未实现或未能完整 实现方法区类型卸载的收集器存在,方法区垃圾收集 的“性价比”通常也是比较低的。

方法区的垃圾收集主要回收两部分内容:废弃的常量和不再使用的类型

回收常量

常量包括字面量、常量池中其他类(接 口)、方法、字段的符号引用等。

判断常量是否“废弃”,需要同时满足:

  • 没有任何对象引用该常量
  • 虚拟机中没有其他地方引用该常量

如果在这时发生内存回收,而且 垃圾收集器判断确有必要的话,该常量就会被清理出常量池。

回收类

判定一个类型是否属于“不再被使用的类”的条件,需要同时满足:

  • 该类所有的实例都已经被回收,也就是Java堆中不存在该类及其任何派生子类的实例。
  • 加载该类的类加载器已经被回收,这个条件除非是经过精心设计的可替换类加载器的场景,如OSGi、JSP的重加载等,否则通常是很难达成的。
  • 该类对应的java.lang.Class对象没有在任何地方被引用,无法在任何地方通过反射访问该类的方法。

Java虚拟机被允许对满足上述三个条件的无用类进行回收,这里说的仅仅是“被允许”,而并不是
和对象一样,没有引用了就必然会回收。

在大量使用反射、动态代理、CGLib等字节码框架,动态生成JSP以及OSGi这类频繁自定义类加载器的场景中,通常都需要Java虚拟机具备类型卸载的能力,以保证不会对方法区造成过大的内存压力。

垃圾收集算法

简介

从如何判定对象消亡的角度出发,垃圾收集算法可以划分为“引用计数式垃圾收集”(Reference
Counting GC)和“追踪式垃圾收集”(Tracing GC)两大类,这两类也常被称作“直接垃圾收集”和“间接 垃圾收集”。

分代收集理论

传统“分代收集”理论

理论内容:

  • 弱分代假说(Weak Generational Hypothesis):绝大多数对象都是朝生夕灭的。
  • 强分代假说(Strong Generational Hypothesis):熬过越多次垃圾收集过程的对象就越难以消亡

这两个分代假说共同奠定了多款常用的垃圾收集器的一致的设计原则:收集器应该将Java堆划分出不同的区域,然后将回收对象依据其年龄(年龄即对象熬过垃圾收集过程的次数)分配到不同的区域之中存储。设计者一般至少会把Java堆划分为新生代(Young Generation)和老年代(Old Generation)两个区域。

缺陷:

  • 当存在跨代引用的情况,比如新生代中的对象是被老年代所引用的,为了找出新生代中的存活对象,不得不在固定的GC Roots之外,再额外遍历整老年代中所有对象来确保可达性分析结果的正确性,为内存回收带来很大的性能负担。
改进的传统“分代收集”理论:

理论内容:

  • 弱分代假说(Weak Generational Hypothesis):绝大多数对象都是朝生夕灭的。
  • 强分代假说(Strong Generational Hypothesis):熬过越多次垃圾收集过程的对象就越难以消亡
  • 跨代引用假说(Intergenerational Reference Hypothesis):跨代引用相对于同代引用来说仅占极少数。

这其实是可根据前两条假说逻辑推理得出的隐含推论:存在互相引用关系的两个对象,是应该倾向于同时生存或者同时消亡的。举个例子,如果某个新生代对象存在跨代引用,由于老年代对象难以 消亡,该引用会使得新生代对象在收集时同样得以存活,进而在年龄增长之后晋升到老年代中,这时 跨代引用也随即被消除

解决跨代应用的做法:

在新生代上建立一个全局的数据结构(该结构被称 为“记忆集”,Remembered Set),这个结构把老年代划分成若干小块,标识出老年代的哪一块内存会 存在跨代引用。此后当发生Minor GC时,只有包含了跨代引用的小块内存里的对象才会被加入到GC Roots进行扫描。

缺点:

  • 需要在对象改变引用关系(如将自己或者某个属性赋值)时维护记录数 据的正确性,会增加一些运行时的开销,但比起收集时扫描整个老年代来说仍然是划算的。

优点:

  • 不必再为了少量的跨代引用去扫描整个老年代
  • 也不必浪费空间专门记录每一个对象是否存在及存在哪些跨代引用

标记-清除算法

Mark-Sweep,最早,最基础的算法,后续的收集算法大多都是以标记-清除算法为基础。

算法分为“标记”和“清除”两个阶段:首先标记出所有需要回收的对象,在标记完成后,统一回收掉所有被标记的对象,也可以反过来,标记存活的对象,统一回收所有未被标记的对象。标记过程就是对象是否属于垃圾的判定过程。通常,标记-清除算法也是需要停顿用户线程来标记、清理可回收对象的,只是停顿时间相对而言要 来的短而已

image-20220619205758568

缺点:

  • 执行效率不稳定。如果Java堆中包含大量对 象,而且其中大部分是需要被回收的,这时必须进行大量标记和清除的动作,导致标记和清除两个过 程的执行效率都随对象数量增长而降低。
  • 内存空间的碎片化。标记、清除之后会产生大 量不连续的内存碎片,空间碎片太多可能会导致当以后在程序运行过程中需要分配较大对象时无法找到足够的连续内存而不得不提前触发另一次垃圾收集动作。

标记-复制算法

简介

标记-复制算法常被简称为复制算法。

半区复制

将可用内存按容量划分为大小相等的两块,每次只使用其中的一块。当这一块的内存用完了,就将还存活着 的对象复制到另外一块上面,然后再把已使用过的内存空间一次清理掉。

image-20220619205932778

优点:

  • 对于多数对象都是可回收的情况,算法需要复制的就是占少数的存活对象
  • 每次都是针对整个半区进行内存回收,分配内存时也就不用考虑有空间碎片的复杂情况,只要移动堆顶指针,按顺序分配即可。这样实现简单,运行高效

缺点:

  • 如果内存中多数对象都是存活的,这种算法将会产生大量的内存间复制的开销
  • 将可用内存缩小为了原来的一半,空间浪费太多。
Appel式回收

IBM公司曾有一项专门研究对新生代“朝生夕灭”的特点做了更量化的诠释——新生代中的对象有98%熬不过第一轮收集。因此并不需要按照1∶1的比例来划分新生代的内存空间

把新生代分为一块较大的Eden空间和两块较小的Survivor空间,每次分配内存只使用Eden和其中一块Survivor。发生垃圾搜集时,将Eden和Survivor中仍然存活的对象一次性复制到另外一块Survivor空间上,然后直接清理掉Eden和已用过的那块Survivor空间。

HotSpot虚拟机默认Eden和Survivor的大小比例是8∶1,也即每次新生代中可用内存空间为整个新生代容量的90%(Eden的80%加上一个Survivor的10%),只有一个Survivor空间(10%的新生代)是会被“浪费”的。

当Survivor空间不足以容纳一次Minor GC之后存活的对象时,就需要依赖其他内存区域(实际上大多就是老年代)进行分配担保(Handle Promotion),这些对象将通过分配担保机制直接进入老年代,这对虚拟机来说就是安全的。

优点
  • 解决标记-清除算法面对大量可回收对象时执行效率低的问题
缺点
  • 在对象存活率较高时就要进行较多的复制操作,效率将会降低
  • 使用半区复制会浪费50%的内存空间,如果不使用半区复制,就需要有额外的空间进行分配担保,以应对被使用的内存中所有对象都100%存 活的极端情况,所以在老年代一般不能直接选用这种算法。

标记-整理算法

Mark-Compact,标记过程仍然与“标记-清除”算法一样,标记所有存活的对象或者标记所有需要回收的对象,让所有存活的对象都向内存空间一端移动,然后直接清理掉边界以外的内 存。

标记-清除算法与标记-整理算法的本质差异在于前者是一种非移动式的回收算法,而后者是移动 式的。

image-20220619211226720

是否移动回收后的存活对象是一项优缺点并存的风险决策:

  • 如果移动存活对象,内存回收时会更复杂。尤其是在老年代这种每次回收都有大量对象存活区域,移动存活对象并更新所有引用这些对象的地方将会是一种极为负重的操作,而且这种对象移动操作必须全程暂停用户应用程序(Stop The World)才能进行,增加垃圾收集时的停顿时间。
  • 完全不考虑移动和整理存活对象,内存分配时会更复杂。弥散于堆中的存活对象导致的空间碎片化问题就只能依赖操作系统更为复杂的内存分配器和内存访问器来解决。内存的访问是用户程序最频繁的操作,假如在这个环节上增加了额外的负担,势必会直接影响应用程序的吞吐量。不移动对象,垃圾收集的停顿时间会更短,甚至可以不需要停顿,使得收集器的效率提升一些,但因内存分配和访问比垃圾收集频率要 高得多,内存分配和访问这部分的耗时增加,总吞吐量仍然是下降的。

一种折中的做法是:让虚拟机平时多数时间都采用标记-清除算法,暂时容忍内存碎片的存在,直到内存空间的碎片化程度已经 大到影响对象分配时,再采用标记-整理算法收集一次,以获得规整的内存空间。

STOP THE WORLD

STW指的是用户线程在 运行至安全点(safe point)或安全区域(safe region)之后,就自行挂起,进入暂停状态,对外的表 现就是卡顿。

不论是minor gc还是major gc都会STW,区别只在于 STW的时间长短。

根结点枚举

固定可作为GC Roots的节点主要在全局性的引用(例如常量或类静态属性)与执行上下文(例如 栈帧中的本地变量表)中。

迄今为止,所有收集器在根节点枚举这一步骤时都是必须暂停用户线程的,因此根节点枚举与之前提及的整理内存碎片一样会面临相似的“Stop The World”的困扰。

现在可达性分析算法耗时最长的查找引用链的过程已经可以做到与用户线程一起并发,但根节点枚举始终还是必须在一个能保障一致性的快照中才得以进行——这里“一致性”的意思是整个枚举期间执行子系统看起来就像被冻结在某个时间点上,不会出现分析过程中根节点集合的对象引用关系还在不断变化的情况,否则分析结果准确性也就无法保证。这是导致垃圾收集过程必须停顿所有 用户线程的其中一个重要原因。

所以当用户线程停顿下来之后,其实并不需要一个不漏地检查完所有 执行上下文和全局的引用位置。在HotSpot,是使用一组称为OopMap的数据结构来达到这个目的。一旦类加载动作完成的时候, HotSpot就会把对象内什么偏移量上是什么类型的数据计算出来,在即时编译过程中,也会在特定的位置记录下栈里和寄存器里哪些位置是引用。这样收集器在扫描时就可以直接得知这些信 息了,并不需要真正一个不漏地从方法区等GC Roots开始查找。

安全点

在OopMap的协助下,HotSpot可以快速准确地完成GC Roots枚举,但存在一种情况:导致引用关系变化,或者说导致OopMap内容变化的指令非常多。如果为每一条指令都生成对应的OopMap,那将会需要大量的额外存储空间,这样垃圾收集会产生高昂空间成本。

实际上HotSpot也的确没有为每条指令都生成OopMap,只是在“特定的位置”记录了这些信息,这些位置被称为安全点(Safepoint)。

安全点的机制,决定了用户程序执行时不能在代码指令流的任意位置都能够停顿下来开始垃圾收集,而是强制要求必须执行到达安全点后才 能够暂停。

因此,安全点的选定既不能太少以至于让收集器等待时间过长,也不能太过频繁以至于过 分增大运行时的内存负荷。安全点位置的选取基本上是以“是否具有让程序长时间执行的特征”为标准 进行选定的,因为每条指令执行的时间都非常短暂,程序不太可能因为指令流长度太长这样的原因而 长时间执行,“长时间执行”的最明显特征就是指令序列的复用,例如方法调用、循环跳转、异常跳转 等都属于指令序列复用,所以只有具有这些功能的指令才会产生安全点。

需要在垃圾收集发生的时候让所有线程(不包括执行JNI调用的线程)都跑到最近的安全点,然后暂停。有两种方式:抢先式中断 (Preemptive Suspension)和主动式中断(Voluntary Suspension)

  • 抢先式中断不需要线程的执行代码 主动去配合,在垃圾收集发生时,系统首先把所有用户线程全部中断,如果发现有用户线程中断的地 方不在安全点上,就恢复这条线程执行,让它一会再重新中断,直到跑到安全点上。现在几乎没有虚 拟机实现采用抢先式中断来暂停线程响应GC事件。
  • 主动式中断的思想是当垃圾收集需要中断线程的时候,不直接对线程操作,仅仅简单地设置一个标志位,各个线程执行过程时会不停地主动去轮询这个标志,一旦发现中断标志为真时就自己在最 近的安全点上主动中断挂起。轮询标志的地方和安全点是重合的,另外还要加上所有创建对象和其他 需要在Java堆上分配内存的地方,这是为了检查是否即将要发生垃圾收集,避免没有足够内存分配新 对象。

由于轮询操作在代码中会频繁出现,这要求它必须足够高效。HotSpot使用内存保护陷阱的方式,把轮询操作精简至只有一条汇编指令的程度。当需要暂停用户线程时,虚拟机把最近一个安全点的内存页设置为不可读,那线程执行到test指令时就会产生一个自陷异常信号,然后在预先注册的异常处理器中挂起线程实现等待,这样仅通过一条汇编指 令便完成安全点轮询和触发线程中断了。

image-20220620233042732

优点

  • 安全点机制保证了程序执行时,在不太长的时间内就会遇到可进入垃圾收集 过程的安全点

缺点

  • 程序没有分配处理器时间时,即程序不执行的时候,就无法进入安全点。典型的场景便是用户线程处于Sleep状态或者Blocked状态,这时候线程无法响应虚拟机的中断请求,不能再走 到安全的地方去中断挂起自己,虚拟机也显然不可能持续等待线程重新被激活分配处理器时间。

安全区域

安全区域是指能够确保在某一段代码片段之中,引用关系不会发生变化,因此,在这个区域中任意地方开始垃圾收集都是安全的。

可以把安全区域看作被扩展拉伸了的安全点。

当用户线程执行到安全区域里面的代码时,首先会标识自己已经进入了安全区域,那样当这段时间里虚拟机要发起垃圾收集时就不必去管这些已声明自己在安全区域内的线程了。

当处于安全区域的线程要离开安全 区域时,它要检查虚拟机是否已经完成了根节点枚举(或者垃圾收集过程中其他需要暂停用户线程的 阶段),如果完成了,那线程就当作没事发生过,继续执行;否则它就必须一直等待,直到收到可以 离开安全区域的信号为止。

记忆集

简介

记忆集是一种用于记录从非收集区域指向收集区域的指针集合的抽象数据结构。用于解决跨代引用问题,可以缩减GC Roots扫描范围。

实现记忆集的精度

  • 字长精度:每个记录精确到一个机器字长(就是处理器的寻址位数,如常见的32位或64位,这个精度决定了机器访问物理内存地址的指针长度),该字包含跨代指针。
  • 对象精度:每个记录精确到一个对象,该对象里有字段含有跨代指针。
  • 卡精度:每个记录精确到一块内存区域,该区域内有对象含有跨代指针。

对象数组

对象精度。

最简单的实现是对象数组的数据结构实现,对象数组存放非收集区域中含跨代应用的对象的指针。缺点是空间占用和维护成本都相当高昂。

卡表

卡精度。

是目前最常用的一种记忆集实现形式。定义了记忆集的记录精度、与堆内存的映射关系等。

卡表相当于一个HashMap。卡表最简单的形式可以只是一个字节数组,比如HotSpot虚拟机的实现。

// HotSpot默认的卡表标记逻辑
CARD_TABLE [this address >> 9] = 0;

字节数组CARD_TABLE的每一个元素都对应着其标识的内存区域中一块特定大小的内存块,这个内存块被称作“卡页”(Card Page)。一般来说,卡页大小都是以2的N次幂的字节数,HotSpot中使用的卡页是2的9次幂,即512字节(地址右移9位,相当于用地址除以512)。

一个卡页的内存中通常包含不止一个对象,只要卡页内有一个(或更多)对象的字段存在着跨代指针,那就将对应卡表的数组元素的值标识为1,称为这个元素变脏(Dirty),没有则标识为0。在垃圾收集发生时,只要筛选出卡表中变脏的元素,就能轻易得出哪些卡页内存块中包含跨代指针,把它们加入GC Roots中一并扫描。

image-20220623155434104

发生引用类型字段赋值的那一刻,需要更新卡表对应元素的状态,这个更新是在虚拟机层面实现的。HotSpot虚拟机通过写屏障(Write Barrier)技术维护卡表状态。

即如何在对象赋值的那一刻去更新维护卡表呢?假如是解释执行的字节码,那相对好处理,虚拟 机负责每条字节码指令的执行,有充分的介入空间;但在编译执行的场景中呢?经过即时编译后的代 码已经是纯粹的机器指令流了,这就必须找到一个在机器码层面的手段,把维护卡表的动作放到每一 个赋值操作之中。

写屏障可以看作在虚拟机层面对“引用类型字段赋值”这个动作的AOP切 面,在引用对象赋值时会产生一个环形(Around)通知,供程序执行额外的动作,也就是说赋值的前后都在写屏障的覆盖范畴内。在赋值前的部分的写屏障叫作写前屏障(Pre-Write Barrier),在赋值 后的则叫作写后屏障(Post-Write Barrier)。应用写屏障后,虚拟机就会为所有赋值操作生成相应的指令,完成卡表状态更新。

void oop_field_store(oop* field, oop new_value) { 
    // 引用字段赋值操作 
    *field = new_value; 
    // 写后屏障,在这里完成卡表状态更新 
    post_write_barrier(field, new_value);
}

写屏障的缺点

  • 一旦收集器在写屏障中增加了更新卡表操作,无论更新的是不是老年代对新生代对象的引用,每次只要对引用进行更新,就会产生额外的开销,不过这个开销与Minor GC时扫描整个老年代的代价相比还是低得多的。
  • 在高并发场景下还面临着“伪共享”(False Sharing)问题。

假设处理器的缓存行大小为64字节,由于一个卡表元素占1个字节,64个卡表元素将共享同一个缓存行。这64个卡表元素对应的卡页总的内存为32KB(64×512字节),也就是说如果不同线程更新的对 象正好处于这32KB的内存区域内,就会导致更新卡表时正好写入同一个缓存行而影响性能。

解决方法之一是不采用无条件的写屏障,先检查卡表标记,只有当该卡表元素未被标记过时才将其标记为变脏。

// HotSpot更新后的的卡表标记逻辑
if (CARD_TABLE [this address >> 9] != 0) 
    CARD_TABLE [this address >> 9] = 0;

在JDK 7之后,HotSpot虚拟机增加了一个新的参数-XX:+UseCondCardMark,用来决定是否开启卡表更新的条件判断。开启会增加一次额外判断的开销,但能够避免伪共享问题,两者各有性能损 耗,是否打开要根据应用实际运行情况来进行测试权衡。

垃圾收集器

默认GC

JDK8 默认垃圾收集器是Parallel Scavenge+Parallel Old。

HotSpot虚拟机的垃圾收集器

简介

image-20220623170028475

如果两个收集器之间存在连线,就说明它们可以搭配,但这种关系也不是一定不变的。

并行(Parallel):并行描述的是多条垃圾收集器线程之间的关系,说明同一时间有多条这样的线程在协同工作,通常默认此时用户线程是处于等待状态。

并发(Concurrent):并发描述的是垃圾收集器线程与用户线程之间的关系,说明同一时间垃圾收集器线程与用户线程都在运行。由于用户线程并未被冻结,所以程序仍然能响应服务请求,但由于垃圾收集器线程占用了一部分系统资源,此时应用程序的处理的吞吐量将受到一定影响。

各款收集器的并发情况

image-20220628203251401

衡量垃圾收集器性能指标
  • 内存占用 FootPrint
  • 吞吐量 ThroughPut
  • 延迟 Latency

硬件的规格和性能越高(比如可用内存增加),吞吐量就越高

内存增加,虚拟机回收堆内存耗时变长

选择因素

  • 应用程序的主要关注点
    • 吞吐量:尽快算出结果
    • 延迟:停顿时间影响服务质量
    • 内存占用:客户端应用、嵌入式应用等
  • 运行应用的基础设施条件
    • 硬件规格
    • 处理器数量
    • 分配内存大小
    • 操作系统
  • JDK
    • 发行商
    • 版本号
    • 对应《Java虚拟机规范》版本

Serial收集器

简介

新生代收集器

Serial收集器是最基础、历史最悠久的收集器,这个收集器是一个单线程工作的收集器,使用标记-复制算法,主要供客户端模式的HotSpot虚拟机使用。

“serial”强调在它进行垃圾收集时,必须暂停其他所有工作线程(Stop The World),直到它收集结束。

image-20220625172038485

缺点
  • Stop The World由虚拟机在后台自动发起和自动完成,在用户不可知、不可控的情况下把用户的正常工作的线程全部停掉。
优点
  • 与其他收集器的单线程相比,简单而高效。
  • 对于内存资源受限的环境,它是所有收集器里额外内存消耗(Memory Footprint)最小的;对于单核处理器或处理器核心数较少的环境来说,Serial收集器由于没有线程交互的开销,专心做垃圾收集自然可以获得最高的单线程收集效率。
  • 是HotSpot虚拟机运行在客户端模式下的默认新生代收集器。在用户桌面的应用场景以及近年来流行的部分微服务应用中,分配给虚 拟机管理的内存一般来说并不会特别大,Serial收集器对于运行在客户端模式下的虚拟机来说是一个很好的选择。

ParNew收集器

简介

新生代收集器

实质上是Serial收集器的多线程并行版本,除了同时使用多条线程进行垃圾收集之外,其他与serial相同。默认开启的收集线程数与处理器核心数量相同

image-20220625172650067

ParNew收集器是激活CMS后的默认新生代收集器。

优点
  • 支持多线程并行收集
  • 是不少运行在服务端模式下的HotSpot虚拟机,尤其是JDK 7之前的遗留系统中首选的新生代收集器(除了Serial收集器外,目前只有它能与CMS收集器配合工作)

缺点

  • 单核心处理器的环境中不会有比Serial收集器更好的效果

Parallel Scavenge收集器

简介

新生代收集器

被称作“吞吐量优先收集器”。基于标记-复制算法,是能够并行收集的多线程收集器。

image-20220625174422054

目标则是达到一个可控制的吞吐量(Throughput)。CMS等收集器的关注点是尽可能地缩短垃圾收集时用户线程的停顿时间。

吞吐量就是处理器用于运行用户代码的时间与处理器总消耗时间的比值

image-20220625173333012

  • 停顿时间越短就越适合需要与用户交互或需要保证服务响应质量的程序,良好的响应速度能提升用户体验;
  • 高吞吐量则可以最高效率地利用处理器资源,尽快完成程序的运算 任务,主要适合在后台运算而不需要太多交互的分析任务。

Parallel Scavenge收集器有一个开关参数-XX:+UseAdaptiveSizePolicy,虚拟机会根据当前系统的运行情况收集性能监控信息,动态调整这些参数以提供最合适的停顿时间或者最大的吞吐量。这种调节方式称为垃圾收集的自适应的调节策略。

优点
  • 多线程并行收集
  • 提供给自适应调节策略,达到一个可控制的吞吐量。

Serial Old收集器

简介

Serial Old是Serial收集器的老年代版本,它同样是一个单线程收集器,使用标记-整理算法。

image-20220625172038485

主要供客户端模式下的HotSpot虚拟机使用。

服务端模式的使用情况:

  • 在JDK 5以及之前的版本中与Parallel Scavenge收集器搭配使用。
  • 作为CMS 收集器发生失败时的后备预案,在并发收集发生Concurrent Mode Failure时使用。

Parallel Old收集器

简介

Parallel Old是Parallel Scavenge收集器的老年代版本,支持多线程并发收集,基于标记-整理算法实现。

image-20220625174436693

优点

在注重吞吐量或者处理器资源较为稀缺的场合,都可以优先考虑Parallel Scavenge加Parallel Old收集器这个组合。

CMS收集器

简介

CMS(Concurrent Mark Sweep)收集器是一种以获取最短回收停顿时间为目标的收集器,基于标记-清除算法实现。

image-20220625174914613

CMS默认启动的回收线程数 = (处理器核心数量 +3)/4

如果处理器核心数在四个或以上,并发回收时垃圾收集线程只占用不超过25%的 处理器运算资源,并且会随着处理器核心数量的增加而下降。但是当处理器核心数量不足四个时, CMS对用户程序的影响就可能变得很大。

运作过程
  • 初始标记(CMS initial mark):初始标记仅仅只是标记一下GCRoots能直接关联到的对象,速度很快;
  • 并发标记(CMS concurrent mark):并发标记阶段就是从GC Roots的直接关联对象开始遍历整个对象图的过程,这个过程耗时较长但是不需要停顿用户线程,可以与垃圾收集线程一起并发运行;
  • 重新标记(CMS remark):重新标记阶段则是为了修正并发标记期间,因用户程序继续运作而导致标记产生变动的那一部分对象的标记记录(增量更新),这个阶段的停顿时间通常会比初始标记阶段稍长一 些,但也远比并发标记阶段的时间短;
  • 并发清除(CMS concurrent sweep):并发清除阶段,清理删除掉标记阶段判断的已经死亡的对象,由于不需要移动存活对象,所以这个阶段也是可以与用户线程同时并发的。

由于在整个过程中耗时最长的并发标记和并发清除阶段中,垃圾收集器线程都可以与用户线程一起工作,所以从总体上来说,CMS收集器的内存回收过程是与用户线程一起并发执行的

优点
  • 并发收集
  • 低停顿
缺点
  • 对处理器资源非常敏感。在并发阶段,它虽然不会导致用户线程停顿,但却会因为占用了一部分线程(或者说处理器的计算能力)而导致应用程序变慢,降低总吞吐量。
  • 无法处理浮动垃圾,有可能出现“Con-current Mode Failure”并发失败进而导致另一次完全“Stop The World”的Full GC的产生。在CMS的并发标记和并发清理阶段,用户线程是还在继续运行的,程序在运行自然就还会伴随有新的垃圾对象不断产生,但这一部分垃圾对象是出现在标记过程结束以后,CMS无法在当次收集中处理掉它们,只好留待下一次垃圾收集时再清理掉。这一部分垃圾就称为“浮动垃圾”。同样也是由于在垃圾收集阶段用户线程还需要持续运行,那就还需要预留足够内存空间提供给用户线程使用,因此CMS收集器不能像其他收集器那样等待到老年代几乎完全被填满了再进行收集,必须预留一部分空间供并发收集时的程序运作使用。
  • 收集结束时会有大量空间碎片产生。空间碎片过多时,将会给大对象分配带来很大麻烦,往往会出现老年代还有很多剩余空间,但就是无法找 到足够大的连续空间来分配当前对象,而不得不提前触发一次Full GC的情况。

对于当处理器数量少于4个的情况,虚拟机提供了一种称为“增量式并发收集器”(Incremental Concurrent Mark Sweep/i-CMS)的CMS收集器变种, 所做的事情和以前单核处理器年代PC机操作系统靠抢占式多任务来模拟多核并行多任务的思想一样, 是在并发标记、清理的时候让收集器线程、用户线程交替运行,尽量减少垃圾收集线程的独占资源的 时间,这样整个垃圾收集的过程会更长,但对用户程序的影响就会显得较少一些,直观感受是速度变慢的时间更多了,但速度下降幅度就没有那么明显。实践证明增量式的CMS收集器效果很一般。

对于内存碎片的问题

  • CMS收集器提供了一个-XX:+UseCMS-CompactAtFullCollection开关参数(默认是开启的,此参数从 JDK 9开始废弃),用于在CMS收集器不得不进行Full GC时开启内存碎片的合并整理过程。由于这个内存整理必须移动存活对象,(在Shenandoah和ZGC出现前)是无法并发的,这样空间碎片问题是解决了,但停顿时间又会变长
  • 虚拟机设计者们还提供了另外一个参数-XX:CMSFullGCsBeforeCompaction(此参数从JDK 9开始废弃),作用是要求CMS收集器在执行过若干次(数量 由参数值决定)不整理空间的Full GC之后,下一次进入Full GC前会先进行碎片整理(默认值为0,表 示每次进入Full GC时都进行碎片整理)。

Garbage First收集器

简介

Garbage First(简称G1),G1是一款主要面向服务端应用的垃圾收集器,是满足停顿时间模型的收集器。JDK 9之后,G1宣告取代Parallel Scavenge加Parallel Old组合,成为服务端模式下的默认垃圾收集器。

G1开创了收集器面向局部收集的设计思路和基于Region的内存布局形式。它可以面向堆内存任何部分来组成回收集(Collection Set,一般简称CSet)进行回收,衡量标准不再是它属于哪个分代,而是哪块内存中存放的垃圾数量最多,回收收益最大,这就是G1收集器的Mixed GC模式。

以往的收集器按照固定大小以及固定数量的分代区域划分,G1是把连续的Java堆划分为多个大小相等的独立区域(Region),每一个Region都可以根据需要,扮演新生代的Eden空间、Survivor空间,或者老年代空间。G1将Region作为单次回收的最小单元,即每次收集到的内存空间都是Region大小的整数倍,这样可以有计划地避免在整个Java堆中进行全区域的垃圾收集。更具体的处理思路是让G1收集器去跟踪各个Region里面的垃圾堆积的“价值”大小,价值即回收所获得的空间大小以及回收所需时间的经验值,然后在后台维护一 个优先级列表,每次根据用户设定允许的收集停顿时间(使用参数-XX:MaxGCPauseMillis指定,默认值是200毫秒),优先处理回收价值收益最大的那些Region。

image-20220625204942160

G1收集器能够对扮演不同角色的Region采用不同的策略去处理,这样无论是新创建的对象还是已经存活了一段时间、熬过多次收集的旧对象都能获取很好的收集效果。

Region中还有一类特殊的Humongous区域,专门用来存储大对象。G1认为只要大小超过了一个Region容量一半的对象即可判定为大对象。每个Region的大小可以通过参数-XX:G1HeapRegionSize设 定,取值范围为1MB~32MB,且应为2的N次幂。而对于那些超过了整个Region容量的超级大对象, 将会被存放在N个连续的Humongous Region之中,G1的大多数行为都把Humongous Region作为老年代的一部分来进行看待。

停顿时间模型

指能够支持指定在一个长度为M毫秒的时间片段内,消耗在垃圾收集上的时间大概率不超过N毫秒这样的目标。

问题

跨Region引用对象

将Java堆分成多个独立Region后,Region里面可能存在跨Region引用对象。解决思路是使用记忆集避免全堆作为GC Roots扫描。

G1收集器的每个Region都维护有自己的记忆集,这些记忆集会记录下别的Region 指向自己的指针,并标记这些指针分别在哪些卡页的范围之内。

G1的记忆集在存储结构的本质上是一 种哈希表,Key是别的Region的起始地址,Value是一个集合,里面存储的元素是卡表的索引号。

这 种“双向”的卡表结构(卡表是“我指向谁”,这种结构还记录了“谁指向我”)比原来的卡表实现起来更复杂,同时由于Region数量比传统收集器的分代数量明显要多得多,因此G1收集器要比其他的传统垃圾收集器有着更高的内存占用负担。

而G1卡表维护操作,除了使用写后屏障来,为了实现原始快照搜索 (SATB)算法,还需要使用写前屏障来跟踪并发时的指针变化情况。

由于G1对写屏障的复杂操作 要比CMS消耗更多的运算资源,所以CMS的写屏障实现是直接的同步操作,而G1就不得不将其实现 为类似于消息队列的结构,把写前屏障和写后屏障中要做的事情都放到队列里,然后再异步处理。

并发标记阶段收集线程与用户线程的并发问题

必须在一个能保障一致性的快照上才能进行对象图的遍历,CMS收集器采用增量更新算法,G1 收集器则是通过原始快照(SATB)算法来实现。

相比起增量更新算法,原始快照 搜索能够减少并发标记和重新标记阶段的消耗,避免CMS那样在最终标记阶段停顿时间过长的缺点, 但是在用户程序运行过程中确实会产生由跟踪引用变化带来的额外负担。

垃圾收集对用户线程的影响还体现在回收过程中新创建对象的内存分配上。

用户线程继续运行就肯定会持续有新对象被创建,G1为每一个Region设计了两个名为TAMS(Top at Mark Start)的指针,把Region中的一部分空间划分出来用于并发回收过程中的新对象分配,并发回收时新分配的对象地址都必须要在这两个指针位置以上。G1收集器默认在 这个地址以上的对象是被隐式标记过的,即默认它们是存活的,不纳入回收范围。与CMS中 的“Concurrent Mode Failure”失败会导致Full GC类似,如果内存回收的速度赶不上内存分配的速度, G1收集器也要被迫冻结用户线程执行,导致Full GC而产生长时间“Stop The World”。

停顿预测模型的可靠性

用户通过-XX:MaxGCPauseMillis参数指定的停顿时间
只意味着垃圾收集发生之前的期望值,但G1收集器要怎么做才能满足用户的期望呢?

G1收集器的停顿 预测模型是以衰减均值(Decaying Average)为理论基础来实现的,在垃圾收集过程中,G1收集器会记录每个Region的回收耗时、每个Region记忆集里的脏卡数量等各个可测量的步骤花费的成本,并分析得 出平均值、标准偏差、置信度等统计信息。

这里强调的“衰减平均值”是指它会比普通的平均值更容易 受到新数据的影响,平均值代表整体平均状态,但衰减平均值更准确地代表“最近的”平均状态。换句话说,Region的统计状态越新越能决定其回收的价值。然后通过这些信息预测现在开始回收的话,由 哪些Region组成回收集才可以在不超过期望停顿时间的约束下获得最高的收益。

运作过程

image-20220625233521409

  • 初始标记(Initial Marking):仅仅只是标记一下GC Roots能直接关联到的对象,并且修改TAMS指针的值,让下一阶段用户线程并发运行时,能正确地在可用的Region中分配新对象。这个阶段需要 停顿线程,但耗时很短,而且是借用进行Minor GC的时候同步完成的,所以G1收集器在这个阶段实际并没有额外的停顿。
  • 并发标记(Concurrent Marking):从GC Root开始对堆中对象进行可达性分析,递归扫描整个堆里的对象图,找出要回收的对象,这阶段耗时较长,但可与用户程序并发执行。当对象图扫描完成以 后,还要重新处理SATB记录下的在并发时有引用变动的对象。
  • 最终标记(Final Marking):对用户线程做另一个短暂的暂停,用于处理并发阶段结束后仍遗留下来的最后那少量的SATB记录。
  • 筛选回收(Live Data Counting and Evacuation):负责更新Region的统计数据,对各个Region的回收价值和成本进行排序,根据用户所期望的停顿时间来制定回收计划,可以自由选择任意多个Region 构成回收集,然后把决定回收的那一部分Region的存活对象复制到空的Region中,再清理掉整个旧 Region的全部空间。这里的操作涉及存活对象的移动,是必须暂停用户线程,由多条收集器线程并行 完成的。

G1收集器除了并发标记外,其余阶段也是要完全暂停用户线程的。

优点
  • 可以由用户指定期望的停顿时间,设置不同的期望停顿时间,可使得G1在不同应用场景中取得关注吞吐量和关注延迟之间的最佳平衡。通常把期望停顿时间设置为一两百毫秒或者两三百毫秒会是比较合理的,太低很可能出现的结果就是由于停顿目标时间太短,导致每次选出来的回收集只占堆内存很小的一部分,收集器收集的速 度逐渐跟不上分配器分配的速度,导致垃圾慢慢堆积,最终占满堆引发Full GC反而降低性能。
  • 分Region的内存布局
  • 收益动态确定回收集
  • G1从整体来看是基于“标记-整理”算法实现的收集器,但从局部(两个Region 之间)上看又是基于“标记-复制”算法实现。无论如何,这两种算法都意味着G1运作期间不会产生内存 空间碎片,垃圾收集完成之后能提供规整的可用内存。
缺点
  • G1无论是为了垃圾收集产生的内存占用(Footprint)还是程序运行时的额外执行负载 (Overload)都要比较大。
  • G1堆中每个Region,无论扮演的是新生代还是老年代角色,都必须有一份卡表,这导致G1的记忆集(和 其他内存消耗)可能会占整个堆容量的20%乃至更多的内存空间

Shenandoah收集器

简介

目标是实现一种能在任何堆内存大小下都可以把垃圾收集的停顿时间限制在十毫秒以内的垃圾收集器

和G1在堆内存布局,在初始标记、并发标记等许多阶段的处理思路上 都高度一致

使用基于Region的堆内存布局,有着用于存放大对象的Humongous Region,默认的回收策略也同样是优先处理回收价值最大的 Region

支持并发的整理算法

默认不使用分代收集

用名为“连接矩阵”(Connection Matrix)的全局数据结构来记录跨Region的引用关系,降低了处理跨代指针时的记忆集维护消耗,也降 低了伪共享问题的发生概率

image-20220628203637526

工作流程

最主要的是并发标记、并发回收、并发引用更新。

  • 初始标记(Initial Marking):首先标记与GC Roots直接关联的对象,这个阶段仍是“Stop The World”的,但停顿时间与堆大小无关,只与GC Roots的数量相关。
  • 并发标记(Concurrent Marking):遍历对象图,标记出全部可达的对象,这个阶段是与用户线程一起并发的,时间长短取决于堆中存活对象的数量以及对象图的结构复杂程度。
  • 最终标记(Final Marking):处理剩余的SATB扫描,并在这个阶段统计出回收价值最高的Region,将这些Region构成一组回收集(Collection Set)。最终标记阶段也会有一小段短暂的停顿。
  • 并发清理(Concurrent Cleanup):这个阶段用于清理那些整个区域内连一个存活对象都没有找到的Region(这类Region被称为Immediate Garbage Region)。
  • 并发回收(Concurrent Evacuation):并发回收阶段是Shenandoah与之前HotSpot中其他收集器的核心差异。在这个阶段,Shenandoah要把回收集里面的存活对象先复制一份到其他未被使用的Region之 中。其困难点是在移动对象的同时,用户线程仍然可能不停对被移动的对象 进行读写访问,移动对象是一次性的行为,但移动之后整个内存中所有指向该对象的引用都还是旧对象的地址,这是很难一瞬间全部改变过来的。对于并发回收阶段遇到的这些困难,Shenandoah将会通过读屏障和被称为“Brooks Pointers”的转发指针来解决。并发回收阶段运行的时间长短取决于回收集的大小。
  • 初始引用更新(Initial Update Reference):并发回收阶段复制对象结束后,还需要把堆中所有指向旧对象的引用修正到复制后的新地址,这个操作称为引用更新。引用更新的初始化阶段实际上并未 做什么具体的处理,设立这个阶段只是为了建立一个线程集合点,确保所有并发回收阶段中进行的收集器线程都已完成分配给它们的对象移动任务而已。初始引用更新时间很短,会产生一个非常短暂的停顿。
  • 并发引用更新(Concurrent Update Reference):真正开始进行引用更新操作,这个阶段是与用户线程一起并发的,时间长短取决于内存中涉及的引用数量的多少。并发引用更新与并发标记不同,它不再需要沿着对象图来搜索,只需要按照内存物理地址的顺序,线性地搜索出引用类型,把旧值改为新值即可。
  • 最终引用更新(Final Update Reference):解决了堆中的引用更新后,还要修正存在于GC Roots中的引用。这个阶段是Shenandoah的最后一次停顿,停顿时间只与GC Roots的数量相关。
  • 并发清理(Concurrent Cleanup):经过并发回收和引用更新之后,整个回收集中所有的Region已再无存活对象,这些Region都变成Immediate Garbage Regions了,最后再调用一次并发清理过程来回收 这些Region的内存空间,供以后新对象分配使用。
转发指针

Brooks Pointer

实现对象移动与用户程序并发的传统做法

  • 做法:在被移动对象原有的内存上设置保护陷阱(Memory Protection Trap),一旦用户程序访问到归属于旧对象的内存空间就会产生自陷中段,进入预设好的异常处理器中,再由其中的代码逻辑把访问转发到复制后的新对象上。
  • 优点:能够实现对象移动与用户线程并发
  • 缺点:如果没有操作系统层面的直接支持,这种方案将导致用户态频繁切换到核心态,代价是非常大的,不能频繁使用。

转发指针的做法:

  • 做法:原有对象布局结构的最前面统一增加一个新的引用字段,在正常不处于并发移动的情况下,该引用指向对象自己。当对象拥有了一份新的副本时,只需要修改旧对象上转发指针的引用位置,使其指向新对象,便可将所有对该对象的访问转发到新的副本上。
  • 优点:能够实现对象移动与用户线程并发
  • 缺点:
    • 每次对象访问会带来一次额外的转向开销
    • 带来多线程竞争问题。收集器线程移动对象的同时,用户线程对对象进行修改。

image-20220628205006185

Shenandoah收集器保证并发时原对象与复制对象的访问一致性的方法:

  • 通过CAS操作保证并发时对象访问的正确
  • 发生对象访问操作时,同时设置读、写屏障(读屏障的代价比写屏障更大,而代码里对象读取的出现频率要比对象写入的频率高出很多),并且在读、写屏障中都加入了额外的转发处理。
  • JDK 13中将Shenandoah的内存屏障模型改进为基于引用访问屏障(Load Reference Barrier),所谓“引用访问屏障”是指内存屏障只拦 截对象中数据类型为引用类型的读写操作,而不去管原生数据类型等其他非引用字段的读写,这能够 省去大量对原生类型、对象比较、对象加锁等场景中设置内存屏障所带来的消耗。

ZGC收集器

简介

Z Garbage Collector

目标是实现一种能在任何堆内存大小下都可以把垃圾收集的停顿时间限制在十毫秒以内的垃圾收集器

ZGC收集器是一款基于Region内存布局的,(暂时) 不设分代的,使用了读屏障、染色指针和内存多重映射等技术来实现可并发的标记-整理算法的,以低延迟为首要目标的一款垃圾收集器。

ZGC的Region具有动态性——动态创建和销毁,以及动态的区域容量大小。在x64硬件平台下,ZGC的 Region可以具有大、中、小三类容量:

  • 小型Region(Small Region):容量固定为2MB,用于放置小于256KB的小对象。
  • 中型Region(Medium Region):容量固定为32MB,用于放置大于等于256KB但小于4MB的对象。
  • 大型Region(Large Region):容量不固定,可以动态变化,但必须为2MB的整数倍,用于放置4MB或以上的大对象。每个大型Region中只会存放一个大对象,这也预示着虽然名字叫作“大型 Region”,但它的实际容量完全有可能小于中型Region,最小容量可低至4MB。大型Region在ZGC的实现中是不会被重分配(重分配是ZGC的一种处理动作,用于复制对象的收集器阶段的,因为复制一个大对象的代价非常高昂。

image-20220628210300524

ZGC都并未使用任何写屏障,只使用了读屏障。原因

  • 染色指针
  • ZGC现在还不支持分代收集,天然就没有跨代引用的问题
染色指针

Linux下64位指针的高18位不能用来寻址,ZGC的染色指针技术将其高4位提取出来存储四个标志信息。

  • 三色标记状态
  • 是否进入了重分配集(即被移动过)
  • 是否只能通过finalize()方法才能被访问到

image-20220628211356932

染色指针的自愈能力 SelfHealing

  • 得益于染色指针的支持,ZGC收集器能仅从引用上就明确得知一个对象是否处于重分配集之中,如果用户线程此时并发访问了位于重分配集中的对象,这次访问将会被预置的内存屏障所截获,然后立即根据Region上的转发表记录将访问转发到新复制的对象 上,并同时修正更新该引用的值,使其直接指向新对象。
  • 简单说,只有第一次访问旧对象会陷入转发,而Shenandoah的Brooks转发指针是每次对象访问都必须付出的固定开销。

缺点

  • 由于这些标志位进一步压缩了原本就只有46位的地址空间,也直接导致 ZGC能够管理的内存不可以超过4TB(2的42次幂)
  • 不能支持32位平台
  • 不能支持压缩指针
  • 由于自愈性,堆中几乎所有对象都存活的极端情况,需要 1∶1复制对象到新Region的话,就必须要有一半的空闲Region来完成收集。

优点

  • 只有第一次访问旧对象会陷入转发
  • 染色指针可以使得一旦分配集中某个Region的存活对象被复制移走之后,这个Region立即就能够被释放和重用掉,而不必等待整个堆中所有指向该Region的引用都被修正后才能清理,堆中旧指针一旦被使用,它们都是可以自愈的。
  • 染色指针可以大幅减少在垃圾收集过程中内存屏障的使用数量。ZGC都并未使用任何写屏障,只使用了读屏障。
  • 染色指针可以作为一种可扩展的存储结构用来记录更多与对象标记、重定位过程相关的数据。可以利用指针地址其余位置再存储更多的标志

不同层次的虚拟内存到物理内存的转换关系可以在硬件层面、操作系统层面或者软件进程层面实现,如何完成地址转换,是一对一、多对一还是一对多的映射,也可以根据实际需要来设计。

Linux/x86-64平台上的ZGC使用了多重映射(Multi-Mapping)将多个不同的虚拟内存地址映射到同一 个物理内存地址上,这是一种多对一映射,意味着ZGC在虚拟内存中看到的地址空间要比实际的堆内存容量来得更大。把染色指针中的标志位看作是地址的分段符,那只要将这些不同的地址段都映射到同一个物理内存空间。

一个染色指针可以映射到虚拟内存的不同区域,即不同的虚拟内存地址映射到同一 个物理内存地址。

image-20220628212452132

工作流程

image-20220628212638342

  • 并发标记(Concurrent Mark):并发标记是遍历对象图做可达性分析的阶段,前后也要经过类似于G1、Shenandoah的初始标记、最终标记(尽管ZGC中的名字不叫这些)的短暂停顿,而且这些停顿阶段所做的事情在目标上也是相类似的。标记阶段会更新染色指针中的Marked 0、Marked 1标志位。
  • 并发预备重分配(Concurrent Prepare for Relocate):这个阶段,会扫描所有的Region,用范围更大的扫描成本换取省去G1中记忆集的维护成本,需要根据特定的查询条件统计得出本次收集过程要清理哪些Region,将这些Region组成重分配集(Relocation Set),重分配集只是决定了里面的存活对象会被重新复制到其他的Region中,里面 的Region会被释放,而并不能说回收行为就只是针对这个集合里面的Region进行。ZGC划分Region的目的并非为了像G1那样做收益优先的增量回收。此外,在JDK 12的ZGC中开始支持的类卸载以及弱引用的处理,也是在这个阶段中完成的。
  • 并发重分配(Concurrent Relocate):重分配是ZGC执行过程中的核心阶段,这个过程要把重分配集中的存活对象复制到新的Region上,并为重分配集中的每个Region维护一个转发表(Forward Table),记录从旧对象到新对象的转向关系。
  • 并发重映射(Concurrent Remap):重映射所做的就是修正整个堆中指向重分配集中旧对象的所有引用,ZGC的并发重映射并不是一个必须要“迫切”去完成的任务,因为即使是旧引用,它也是可以自愈的,最多只是第一次使用时多一次转发和修正操作。重映射清理这些旧引用的主要目的是为了不变慢(还有清理结束 后可以释放转发表这样的附带收益),所以说这并不是很“迫切”。因此,ZGC很巧妙地把并发重映射阶段要做的工作,合并到了下一次垃圾收集循环中的并发标记阶段里去完成,反正它们都是要遍历所有对象的,这样合并就节省了一次遍历对象图的开销。一旦所有指针都被修正之后,原来记录新旧对象关系的转发表就可以释放掉了。
优点
  • 停顿时间控制在十毫秒之内
  • 支持“NUMA-Aware”的内存分配。NUMA(NonUniform Memory Access,非统一内存访问架构)是一种为多处理器或者多核处理器的计算机所设计的 内存架构。在NUMA架构下,ZGC收集器会优先尝 试在请求线程当前所处的处理器的本地内存上分配对象,以保证高效内存访问。
缺点
  • ZGC能够管理的内存不可以超过4TB
  • 能承受的对象分配速率不会太高。每一次完整的并发收集周期回收到的内存空间持续小于期间并发产生的浮动垃圾所占的空间,堆中剩余空间就越来越小了。从根本上提升ZGC能够应对 的对象分配速率,还是需要引入分代收集,让新生对象都在一个专门的区域中创建,然后专门针对这 个区域进行更频繁、更快的收集。

Epsilon收集器

这是一款以不能够进行垃圾 收集为“卖点”的垃圾收集器

一个垃圾收集器除了垃圾收集这个本职工作之外,它还要负责堆的管理与布局、对象的分配、与解释器的协作、与编译器的协作、与监控子系统协作等职责,其中至少堆的管理和对象的分配这部分功能是Java虚拟机能够正常运作的必要支持,是一个最小化功能 的垃圾收集器也必须实现的内容。

从JDK 10开始,为了隔离垃圾收集器与Java虚拟机解释、编译、监 控等子系统的关系,RedHat提出了垃圾收集器的统一接口,即JEP 304提案,Epsilon是这个接口的有效 性验证和参考实现,同时也用于需要剥离垃圾收集器影响的性能测试和压力测试。

如果应用只要运行数分钟甚至数秒, 只要Java虚拟机能正确分配内存,在堆耗尽之前就会退出,那显然运行负载极小、没有任何回收行为 的Epsilon便是很恰当的选择。

数据类型

reference

参考

  • https://mp.weixin.qq.com/s?__biz=Mzg2MjUzODc5Mw==&mid=2247487868&idx=1&sn=715f982805871beca12e4891a810015c&scene=21#wechat_redirect

简介

在JDK 1.2版之前,Java里面的引用是很传统的定义: 如果reference类型的数据中存储的数值代表的是另外一块内存的起始地址,就称该reference数据是代表 某块内存、某个对象的引用。

在JDK 1.2版之后,Java对引用的概念进行了扩充,将引用分为

  • 强引用(Strongly Re-ference):垃圾收集器永远不会回收掉被引用的对象
  • 软引用(Soft Reference):用来描述一些有用但非必需的对象,在内存发生溢出之前会被回收
  • 弱引用(Weak Reference):用来描述一些有用但非必需的对象,在下一次垃圾回收时被回收
  • 虚引用(Phantom Reference):最弱的一种引用关系,无法通过虚引用来获取一个对象,虚引用的唯一目的就是能在这个 对象被回收时收到一个系统通知

这4种引用强度依次逐渐减弱。

不同的引用类型,主要体现的是对象不同的可达性(reachable)状态和对垃圾收集的影响。

强引用

是最传统的“引用”的定义,是指在程序代码之中普遍存在的引用赋值,即类似“Object obj=new Object()”这种引用关系。无论任何情况下,只要强引用关系还存在,垃圾收集器就永远不会回 收掉被引用的对象。

当该对象被显示地赋值为 null 时,或者没有被其他存活的对象继续引用时,它就会成为垃圾收集器的目标,等待被收回。

软引用

是用来描述一些还有用,但非必须的对象。只被软引用关联着的对象,在系统将要发生内存溢出异常前,会把这些对象列进回收范围之中进行第二次回收,如果这次回收还没有足够的内存, 才会抛出内存溢出异常。在JDK 1.2版之后提供了SoftReference类来实现软引用。

简单说,当内存不足时会被回收

@Test
public void softReferenceTest() {
    SoftReference<String> stringSoftReference = new SoftReference<>("ssy");
    String s = stringSoftReference.get();
    // 被回收后,这里会打印 null 而不是 ssy
    log.info(s);
}

弱引用

也是用来描述那些非必须对象,但是它的强度比软引用更弱一些,被弱引用关联的对象只能生存到下一次垃圾收集发生为止。当垃圾收集器开始工作,无论当前内存是否足够,都会回收掉只被弱引用关联的对象。在JDK 1.2版之后提供了WeakReference类来实现弱引用。

简单说,当 垃圾回收器 进行垃圾回收时,无论内存足与否,它都会被垃圾回收器回收

@Test
public void weakReferenceTest() {
    WeakReference<String> stringWeakReference = new WeakReference<>("ssy");
    String s = stringWeakReference.get();
    // 被回收后,这里会打印 null 而不是 ssy
    log.info(s);
}

虚引用

参考

  • https://blog.csdn.net/xiaoduanayu/article/details/103961241

也称为“幽灵引用”或者“幻影引用”,它是最弱的一种引用关系。一个对象是否有虚引用的存在,完全不会对其生存时间构成影响,也无法通过虚引用来取得一个对象实例。为一个对象设置虚引用关联的唯一目的只是为了能在这个对象被收集器回收时收到一个系统通知。在JDK 1.2版之后提供 了PhantomReference类来实现虚引用。

随时会被回收,不过它的作用更像一个标记,当对象被回收时,它不为 null ,但是要注意,无论什么时候去调用 虚引用的 get 方法,都只能获取到一个 null 值。

public class PhantomReferenceTest {

    public static void main(String[] args) throws InterruptedException {
        User user = new User(1, "debo");
        ReferenceQueue<User> userReferenceQueue = new ReferenceQueue<>();
        // 建立User对象的虚引用
        PhantomReference<User> phantomReference = new PhantomReference<>(user, userReferenceQueue);
        // 去掉强引用
        user = null;
        System.out.println(phantomReference.get());
        // 手动触发GC
        System.gc();
        System.out.println("GC: " + phantomReference.get());
        Reference<? extends User> reference = userReferenceQueue.remove(1000);
        if (reference != null) {
            System.out.println("对象User被回收了");
        }
    }

}

// 输出如下

// null
// GC: null
// 对象User被回收了

可以发现,在GC之前调用phantomReference.get()也无法获得User对象,虚引用就和没有引用一样。那么虚引用存在的意义是什么呢?当GC准备回收对象时,如果发现该对象存在虚引用,就会在回收对象后,将这个虚引用加入引用队列,以通知应用程序对象回收情况,所以也可以将一些资源释放和记录操作放到虚引用中执行。

对象可达性

image-20220820095825160

  • 强可达:比如 创建一个对象时,创建它的线程对该对象就是强可达
  • 软可达:只能通过软引用访问
  • 弱可达:只能通过弱引用访问
  • 虚可达:当对象没有 强,软,弱 引用关联时,并且 finalize 过,就会进入该状态
  • 不可达:意味着该对象可以被清除了。

软引用和弱引用和强引用这三者间可以进行转换( 通过 Referenceget() 可获取到原对象),这意味着,对于软引用、弱引用之类,垃圾收集器可能会存在二次确认的问题,以保证处于弱引用状态的对象,没有改变为强引用。

JDK8 中,还可以通过 指定参数打印引用的相关信息

-XX:+PrintGCDetails

-XX:+PrintGCTimeStamps -XX:+PrintReferenceGC

其他

SpringbootConcurrentReferenceHashMap ,对对象进行一个合理的存储,间接地优化jvm ,提高垃圾回收的效率。

class文件

参考

  • http://learnjvm.com/#/jvm/jvm_serial_05_jvm_bytecode_analysis

简介

作为一个通用的、与机器无关的执行平 台,任何其他语言的实现者都可以将Java虚拟机作为他们语言的运行基础,以Class文件作为他们产品 的交付媒介。编程语言最终由多条字节码指令组合来表达,字节码指令能提供的语言描述能力比编程语言本身有效支持的语言特性更强大。

任何一个Class文件都对应着唯一的一个类或接口的定义信息,但是反过来说,类或接口并不一定都得定义在文件里(譬如类或接口也可以动态生成,直接送入类加载器中)。

Class文件是一组以8个字节为基础单位的二进制流,各个数据项目严格按照顺序紧凑地排列在文件之中,中间没有添加任何分隔符。当遇到需要占用8个字节以上空间的数据项时,则会按照高位在前的方式分割成若干个8个字节进行存储。

大端:高地址存低字节,低地址存高字节

小端:低地址存低字节,高地址存高字节

人类读写数据习惯是大端字节序,大数据先读,小数据后读。所以小端是反人类的方式

Class文件格式:一种类似于C语言结构体的伪结构来存储数据

数据类型

  • 无符号数:是基本的数据类型,以u1、u2、u4、u8来分别代表1个字节、2个字节、4个字节和8个字节的无符号数,无符号数可以用来描述数字、索引引用、数量值或者按照UTF-8编码构成字符串值。
  • 表:是由多个无符号数或者其他表作为数据项构成的复合数据类型,表用于描述有层次关系的复合结构的数据,整个Class文件本质上也可以视 作是一张表。所有表的命名都习惯性地以“_info”结尾。

class文件结构

image.png

魔数

每个Class文件的头4个字节被称为魔数(Magic Number),它的唯一作用是确定这个文件是否为一个能被虚拟机接受的Class文件。

使用魔数而不是扩展名来进行 识别主要是基于安全考虑,因为文件扩展名可以随意改动。文件格式的制定者可以自由地选择魔数 值,只要这个魔数值还没有被广泛采用过而且不会引起混淆。

class文件的魔数值是0xCAFEBABE。

版本

第5和第6个字节是次版本号(Minor Version)

第7和第8个字节是主版本号(Major Version)

Java的版本号是从45开始的,JDK 1.1之后 的每个JDK大版本发布主版本号向上加1(JDK 1.0~1.1使用了45.0~45.3的版本号),高版本的JDK能 向下兼容以前版本的Class文件,但不能运行以后版本的Class文件,因为《Java虚拟机规范》在Class文 件校验部分明确要求了即使文件格式并未发生任何变化,虚拟机也必须拒绝执行超过其版本号的Class 文件。

image-20220706232956522

常量池

量池的入口需要放置一项u2类型的数据,代表常量池容量计数值(constant_pool_count),这个容量计数是从1而不是0开始。(Class文件结构中只有常量池的容量计数是从1开始,其他都是从0开始)

常量池容量计数值0用于表达“不引用任何一个常量池项目”的含义。

常量池中主要存放两大类常量

  • 字面量(Literal)
  • 符号引用(Symbolic References)

常量池中的常量

  • 被模块导出或者开放的包(Package)
  • 类和接口的全限定名(Fully Qualified Name)
  • 字段的名称和描述符(Descriptor)
  • 方法的名称和描述符
  • 方法句柄和方法类型(Method Handle、Method Type、Invoke Dynamic)
  • 动态调用点和动态常量(Dynamically-Computed Call Site、Dynamically-Computed Constant)

Java代码在进行Javac编译的时候,并不像C和C++那样有“连接”这一步骤,而是在虚拟机加载Class文件的时候进行动态连接。当虚拟机做类加载时,将会从常量池获得对应的符号 引用,再在类创建时或运行时解析、翻译到具体的内存地址之中。

常量池中每一项常量都是一个表,表结构起始的第一位是个u1类型的标志位,代表着当前常量属于哪种常量类型。

image-20220705224449994image-20220705224507005

image.png

由于Class文件中方法、字段等都需要引用CONSTANT_Utf8_info型常量来描述名称,所以CONSTANT_Utf8_info型常量的最大长度也就是Java中方法、字段名的最大长度,即65535,超过则无法编译。

访问标志

access_flags,这个标志用于识别一些类或者接口层次的访问信息。

image-20220706234100124

access_flags的值=使用的标志按位或。比如0x0001|0x0020=0x0021

类索引、父类索引与接口索引集合

索引(this_class)和父类索引(super_class)是一个u2类型的数据

接口索引集合(interfaces)是一组u2类型的数据的集合

类索引用于确定这个类的全限定名,父类索引用于确定这个类的父类的全限定名。

由于Java语言不允许多 重继承,所以父类索引只有一个,除了java.lang.Object之外,所有的Java类都有父类,因此除了 java.lang.Object外,所有Java类的父类索引都不为0。

接口索引集合就用来描述这个类实现了哪些接口,接口索引集合,入口的第一项u2类型的数据为接口计数器(interfaces_count),表示索引表
的容量。如果该类没有实现任何接口,则该计数器值为0,后面接口的索引表不再占用任何字节。

image-20220706234520315

字段表集合

字段表(field_info)用于描述接口或者类中声明的变量。

Java语言中的“字段”(Field)包括类级变量以及实例级变量,但不包括在方法内部声明的局部变量。

字段可以包括的修饰符有

  • 字段的作用域(public、private、protected修饰符)
  • 是实例变量还是类变量(static修饰符)
  • 可变性(final)
  • 并发可见性(volatile修饰符,是否强制从主内存读写)
  • 可否被序列化(transient修饰符)
  • 字段数据类型(基本类型、对象、数组)、
  • 字段名称。

各个修饰符都是布尔值,使用标志位来表示。字段叫做什么名字、字段被定义为什么数据类型,这些都是无法固定的,只能引用常量池中的常量来描述

“简单名称”“描述符”以及“全限定名”这三种特殊字符串的概念

  • 全限定名:仅仅是把类全名中的“.”替换成了“/”而已,为了使连续的多个全限定名之间不产生混 淆,在使用时最后一般会加入一个“;”号表示全限定名结束.
  • 简单名称:指没有类型和参数修饰 的方法或者字段名称,这个类中的inc()方法和m字段的简单名称分别就是“inc”和“m”。
  • 描述符:用来描述字段的数据类型、方法的参数列表(包括数量、类型以及顺序)和返回值。对于数组类型,每一维度将使用一个前置的“[”字符来描述

image-20220706235126908

用描述符来描述方法时,按照先参数列表、后返回值的顺序描述,参数列表按照参数的严格顺序放在一组小括号“()”之内。

  • 方法void inc()的描述符为“()V”
  • 方法java.lang.String toString()的描述符 为“()Ljava/lang/String;”
  • 方法int indexOf(char[]source,int sourceOffset,int sourceCount,char[]target, int targetOffset,int targetCount,int fromIndex)的描述符为“([CII[CIII)I”。

字段表集合中不会列出从父类或者父接口中继承而来的字段,但有可能出现原本Java代码之中不存在的字段,譬如在内部类中为了保持对外部类的访问性,编译器就会自动添加指向外部类实例的字 段。

在Java语言中字段是无法重载的,两个字段的数据类型、修饰符不管是否相同,都必须使 用不一样的名称,但是对于Class文件格式来讲,只要两个字段的描述符不是完全相同,那字段重名就 是合法的。

方法表集合

Class文件存储格式中对方法的描述与对字段的描述采用了几乎完全一致的方式,方法表的结构如同字段表一样

方法里的Java代码,经过Javac编译器编译成字节码指令之后,存放在方法属性表集合中一个名为“Code”的属性里面

如果父类方法在子类中没有被重写(Override),方法表集合中就不会出
现来自父类的方法信息。但同样地,有可能会出现由编译器自动添加的方法,最常见的便是类构造 器“<clinit>()”方法和实例构造器“<init>()”方法

在Java语言中,要重载(Overload)一个方法,除了要与原方法具有相同的简单名称之外,还要求必须拥有一个与原方法不同的特征签名。因为返回值不会包含在特征签名之中,所以Java语言里面是无法仅仅依靠返回值 的不同来对一个已有方法进行重载的。如果两个方法有相同的名称和特征签 名,但返回值不同,那么也是可以合法共存于同一个Class文件中的。

Java代码的方法特征签名只包括方法名称、参数 顺序及参数类型,而字节码的特征签名还包括方法返回值以及受查异常表。

属性表集合

attribute_info

Code属性

image-20220707000019890

  • attribute_name_index是一项指向CONSTANT_Utf8_info型常量的索引,此常量值固定为“Code”,它代表了该属性的属性名称
  • attribute_length指示了属性值的长度
  • max_stack代表了操作数栈(Operand Stack)深度的最大值。在方法执行的任意时刻,操作数栈都 不会超过这个深度。虚拟机运行的时候需要根据这个值来分配栈帧(Stack Frame)中的操作栈深度
  • max_locals代表了局部变量表所需的存储空间。max_locals的单位是变量槽(Slot),变量槽是虚拟机为局部变量分配内存所使用的最小单位。对于byte、char、float、int、short、boolean和 returnAddress等长度不超过32位的数据类型,每个局部变量占用一个变量槽,而double和long这两种64 位的数据类型则需要两个变量槽来存放。。方法参数(包括实例方法中的隐藏参数“this”)、显式异常处 理程序的参数(Exception Handler Parameter,就是try-catch语句中catch块中所定义的异常)、方法体中 定义的局部变量都需要依赖局部变量表来存放。Javac编译器会根据变量的作用域来分配变量槽给各个变量使用,根据 同时生存的最大局部变量数量和类型计算出max_locals的大小,不是局部变量所占变量槽数量之和。
  • code_length代表字节码长度,虽然它是一个u4类型的长度值,理论上最大值可以达到2的32次幂,但是《Java虚拟机规范》中明确限制了一个方法不允许超过65535条字节码指令,即它 实际只使用了u2的长度,如果超过这个限制,Javac编译器就会拒绝编译。
  • code是用于存储字节码指令的一系列字节流。个u1 数据类型的取值范围为0x00~0xFF,对应十进制的0~255,也就是一共可以表达256条指令

在实例方法的局部变量表中至少会存在一个指向当前对象实例的局部变 量,局部变量表中也会预留出第一个变量槽位来存放对象实例的引用,所以实例方法参数值从1开始计算。Args_size>=1。

而静态方法的Args_size>=0

异常表

image-20220707000855774

编译器为这段Java源码生成了三条异常表记录,对应三条可能出现的代码执行路径。从Java代码的语义上讲,这三条执行路径分别为:

  • 如果try语句块中出现属于Exception或其子类的异常,转到catch语句块处理;
  • 如果try语句块中出现不属于Exception或其子类的异常,转到finally语句块处理;
  • 如果catch语句块中出现任何异常,转到finally语句块处理。

字节码指令

简介

Java虚拟机的指令由一个字节长度的、代表着某种特定操作含义的数字(称为操作码,Opcode)以及跟随其后的零至多个代表此操作所需的参数(称为操作数,Operand)构成。

由于Java虚拟机采用 面向操作数栈而不是面向寄存器的架构,所 以大多数指令都不包含操作数,只有一个操作码,指令参数都存放在操作数栈中。

由于限制了Java虚拟机操作码的长度为一个字节(即0~255),这意味着指令集的操作码总数不能够超过256条

由于 Class文件格式放弃了编译后代码的操作数长度对齐,这就意味着虚拟机在处理那些超过一个字节的数据时,不得不在运行时从字节中重建出具体数据的结构,也就意味着可以省略掉大量的填充和间隔符号

如果不考虑异常处理的话,那Java虚拟机的解释器可以使用下面这段伪代码作为最基本的执行模型来理解

do {
	自动计算PC寄存器的值加1;
    根据PC寄存器指示的位置,从字节码流中取出操作码; 
    if (字节码存在操作数) 从字节码流中取出操作数; 
    执行操作码所定义的操作;
} while (字节码流长度 > 0);

数据类型

i代表对int类型的数据操作,l代表long,s代表short,b代表byte,c代表char,f代表 float,d代表double,a代表reference。

image-20220707001444055image-20220707001500448

大部分指令都没有支持整数类型boolean、byte、char和short,对这四种类型的操作是用int类型的指令完成的。

类加载机制

类生命周期

参考

  • http://learnjvm.com/#/jvm/jvm_serial_07_jvm_class_loader_mechanism?id=%e7%ac%ac7%e8%ae%b2%ef%bc%9ajvm-%e7%b1%bb%e5%8a%a0%e8%bd%bd%e6%9c%ba%e5%88%b6

简介

一个类型从被加载到虚拟机内存中开始,到卸载出内存为止,它的整个生命周期将会经历加载
(Loading)、验证(Verification)、准备(Preparation)、解析(Resolution)、初始化 (Initialization)、使用(Using)和卸载(Unloading)七个阶段,其中验证、准备、解析三个部分统称 为连接(Linking)。

image-20220716195540764

加载、验证、准备、初始化和卸载这五个阶段的顺序是确定的,类型的加载过程必须按照这种顺序按部就班地开始,但不一定按这个顺序结束,因为这些阶段通常都是互相交叉地混合进行的,会在一个阶段执行的过程中调用、激活另一个阶段。

解析阶段在某些情况下可以在初始化阶段之后再开始, 这是为了支持Java语言的运行时绑定特性(也称为动态绑定或晚期绑定)

初始化时机

类初始化

对于初始化阶段,《Java虚拟机规范》 则是严格规定了有且只有六种情况必须立即对类进行“初始化”。

这六种场景中的行为称为对一个类型进行主动引用。除此之外,所有引用类型的方式都不会触发初始化,称为被动引用。

主动引用

  • 遇到new、getstatic、putstatic或invokestatic这四条字节码指令时,如果类型没有进行过初始
    化,则需要先触发其初始化阶段。能够生成这四条指令的典型Java代码场景有:
    • 使用new关键字实例化对象的时候。
    • 读取或设置一个类型的静态字段(被final修饰、已在编译期把结果放入常量池的静态字段除外)
      的时候。
    • 调用一个类型的静态方法的时候。
  • 使用java.lang.reflect包的方法对类型进行反射调用的时候,如果类型没有进行过初始化,则需
    要先触发其初始化。
  • 当初始化类的时候,如果发现其父类还没有进行过初始化,则需要先触发其父类的初始化。
  • 当虚拟机启动时,用户需要指定一个要执行的主类(包含main()方法的那个类),虚拟机会先
    初始化这个主类。
  • 当使用JDK 7新加入的动态语言支持时,如果一个java.lang.invoke.MethodHandle实例最后的解
    析结果为REF_getStatic、REF_putStatic、REF_invokeStatic、REF_newInvokeSpecial四种类型的方法句 柄,并且这个方法句柄对应的类没有进行过初始化,则需要先触发其初始化。
  • 当一个接口中定义了JDK 8新加入的默认方法(被default关键字修饰的接口方法)时,如果有
    这个接口的实现类发生了初始化,那该接口要在其之前被初始化。

被动引用

  • 通过子类引用父类的静态字段,只会触发父类的初始化,子类不会被初始化
  • 通过数组定义来引用类,不会触发该类的初始化
  • 如果一个类定义了常量,另一个类引用了这个类的常量,在编译阶段,这个类的常量会放入另一个类的常量池,不会触发这个类的初始化。

接口初始化

接口初始化的区别与类初始化的区别,在于一个接口初始化的时候,不要求其全部父接口完成初始化,只有在真正使用到父接口的时候(如引用接口中定义的常量)才会初始化。


The end.


网站公告

今日签到

点亮在社区的每一天
去签到