系列文章目录
第一章: 走近Java
第二章: Java内存区域与内存溢出异常
第三章: Java垃圾收集器与内存分配策略
Java与C++之间有一堵由内存动态分配和垃圾收集技术所围成的高墙,墙外面的人想进去,墙里面的人却想出来。
一、概述
垃圾收集简称GC,这项技术并不是Java语言的伴生物,垃圾收集的历史远远比Java久远。(1960诞生的Lisp语言是第一次看是使用内存动态分配和垃圾收集技术的语言)。垃圾收集需要完成的3件事:
1、哪些内存需要回收?
2、什么时候回收?
3、如何回收?
我们为什么要了解垃圾回收和内存分配?当需要排查各种内存溢出、内存泄漏问题时,当垃圾收集成为系统到达更高并发量的瓶颈时,我们就必须对这些技术实施必要的监控和调节。
Java语言中程序计数器、虚拟机栈、本地方法栈这3各区域生命周期随线程而生,随线程而灭,栈中的栈帧随着方法的进入和退出有条不紊地执行出栈和入栈操作,每一个栈帧中分配多少内存基本随类结构确定下来就已知,因此这几个区域的内存分配和回收具备确定性,不需要过多考虑如何回收,当方法结束或线程结束时,内存就自然回收了。而Java堆与方法区有很显著的不确定性:一个接口的多个实现类需要的内存可能会不一样,一个方法执行的不同条件的不同分支所需的内存也可能不一样,只有运行期间,我们才能知道程序会创建哪些对象,多少对象,这部分内存的分配和回收是动态的(垃圾收集器关注的即这一部分)
二、对象已死?
引用计数法算法
思路:在对象中添加一个引用计数器,每当有地方引用该对象时,计数器就加1,当引用失效时,引用就减1,当引用计数器为0的时候就意味着该对象不可能在被使用。(微软COM技术、Python语言以及在游戏脚本领域的Squirrel都是用引用计数管理内存,但主流的Java虚拟机都没有选用引用计数算法管理内存)
优点:原理简单,判定效率高,例如Python语言等使用引用计数法进行内存管理
缺点:需要考虑很多额外情况,必须要配合大量的额外处理才能保证正确工作。例如单纯的引用计数法就很难解决对象循环引用的问题(譬如Python没有解决引用计数的循环引用问题,只是结合非传统的标记-清除方案兜底)
可达性分析算法
思路:通过一系列称为“GC Roots”的根对象作为起始节点集,从这些节点开始,根据引用关系乡下搜索,搜索过程的路径称为“引用链”,如果某个对象到GC Roots间没有任何引用链相连,即GC Roots到这个对象不可达,则认为该对象是不可能再被使用的。Java体系中固定可作为GC Roots的对象包括以下几种:
- 虚拟机栈(栈帧中的本地变量表)中引用的对象,譬如各个线程被调用的方法堆栈中使用到的参数、局部变量表、临时变量等
- 方法区中类静态属性引用的变量,譬如字符串常量池(String Table)中的引用
- 本地方法栈中JNI(即Native方法)引用的对象
- Java虚拟机内部的引用,如基本数据类型对应的Class类型,一些常驻的异常对象(NullException)等,还有系统类加载器
- 所有被同步锁(synchronized关键字)持有的对象
- 反映Java虚拟机内部情况的JMXBean、JVMTI中注册的回调、本地代码缓存等
除了这些固定的GC Roots以外,根据用户所选的垃圾收集器以及当前回收的内存区域不同,还可以有其他对象“临时性”加入,共同构成GC Roots集合(譬如跨代引用情况)。
优点:精确和严谨,可以分析出循环数据结构相互引用的情况
缺点:实现比较复杂;需要分析大量数据,消耗大量时间;分析过程需要GC停顿(引用关系不能发生变化),即停顿所有Java执行线程(称为"Stop The World")
再谈引用
JDK2版之前Java引用定义:如果reference类型的数据中存储的数值代表的是另外一块内存的起始地址,就称该reference对象是代表某块内存、某个对象的引用,即一个对象在这种定义下只有“被引用”和“未被引用”两种状态
JDK2版之后对引用的概念扩展为:
- 强引用:描述在程序代码中普遍存在的引用赋值,即类似Obejct o = new Object()这种引用关系,无论任何情况,只要强引用关系还存在,垃圾收集器就不会回收被引用的对象
- 软引用:描述一些有用但非必须的对象,只被软引用关联的对象,在系统内存溢出异常前,会把这些对象列进回收范围进行二次回收(Jdk2版之后提供SoftReference类实现软引用)
- 弱引用:描述一些有用但非必须的对象,强度比软引用更弱一些,在下次垃圾回收时无论当前内存是否足够都会回收掉被弱引用关联的对象(Jdk2版之后提供WeakReference类实现弱引用)
- 虚引用:最弱的一种引用关系。一个对象是否持有虚引用的存在并不会影响其生存时间,也无法通过虚引用获得一个对象实例,为一个对象设置虚引用关联的唯一目的只是为了能在这个对象被垃圾收集器回收时收到一个系统通知(Jdk2版之后提供PhantomReference实现虚引用,虚引用并不会决定对象的生命周期,如果一个对象仅持有虚引用,那么它就和没有任何引用一样,任何时候都可能会被垃圾回收器回收;虚引用主要用来跟踪对象被垃圾回收器回收的活动。虚引用与弱引用的区别:虚引用必须和引用队列(ReferenceQueue)联合使用,当垃圾回收器准备回收一个对象时,如果发现它还有虚引用,就会在回收对象内存之前,将这个虚引用加入与之关联的引用队列中)
生存还是死亡
回收过程:
- 第一次标记:可达性分析后发现没有与GC Roots相连的引用链,那它将会被第一次标记,随后进行一次筛选,筛选的条件是此对象是否有必要执行finalize()方法,假如对象没有覆盖finalize方法或finalize方法已经被对象调用过,虚拟机视这两种情况为“没有必要执行”,假如对象覆盖finalize()方法且没有被调用过,则将该对象放置到F-Queue队列中,稍后会由一条由虚拟机自动建立、低调度优先级的Finalizer线程执行相应的finalize()方法。(这里的“执行”指的是虚拟机会触发这个方法开始运行,但并不承诺一定会等待运行结束,因为如果某个对象的finalize()方法执行缓慢或死循环,会导致F-Queue队列中其他对象永久处于等待,甚至导致整个内存回收子系统崩溃),finalize()方法是对象自救的最后机会:可以重新与引用链上任何一个对象绑定关系,譬如使用this关键字赋值给某个类变量或对象的成员变量,那在第二次标记时它将被移出“即将回收”的集合(不一定能救活,因为优先级低,过时方法,并不建议使用)
- 第二次标记:对F-Queue中的对象进行第二次小规模的标记,如果对象要在finalize()中成功拯救自己——只要重新与引用链上的任何一个对象建立关联即可,譬如把自己(this关键字)赋值给某个类变量或者对象的成员变量,那在第二次标记时它将被移出“即将回收”的集合;如果对象这时候还没有逃脱,那基本上它就真的要被回收了
回收方法区
《Java虚拟机规范》中提到可以不要求虚拟机在方法区实现,相比堆,方法区的垃圾收集因为苛刻的判定条件回收成果很低。
方法区垃圾收集主要回收两部分内容:
- 废弃的常量
判断回收废弃常量与回收堆对象类似,假如一个字符串“java”曾经进入常量池中,但当前并没有任何一个字符串值是“java”且虚拟机其他地方也没有引用这个字面量,那么在垃圾回收器判断有必要的话,这个常量就会被回收,常量池中其他类(接口)、方法、字段的符号引用也类似
- 不再使用的类型
判断一个类型属于不再被使用的条件比较苛刻,需要同时满足3个条件:
1)该类的所有实例已经被回收,即Java堆中不存在该类及其任何派生的子类
2)加载该类的类加载器已经被回收(这个条件除非是精心设计的可替换类加载器的场景,如OSGIi、JSP的重加载等,否则很难达成)
3)该类对应的Class对象没有在任何地方被引用,无法在任何地方通过反射访问该类的方法
Java虚拟机被允许对满足这3个条件的无用类进行回收,但不是和对象一样,没有引用就必然回收。关于类型是否要进行回收,Hotspot虚拟机提供了-Xnoclassgc参数控制
三、垃圾收集算法
分代收集理论( 分代收集并不是具体的垃圾收集算法)
当前主流的商业虚拟机大多遵循“分代收集”理论设计,它建立在三个分代假说之上:
- 1、弱分代假说:绝大多数对象是朝生夕灭的
- 2、强分代假说:熬过多次垃圾收集过程的对象就越难以消亡
- 3、跨代引用假说:跨代引用相对于同代引用来说仅占极少数(这其实是根据前两条假说逻辑推论得出的隐含结论:存在互相引用的两个对象,是应该倾向于同时生存或同时消亡的)
部分收集(Partial GC): 指目标不是完整收集整个Java堆的垃圾收集
新生代收集(Minor GC/Young GC):指目标只是新生代的垃圾收集
老年代收集(Major GC/Old GC):指目标只是老年代的垃圾收集(目前只有CMS收集器会有单独收集老年代的行为)混合收集(Mixed GC):指目标是收集整个新生代以及部分老年代的垃圾收集(目前只有G1收集器会有这种行为)
整堆收集(Full GC):指目标是收集整个Java堆和方法区的垃圾收集
JDK8分代布局
概览图
标记-清除算法
过程:标记清除算法是最基础的垃圾收集算法,分为“标记”和“清除”两个阶段:首先标记出所有需要回收的对象,标记完成后,统一回收掉所有被标记的对象,也可以反过来。(标记过程就是判定对象是否属于垃圾的过程)
优点:算法实现简单
缺点:
执行效率不稳定(如果Java堆中包含大量要回收的对象,这是就必须进行大量的标记和清除过程,导致标记清除过程的执行效率都随对象数量增长而降低)
内存碎片化问题(标记清除后会产生大量不连续的内存碎片,内存碎片太多可能会导致以后程序运行过程中分配较大对象时无法找到足够的连续的内存而不得不提前触发另一次垃圾收集动作)
标记-复制算法
过程:标记复制算法将可用内存分为大小相等的两部分,每次使用一块,当这一块的内存用完了就将存活着的对象复制到另一块上面,然后把已使用过的那块内存空间一次清理掉。(目前主流商用Java虚拟机优先采用标记复制回收新生代)
Hotspot分代布局:新生代分为一块较大的Eden空间和两块较小的Survivor空间,每次分配内存只使用Eden和其中一块Survivor,发生垃圾收集时,将Eden和Survivor仍然存活的对象一次性复制到另一块Survivor空间上,然后清理掉Eden和已用过的那块Survivor空间,Hotspot虚拟机默认Eden和Survivor大小比例为8:1,即每次新生代中可用内存空间为90%。(“逃生门”设计:当Survivor空间不足以容纳一次Minor GC之后存活的对象时,就需要依赖其他内存区域(大多是老年代)进行担保,这些对象对象就通过分配担保机制直接进入老年代)
优点:不会产生不连续的内存碎片;提高效率(每次都是对整个半区进行回收,分配时也不用考虑内存碎片问题,只要移动堆顶指针,按顺序分配内存即可)
缺点:
内存空间利用率不高,适合GC过后只有少量存活的新生代,可以根据实际情况,将内存块大小比例适当调整
回收区域多数对象存活时会产生大量内存空间复制的开销
标记-整理算法
过程:标记整理算法首先标记出所有需要回收的对象,让所有存活的对象都向内存空间一侧移动,然后直接清理掉边界以外的内存
优点:不会产生内存碎片;不需要浪费额外的空间进行分配担保
缺点:移动存活对象,尤其是老年代这种每次回收大量对象存活的区域,移动对象并更新所有引用这些对象的地方是一种极为负重的操作,而且这种对象移动操作必须全程暂停用户应用程序,即"Stop The World"
标记清理与标记整理算法的本质差异在于前者是一种非移动式回收算法,而后者是移动式的。是否移动回收后的对象是一项优缺点并存的风险决策:移动则内存回收时会更复杂,停顿时间相对更长;不移动内存分配更复杂,程序总吞吐量下降(Hotspot虚拟机里关注吞吐量的Parallel Scavenge收集器基于标记整理算法,关注延迟的CMS收集器基于垃圾清除算法(平时使用标记清除算法,暂时容忍内存碎片存在,直到内存碎片影响对象分配时采用标记整理收集以获得规整的内存空间))
四、Hotspot的算法实现细节
根节点枚举
根节点枚举指的是查找GC Roots集合,固定可作为GC Roots的节点主要在全局性的引用(例如常量或类静态属性)与执行上下文(例如栈帧中的本地变量表),至今所有收集器在根节点枚举步骤时都必须暂停用户线程(包括号称不会发生停顿的CMS、G1、Shenandoah、ZGC收集器)
目前主流Java虚拟机使用的都是准确式垃圾收集,即虚拟机可以知道内存中某个位置的数据具体是什么类型(基本或引用),所以当用户线程停顿后,并不需要一个不漏的检查完所有的引用位置,虚拟机应当有办法直接得到哪些地方存放着对象引用的。Hotspot是使用一组称为OopMap的数据结构来达到这个目的,一旦类加载完成的时候,Hotspot就会把对象内什么偏移量是什么类型的数据计算出来,在即时编译过程中也会在特定位置记录下栈里和寄存器里哪些位置是引用,这样收集器扫描时就可以直接得知
安全点
安全点指的是Hotspot在“特定的位置”记录OopMap信息(因为引用的关系的变化会导致OopMap的内容变化的指令非常多,如果为每一条指令生成对应的OopMap需要大量的额外内存空间),这些位置被称为“安全点”
安全点的位置:
安全点的设定决定了用户并不能在代码指令流的任意文职停顿下来开始垃圾收集,而是强制要求必须执行到达安全点后才能够暂停,因此安全点的选取既不能太少让收集器等待时间过长,也不能太多以至于只能打运行时的内存负荷,安全点的选取基本是以“是否具有让程序长时间执行的特征”为标准进行选定的(指令序列的复用:如方法调用、循环跳转、异常跳转等)
如何实现在垃圾收集时让所有线程跑到最近的安全点 :
- 1)抢占式中断:不需要用户线程的执行代码主动配合,在垃圾收集时,系统会把所有用户线程全部中断,如果有用户线程中断的地方不在安全点上,就恢复这条线程执行,让它一会再重新中断,直到跑到安全点上(现在几乎没有虚拟机实现采用抢占式中断来暂停线程响应GC事件)
- 2)主动式中断:当垃圾收集需要中断线程时,不直接对线程操作,仅仅简单设置一个标志位,各个线程执行过程中会不停地主动去轮询这个标志位,一旦发现标志中断位为真时就自己在最近的安全点上主动中断挂起。轮询标志位的位置和安全点重合,另外还要加上所有创建对象和其他需要在堆上分配内存的地方,这是为了检查是否即将要发生垃圾收集,避免没有足够的内存重新分配对象
Hotspot属于主动式中断,使用内存保护陷阱的方式,把轮询操作精简至只有一条汇编指令的程度来保证高效(如图,test指令就是Hotpost生成的轮询指令,当需要暂停用户线程时,虚拟机把0x160100的内存页设置为不可读,那线程执行到test指令时就会产生一个自陷异常信号,然后在预先注册的异常处理器中挂起线程实现等待,这样通过一条汇编指令便完成安全点轮询和触发线程中断)
安全区域
安全点机制保证了程序执行时在不太长的时间内就会遇到可进入垃圾收集过程的安全点,安全区域解决的是程序不执行的时候,即没有分配处理器时间,如用户线程处于Sleep状态或Blocked状态,这时线程无法响应虚拟机的中断请求,不能到达安全点中断挂起自己,虚拟机也不可能持续等待线程重新被激活分配处理器时间。
安全区域指能够确保在某一段代码片段中引用关系不会发生变化,因此在这个区域任意地方开始垃圾收集都是安全的。当用户线程执行到安全区域里面的代码时,首先会标识自己已经进入安全区域,当这段时间里虚拟机要发起垃圾收集时就不必去管这些以声明在安全区域内的线程,当线程要离开安全区域时,他要检查虚拟机是否已经完成根节点枚举(或者垃圾收集过程其他需要暂停用户线程的阶段),如果完成了则线程就当做没事发生继续执行,否则就必须一直等待,直至收到可以离开安全区域的信号为止
枚举根节点过程图解
如图所示:存在调用关系
ClassA.invokeA() --> ClassB.invokeB() --> doinvokeB() -->ClassC.execute()
每个调用(方法)对应一个栈帧,栈帧里面的本地变量表存储了GC Roots的引用,如果直接遍历所有的栈帧去查找GC Roots,效率太低,为此引入了OopMap和安全点的概念
OopMap记录栈上本地变量到堆上对象的引用关系,每当触发GC的时候,程序都都先跑到最近的安全点然后自动挂起,然后再触发更新OopMap,然后进行枚举GC ROOT,进行垃圾回收
安全区域:在一段代码片段之中,引用关系不会发生变化,因此在这个区域中的任意位置开始 GC 都是安全的。如处于Sleep或者Blocked状态的线程。
为了在枚举GC Roots的过程中,对象的引用关系不会变更,所以需要一个GC停顿。
记忆集与卡表
记忆集是一种用于记录从非收集区域指向收集区域的指针集合的抽象数据结构。(垃圾收集器在新生代中建立“记忆集”的数据结构,避免回收Minor GC时扫描整个老年代,缩减GC Roots扫描范围)
记忆集有多种实现:
- 1)字长精度:每个记录精确到一个机器字长,该字包含跨代指针
- 2)对象精度:每个记录精确到一个对象,该对象里字段含有跨代指针
- 3)卡精度:每个记录精确到一块内存区域,该区域内有对象含有跨代指针(目前最常用的一种实现方式,即“卡表”)
Hotspot虚拟机中源码默认卡表是一个字节数组 CARD_TABLE [this address >> 9] = 0;
字节数组CARD_TABLE的每一个元素对应着其标识的内存区域中一块特定大小的内存块,称为“卡页”,一般来说卡页大小是2的N次幂的字节数,如上可知Hotspot中使用的卡页大小为512字节。
一个卡页的内存中通常包含不止一个对象,只要卡页内有一个对象的字段存在跨代指针,就将对应卡表的数组元素值标识为1,称为元素变脏,没有则标识为0,当垃圾收集发生时,只需要筛选出卡表中变脏的元素将其加入GC Roots中扫描
写屏障
Hotspot虚拟机通过写屏障技术维护卡表状态(决定何时变脏,如何变脏等),写屏障可以看做在虚拟机层面对“引用类型字段赋值”这个动作的AOP切面,在引用对象赋值时会产生一个环形通知,供程序执行额外的动作,在赋值前的部分叫写前屏障,赋值后的部分叫写后屏障,如下是一段更新卡表的简化逻辑:
void oop_field_store(oop* field, oop new_value) {
// 引用字段赋值操作
*field = new_value;
// 写后屏障,在这里完成卡表状态更新
post_write_barrier(field, new_value);
}
应用写屏障后虚拟机会为所有赋值操作生成相应的指令,一旦收集器在写屏障中更新卡表操作,无论更新的是不是老年代对新生代的引用,每次只要引用进行更新,就会产生额外的开销,不过这个开销相对于Minor GC时扫描整个老年代的代价还是低得多
如何解决伪共享问题:
伪共享问题指的是当多个线程修改互相独立的变量时,如果这些变量恰好共享同一个缓存行,就会彼此影响(写会、无效化、同步)而导致性能降低。例处理器缓存行大小为64字节,由于一个卡表元素占一个字节,64个卡表元素将共享同一个缓存行,这64个卡表元素对应的卡表内存为32kb(64*512字节),当不同线程更新的对象正好处于这32kb的内存区域时,就会导致更新卡表时正好写入同一个缓存行而影响性能
解决方案:不采用无条件的写屏障,先检查卡表标记,只有当卡表未被标记过时才将其标记为变脏,如下:
if (CARD_TABLE [this address >> 9] != 0)
CARD_TABLE [this address >> 9] = 0;
JDK7后Hotspot虚拟机通过-XX:+UseCondCardMark 参数决定是否开启卡表更新的条件判断(开启会增加一次额外判断逻辑,但能避免伪共享)
并发的可达性分析
在可达性分析算法中,需要有根节点枚举,然后从根节点开始遍历对象图。根节点枚举中,GC Roots相比于整个堆的对象占比还是较小的,且有OopMap这种优化技巧,因此根节点枚举带来的停顿时间是短暂且相对固定的(不会随着堆容量而增长)。但是!从GC Roots遍历对象图,这一步的停顿时间是会和堆容量成正比的
三色标记:
要解决或降低用户线程的停顿,需要明白一点,就是必须在一个保障一致性的快照上才能进行对象图的遍历。三色标记可以帮助理解这一点。按照“是否被访问过”这个条件将对象标记成以下三个颜色:
1)白色:对象尚未被垃圾收集器访问过。在可达性分析开始阶段,所有对象都是白色的。
2)黑色:表示对象已经被垃圾收集器访问过,且这个对象的所有引用都已经扫描过。黑色对象是存活的,如果有对象引用指向了黑色对象,无须在扫描一遍。(标识最开始的时候,GC Roots都是黑色的,其他对象都是白色的。)
3)灰色:表示对象已经被垃圾收集器访问过,但这个对象上至少存在一个引用还没有被扫描过。
如果标识的过程中用户线程是冻结的,只有收集器在工作,那就不会有任何问题;如何用户线程和收集线程是并发进行的,那就可能出现如下情况:用户线程将一个黑色对象引用了一个白色对象。由于黑色对象不会再扫描,最终导致被白色对象被认为不可达
错误地认为对象不可达必须同时满足两个条件:
1)赋值器插入了一条或多条从黑色对象到白色对象的新引用
2)赋值器删除了全部从灰色对象到该白色对象的直接或间接引用
解决方案:只要破环两个条件中的任意一个即可。因此有两种方案
- 增量更新(Incremental Update):破坏的是第一个条件,当黑色对象插入新的指向白色对象的引用关系时,记录这个引用,等并发扫描结束后重新扫描一遍。相当于黑色对象变成了灰色对象。
- 原始快照(Snapshot At The Beginning, SATB):破坏的是第二个条件,当灰色对象要删除白色对象的引用关系时,记录删除的引用,等并发扫描结束后将记录中的灰色节点为根重新扫描一次
对于引用关系记录的插入还是删除,虚拟机的记录操作都是通过写屏障实现的。CMS是基于增量更新来做并发标记的,G1、Shenandoah则是原始快照。
思考:关于三色标记,如果是新创建对象,然后黑色对象引用了这个对象,这种情况这个新对象也是不会被标记为黑色对象的,也就是不需要满足第二点也能出现黑色对象(假设新创建的对象不应该被回收)被误标记为白色的情况?
!!三色标记只是为了辅助帮助理解并发标记,新创建的对象可以理解为天然满足第二个条件
五、经典垃圾收集器
如图展示了7种作用域不同分代的收集器,如果两个收集器之间存在连线,就说明他们之间可以搭配使用(出现垃圾收集器无法组合使用的原因:设计上没有使用相同的方案,如分代框架等)
衡量垃圾收集器最重要的指标:内存占用、吞吐量、延迟
并行(Parallel):指多条垃圾收集器线程之间的关系,说明同一时间有多条这样的线程在协同工作,通常默认此时用户线程处于等待状态
并发(Concurrent):指垃圾收集器线程与用户线程之间的关系,说明同一时间垃圾收集器与用户线程都在运行,由于用户线程并未冻结,所以程序仍然能够响应请求,但由于垃圾收集器占用了一部分系统资源,此时应用吞吐量会有一定影响
吞吐量(Throughput):吞吐量=运行用户代码的时间 / (运行用户代码的时间 + 运行垃圾收集时间),低停顿适合需要与用户交互或保证程序响应质量的程序,高吞吐量则可以高效利用处理器资源
Serial收集器(串行)
针对区域:新生代
原理:一个单线程工作的收集器,使用标记复制算法,是Hotspot虚拟机运行在客户端模式下默认新生代收集器
特点:简单高效(相对于其他单线程,Serial收集器由于没有线程交互的开销,专心做垃圾收集自然可以获得最高的单线程收集效率)、额外内存消耗最小(额外内存指为保证垃圾收集能够顺利高效地进行而存储的额外信息)
运行过程:
缺点:垃圾收集停顿时间长
适用场景:
特别适合限定单CPU的环境;
Client模式下的默认新生代收集器,用户桌面应用场景分配给虚拟机的内存一般不会很大,所以停顿时间也是在一百多毫秒以内,影响不大
ParNew收集器
针对区域:新生代
原理:Serial收集器的多线程并行版本,行为与Serial基本一致,使用标记复制算法,同时使用多条垃圾收集线程进行垃圾收集,默认开启的收集线程数与处理器核心数相同
特点:支持多线程并行收集、除Serial收集器外只有ParNew能与CMS(JDK5发布)收集器搭配工作(JDK9之前)
运行过程:
适用场景:
许多运行在Server模式下的虚拟机中的首选新生代收集器;
除了Serial收集器外,只有它能和CMS收集器搭配使用
-XX:+UseConcMarkSweepGC选型默认使用ParNew收集器。也可以使用-XX:+UseParNewGC选项强制指定它。
ParNew收集器在单CPU环境比Serial收集器效果差(存在线程交互开销)。
CPU数量越多,ParNew效果越好,默认开启收集线程数=CPU数量。可以使用-XX:ParallelGCThreads参数限制垃圾收集器的线程数。
Parallel Scavenge收集器(吞吐量优先收集器)
针对区域:新生代
原理:Parallel:并行 Scavenge:回收。并行收集的多线程收集器,使用标记复制算法
特点:可控制的吞吐量、高吞吐量高效利用CPU
-XX:MaxGCPauseMillis:设置一个大于0的毫秒数,收集器将尽力保证内存回收花费的时间不超过用户设定值(该参数值并不是设置的越小就能使得系统的垃圾收集速度更快,垃圾收集停顿时间缩短是以牺牲吞吐量和新生代空间为代价换取的)
-XX:GCTimeRatio:设置一个大于0小于100的整数,就是垃圾收集时间占总时间的比率,即吞吐量的倒数
-XX:+UseAdaptiveSizePolicy:垃圾收集的自适应调节策略开关参数,被激活后,就不需要手动指定新生代大小(-Xmn)、Eden区与Survivor区的比例(-XX:SurvivorRatio)、晋升老年代对象大小(-XX:PretenureSizeThreshold)等细节参数,虚拟机会根据当前系统运行情况收集性能监控信息,动态调整这些参数以提供最合适的停顿时间或最大的吞吐量
运行过程:
适用场景:
可以高效利用CPU时间,尽快完成程序的运算任务,适合后台运算不需要太多交互的任务;
Serial Old收集器
针对区域:老年代
原理:Serial收集器的老年代版本,同样是一个单线程收集器,使用标记整理算法
运行过程:
适用场景:
主要供客户端模式下的Hotspot使用;
在Server服务端可能有两种用途:
1)在JDK5及之前版本与Parallel Scavenge收集器搭配使用;
2)作为CMS收集器发生失败后的后备预案
Parallel Old收集器
针对区域:老年代
原理:Parallel Scavenge收集器的老年代版本,支持多线程并发收集,使用标记整理算法(JDK6提供,其实就是Serial Old换了一层皮,支持并行)
运行过程:
适用场景:
主要配合Parallel Scavenge使用,提高吞吐量。在注重吞吐量以及CPU资源敏感的场合,都可以优先考虑这个组合。
CMS收集器(Concurrent Mark Sweep 并发低停顿收集器)
针对区域:老年代
原理:一种以获取最短回收停顿时间为目标的收集器,使用标记清除算法
特点:并发收集、低停顿(关注尽可能缩短垃圾收集时用户线程的停顿时间)
运行过程:
1)初始标记(Stop The World):仅仅标记GC Roots能直接关联到的对象,速度很快
2)并发标记:从GC Roots的直接关联对象开始遍历整个对象图的过程,耗时较长但不需要停顿用户线程,可以与垃圾收集器并发运行
3)重新标记(Stop The World):修正并发标记期间因用户程序运作而导致标记产生变动的那一部分对象的标记记录(增量更新记录),停顿时间较初始标记稍长,但也远比并发标记阶段时间短
4)并发清除:清理标记阶段判断已经死亡的对象,因为使用标记清除算法,不需要移动对象,所以也是可以与用户线程同时并发
缺点:
1)对处理器资源敏感:在并发阶段虽然不会导致用户线程停顿,但因为占用了一部分线程(或CPU处理器运算能力)而导致应用程序变慢,降低吞吐量(CMS默认启动回收线程数=(处理器核心数+3)/4)
解决方案:升级配置,提升处理器核心数
2)无法处理“浮动垃圾”、并发失败问题(Concurrent Mode Failure):
浮动垃圾指在CMS并发标记、并发清除阶段用户线程还在继续运行,自然就会伴随着新的垃圾对象产生,但这部分对象是出现在标记过程结束后,CMS无法在当次收集中处理,只好留待下一次收集,称为“浮动垃圾”。
并发失败问题指由于垃圾收集期间用户线程持续运行,需要预留足够的内存空间提供给用户使用分配对象,因此不能像其他收集器那样等待老年代几乎完全填满再进行收集,必须预留一部分空间供并发收集程序使用,提供了参数-XX:CMSInitiatingOccupancyFraction设置CMS的触发收集百分比阈值,JDK8默认为92%,但这样可能会因CMS运行期间预留的内存无法满足程序分配对象时出现“并发失败”,虚拟机将不得不启用后备预案:冻结用户线程,临时启用Serial Old收集器进行重新收集,停顿时间就更长了
解决方案:生产环境中根据实际应用情况合理权衡设置-XX:CMSInitiatingOccupancyFraction参数
3)内存空间碎片问题:CMS基于标记清除算法,收集结束会产生大量内存碎片,当内存空间碎片过多,无法找到足够大的连续空间分配对象时会提前触发一次Full GC.
解决方案:
设置-XX:+UseCMS-CompactAtFullCollection开关参数(默认开启,JDK9废弃),用于CMS收集器在不得不进行Full GC时开启内存碎片的合并整理过程,由于内存整理需要移动对象会导致停顿时间变长
设置-XX:CMSFullGCsBeforeCompaction参数(此参数从JDK 9开始废弃)要求CMS收集器在执行过若干次(数量由参数值决定)不整理空间的Full GC之后,下一次进入Full GC前会先进行碎片整理(默认值为0,表示每次进入Full GC时都进行碎片整理)
适用场景:
关注服务的响应速度,希望系统停顿时间尽可能短,以给用户带来良好的交互体验。譬如互联网网站或者B/S系统的服务器;
Garbage First收集器(全功能的垃圾收集器)
针对区域:堆内存任意部分
原理:
Garbage(G1)收集器是垃圾收集技术发展史的里程碑成果,开创了收集器面向局部的设计思路和基于Region的内存布局形式,是一款面向服务端应用的垃圾收集器。G1出现之前所有收其他收集器,包括CMS在内,垃圾收集的范围要么是整个新生代(Minor GC)、要么是整个老年代(Major GC)、要么是整个堆(Full GC),而G1面向堆内存任何部分来组成回收集(Collection Set, CSet)进行回收,衡量标准不再是它属于那个分代,而是哪块内存中存放的垃圾数量最多,回收效益最大,这就是G1收集器的Mixed GC模式。
G1基于Region的堆内存布局:遵循分代理论设计,但不再坚持固定大小以及固定数量的分代区域划分,而是把连续的Java堆划分为多个大小相等的独立区域(Region),每个Region都可以根据需要扮演不同的角色(新生代的Eden空间、Survivor区、老年代空间),收集器堆扮演不同角色Region区采用不同的策略处理。(G1仍然保留新生代和老年代的概念,但新生代和老年代不再是固定的。它们都是一系列区域(不需要连续)的动态集合)。
Region中有一类特殊的Humongous区域,专门用来存储大对象,G1认为只要大小超过一个Region容量一半的对象即可判定为大对象,每个Region对象的大小可以通过-XX:G1HeapRegionSize参数设置,取值范围为1MB~32MB,且应为2的N次幂,对于那些超过整个Region容量的超级大对象会使用多个连续的Humongous Region中,G1的大多数行为都把Humongous Region作为老年代的一部分来处理。
G1 之前各款垃圾收集器的堆内存布局
G1 收集器堆内存布局
特点:可预测的停顿,用户可以通过 -XX:MaxPauseMillis 参数 (默认200毫秒)指定期望的最大停顿时间,可使得G1在不同的应用场景中取得关注吞吐量和关注延迟之间的最佳平衡(期望值必须是符合实际的。毕竟G1是要冻结用户线程复制对象的,默认停顿时间是200毫秒,如果设置的过低,很可能出现由于停顿时间过短,导致每次筛选出来的回收集只占堆内存的一小部分,收集器收集的速率逐渐跟不上分配器分配的速率,导致垃圾慢慢堆积,最终引发Full GC反而降低性能)
实现细节:
1)Region里面存在的跨Region引用对象如何解决?
解决思路:使用记忆集避免全堆作为GC Roots扫描,G1收集器的记忆集实现相比CMS实现复杂的多,每个Region维护自己的记忆集,这些记忆集会记录下别的Region指向自己的指针,并标记这些指针分别在哪些卡页的范围之内,G1的记忆集在存储结构本质上是一种哈希表,Key是别的Region的起始地址,Value是一个集合,存储的元素是卡表的索引号。这种“双向”的卡表结构(卡表是“我指向谁”,这种结构还记录“谁指向我”)比原本的卡表实现更复杂,且由于Region数量较多,比其他垃圾收集器占用更多的额外内存
2)并发标记阶段如何保证收集线程与用户线程互不干扰地运行?
解决思路:使用原始快照算法记录垃圾收集与用户线程并发时对象引用关系的变动;为每个Region设计两个名为“TMAS(Top At Mark Start)”的指针,把Region中的一部分空间划分出来用于并发过程中新对象的分配,新分配的对象地址必须要在这两个指针位置以上(G1默认这个地址以上的对象是被隐式标记过的,即默认是存活的,不纳入回收范围)。与CMS并发失败类似,如果回收内存速度赶不上内存分配速度,G1收集器也要被迫冻结用户线程执行,导致Full GC而产生长时间“Stop The World”
3)怎样建立起可靠的停顿预测模型?
解决思路:可靠的停顿预测模型即如何满足用户期望的停顿时间。G1收集器的停顿预测模型是以衰减均值(Decaying Average)为理论基础来实现的,在垃圾收集过程中,G1收集器会记录每个Region的回收耗时、每个Region记忆集里的脏卡数量等各个可测量的步骤花费的成本,并分析得出平均值、标准偏差、置信度等统计信息。这里强调的“衰减平均值”是指它会比普通的平均值更容易受到新数据的影响,平均值代表整体平均状态,但衰减平均值更准确地代表“最近的”平均状态。换句话说,Region的统计状态越新越能决定其回收的价值。然后通过这些信息预测现在开始回收的话,由哪些Region组成回收集才可以在不超过期望停顿时间的约束下获得最高的收益。
运行过程:
1)初始标记(Stop The World):标记GC Roots能直接关联到的对象,并且修改TAMS指针的值,让下一阶段用户线程并发运行时,正确地在可用的Region中分配新对象,停顿耗时很短
2)并发标记:从GC Roots开始对堆中对象进行可达性分析,递归扫描整个堆里的对象图,找出要回收的对象,这阶段耗时较长,但可与用户线程并发运行。当对象图扫描完成时还要重新处理SATB(原始快照)记录的并发时用户线程有引用变动的对象
3)最终标记(Stop The World):短暂暂停用户线程,处理并发阶段结束后仍遗留下来的最后那少量的STAB(原始快照)记录
4)筛选回收(Stop The World):负责更新Region的统计数据,对各个Region的回收价值和成本排序,根据用户所期望的停顿时间制定回收计划,可以自由选择任意多个Region构成回收集,然后把决定回收的那一部分Region的存活对象复制到空的Region中,再清理掉整个旧的Region的全部空间,这部分涉及存活对象的移动,是必须暂停用户线程的,有多条收集器线程并行完成
G1收集器与CMS收集器的优劣对比:
G1相比CMS的优点:
指定最大停顿时间、分Region的内存布局、停顿预测模型、算法结构(CMS使用标记清除算法,G1从整体看是基于标记整理算法实现的,但从局部看(两个Region)之间又是基于标记复制算法,意味着G1运作期间不会产生内存空间碎片,有利于程序长时间运行)
G1相比CMS的缺点:
1)较高的内存占用(G1和CMS都是用卡表处理跨代指针引用,但G1的卡表实现更为复杂,并且每个Region中都维护自己的卡表,而CMS的卡表只有唯一一份,而且只需要处理老年代到新生代的引用,反过来则不需要(反过来的话CMS是直接把新生代加入GC Roots扫描的))
2)较高的额外执行负载(例如它们都使用了写屏障,CMS用写屏障来更新维护卡表,而G1处理使用写后屏障维护卡表外,还需要使用写前屏障跟踪并发时的指针变化情况(SATB原始快照))
六、低延迟垃圾收集器
衡量垃圾收集器的三项指标:内存占用(Footprint)、吞吐量(Throughput)、延迟(Latency)。三者共同构成一个“不可能三角”,一款优秀的收集器通常最多可以达成其中的两项。
Shenandoah收集器
Shenandoah收集器是由RedHat公司发展的收集器项目(OpenJDK12的正式特性之一)
原理:Shenandoah 与 G1 有很多相似之处,比如都是基于 Region 的内存布局,都有用于存放大对象的 Humongous Region,默认回收策略也是优先处理回收价值最大的 Region。不过管理堆内存方面也有三个重大的区别:
- 1)Shenandoah支持并发的整理算法,G1 的整理阶段虽是多线程并行,但无法与用户程序并发执行;
- 2)Shenandoah默认不使用分代收集理论,即不会有专门的新生代Region或者老年代Region的存在;
- 3)Shenandoah使用连接矩阵 (Connection Matrix)记录跨 Region 的引用关系,替换掉了 G1 中的记忆级 (Remembered Set),内存和计算成本更低(连接矩阵可以简单理解为一张二维表格,如果Region N有对象指向Region M,就在表格的N行M列中打上一个标记)如图
运行过程:
1)初始标记(Stop The World):同G1,标记与GC Roots直接关联到的对象,短暂停顿
2)并发标记:同G1,遍历对象图,标记可达对象
3)最终标记(Stop The World):同G1,处理剩余的STAB(原始快照)扫描,并统计回收价值最高的Region,组合成回收集。短暂停顿
4)并发清理:清理那些整个区域连一个存活对象都没有的Region
5)并发回收:将回收集里面的存活兑现复制一份到其他未使用的Region中(对象复制与用户线程同时并发进行,通过读屏障和“Brooks Pointers”转发指针解决)
6)初始引用更新(Stop The World):建立一个线程集合点,确保所有并发阶段中进行回收的收集器线程都完成了对象移动,短暂停顿
7)并发引用更新:并发更新堆中所有指向旧对象的引用修正到复制到新地址,与用户线程并发进行,时间长短取决于内存设计的引用数量多少,并发引用更新与并发标记不同,不需要沿着对象图搜索,只需要按照内存物理地址的顺序,线性所有除引用类型更改值即可
8)最终引用更新(Stop The World):修正GC Roots中的引用,短暂停顿,停顿时间与GC Roots数量有关
9)并发清理:并发清理回收集
支持并发整理核心概念-转发指针(Brooks Pointer):
并发整理的困难点在于移动对象的同时,用户线程仍然可能不停对被移动的对象进行读写访问,移动对象是一次性的行为,但移动之后整个内存中所有指向该对象的引用都还是旧对象的地址,这是很难一瞬间全部改变过来的。Shenandoah 将会通过读屏障、写屏障和被称为“ Brooks Pointers”的转发指针来解决。
在原有内存布局结构最前面统一添加一个新的引用字段,正常不处于并发移动时,该引用指向对象自己,当GC对象并发移动时,只需要修改旧对象上转发指针的引用位置,使其指向新对象即可将所有对该对象的访问转发到新副本上,这样只要旧对象的内存依然存在,未被清理掉,虚拟机内存中所有通过旧引用地址访问的代码仍然可用,都会被转发到新对象继续工作。如下图所示:
从结构上看转发指针与某些早期Java虚拟机使用的句柄定位相似,两者都是一种间接性的对象访问方式,差别是句柄通常会统一存放在专门的句柄池中,而转发指针存储在每个对象头里面。
通过转发指针可以解决用户线程与收集线程并发读的问题,但是对于并发写的问题却没解决,如以下场景:
1、最初转发指针指向自己,垃圾回收线程复制对象到新地址
2、用户线程更新旧对象某个字段
3、收集器线程更新转发指针的引用值为新副本地址
这个场景中,用户线程对数据的修改最终会丢失,Shenandoah是使用CAS算法解决并发写的问题
转发指针解决了并发回收的问题,但相对而言每次访问对象都会带来额外一次的转向开销,而对象访问是很重的,代码中比比皆是,Shenandoah同时设置读、写屏障去拦截转发处理(JDK13中将Shenandoah的内存屏障模型改为基于引用访问屏障的实现)
Z Garbage Collector(ZGC)收集器
ZGC收集器是Oracle公司在JDK11中加入的低延迟垃圾收集器
原理:ZGC收集器是一款基于Region内存布局、(暂时)不设分代、使用读屏障|染色指针和内存多重映射等技术实现可并发的标记整理算法、以低延迟为首要目标的垃圾收集器。
内存布局:同G1,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的一种处理动作,用于复制对象的收集器阶段),因为复制一个大对象的代价非常高昂
运行过程:
1)并发标记:与G1、Shenandoah一样,并发标记可达对象,前后也要经过G1、Shenandoah的初始标记、最终标记的短暂停顿,但不同的是ZGC标记是在指针上而不是对象上进行,标记阶段会更新染色指针的Markd0、Marked1标志位
2)并发预备重分配:根据特定条件统计得出本次收集过程要清理那些Region,将这些Region组成重分配集(ZGC的重分配与G1的回收集是有区别的:ZGC划分Region的目的并非为了像G1那样做收益优先的增量回收,相反,ZGC每次回收都会扫描所有的Region,用范围更大的扫描成本换取省去维护G1中记忆集的成本)
3)并发重分配:重分配集中存活的对象复制到新的Region中,并为重分配集中的每个Region维护一个转发表(Forward Table),记录从旧对象到新对象的转向关系,通过染色指针的支持ZGC能仅从引用上得知一个对象是否处于重分配集中,如果此时用户并发访问重分配集中的对象,访问会被预置的内存屏障拦截,然后根据Region上的转发表记录将其转发到新复制的对象上,并同时修正更新改引用的值,使其直接指向新对象,ZGC将这种行为称为指针的“自愈”能力,这样做的好处就是只有第一次访问旧对象时会陷入转发,即只慢一次,而Shenandoah的Brooks转发指针每次对象访问都需要额外的转发开销,还有一个好处就是由于染色指针的存在,一旦重分配集中某个Region中存活对象复制完毕后,这个Region可以直接释放或重新使用(但是转发表还得留着不能释放),哪怕堆中有很多指向这个对象的未更新指针也没关系,因为这些旧指针一旦被使用,都会“自愈”
4)并发重映射:修正整个堆中指向重分配集中旧对象的所有引用,看似与Shenandoah类似,但ZGC的并发重映射并不“迫切”,因为旧引用是可以“自愈”的,所以重映射清理主要是为了不变慢(还有清理结束后释放转发表),ZGC将并发重映射的工作合并到下一次垃圾收集的并发标记阶段,反正都是要遍历所有对象,就节省了一次遍历对象图的开销,一旦所有指针被修正后,原旧记录的转发表就可以释放掉了
支持并发整理核心概念-染色指针:
Shenandoah收集器是在对象头中添加转发指针,而ZGC是直接在指针上设置,即染色指针,这个指针就是Java对象的引用。如:
Object o = new Object();
其中“o” 只是一个引用,也就是指针,指向存在堆上的对象实例,引用自身也是要占内存的,普通引用在32位机器占4个字节,在64位机器上,开启压缩指针(-XX:+UseCompressedOops)的话占4个字节,不开启的话占8个字节。ZGC 的染色指针结构如下
ZGC的染色指针技术将其高4位取出来存储四个标志信息,通过这些标志位,虚拟机可以直接从指针中看到其引用对象的三色标记状态、是否进入重分配集(即被移动过)、是否只能通过finalize方法才能被访问到。
染色指针的三大优势:
1)染色指针使得一旦某个Region的存活对象被移动后,这个Region立即就能被释放和重用,而不必等待整个堆中所有指向该Region的引用被修正后才能清理
2)染色指针可以大幅减少在垃圾收集过程中内存屏障的使用数量(ZGC仅使用读屏障,并未使用写屏障,一部分是因为通常写屏障的目的是为了记录对象引用的变动情况,而这些信息直接维护在指针中,另一部分原因是ZGC默认不支持分代收集,也就不存在跨代引用)
3)染色指针可以作为一种可扩展的存储结构用来记录更多与对象标记、重定位过程相关的数据,便于后期进一步提高性能
对比G1、Shenandoah:
G1 需要通过写屏障来维护记忆集,才能处理跨代指针,得以实现Region的增量回收。记忆集要占用大量的内存空间,写屏障也对正常程序运行造成额外负担。而ZGC就完全没有使用记忆集,它甚至连分代都没有,连像CMS中那样只记录新生代和老年代间引用的卡表也不需要,因而完全没有用到写屏障,所以给用户线程带来的运行负担也要小得多。但是ZGC的这种选择[11]也限制了它能承受的对象分配速率不会太高,即当ZGC准备对一个很大的堆做一次完整的并发收集时,由于应用的对象分配速率很高,将创造大量的新对象,产生大量的浮动垃圾,如果这种高速分配持续维持,每一次并发周期都会很长,回收到的内存空间持续小于期间并发产生的浮动垃圾所占空间,堆中剩余空间越来越小。目前唯一方法就是尽可能增加堆容量大小。
七、选择合适的垃圾收集器(因地制宜,按需选用)
Epsilon收集器:一款不能进行垃圾收集的垃圾收集器,如果应用只需要运行数分钟甚至数秒,只要Java虚拟机能正确分配内存,在堆内存耗尽前退出,那显然运行负载极小,没有任何回收行为的Epsilon便是很恰当的选择
收集器的权衡:选择一款适合自己的收集器主要考虑三个因素:
1)应用程序的关注点是什么?吞吐量、延迟、内存占用
2)运行应用的基础设施如何?如硬件规格、系统架构、操作系统等
3)使用JDK的发行商是什么?版本号是多少?
虚拟机及垃圾收集器日志:Oracle官方java命令选项参数
八、实战:内存分配与回收策略
对象优先在Eden分配
大多数情况下,对象在Eden区中分配,当Eden区没有足够空间进行分配时,虚拟机将发起一次Minor GC
大对象直接进入老年代
大对象指需要大量连续内存空间的Java对象,最典型的比如很长的字符串或元素数量很多的数组,分配空间时,大量的大对象容易导致提前触发垃圾收集,而且复制大对象时也是高额的开销
长期存活的对象将进入老年代
实现分代收集垃圾器的虚拟机给每个对象定义了一个对象年龄计数器(Age),存储在对象头中,新生对象通常在Eden区诞生,如果经过一次Minor GC后仍然存活且能被Survivor区空间容纳,该对象就会被复制到Surivor空间中,并且将其对象年龄设为1岁,后续每次熬过一次Minor GC,年龄就增加一岁,当年龄增加到一定程度(默认为15),就会被晋升到老年代中。
设置年龄阈值参数(-XX:MaxTenuringThreshold)
动态对象年龄判定
为了更好的适应不同程序的内存状况,Hotspot虚拟机并不是要求对象的年龄必须达到-XX:MaxTenuringThreshold才能晋升老年代,如果Survivor空间中相同年龄所有对象大小的总和大于Surivor空间的一半,年龄大于或等于该年龄的对象就可以直接进入老年代,无须等到设置的年龄阈值
空间分配担保
JDK 6 Update 24之前:
发生Minor GC前,虚拟机必须检查老年代最大可用的连续内存空间是否大于新生代所有对象的总和,条件成立的话可以确保这次Minor GC是安全的,否则会查看-XX:HandlePromotionFailure参数的设置值是否允许担保失败,允许的话会继续检查老年代最大可用连续内存空间是否大于历次晋升到老年代对象的平均大小,大于的话会尝试进行一次Minor GC,小于或者未开启担保失败则会改为进行一次Full GC。
JDK 6 Update 24之后(-XX:HandlePromotionFailure参数不会再影响到虚拟机的空间分配担保策略):
发生Minor GC前,只要老年代可用连续内存空间大于新生代对象总大小或者历次晋升的平均大小就会进行Minor GC,否则将进行Full GC
九、附录
class常量池、字符串常量池和运行时常量池的区别
Java引用类型原理深度剖析
Java JVM Hotspot GC研究- GC安全点 (Safepoint&Stop The World)
虚拟机OopMap
JVM 执行篇:使用 HSDIS 插件分析 JVM 代码执行细节
深度揭秘Java GC底层,这次让你彻底弄懂她
jvm大局观之内存管理篇(五):经典垃圾收集器
如何理解Latency和Throughput: 吞吐量和延迟
并行、延迟与吞吐量
Java 虚拟机系列三:垃圾收集器一网打尽,船新的 ZGC 和 Shenandoah 听说过吗
Shenandoah收集器官网介绍
ZGC收集器官网介绍
新一代垃圾回收器ZGC的探索与实践
新生代、老年代、为什么要有Survivor区?看这一篇就够了
图解Java垃圾回收机制 | 可达性分析,垃圾收集器