Java底层知识之JVM基础(下)

53 阅读37分钟

对象实例化

下面是面试题

image-20221220010456019

实例化对象有多种方式,下面展示了六种,其中第二种方法已经过时,因为其要求必须构造器为无参且权限为public,条件苛刻,一般推荐使用第三种方式作为代替

image-20221220010510848

创建对象含有六个步骤,第一个步骤是判断该类是否已经加载,若为加载则进行加载。

其次是为对象分配内存,如果内存规整则会用指针指向未分配内存起始位置,然后每分配一个位置指针就再次前进到未分配起始处,这种方式称为指针碰撞。如果内存不规整,则会维护一个记录表,记录空间中的空闲位置并分配一个足够大的空间给对象

堆空间内的对象是线程共享的,因此会有并发安全问题,解决该问题一般采用CAS配上失败重试或者令每个线程预先分配一块TLAB

接着进行初始化分配空间并为所有属性设置默认值

image-20221220093356313

然后要设置对象的对象头,对象头中存有类信息和一些其他信息

最后要执行init方法进行初始化,初始化操作有属性默认初始化,代码块中初始化以及构造器中初始化,属性最后的值不是默认值的初始化方式均为显式初始化

image-20221220095029430

下面的内容中就有各种初始化案例,左边的字节码文件中可以看到各种初始化都会转换为字节码指令并执行

image-20221220095411209

对象的内存布局

对象中首先存有对象头对象头包含两部分,一部分是运行时元数据,其中存在用于指向堆空间中的对象的具体位置的哈希值,GC分代年龄标志以及锁状态标志等内容。同时还有一个类型指针,该指针指向方法区中其元数据InstanceKlass,用于确定对象所属的类型,如果对象是数组,那么还需要记录数组的长度

image-20221220101323659

实例数据是真正存储的有效信息,其有各种规则,最后是对其填充的内容,这个只起占位符的作用

下面的运行图中,我们new一个Cusomer实例,该实例存在acct对象

image-20221220102059048

因此上图中栈中的局部变量表中存在cust对象的指向,内存空间里you有哦acct的指向,字符串在字符串常量池中,两个对象都维护类型指针,指向方法区中的它们的类元信息

对象访问定位

JVM中存放有栈帧,栈帧指向堆区内的实际对象,堆区内还有指向方法区的元数据指针,用于表明该对象的数据类型

image-20221220103934253

对象访问方式有句柄访问和直接指针两种方式

image-20221220104015516

句柄访问指的是栈帧中的对象指向句柄池,句柄池内有指向对象实例数据的指针和类型数据的指针。句柄访问不但需要额外开辟空间,而且效率也不如直接指针,但是其稳定性高,如果对象的位置发生变化,栈帧中的引用不必变化,句柄池中的指针变化即可

image-20221220104115218

直接指针在栈帧中直接访问对象,对象存放有实例数据和对象类型数据的指针,其效率高,也不需要额外开辟空间,缺点是稳定性低。HotSpot采取的就是直接指针

image-20221220104208047

直接内存

直接内存是向系统申请的内存空间,其来源于NIO,读写性能高

image-20221220105443013

出于性能考虑,读写频繁的场合会考虑使用直接内存。下图是IO与NIO的比较

image-20221220105645354

使用下图的代码可以申请直接内存并使用

通过ByteBuffer.allocateDirect();方法可以调用DirectByteBuffer来操作Native内存

使用IO流的方式需要应用程序通过用户地址空间的缓存与内核地址空间的缓存进行交互,前者与应用程序交互,后者与物理磁盘交互,最后通过其形成的通路进行读写,需要经过的层级较多,因此效率较低

image-20221220124636651

而对于NIO而言,应用程序与物理磁盘之间直接通过物理内存映射文件进行交互,效率较高,适合对大文件的读写操作

image-20221220124757622

直接内存也存在OOM,可以指定大小,默认情况下其大小与堆的最大值参数一致

image-20221220125647322

直接内存的缺点主要在于其分配回收成本较高且不受JVM内存回收管理

执行引擎

执行引擎位于运行时数据区之外

image-20221220130930580

下图展示了执行引擎的内部结构

image-20221220131401751

虚拟机能够执行不被硬件直接支持的指令集格式

image-20221220131426525

而执行引擎的作用是翻译,将字节码指令翻译为机器指令并输入给操作系统

image-20221220131729517

执行引擎需要执行什么字节码指令完全依赖于PC寄存器

image-20221220132102632

在方法执行过程中,执行引擎也可能会通过存储到局部变量表中的对象引用准确定位到Java堆中的对象实例信息,以及通过对象头中的元数据指针定位到目标对象的类型信息

image-20221220132309177

编译和执行

代码需要通过javac编译最后变成一串连续的二进制的字节码文件,下图中黄色的栏框则代表Javac的工作

image-20221220133841380

这些过程都是Javac,也就是Java源码编译器来完成的

image-20221220134449347

Java字节码的执行则是由JVM执行引擎来完成,其有字节码解释器和JIT编译器两种形式,两种形式都可以解释字节码并执行

image-20221220134516165

解释器是采用预定义规范将字节码采用逐行解释的方式执行的应用,而JIT编译器则是将源代码直接编译成本地及其平台相关的机器语言的应用

注意这里是二选一的形式,而不是两个按顺序执行

image-20221220134609505

正式因为有两种可选择的翻译方式,因此说Java是半编译半解释性语言

image-20221220140211080

机器码、指令、汇编语言

下图形象解释了代码的翻译和执行过程

image-20221220140937604

用二进制编码方式表示的指令就是机器指令码,也就是机器语言,其执行速度最快,但对人不友好

image-20221220141139129

由于机器码的可读性太差,于是人们发明了指令,还产生了不同的指令集

image-20221220141349460

为了进一步提高可读性,还发明了汇编语言。汇编语言在执行时必须翻译成机器指令才能被计算机执行

image-20221220141516935

再进一步就出现了各种高级语言,如C、Java、Python等

image-20221220141601119

高级语言翻译成汇编语言是编译过程,汇编到机器指令是汇编过程

image-20221220141634431

其取名源于C、C++源程序执行过程

image-20221220141827395

字节码则是一种中间状态的二进制文件,其可以翻译成机器码,其存在的意义是为了满足Java的跨平台特性

高级语言翻译成字节码文件,而字节码文件可以通过JVM翻译成汇编语言到机器指令,这样就能实现有一个字节码文件就可以在各个不同的平台中使用

image-20221220141844896

解释器

即使不存在字节码文件,也是满足跨平台的,但是字节码文件的存在可以解耦合,因此出现了字节码这一环节

image-20221220142909768

解释器就是一个翻译者,其会根据PC寄存器不断进行逐行翻译并执行

image-20221220143427068

在Java发展历史里,有两套解释执行器,分别是字节码解释器和模板解释器,后者的效率高于前者

image-20221220143515189

但无论是那种解释器,解释器执行都是低效率的,因此JVM支持即使编译技术,将整个函数体编译为机器码,每次函数执行时只执行编译后的机器码,该方式可以大幅度提高效率

image-20221220143637195

JIT编译器

JVM中将字节码文件翻译成机器指令有两种方式,分别是解释器方式与JIT编译器方式

image-20221220144925464

HotSpot虚拟机采用解释器与编译器共存的架构

image-20221220144943411

JIT的执行效率虽然高,但其必须要先将代码编译成本地代码,这就导致其最开始的效率较低。而解释器在程序启动后就可以立刻发挥作用,可以省去编译时间,立刻执行

因此在虚拟机中我们的是两者兼具并用的,当虚拟器启动时,解释器首先发挥作用,省去编译时间,随着时间推移编译器再发挥作用,将代码编译成本地代码来获得更高的执行效率

image-20221220145458157

同时如果解释执行时编译器的激进优化不成立,那么我们仍然可以用解释器来执行代码

image-20221220145540864

一般来说,我们在运行之后,编译器会根据热点探测功能将有价值的字节码编译为本地机器指令来换取更高的执行效率

image-20221220145742695

上面是一个案例,了解即可

热点代码探测

Java语言的编译器分有前端编译器(Javac)、后端运行期编译器(JIT)以及静态提前编译器(AOT)

image-20221220152052774

是否要启动JIT编译器将字节码编译为对应平台的机器指令取决于代码是否是热点代码

image-20221220152447811

判断代码是否是热点代码基于热点探测功能,热点探测功能有方法调用计数器和回边计数器,HotSpot使用的是前者

image-20221220152717689

计数器直接统计方法被调用次数,当到达阈值时则会向编译器提交该方法的编译请求,随后该方法会被编译

image-20221220152919929

下面是方法调用时执行的流程图

image-20221220153127940

如果没有时间限制,那么只要时间足够久那么所有代码都会被编译。为了避免该情况因此出现了热度衰减,在一定时间内调用次数若仍不满足,则计数器的值会减半

热点衰减可以关闭,半衰周期也可以手动设置

image-20221220153248420

回边计数器是统计循环体中代码执行的次数

image-20221220155115004

下图是回边计数器的执行流程

image-20221220155142592

C1与C2编译器

默认情况下虚拟机采用的是解释器和编译器并存的架构,当然,我们也可以手动设置为只用某个应用的架构

image-20221220160745249

JIT编译器中有C1和C2两个编译器,C1运行在Client模式下,而C2在Server模式下

image-20221220161447029

C1的优化耗时短,C2耗时长且会激进优化,但优化后的代码执行效率更高

image-20221220161919743

C2的优化主要在全局层面,跟我们直接堆空间里讲述的逃逸分析的代码优化差不多,所以说我们的堆空间里的逃逸分析只能程序爱你在Server模式下

image-20221220161911355

其还有分层编译策略,会将c1和c2共同使用,相互协作共同执行编译人物

image-20221220162119679

其他编译器

从JDK10起加入了Graal编译器

image-20221220163104504

JDK9引入了AOT编译器,其可以提前编译为机器指令的文件并给予使用

image-20221220163153239

其好处是可以直接执行,不必等待预热,缺点如下图所示

image-20221220163322425

String

String在jdk8及之前以char带表示,9时使用byte

image-20221220165014827

连带其他的Stirng类也使用byte存储,对于两个字节的字符,会使用编码标记辅助,这样能节约空间

image-20221220165602645

String最重要的特性是不可变性

image-20221220165637622

底层结构

字符串底层的结构是不会存储相同内容的字符串的,其实现的原理是字符串常量池其实是一个固定大小的Hashtable

image-20221220171143551

在JDK8之后,默认 长度为60013,最小可设置长度为1009

StringTable中也存在垃圾回收,使用下面参数可以打印垃圾回收的信息

image-20221220224221582

内存分配

String中字面量声明的字符串存放在常量池中,new的对象或者使用String.intern();方法后面谈

image-20221220173426917

Java7之后,字符串常量池的位置调整到Java堆中

image-20221220173536793

位置调整的原因如下

image-20221220174041720

字符串拼接

常量之间的拼接会直接在编译器拼接,常量指的是被final修饰的字符串或者是字面量定义的字符串

image-20221220195205568

在定义字符串时,只要有一个是变量那么其就会使用StringBuilder进行拼接,最后结果的位置就在堆中

image-20221220201406891

常量字符串指的是字面量定义的字符串,而常量引用指的是被final修饰的字符串变量,两者使用时都会在编译器被拼接

image-20221220201956642

用String字符串拼接方式会创建多个SB和String对象,造成空间浪费,多次创建也会降低效率。直接使用SB则能有效提升效率,进一步提升效率可以在一开始就给SB对象指定足够大的值,这样可以避免其扩容

intern()

使用该方法会将字符串在常量池中比较,如果没有一样的对象,则将其保存在常量池中,然后返回常量池中该字符串的地址,若有,则直接返回该常量池的地址

image-20221220204123158

同时对于程序中大量存在的字符串,使用intern()可以节省内存空间

面试题

来看看下面的题目

image-20221220210250146

第一种方式会先往常量池中加入"ab",然后在堆中创建对应的字符串,看字节码指令可以得到验证,因此一共会创建两个对象

image-20221220211201904

第二种方式会创建五个对象,每个new两个,由于字符串拼接所以还要创建一个SB对象,这个对象内部调用to String时还会调用new String("ab")方法,但是这次的方法不会往常量池中加入ab字符串

这就是为什么说new String();和sb.toString()方法并不是完全一致的

下面的题目在JDK6中,均为false,而在JDK7/8中,为true和false

image-20221220212434411

先来讲在JDK6的情况,JDK6中方法区在堆外,第一个情况下在常量池中创建了"1"的对象,同时返回堆中的"1"对象,比较时常量池与堆中的对象不相同,故为false,第二个也是同样道理

但是在JDK7/8时,由于方法区在堆中,为了节约空间,其在调用intern()方法往常量池中存放对象时如果堆中已经有该字符串对象,那么其会直接拿该字符串对象的内存地址到保存到常量池中,这就导致我们在JDK7/8时保存的s3与s4对象其实是一致的,虽然其一个是从堆中获取的对象,另一个是从常量池中获取的,但是其最后都指向同一个对象

image-20221220215537446

G1的去重操作

在Java应用于有许多String对象是重复的,因此出现了G1垃圾收集器,其实现自动持续对重复的String对象去重,这样就能避免浪费

下面是其实现逻辑

image-20221220225248687

其默认是不开启的,我们可以开命令行里开启

image-20221220225339004

垃圾回收(GC)

垃圾收集GC是Java的招牌机制,极大地提高了开发效率

image-20221221004213770

下面是面试题

image-20221221004331987

image-20221221004403604

垃圾是指在运行程序中没有任何指针指向的对象

image-20221221004529068

需要GC的理由如下

image-20221221004903974

早期的垃圾回收是手工进行的,虽然灵活,但是会给程序员造成负担

image-20221221005103172

现在自动垃圾回收方式已经成为现代开发语言必备的标准

image-20221221005205057

Java自动内存管理

Java的自动内存管理能降低内存泄露和内存溢出风险,可以让程序员更专注于业务开发

image-20221221005902167

其坏处在于会弱化Java开发任意对于出现内存溢出时的解决问题的能力

image-20221221010109333

GC主要针对方法区和堆区进行回收

image-20221221010222111

Java堆是垃圾回收的重点,年轻代回收的最为频繁,老年代较少手机,元空间基本不动

image-20221221010546271

垃圾标记相关算法

要进行垃圾回收必须先判断对象是否是垃圾,判断的算法有引用技术算法可达性分析算法两种

image-20221221011645793

引用技术算法无法处理循环引用的情况,因此Java的垃圾回收器中没有使用该算法

image-20221221012123789

下图是循环引用的案例,可以看到当P取出指向之后,内部的类的计数器仍不为0,此时会导致这三个对象无法被回收,也就是发生内存泄露

image-20221221012725181

引用技术算法仍然被python选择,同时python也就解决其缺点的方案

image-20221221013149741

Java采用可达性分析算法,其又被称为根追踪算法,追踪性垃圾收集

image-20221221134209431

可达性分析算法通过搜索根对象集合所连接的对象是否可达来判断是否是垃圾对象,可达则不是,反之则是

image-20221221134336618

下图是可达性分析算法的执行图

image-20221221134428510

GC Roots一般包括下面的元素

image-20221221134639976

除此之外,根据用户所选用的垃圾收集器以及当前回收的内存区域不同,还会有其他对象临时性的加入来共同构成完整的GC Roots集合,比如分代收集和局部回收

image-20221221135038446

一般来说,如果一个指针指向堆内存中的对象而自己又不存放于堆内存中,那么其就是一个Root

image-20221221135149552

使用可达性分析算法判断内存是否可以回收,必须保证分析工作要在一个能保证一致性的快照中进行,这就是为什么进行GC时一定会STW的重要原因

我们可以使用MAT来查看GC Roots,其本身是一个性能分析工具

image-20221221144650647

也可以使用JVisualVM工具

image-20221221144842425

还可以使用JProfiler来查看,该工具可以溯源垃圾的根路径,一般来说,如果出现问题,我们会查找GC根路径的一个分支,来查看其中的问题,如果出现该回收的垃圾却仍然有指向的情况,也就是内存泄露,此时我们可以通过垃圾溯源找到问题,然后将其指向手动切断来解决问题

对象的finalization机制

Java语言提供了finalization机制来允许开发人员提供对象被销毁之前的自定义处理逻辑,当垃圾回收器回收此对象之前,总会调用其方法,该方法也允许在子类中重写

image-20221221141136946

对象的finalize()方法不可主动调用,其应交给GC调用

image-20221221141536518

当对象可达时,处于可触及状态,不可达时,处于可复活状态,当不可达且已经调用过finalize()方法且没复活时或finalize()方法没重写时处于不可触及的状态,只有在对象不可触及时才可以会被回收

image-20221221142327249

下面是该方法的执行过程

image-20221221142546402

垃圾清除相关算法

垃圾清除算法比较常见的有标记-清除算法复制算法标记-压缩算法

image-20221221151400819

标记清除算法指的是先进行遍历标记所有被引用的对象,一般是可达对象,然后将没有标记为可达对象的对象进行回收

image-20221221151652803

其执行过程如下图所示

image-20221221152016340

其缺点是效率不高,用户体验差且需要维护空闲列表

image-20221221152331251

其清除并不是真的置空,而且是将需要清除的对象保存在空闲的地址列表中

复制算法的核心思想是将内存分为两块,每次将活着的对象移动到活内存中,然后释放死内存中的空间

image-20221221155654204

下图是该算法的执行过程

image-20221221233928024

其优点是实现简单,运行高效,且能保证空间的连续性。但是其缺点是该算法需要两倍空间,且因为其是复制而不是移动,是需要维护原本方法区指向堆空间位置的引用的,简单来说就是堆空间的对象发生了移动,因此对应方法区的变量的指向地址也需要变化,这些也是需要开销的。

image-20221221234524412

其适合的场景是存活数量不多而垃圾对象很多,JVM中的新生代显然就很适合,因此其中就使用该算法进行回收

image-20221221234859289

标记-压缩算法是基于复制算法的改进算法

image-20221222000731960

其与复制算法的不同是其标记引用转换,会将对应的引用转移到指定位置中完成碎片整理,由于是移动式的,因此其不需要双倍的空间

image-20221222131049971

标记压缩算法是移动式的算法

image-20221222131149080

对于内存中的分布情况,如果是有序的,那么其分配方式就是指针碰撞

image-20221222131351421

标记-压缩算法虽然消除了内存减半的大家,但是其效率比复制算法要低,同时也需要维护方法区中的引用地址

image-20221222131404000

最后我们来看看总结

image-20221222132440311

没有最优秀的算法,只有最适合的算法,这些分代收集算法里,我们需要对不同的区域采用合适的算法

image-20221222132654709

目前几乎所有的GC都是采用分代收集算法来执行垃圾回收的

image-20221222132826652

新生代一般使用复制算法,而老电脑一般是标记清除算法和标记整理算法混合实现

image-20221222133433698

分代的思想被现有的虚拟机广泛使用,几乎所有的垃圾回收器都区分新生代和老年代

增量收集算法通过收集一小片区域的内存空间和快速切换应用程序线程来避免STW

image-20221222134806873

其缺点是造成系统吞吐量的下降且会使得垃圾回收的总体成本上升

image-20221222135005246

分区算法是将整个堆空间划分成连续的不同小区间,根据目标停顿时间来回收其中的若干个小区间的算法

image-20221222135435677

使用分区算法同样可以避免STW,当然,也同样会造成吞吐量的下降,下图时其分区结果

image-20221222135541645

当然,上面介绍的都只是算法的基本思路,实际GC过程要比这复杂得多

image-20221222135643394

垃圾回收相关概念

System.gc()与Runtime.getRuntime().gc()是一样的,都会显示触发Full GC

但是这个调用只是建议JVM执行,并不是一定会执行的

image-20221222141526830

调用System.runFinalization()会强制执行失去引用的对象的方法的fianlize()方法

下面的第一个方法里,由于其中有一个变量,因此局部变量表的大小会为2,一个存储this对象,另一个存储buffer,此时调用gc并不会被回收,因为局部变量表对空间的回收是被占用时回收,而slot不被占用时是不会回收的,因此此时buffer对象仍然在局部变量表中,仍然有指向,此时会被存放到老年代中

image-20221222143614892

而第二种方法新创建了一个变量,此时就会占用buffer的位置,此时对象失去引用,就会被成功回收

内存溢出与内存泄露

内存溢出,也就是OOM。造成OOM的情况有很多,其中一个就是内存占用增长速度超越了垃圾回收的速度

image-20221222144518957

没有空闲内存的可能原因是堆内存设置不够或者代码创建了大量大对象且长时间不能被GC

image-20221222144852089

通常在OOM之前,都会触发一次GC,但是有些特殊情况下也会直接OOM

image-20221222145058061

对象不会被程序用到了,但是仍然存有引用,此时GC无法回收它们,这种情况成为内存泄露

image-20221222145607061

比方说单例模式的对象一旦引用了其他对象,这时其他对象就无法被回收,此时就发生了内存泄露。又或者是一些需要close的资源未关闭导致的内存泄露

image-20221222150921245

Stop The World

STW指的是GC事件发生过程中,会产生引用程序的停顿

image-20221222152216012

每一个GC都会有STW事件,我们应该要尽可能避免这个事件

image-20221222152651204

并行与并发

并发指的是一个极短的时间段内多个程序在一个处理器上运行,CPU在多个程序中切换运行,由于速度极快,所以看起来的感觉就好像是多个程序在运行

image-20221222153339906

并行指的是多个程序在多个CPU中同时执行,我们称之为并行

image-20221222153621970

下面是二者的对比

在垃圾收集器中,并行指的是多条垃圾收集线程并行工作,而用户线程处于等待状态

image-20221222154658205

串行值得是GC线程单线程执行,当内存不足时,程序暂停,等待GC完毕之后再运行

image-20221222154953178

并发指的是用户线程和GC线程交替执行,CPU通过不断切换两个线程运行来达到感觉上像是并行的效果

安全点与安全区域

GC并非什么时候都可以执行,只有在特定的位置停顿下来才可执行,这些位置就称为安全点

image-20221222160000974

一般我们会选择方法调用、循环跳转和异常跳转等会让程序长时间执行的指令作为安全点

image-20221222160313529

GC发生时一般采用主动式中断的方式让线程运行到安全点停止,便于执行GC

image-20221222160400974

有一些线程处于不执行情况,此时无法到达安全点,因此有了安全区的概念,在一段代码片段中,对象的引用关系不会发生变化,这个区域就叫安全区,在这个区域的任何位置执行GC都是安全的

下图是安全区的执行流程

image-20221222160508277

四种引用

在JDK1.2之后将引用分为四种,分别是强引用、软引用、弱引用、虚引用

image-20221222162210541

四种引用的特点如下所示

image-20221222162739258

在Java系统中,默认的引用类型就是强引用,也就是new一个对象。强引用的对象是可触及的,不会被GC,除非该对象失去了指向。同时强引用也是造成Java内存泄露的主要原因之一

image-20221222202143309

下面举出了强引用的例子

image-20221222202633300

强引用的对象不会被GC,同时也可能导致内存泄露

image-20221222202754944

软引用用于描述一些有用但非必须的对象,被软引用关联的对象在系统将要发生OOM之前,会被回收

image-20221222203906815

高速缓存中就有用到软引用

image-20221222204529809

软引用的实现方式除了上面这种之外,还可以直接在软引用的构造方法中创建匿名内部类实现

弱引用的对象只能生存到下一次垃圾收集为止,其和软引用很像,但是软引用对象回收时还需要判断内存是否紧张,而弱引用则是发现即回收,GC效率上来说后者比前者高

image-20221222210621550

软引用和弱引用很适合来保存那些可有可无的缓存数据

image-20221222210828002

虚引用与对象关联的唯一目的就是用于跟踪垃圾回收过程

image-20221222211536319

虚引用必须和引用队列一起使用,当GC时发现虚引用,会将该对象加入到队列中用于通知程序对象的回收情况

image-20221222211645062

终极器引用的说明如下

image-20221222212743302

垃圾回收器分类

从不同角度分析垃圾收集器,可以将其分为不同的类型

image-20221222213404660

可以按线程数分为串行垃圾回收器和并行垃圾回收器

image-20221222213937488

串行与并行的区别

image-20221222214027310

可以按工作模式分为并发式垃圾回收器和独占式垃圾回收器

按碎片处理方式可分为压缩式垃圾回收器和非压缩式垃圾回收器

image-20221222214356981

按工作的内存区间分可以分为年轻代垃圾回收器和老年代垃圾回收器

GC性能指标

GC的性能指标主要看吞吐量、暂停时间和内存占用这三点

image-20221222215025417

这三者构成不可能三角,一个优秀的收集器最多能同时满足其中两项

image-20221225145041960

一般来说,我们主要满足吞吐量和暂停时间,因为内存便宜,不够就加钱买大点

image-20221225150221486

吞吐量优先意味着咋单位时间内STW的时间最短

image-20221225150325804

暂停时间指的是在一个时间段内应用程序暂停的时间

image-20221225150422645

高吞吐量和低暂停时间是互斥的,因此我们可以选选择高吞吐和低暂停两种GC原则

image-20221225150830345

一般来说,我们现在在使用GC算法时的目标时都是在最大吞吐量优先的情况下尽量降低停顿时间

image-20221225151116834

常见的垃圾收集器

Java常见又经典的收集器一共有七种

image-20221225152823303

先来看看垃圾回收器的发展史,在JDK9之后,G1变为默认的垃圾回收器替代CMS

image-20221225153451325

下面是七大经典垃圾回收器

image-20221225153715155

Serial/Parallel Scavenge/ParNew等GC负责新生代,Serial Old/Parallel Old/CMS等GC负责老年代,而G1(G First)则是都可收集

image-20221225154241626

下图是各种GC的关系图

image-20221225154434423

下面是连线说明

image-20221225154753614

没有最好的收集器,只有最合适的收集器

image-20221225193837637

查看JDK当前使用的GC可以下面的命令

image-20221225194355268

Serial GC

Serial收集器是最基本的收集器,采用复制算法且串行的方式来执行内存回收,Serial Old收集器也是串行回收,不过其采用的回收算法是标记压缩算法

image-20221225200608778

Serial类收集器采用一个CPU或收集线程去完成垃圾收集工作,会暂停其他所有线程,直到其收集结束

image-20221225201302373

其优势是简单而高效,在HotSpot虚拟机中可以用-XX:+UseSerialGC参指令来指定使用该GC

image-20221225201417532

但是该GC只限定在单核cpu时才可以使用,现在都不用了,毕竟大伙们都已经六核八核了

image-20221225201540928

ParNew GC

ParNew GC可以理解为是Serial收集器的多线程版本,其与后者的区别只有其采用并行回收的方式,同样会有STW,且其实很多JVM运行在Server模式下的新生代的默认垃圾收集器

image-20221225203425144

下面使其执行过程

image-20221225203547137

在多CPU的场景下,该GC会更加高效,但是在单CPU的场景下还不如Serial

image-20221225203851044

同样可以使用对应的参数来指定该GC

image-20221225204230925

Parrallel GC

ParrallelGC与ParNew几乎一样,唯一不同的是,其实现的是一个可控制的吞吐量,拥有自适应调节策略,是主打吞吐量优先的垃圾收集器

image-20221225210228071

Parallel Old GC采用标记压缩算法,同样基于并行回收

image-20221225210407488

在JDK8中默认使用Parrallel GC,在吞吐量高的场景中,该GC的性能表现也很不错

image-20221225211121469

其参数配置较多

image-20221225211216933

设置垃圾收集器的最大停顿时间参数需要谨慎

image-20221225212135867

还可以设置其最具特点的自适应调节策略

image-20221225212420653

CMS GC

CMS是HotSpot虚拟机中第一款并发收集器,第一次实现了让GC和用户线程同时工作,其采用标记清除算法,该GC拥有低延迟的特点,集中应用在互联网站或者是B/S系统的服务端上

image-20221225213832042

直到今天,仍然有很多系统使用CMS GC

image-20221225214428256

CMS的执行过程如下所示,一共分为四个主要阶段

image-20221225214520918

在初始标记阶段会STW,但是时间很短,几乎可以忽略不计。第二个并发标记阶段,需要从直接关联对象开始遍历,这个阶段是并发运行的。重新标记极端是为了修正并发标记期间的而发生变动的标记记录,简单可以理解为前面的标记对象只是疑似是垃圾,后面还需要进一步确定其是不是垃圾,这一步就是二次确定,同样会STW,同样也是时间极短。最后是并发清除阶段,清理标记的死亡对象,这个阶段是并发执行的

image-20221225214745726

image-20221225215342746

CMS回收时需要确保程序用户线程有作古的内存可用,因此当堆内存使用率达到某一阈值时,就会开始回收,若回收时出现错误,就会启动Serial Old GC进行Full GC

image-20221225215924631

CMS采用标记清除算法,会产生内存碎片

image-20221225221331653

不可以将标记清除算法换为标记压缩算法呢?这是因为在并发过程中用户线程也在执行,而后者会将对象的地址移动,此时若正好移动的对象是线程要使用的对象,那么就会发生null异常,这是不可接受的,因此使用前者

image-20221225221411482

CMS具有并发收集和低延迟的优点,而其缺点也非常明显,不但会产生异常碎片而且对CPU的资源非常敏感,最大的问题还是无法处理浮动垃圾

image-20221225221749659

可以使用对应的参数来执行收集器的内存使用率的阈值和回收人物

image-20221225222357681

当然还可以设置GC后要不要进行压缩整理或者是设置线程数量

image-20221225223136743

然后是上面的各种GC的总结

image-20221225223405012

也正是因为CMS GC的各种缺点,因此在后续的JDK中,CMS被废弃了

image-20221225223510322

G1 GC

G1 GC的目标是在延迟可控的情况下获得尽可能搞的吞吐量,担当全功能收集器的期望和重任

该GC是一个并行收集器,其会将内存分为许多不相关的区域(Region),还会在后台维护一个优先列表,根据允许的收集时间来优先回收价值最大的Region去,其侧重点在于每次收集垃圾最大量的区间,因此成为G1 GC(Garbage First)

image-20221226015159798

G1是面向配备多核CPU集大容量内存的机器,是JDK9以后的默认垃圾回收器,且CMS在JDK9中已经被废弃

image-20221226015527878

G1有许多优势,首先G1使用分区算法,其具有并行性和并发性,虽然存在STW,但是在回收阶段不会发生完全阻塞用户线程的情况

image-20221226015851896

G1属于分代型垃圾回收器,仍然会区分年轻代和老年代,但是其并不要求连续的分代空间和固定大小和固定数量。如下图所示,其会划分一块堆空间并分为各个区域(Region),每个Region可能会是各种区域

image-20221226020217876

Region之间是复制算法,整体实际可以看作是标记-压缩算法,其会让G1在回收垃圾之后仍然保证内存的连续性,而且当Java堆非常大时,G1的优势就会很明显

image-20221226020505825

其次G1具有可预测的停顿时间模型,其会根据后台维护的优先列表,优先回收垃圾多的Region,以此来保证在有限的时间内尽可能提高收集效率

image-20221226022033517

其与CMS比较,还不具备全方位的碾压优势,但是已经是有很大优势了

image-20221226022426949

其下也有许多对应的设置参数,每个Region的大小其值都应该是2的幂,期望达到的最大GC停顿时间不应该设置太低或太高,合适就好,前者会因为生成垃圾时间与每次收集垃圾之差为正数导致垃圾积累最后导致Full GC而得不偿失,后者则会导致停顿时间过长

image-20221226022811159

G1提供了三种垃圾回收模式,YoungGC、Mixed GC和Full GC三种

image-20221226023250335

在下面的情况里,我们推荐使用G1 GC

image-20221226023304077

G1 GC中所有的Region大小相同且在JVM生命周期内不会被改变

image-20221226023648657

其中还有Humongous内存区域,主要用于存储大对象,如果一个对象的大小超过1.5region,则会放到H

image-20221226023952498

H区主要用于存储大对象,且G1中的大多数行为都会把H区作为老年代的一部分来看待,如果一个H区装不下大对线,那么会寻找连续的H区来存储

image-20221226024607632

G1中使用指针碰撞,记录已经使用的空间和剩余的空间,同样也存在每个线程各自的TLAB

image-20221226024749819

其GC主要包括三个环节,分别是年轻代GC、老年代并发标记以及混合回收,同时还存在Full GC,作为失败保护机制

image-20221226025146759

按照顺时针顺序执行进行垃圾回收,如果出现失败就走Full GC

image-20221226025511075

当Eden区用尽时开始年轻代回收过程,回收是并行的,对象会移动到S或O区。当堆内存使用值到达阈值时会开始老年代并发标记过程,然后执行混合回收。

其老年代的回收不需要整个回收,只需要回收一部分老年代的Region即可,一般是和年轻代一起回收的

image-20221226025533644

一个对象可能会被不同区域引用,为此每一个Region都有一个对应的记忆集(Remembered Set),该记忆集会在每次有引用类型数据进行写操作时都会暂时中断操作并检查该引用是否指向其他Region,若是则将对应的地址记录到记忆集中

image-20221226030602701

这样当进行垃圾扫描时,只要在GC的根结点的枚举范围处加入记忆集,就可以在不进行全局扫描的情况下保证不会有遗漏的对象

image-20221226031135813

当Eden空间耗尽时G1会启动YGC,该GC只回收Eden和幸存区,需要STW

image-20221226040303011

可以看到最后S区和E区最后都集中到新的S区和O区中去了,这里使用到了复制算法

image-20221226040557526

下面是YGC的回收过程

image-20221226040624162

之所以更新Rset要使用脏卡队列的原因如下

image-20221226041008566

下面是并发标记过程的执行流程

image-20221226041347606

当老年代的Region超越阈值时,会触发混合回收,会回收整个Young Region和一部分的Old Region

image-20221226041849422

下面混合回收的执行过程

image-20221226042131341

最后是Full GC的发生原因

image-20221226042311187

回收阶段并发执行的实现官方将其实现在了ZGC中

image-20221226042546085

为了提高G1 GC的大小,我们推荐不要设置年轻代大小,同时暂停时间的目标设置不要太过严苛

image-20221226042618224

总结

目前我们已经学习了七种不同的垃圾收集器,下面是他们各自的特点

image-20221226043700604

GC的不同发展阶段,未来会到ZGC阶段

image-20221226043911768

现在互联网的项目基本都使用G1 GC

image-20221226045144831

最后永远是没有最好的收集器,只有最合适的收集器

image-20221226045332505

还有一些可能的面试题

image-20221226045436966

GC日志

GC中有一些用于打印日志的参数

image-20221226050328618

使用下面命令可以打开GC日志

image-20221226050341993

也可以打更详细的日志,下图中有日志对应段落表达的内容解释

image-20221226050626780

还可以给日志加上时间戳

image-20221226050918891

日志分析

日志打印出来还需要分析,下图是对日志内容的解释说明

image-20221226051045067

image-20221226051215038

image-20221226051339908

下面是YGC时的日志分析

image-20221226051400415

Full GC的日志分析

image-20221226051427038

分析工具

值得注意的是文件的路径是从当前的工程路径下出发的,而非对应模块的路径

image-20221226163150804

我们可以用下面的工具来对是生成的GC日志进行分析

image-20221226163637800

垃圾回收器的新发展

GC仍然在快速发展,Serial GC虽然老,但是也有了新的舞台,但是CMS就在JDK14中移除了

image-20221226164641868

后续还出现了Epsilon和ZGC等垃圾回收器

image-20221226164937703

ZGC和shenandoah主打低停顿时间

image-20221226165041282

shenandoah GC是实验性的,且其实收到官方排挤的GC,号称无论堆大小多少,回收时间都能限制在10ms中

image-20221226165137471

但实际测试结果看,虽然停顿时间对比其他垃圾收集器有所提升,但是仍然无法满足目标,且代价是总运行时间的扩大

image-20221226165837278

最后我们来看看总结

image-20221226170337658

ZGC

ZGC采用可并发的标记-压缩算法,是主打低延迟的垃圾收集器,其也会STW,但几乎都花费在初始标记上,因此几乎可以忽略不计

image-20221226170726310

其在吞吐量的表现上并不输于其他垃圾回收器,甚至在某些情况下还会更强,这是他的弱项的情况下

image-20221226170916558

而在其强项中,几乎是直接碾压了,真正做到了将停顿时间限制在10ms以内

image-20221226171019705

下面是总结

image-20221226171058928

未来的GC,必然是ZGC的GC

image-20221226171125755

现在ZGC已经被移植到了Windows和macOS上了

image-20221226171332905

其他垃圾回收器

阿里巴巴基于JVM的G1开发了面向大堆应用场景的AliGC

image-20221226171426796

当然也有其他比较有名的其他厂商提供的独具一格的GC实现,比较知名的例子就是主打低延迟GC的Zing