对象实例化
下面是面试题
实例化对象有多种方式,下面展示了六种,其中第二种方法已经过时,因为其要求必须构造器为无参且权限为public,条件苛刻,一般推荐使用第三种方式作为代替
创建对象含有六个步骤,第一个步骤是判断该类是否已经加载,若为加载则进行加载。
其次是为对象分配内存,如果内存规整则会用指针指向未分配内存起始位置,然后每分配一个位置指针就再次前进到未分配起始处,这种方式称为指针碰撞。如果内存不规整,则会维护一个记录表,记录空间中的空闲位置并分配一个足够大的空间给对象
堆空间内的对象是线程共享的,因此会有并发安全问题,解决该问题一般采用CAS配上失败重试或者令每个线程预先分配一块TLAB
接着进行初始化分配空间并为所有属性设置默认值
然后要设置对象的对象头,对象头中存有类信息和一些其他信息
最后要执行init方法进行初始化,初始化操作有属性默认初始化,代码块中初始化以及构造器中初始化,属性最后的值不是默认值的初始化方式均为显式初始化
下面的内容中就有各种初始化案例,左边的字节码文件中可以看到各种初始化都会转换为字节码指令并执行
对象的内存布局
对象中首先存有对象头对象头包含两部分,一部分是运行时元数据,其中存在用于指向堆空间中的对象的具体位置的哈希值,GC分代年龄标志以及锁状态标志等内容。同时还有一个类型指针,该指针指向方法区中其元数据InstanceKlass,用于确定对象所属的类型,如果对象是数组,那么还需要记录数组的长度
实例数据是真正存储的有效信息,其有各种规则,最后是对其填充的内容,这个只起占位符的作用
下面的运行图中,我们new一个Cusomer实例,该实例存在acct对象
因此上图中栈中的局部变量表中存在cust对象的指向,内存空间里you有哦acct的指向,字符串在字符串常量池中,两个对象都维护类型指针,指向方法区中的它们的类元信息
对象访问定位
JVM中存放有栈帧,栈帧指向堆区内的实际对象,堆区内还有指向方法区的元数据指针,用于表明该对象的数据类型
对象访问方式有句柄访问和直接指针两种方式
句柄访问指的是栈帧中的对象指向句柄池,句柄池内有指向对象实例数据的指针和类型数据的指针。句柄访问不但需要额外开辟空间,而且效率也不如直接指针,但是其稳定性高,如果对象的位置发生变化,栈帧中的引用不必变化,句柄池中的指针变化即可
直接指针在栈帧中直接访问对象,对象存放有实例数据和对象类型数据的指针,其效率高,也不需要额外开辟空间,缺点是稳定性低。HotSpot采取的就是直接指针
直接内存
直接内存是向系统申请的内存空间,其来源于NIO,读写性能高
出于性能考虑,读写频繁的场合会考虑使用直接内存。下图是IO与NIO的比较
使用下图的代码可以申请直接内存并使用
通过ByteBuffer.allocateDirect();方法可以调用DirectByteBuffer来操作Native内存
使用IO流的方式需要应用程序通过用户地址空间的缓存与内核地址空间的缓存进行交互,前者与应用程序交互,后者与物理磁盘交互,最后通过其形成的通路进行读写,需要经过的层级较多,因此效率较低
而对于NIO而言,应用程序与物理磁盘之间直接通过物理内存映射文件进行交互,效率较高,适合对大文件的读写操作
直接内存也存在OOM,可以指定大小,默认情况下其大小与堆的最大值参数一致
直接内存的缺点主要在于其分配回收成本较高且不受JVM内存回收管理
执行引擎
执行引擎位于运行时数据区之外
下图展示了执行引擎的内部结构
虚拟机能够执行不被硬件直接支持的指令集格式
而执行引擎的作用是翻译,将字节码指令翻译为机器指令并输入给操作系统
执行引擎需要执行什么字节码指令完全依赖于PC寄存器
在方法执行过程中,执行引擎也可能会通过存储到局部变量表中的对象引用准确定位到Java堆中的对象实例信息,以及通过对象头中的元数据指针定位到目标对象的类型信息
编译和执行
代码需要通过javac编译最后变成一串连续的二进制的字节码文件,下图中黄色的栏框则代表Javac的工作
这些过程都是Javac,也就是Java源码编译器来完成的
Java字节码的执行则是由JVM执行引擎来完成,其有字节码解释器和JIT编译器两种形式,两种形式都可以解释字节码并执行
解释器是采用预定义规范将字节码采用逐行解释的方式执行的应用,而JIT编译器则是将源代码直接编译成本地及其平台相关的机器语言的应用
注意这里是二选一的形式,而不是两个按顺序执行
正式因为有两种可选择的翻译方式,因此说Java是半编译半解释性语言
机器码、指令、汇编语言
下图形象解释了代码的翻译和执行过程
用二进制编码方式表示的指令就是机器指令码,也就是机器语言,其执行速度最快,但对人不友好
由于机器码的可读性太差,于是人们发明了指令,还产生了不同的指令集
为了进一步提高可读性,还发明了汇编语言。汇编语言在执行时必须翻译成机器指令才能被计算机执行
再进一步就出现了各种高级语言,如C、Java、Python等
高级语言翻译成汇编语言是编译过程,汇编到机器指令是汇编过程
其取名源于C、C++源程序执行过程
字节码则是一种中间状态的二进制文件,其可以翻译成机器码,其存在的意义是为了满足Java的跨平台特性
高级语言翻译成字节码文件,而字节码文件可以通过JVM翻译成汇编语言到机器指令,这样就能实现有一个字节码文件就可以在各个不同的平台中使用
解释器
即使不存在字节码文件,也是满足跨平台的,但是字节码文件的存在可以解耦合,因此出现了字节码这一环节
解释器就是一个翻译者,其会根据PC寄存器不断进行逐行翻译并执行
在Java发展历史里,有两套解释执行器,分别是字节码解释器和模板解释器,后者的效率高于前者
但无论是那种解释器,解释器执行都是低效率的,因此JVM支持即使编译技术,将整个函数体编译为机器码,每次函数执行时只执行编译后的机器码,该方式可以大幅度提高效率
JIT编译器
JVM中将字节码文件翻译成机器指令有两种方式,分别是解释器方式与JIT编译器方式
HotSpot虚拟机采用解释器与编译器共存的架构
JIT的执行效率虽然高,但其必须要先将代码编译成本地代码,这就导致其最开始的效率较低。而解释器在程序启动后就可以立刻发挥作用,可以省去编译时间,立刻执行
因此在虚拟机中我们的是两者兼具并用的,当虚拟器启动时,解释器首先发挥作用,省去编译时间,随着时间推移编译器再发挥作用,将代码编译成本地代码来获得更高的执行效率
同时如果解释执行时编译器的激进优化不成立,那么我们仍然可以用解释器来执行代码
一般来说,我们在运行之后,编译器会根据热点探测功能将有价值的字节码编译为本地机器指令来换取更高的执行效率
上面是一个案例,了解即可
热点代码探测
Java语言的编译器分有前端编译器(Javac)、后端运行期编译器(JIT)以及静态提前编译器(AOT)
是否要启动JIT编译器将字节码编译为对应平台的机器指令取决于代码是否是热点代码
判断代码是否是热点代码基于热点探测功能,热点探测功能有方法调用计数器和回边计数器,HotSpot使用的是前者
计数器直接统计方法被调用次数,当到达阈值时则会向编译器提交该方法的编译请求,随后该方法会被编译
下面是方法调用时执行的流程图
如果没有时间限制,那么只要时间足够久那么所有代码都会被编译。为了避免该情况因此出现了热度衰减,在一定时间内调用次数若仍不满足,则计数器的值会减半
热点衰减可以关闭,半衰周期也可以手动设置
回边计数器是统计循环体中代码执行的次数
下图是回边计数器的执行流程
C1与C2编译器
默认情况下虚拟机采用的是解释器和编译器并存的架构,当然,我们也可以手动设置为只用某个应用的架构
JIT编译器中有C1和C2两个编译器,C1运行在Client模式下,而C2在Server模式下
C1的优化耗时短,C2耗时长且会激进优化,但优化后的代码执行效率更高
C2的优化主要在全局层面,跟我们直接堆空间里讲述的逃逸分析的代码优化差不多,所以说我们的堆空间里的逃逸分析只能程序爱你在Server模式下
其还有分层编译策略,会将c1和c2共同使用,相互协作共同执行编译人物
其他编译器
从JDK10起加入了Graal编译器
JDK9引入了AOT编译器,其可以提前编译为机器指令的文件并给予使用
其好处是可以直接执行,不必等待预热,缺点如下图所示
String
String在jdk8及之前以char带表示,9时使用byte
连带其他的Stirng类也使用byte存储,对于两个字节的字符,会使用编码标记辅助,这样能节约空间
String最重要的特性是不可变性
底层结构
字符串底层的结构是不会存储相同内容的字符串的,其实现的原理是字符串常量池其实是一个固定大小的Hashtable
在JDK8之后,默认 长度为60013,最小可设置长度为1009
StringTable中也存在垃圾回收,使用下面参数可以打印垃圾回收的信息
内存分配
String中字面量声明的字符串存放在常量池中,new的对象或者使用String.intern();方法后面谈
Java7之后,字符串常量池的位置调整到Java堆中
位置调整的原因如下
字符串拼接
常量之间的拼接会直接在编译器拼接,常量指的是被final修饰的字符串或者是字面量定义的字符串
在定义字符串时,只要有一个是变量那么其就会使用StringBuilder进行拼接,最后结果的位置就在堆中
常量字符串指的是字面量定义的字符串,而常量引用指的是被final修饰的字符串变量,两者使用时都会在编译器被拼接
用String字符串拼接方式会创建多个SB和String对象,造成空间浪费,多次创建也会降低效率。直接使用SB则能有效提升效率,进一步提升效率可以在一开始就给SB对象指定足够大的值,这样可以避免其扩容
intern()
使用该方法会将字符串在常量池中比较,如果没有一样的对象,则将其保存在常量池中,然后返回常量池中该字符串的地址,若有,则直接返回该常量池的地址
同时对于程序中大量存在的字符串,使用intern()可以节省内存空间
面试题
来看看下面的题目
第一种方式会先往常量池中加入"ab",然后在堆中创建对应的字符串,看字节码指令可以得到验证,因此一共会创建两个对象
第二种方式会创建五个对象,每个new两个,由于字符串拼接所以还要创建一个SB对象,这个对象内部调用to String时还会调用new String("ab")方法,但是这次的方法不会往常量池中加入ab字符串
这就是为什么说new String();和sb.toString()方法并不是完全一致的
下面的题目在JDK6中,均为false,而在JDK7/8中,为true和false
先来讲在JDK6的情况,JDK6中方法区在堆外,第一个情况下在常量池中创建了"1"的对象,同时返回堆中的"1"对象,比较时常量池与堆中的对象不相同,故为false,第二个也是同样道理
但是在JDK7/8时,由于方法区在堆中,为了节约空间,其在调用intern()方法往常量池中存放对象时如果堆中已经有该字符串对象,那么其会直接拿该字符串对象的内存地址到保存到常量池中,这就导致我们在JDK7/8时保存的s3与s4对象其实是一致的,虽然其一个是从堆中获取的对象,另一个是从常量池中获取的,但是其最后都指向同一个对象
G1的去重操作
在Java应用于有许多String对象是重复的,因此出现了G1垃圾收集器,其实现自动持续对重复的String对象去重,这样就能避免浪费
下面是其实现逻辑
其默认是不开启的,我们可以开命令行里开启
垃圾回收(GC)
垃圾收集GC是Java的招牌机制,极大地提高了开发效率
下面是面试题
垃圾是指在运行程序中没有任何指针指向的对象
需要GC的理由如下
早期的垃圾回收是手工进行的,虽然灵活,但是会给程序员造成负担
现在自动垃圾回收方式已经成为现代开发语言必备的标准
Java自动内存管理
Java的自动内存管理能降低内存泄露和内存溢出风险,可以让程序员更专注于业务开发
其坏处在于会弱化Java开发任意对于出现内存溢出时的解决问题的能力
GC主要针对方法区和堆区进行回收
Java堆是垃圾回收的重点,年轻代回收的最为频繁,老年代较少手机,元空间基本不动
垃圾标记相关算法
要进行垃圾回收必须先判断对象是否是垃圾,判断的算法有引用技术算法和可达性分析算法两种
引用技术算法无法处理循环引用的情况,因此Java的垃圾回收器中没有使用该算法
下图是循环引用的案例,可以看到当P取出指向之后,内部的类的计数器仍不为0,此时会导致这三个对象无法被回收,也就是发生内存泄露
引用技术算法仍然被python选择,同时python也就解决其缺点的方案
Java采用可达性分析算法,其又被称为根追踪算法,追踪性垃圾收集
可达性分析算法通过搜索根对象集合所连接的对象是否可达来判断是否是垃圾对象,可达则不是,反之则是
下图是可达性分析算法的执行图
GC Roots一般包括下面的元素
除此之外,根据用户所选用的垃圾收集器以及当前回收的内存区域不同,还会有其他对象临时性的加入来共同构成完整的GC Roots集合,比如分代收集和局部回收
一般来说,如果一个指针指向堆内存中的对象而自己又不存放于堆内存中,那么其就是一个Root
使用可达性分析算法判断内存是否可以回收,必须保证分析工作要在一个能保证一致性的快照中进行,这就是为什么进行GC时一定会STW的重要原因
我们可以使用MAT来查看GC Roots,其本身是一个性能分析工具
也可以使用JVisualVM工具
还可以使用JProfiler来查看,该工具可以溯源垃圾的根路径,一般来说,如果出现问题,我们会查找GC根路径的一个分支,来查看其中的问题,如果出现该回收的垃圾却仍然有指向的情况,也就是内存泄露,此时我们可以通过垃圾溯源找到问题,然后将其指向手动切断来解决问题
对象的finalization机制
Java语言提供了finalization机制来允许开发人员提供对象被销毁之前的自定义处理逻辑,当垃圾回收器回收此对象之前,总会调用其方法,该方法也允许在子类中重写
对象的finalize()方法不可主动调用,其应交给GC调用
当对象可达时,处于可触及状态,不可达时,处于可复活状态,当不可达且已经调用过finalize()方法且没复活时或finalize()方法没重写时处于不可触及的状态,只有在对象不可触及时才可以会被回收
下面是该方法的执行过程
垃圾清除相关算法
垃圾清除算法比较常见的有标记-清除算法、复制算法、标记-压缩算法
标记清除算法指的是先进行遍历标记所有被引用的对象,一般是可达对象,然后将没有标记为可达对象的对象进行回收
其执行过程如下图所示
其缺点是效率不高,用户体验差且需要维护空闲列表
其清除并不是真的置空,而且是将需要清除的对象保存在空闲的地址列表中
复制算法的核心思想是将内存分为两块,每次将活着的对象移动到活内存中,然后释放死内存中的空间
下图是该算法的执行过程
其优点是实现简单,运行高效,且能保证空间的连续性。但是其缺点是该算法需要两倍空间,且因为其是复制而不是移动,是需要维护原本方法区指向堆空间位置的引用的,简单来说就是堆空间的对象发生了移动,因此对应方法区的变量的指向地址也需要变化,这些也是需要开销的。
其适合的场景是存活数量不多而垃圾对象很多,JVM中的新生代显然就很适合,因此其中就使用该算法进行回收
标记-压缩算法是基于复制算法的改进算法
其与复制算法的不同是其标记引用转换,会将对应的引用转移到指定位置中完成碎片整理,由于是移动式的,因此其不需要双倍的空间
标记压缩算法是移动式的算法
对于内存中的分布情况,如果是有序的,那么其分配方式就是指针碰撞
标记-压缩算法虽然消除了内存减半的大家,但是其效率比复制算法要低,同时也需要维护方法区中的引用地址
最后我们来看看总结
没有最优秀的算法,只有最适合的算法,这些分代收集算法里,我们需要对不同的区域采用合适的算法
目前几乎所有的GC都是采用分代收集算法来执行垃圾回收的
新生代一般使用复制算法,而老电脑一般是标记清除算法和标记整理算法混合实现
分代的思想被现有的虚拟机广泛使用,几乎所有的垃圾回收器都区分新生代和老年代
增量收集算法通过收集一小片区域的内存空间和快速切换应用程序线程来避免STW
其缺点是造成系统吞吐量的下降且会使得垃圾回收的总体成本上升
分区算法是将整个堆空间划分成连续的不同小区间,根据目标停顿时间来回收其中的若干个小区间的算法
使用分区算法同样可以避免STW,当然,也同样会造成吞吐量的下降,下图时其分区结果
当然,上面介绍的都只是算法的基本思路,实际GC过程要比这复杂得多
垃圾回收相关概念
System.gc()与Runtime.getRuntime().gc()是一样的,都会显示触发Full GC
但是这个调用只是建议JVM执行,并不是一定会执行的
调用System.runFinalization()会强制执行失去引用的对象的方法的fianlize()方法
下面的第一个方法里,由于其中有一个变量,因此局部变量表的大小会为2,一个存储this对象,另一个存储buffer,此时调用gc并不会被回收,因为局部变量表对空间的回收是被占用时回收,而slot不被占用时是不会回收的,因此此时buffer对象仍然在局部变量表中,仍然有指向,此时会被存放到老年代中
而第二种方法新创建了一个变量,此时就会占用buffer的位置,此时对象失去引用,就会被成功回收
内存溢出与内存泄露
内存溢出,也就是OOM。造成OOM的情况有很多,其中一个就是内存占用增长速度超越了垃圾回收的速度
没有空闲内存的可能原因是堆内存设置不够或者代码创建了大量大对象且长时间不能被GC
通常在OOM之前,都会触发一次GC,但是有些特殊情况下也会直接OOM
对象不会被程序用到了,但是仍然存有引用,此时GC无法回收它们,这种情况成为内存泄露
比方说单例模式的对象一旦引用了其他对象,这时其他对象就无法被回收,此时就发生了内存泄露。又或者是一些需要close的资源未关闭导致的内存泄露
Stop The World
STW指的是GC事件发生过程中,会产生引用程序的停顿
每一个GC都会有STW事件,我们应该要尽可能避免这个事件
并行与并发
并发指的是一个极短的时间段内多个程序在一个处理器上运行,CPU在多个程序中切换运行,由于速度极快,所以看起来的感觉就好像是多个程序在运行
并行指的是多个程序在多个CPU中同时执行,我们称之为并行
下面是二者的对比
在垃圾收集器中,并行指的是多条垃圾收集线程并行工作,而用户线程处于等待状态
串行值得是GC线程单线程执行,当内存不足时,程序暂停,等待GC完毕之后再运行
并发指的是用户线程和GC线程交替执行,CPU通过不断切换两个线程运行来达到感觉上像是并行的效果
安全点与安全区域
GC并非什么时候都可以执行,只有在特定的位置停顿下来才可执行,这些位置就称为安全点
一般我们会选择方法调用、循环跳转和异常跳转等会让程序长时间执行的指令作为安全点
GC发生时一般采用主动式中断的方式让线程运行到安全点停止,便于执行GC
有一些线程处于不执行情况,此时无法到达安全点,因此有了安全区的概念,在一段代码片段中,对象的引用关系不会发生变化,这个区域就叫安全区,在这个区域的任何位置执行GC都是安全的
下图是安全区的执行流程
四种引用
在JDK1.2之后将引用分为四种,分别是强引用、软引用、弱引用、虚引用
四种引用的特点如下所示
在Java系统中,默认的引用类型就是强引用,也就是new一个对象。强引用的对象是可触及的,不会被GC,除非该对象失去了指向。同时强引用也是造成Java内存泄露的主要原因之一
下面举出了强引用的例子
强引用的对象不会被GC,同时也可能导致内存泄露
软引用用于描述一些有用但非必须的对象,被软引用关联的对象在系统将要发生OOM之前,会被回收
高速缓存中就有用到软引用
软引用的实现方式除了上面这种之外,还可以直接在软引用的构造方法中创建匿名内部类实现
弱引用的对象只能生存到下一次垃圾收集为止,其和软引用很像,但是软引用对象回收时还需要判断内存是否紧张,而弱引用则是发现即回收,GC效率上来说后者比前者高
软引用和弱引用很适合来保存那些可有可无的缓存数据
虚引用与对象关联的唯一目的就是用于跟踪垃圾回收过程
虚引用必须和引用队列一起使用,当GC时发现虚引用,会将该对象加入到队列中用于通知程序对象的回收情况
终极器引用的说明如下
垃圾回收器分类
从不同角度分析垃圾收集器,可以将其分为不同的类型
可以按线程数分为串行垃圾回收器和并行垃圾回收器
串行与并行的区别
可以按工作模式分为并发式垃圾回收器和独占式垃圾回收器
按碎片处理方式可分为压缩式垃圾回收器和非压缩式垃圾回收器
按工作的内存区间分可以分为年轻代垃圾回收器和老年代垃圾回收器
GC性能指标
GC的性能指标主要看吞吐量、暂停时间和内存占用这三点
这三者构成不可能三角,一个优秀的收集器最多能同时满足其中两项
一般来说,我们主要满足吞吐量和暂停时间,因为内存便宜,不够就加钱买大点
吞吐量优先意味着咋单位时间内STW的时间最短
暂停时间指的是在一个时间段内应用程序暂停的时间
高吞吐量和低暂停时间是互斥的,因此我们可以选选择高吞吐和低暂停两种GC原则
一般来说,我们现在在使用GC算法时的目标时都是在最大吞吐量优先的情况下尽量降低停顿时间
常见的垃圾收集器
Java常见又经典的收集器一共有七种
先来看看垃圾回收器的发展史,在JDK9之后,G1变为默认的垃圾回收器替代CMS
下面是七大经典垃圾回收器
Serial/Parallel Scavenge/ParNew等GC负责新生代,Serial Old/Parallel Old/CMS等GC负责老年代,而G1(G First)则是都可收集
下图是各种GC的关系图
下面是连线说明
没有最好的收集器,只有最合适的收集器
查看JDK当前使用的GC可以下面的命令
Serial GC
Serial收集器是最基本的收集器,采用复制算法且串行的方式来执行内存回收,Serial Old收集器也是串行回收,不过其采用的回收算法是标记压缩算法
Serial类收集器采用一个CPU或收集线程去完成垃圾收集工作,会暂停其他所有线程,直到其收集结束
其优势是简单而高效,在HotSpot虚拟机中可以用-XX:+UseSerialGC参指令来指定使用该GC
但是该GC只限定在单核cpu时才可以使用,现在都不用了,毕竟大伙们都已经六核八核了
ParNew GC
ParNew GC可以理解为是Serial收集器的多线程版本,其与后者的区别只有其采用并行回收的方式,同样会有STW,且其实很多JVM运行在Server模式下的新生代的默认垃圾收集器
下面使其执行过程
在多CPU的场景下,该GC会更加高效,但是在单CPU的场景下还不如Serial
同样可以使用对应的参数来指定该GC
Parrallel GC
ParrallelGC与ParNew几乎一样,唯一不同的是,其实现的是一个可控制的吞吐量,拥有自适应调节策略,是主打吞吐量优先的垃圾收集器
Parallel Old GC采用标记压缩算法,同样基于并行回收
在JDK8中默认使用Parrallel GC,在吞吐量高的场景中,该GC的性能表现也很不错
其参数配置较多
设置垃圾收集器的最大停顿时间参数需要谨慎
还可以设置其最具特点的自适应调节策略
CMS GC
CMS是HotSpot虚拟机中第一款并发收集器,第一次实现了让GC和用户线程同时工作,其采用标记清除算法,该GC拥有低延迟的特点,集中应用在互联网站或者是B/S系统的服务端上
直到今天,仍然有很多系统使用CMS GC
CMS的执行过程如下所示,一共分为四个主要阶段
在初始标记阶段会STW,但是时间很短,几乎可以忽略不计。第二个并发标记阶段,需要从直接关联对象开始遍历,这个阶段是并发运行的。重新标记极端是为了修正并发标记期间的而发生变动的标记记录,简单可以理解为前面的标记对象只是疑似是垃圾,后面还需要进一步确定其是不是垃圾,这一步就是二次确定,同样会STW,同样也是时间极短。最后是并发清除阶段,清理标记的死亡对象,这个阶段是并发执行的
CMS回收时需要确保程序用户线程有作古的内存可用,因此当堆内存使用率达到某一阈值时,就会开始回收,若回收时出现错误,就会启动Serial Old GC进行Full GC
CMS采用标记清除算法,会产生内存碎片
不可以将标记清除算法换为标记压缩算法呢?这是因为在并发过程中用户线程也在执行,而后者会将对象的地址移动,此时若正好移动的对象是线程要使用的对象,那么就会发生null异常,这是不可接受的,因此使用前者
CMS具有并发收集和低延迟的优点,而其缺点也非常明显,不但会产生异常碎片而且对CPU的资源非常敏感,最大的问题还是无法处理浮动垃圾
可以使用对应的参数来执行收集器的内存使用率的阈值和回收人物
当然还可以设置GC后要不要进行压缩整理或者是设置线程数量
然后是上面的各种GC的总结
也正是因为CMS GC的各种缺点,因此在后续的JDK中,CMS被废弃了
G1 GC
G1 GC的目标是在延迟可控的情况下获得尽可能搞的吞吐量,担当全功能收集器的期望和重任
该GC是一个并行收集器,其会将内存分为许多不相关的区域(Region),还会在后台维护一个优先列表,根据允许的收集时间来优先回收价值最大的Region去,其侧重点在于每次收集垃圾最大量的区间,因此成为G1 GC(Garbage First)
G1是面向配备多核CPU集大容量内存的机器,是JDK9以后的默认垃圾回收器,且CMS在JDK9中已经被废弃
G1有许多优势,首先G1使用分区算法,其具有并行性和并发性,虽然存在STW,但是在回收阶段不会发生完全阻塞用户线程的情况
G1属于分代型垃圾回收器,仍然会区分年轻代和老年代,但是其并不要求连续的分代空间和固定大小和固定数量。如下图所示,其会划分一块堆空间并分为各个区域(Region),每个Region可能会是各种区域
Region之间是复制算法,整体实际可以看作是标记-压缩算法,其会让G1在回收垃圾之后仍然保证内存的连续性,而且当Java堆非常大时,G1的优势就会很明显
其次G1具有可预测的停顿时间模型,其会根据后台维护的优先列表,优先回收垃圾多的Region,以此来保证在有限的时间内尽可能提高收集效率
其与CMS比较,还不具备全方位的碾压优势,但是已经是有很大优势了
其下也有许多对应的设置参数,每个Region的大小其值都应该是2的幂,期望达到的最大GC停顿时间不应该设置太低或太高,合适就好,前者会因为生成垃圾时间与每次收集垃圾之差为正数导致垃圾积累最后导致Full GC而得不偿失,后者则会导致停顿时间过长
G1提供了三种垃圾回收模式,YoungGC、Mixed GC和Full GC三种
在下面的情况里,我们推荐使用G1 GC
G1 GC中所有的Region大小相同且在JVM生命周期内不会被改变
其中还有Humongous内存区域,主要用于存储大对象,如果一个对象的大小超过1.5region,则会放到H
H区主要用于存储大对象,且G1中的大多数行为都会把H区作为老年代的一部分来看待,如果一个H区装不下大对线,那么会寻找连续的H区来存储
G1中使用指针碰撞,记录已经使用的空间和剩余的空间,同样也存在每个线程各自的TLAB
其GC主要包括三个环节,分别是年轻代GC、老年代并发标记以及混合回收,同时还存在Full GC,作为失败保护机制
按照顺时针顺序执行进行垃圾回收,如果出现失败就走Full GC
当Eden区用尽时开始年轻代回收过程,回收是并行的,对象会移动到S或O区。当堆内存使用值到达阈值时会开始老年代并发标记过程,然后执行混合回收。
其老年代的回收不需要整个回收,只需要回收一部分老年代的Region即可,一般是和年轻代一起回收的
一个对象可能会被不同区域引用,为此每一个Region都有一个对应的记忆集(Remembered Set),该记忆集会在每次有引用类型数据进行写操作时都会暂时中断操作并检查该引用是否指向其他Region,若是则将对应的地址记录到记忆集中
这样当进行垃圾扫描时,只要在GC的根结点的枚举范围处加入记忆集,就可以在不进行全局扫描的情况下保证不会有遗漏的对象
当Eden空间耗尽时G1会启动YGC,该GC只回收Eden和幸存区,需要STW
可以看到最后S区和E区最后都集中到新的S区和O区中去了,这里使用到了复制算法
下面是YGC的回收过程
之所以更新Rset要使用脏卡队列的原因如下
下面是并发标记过程的执行流程
当老年代的Region超越阈值时,会触发混合回收,会回收整个Young Region和一部分的Old Region
下面混合回收的执行过程
最后是Full GC的发生原因
回收阶段并发执行的实现官方将其实现在了ZGC中
为了提高G1 GC的大小,我们推荐不要设置年轻代大小,同时暂停时间的目标设置不要太过严苛
总结
目前我们已经学习了七种不同的垃圾收集器,下面是他们各自的特点
GC的不同发展阶段,未来会到ZGC阶段
现在互联网的项目基本都使用G1 GC
最后永远是没有最好的收集器,只有最合适的收集器
还有一些可能的面试题
GC日志
GC中有一些用于打印日志的参数
使用下面命令可以打开GC日志
也可以打更详细的日志,下图中有日志对应段落表达的内容解释
还可以给日志加上时间戳
日志分析
日志打印出来还需要分析,下图是对日志内容的解释说明
下面是YGC时的日志分析
Full GC的日志分析
分析工具
值得注意的是文件的路径是从当前的工程路径下出发的,而非对应模块的路径
我们可以用下面的工具来对是生成的GC日志进行分析
垃圾回收器的新发展
GC仍然在快速发展,Serial GC虽然老,但是也有了新的舞台,但是CMS就在JDK14中移除了
后续还出现了Epsilon和ZGC等垃圾回收器
ZGC和shenandoah主打低停顿时间
shenandoah GC是实验性的,且其实收到官方排挤的GC,号称无论堆大小多少,回收时间都能限制在10ms中
但实际测试结果看,虽然停顿时间对比其他垃圾收集器有所提升,但是仍然无法满足目标,且代价是总运行时间的扩大
最后我们来看看总结
ZGC
ZGC采用可并发的标记-压缩算法,是主打低延迟的垃圾收集器,其也会STW,但几乎都花费在初始标记上,因此几乎可以忽略不计
其在吞吐量的表现上并不输于其他垃圾回收器,甚至在某些情况下还会更强,这是他的弱项的情况下
而在其强项中,几乎是直接碾压了,真正做到了将停顿时间限制在10ms以内
下面是总结
未来的GC,必然是ZGC的GC
现在ZGC已经被移植到了Windows和macOS上了
其他垃圾回收器
阿里巴巴基于JVM的G1开发了面向大堆应用场景的AliGC
当然也有其他比较有名的其他厂商提供的独具一格的GC实现,比较知名的例子就是主打低延迟GC的Zing