垃圾回收算法

~信~仰~ 2019-11-09

算法 线程 Image

1. 确认待回收对象

垃圾收集器回收对象时,第一件事就是确认哪些对象需要被回收,确认算法有引用计数法和可达性分析。

1.1 引用计数法

在这中算法下,每个对象实例都会被分配一个引用计数器,每当一个地方引用它,则计数器值加1;当引用失效时,计数器值就减一;任何时刻计数器为0的对象都表示没有任何引用。

引用计数法的缺点是无法检测出循环引用,会造成内存泄漏。

1.2 可达性分析

可达性分析是通过一系列称为GC Roots的对象作为起点,从这些节点开始向下搜索,搜索所走过的路径称为引用链(Reference Chain),当一个对象到GC Roots没有任何引用链相连,则证明此对象是不可用的:

image

图中object5和object6虽然互相引用,但是它们到GC Roots是不可达的,因此可以被回收。

在Java语言中,可作为GC Roots的对象包括下面几种:

1) 虚拟机栈(栈帧中的局部变量表)中引用的对象;

2) 方法区中类静态属性引用的对象;

3) 方法区中常量引用的对象;

4) 本地方法栈中JNI(Native方法)引用的对象。

GC进行垃圾回收主要区域是Java堆,方法区、栈和本地方法区不被GC管理,因此选择这些区域的对象作为GC roots。

被栈帧的局部变量表中引用的对象作为GC Roots且没有逃逸时,该方法执行结束该对象可以被回收,以其为GC Roots的对象也都可以被回收。

1.2.1 finalize

即使在可达性分析算法中不可达的对象,也并非是一定要被回收的,要真正回收一个对象,至少要经历两次标记过程:如果对象在进行可达性分析后发现没有与GC Roots相连接的引用链,那它将会被第一次标记并且进行一次筛选,筛选的条件是此对象是否有必要执行finalize()方法。当对象没有覆盖finalize()方法,或者finapze()方法已经被虚拟机调用过,虚拟机将这两种情况都视为“没有必要执行”。

如果这个对象被判定为有必要执行finalize()方法,那么这个对象将会放置在一个叫做F-Queue的队列之中,并在稍后由一个由虚拟机自动建立的、低优先级的Finalizer线程去执行它。

finalize()方法是对象逃脱死亡命运的最后一次机会,稍后GC将对F-Queue中的对象进行第二次小规模的标记,如果对象要在finalize()中拯救自己,只要重新与引用链上的任何一个对象建立关联即可,譬如把自己赋值给某个类变量或者对象的成员变量,那么在第二次标记时它将被移除出“即将回收”的集合;如果对象这时候还没有逃脱,那么基本上它就真的被回收了。

1.2.2 Java中的引用你了解多少

无论是通过引用计数算法判断对象的引用数量,还是通过可达性分析算法判断对象的引用链是否可达,判定对象是否存活都与“引用”有关。在JDK1.2以前,Java中的引用的定义很传统:如果reference类型的数据中存储的数值代表的是另外一块内存的起始地址,就称这块内存代表着一个引用。

在JDK 1.2之后,Java对引用的概念进行了扩充,将引用分为强引用(Strong Reference)、软引用(Soft Reference)、弱引用(Weak Reference)、虚引用(Phantom Reference)4种,这4种引用强度依次逐渐减弱。

1.2.2.1 强引用

在程序代码中普遍存在的,类似 Object obj = new Object()这类引用,只要强引用还存在,垃圾收集器永远不会回收掉被引用的对象。

无论引用计数算法还是可达性分析算法都是基于强引用而言的。

1.2.2.2 软引用

用来描述一些还有用但并非必须的对象。对于软引用关联着的对象,在系统将要发生内存溢出异常之前,将会把这些对象列进回收范围之中进行第二次回收。如果这次回收后还没有足够的内存,才会抛出内存溢出异常。

1.2.2.3 弱引用

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

1.2.2.4 虚引用

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

2 垃圾回收算法

2.1 标记 -清除算法(Mark-Sweep)

最基础的收集算法,如它的名字一样,算法分为“标记”和“清除”两个阶段:首先标记出所有需要回收的对象,在标记完成后统一回收掉所有被标记的对象。之所以说它是最基础的收集算法,是因为后续的收集算法都是基于这种思路并对其缺点进行改进而得到的。

image

标记清除算法的缺点如下:

效率问题:标记和清除过程的效率都不高;

空间问题:标记清除之后会产生大量不连续的内存碎片,空间碎片太多可能会导致,碎片过多会导致大对象无法分配到足够的连续内存,从而不得不提前触发另一次GC。

2.2 复制算法

为解决效率问题,“复制”收集算法出现了。它将可用内存按容量划分为大小相等的两块,每次只使用其中的一块。当这一块的内存用完了,就将还存活着的对象复制到另外一块上面,然后再把已使用过的内存空间一次清理掉。

这样使得每次都是对其中的一块进行内存回收,内存分配时也就不用考虑内存碎片等复杂情况,只要移动堆顶指针,按顺序分配内存即可,实现简单,运行高效。

image

复制算法的缺点如下:

效率问题:在对象存活率较高时,复制操作次数多,效率降低;

空间问题:內存缩小了一半;需要額外空间做分配担保(老年代)

现代的商业虚拟机都采用复制算法来回收新生代的对象,IBM研究表明新生代中的对象98%都是“朝生夕死”的,所以并不需要按照1:1的比例来划分内存空间,而是将内存分为一块较大的Eden空间和两块较小的Survivor空间,每次使用Eden和其中一块Survivor区。

两块Survivor中,和Eden一起使用的叫作survivor from,另一个叫作survivor to。当回收时,将Eden和Survivor from中还存活着的对象一次性复制到Survivor to,最后清理掉Eden和Survivor from中的数据。

需要注意的是,两个Survivor区中from和to是相对的,根据每次进行MinorGC后哪个区被清空没有对象了,这个区就会成为to区,而通过复制算法复制的还存活下的对象所在的那个区,也就是有对象的区即为from。

Hotspot默认Eden和survivor比例为8:1,也就是每次新生代中可用内存空间为整个新生代容量的90%,只有10%的内存会被“浪费”。

当然,98%的对象可回收只是一般场景下的数据,我们没有办法保证每次回收都只有不多于10%的对象存活,当Survivor空间不够用时,需要依赖其他内存(这里指老年代)进行分配担保。当另外一块survivor空间没有足够空间存放上一次新生代收集下来的存活对象时,这些对象将直接通过分配担保机制进入老年代。

2.3 标记整理算法

复制收集算法在对象存活率较高时就要执行较多的复制操作,效率将会变低。更关键的是,如果不想浪费survivor to的空间,就需要有额外的空间进行分配担保,以应对被使用的内存中对象存活大于Eden和survivor from空间的极端情况,所以在老年代一般不能直接选用这种算法。

根据老年代的特点,有人提出了另外一种“标记-整理”(Mark-Compact)算法,标记过程仍然与“标记-清除”算法一样,但后续步骤不是直接对可回收对象进行清理,而是让所有存活的对象都向一端移动,然后直接清理掉端边界以外的内存。

image

2.4 分代收集算法

根据对象生活周期的不同将内存划分为几块,一般是把Java堆分为新生代和老年代,这样就可以根据各个年代的特点采用最适当的算法。在新生代中,每次垃圾收集时都发现有大批对象死去,只有少量存活,那就选用复制算法,只需要付出少量存活对象的复制成本就可以完成收集。在老年代中,因为对象存活率高、没有额外空间对它进行分配担保,就必须使用标记清理或标记整理算法来 实现。

3. oopMap和safe point

3.1 oopMap

在正式的GC之前,要进行可达性分析来标记出需要被回收的对象。分析期间必须在一个能确保一致性的快照中进行,不可以出现分析过程中还在不断变化的情况,该点不满足的话分析结果的准确性就无法保证,因此所有执行线程要停止来配合可达性分析,这种情况称为Stop The World,简称STW

线程配合GC枚举根节点的停顿是不可避免的,即使是号称几乎不会停顿的CMS收集器中,枚举根节点时也是必须要停顿的。

根据可达性分析查找识别引用类型方式可分为保守式GC和准确式GC,保守式和准确式的区别是能够识别数据是引用类型还是基本类型,或者说能否识别指针和非指针。

3.1.1 保守式GC

在进行GC的时候,会从GC Roots开始扫描,扫描到数字时就检查该数字是不是指向GC堆的一个地址,检查方式包括堆的上下边界检查(GC堆的上下界是已知的)、对齐检查(通常分配空间的时候会有对齐要求,假如说是4字节对齐,那么不能被4整除的数字就肯定不是指针),通过这些基本检查之后的数字,保守式GC无法识别这些值是基本数据的数字还是指针,如:

image

图中,对于变量A,JVM在得到A的值后,通过对齐检查可以判断为非指针,因为引用是一个地址,JVM中地址是32位的,也就是8位的16进制,很明显A是一个4位16进制,没有对齐,不能作为引用。对于变量D,因为Java堆的上下边界是已知的,如同图中所标识的堆起始地址和最后地址,而D的值超出了堆的边界,因此也不是指针。

但是对于变量B和C,JVM就不能判断哪个是数字哪个是指针了。这种情况下,当执行B=null之后,对象B的实例就没有了任何引用,但是因为JVM不能正确的识别C是数字,JVM会错误的认为C可能是对象B的引用,基于保守策略,不能回收对象B。

另一种情况,变量B一直保持对实例B的引用,此时进行了一次GC,对象B经过新生代的复制算法进入Survivor to区,此时对象B的内存地址发生了改变,此时JVM现在很慌,因为C变量如果当做引用,它也会指向对象B的实例,此时C的值也应该随实例B内存的变量而改变C的值,但是万一C变量不是一个引用,而就是一个int型数据呢。于是保守式GC真正的内存模型出来了:

image

在java堆中增加了一个句柄池,当变量B的实例更改存放内存的地方后,JVM只要改变句柄值,而不用改变变量B和变量C的值。

保守式GC的缺点:
对于死掉的对象,很可能误认为仍有地方引用他们,例如执行B=null之后应当回收实例B,但是因为有变量C存在,GC就不会去回收它,造成了内存浪费。

由于不能识别是否指针,所以它们的值都不能改写,因此引入了句柄池,但是句柄池在定位对象时造成了二次访问,访问效率变低了。

3.1.2 准确式GC

准确式GC就是能准确识别指针的GC,即虚拟机可以知道内存中某个位置的数据具体是什么类型,比如内存中有一个32位的整数234567,虚拟机有能力分辨出来它到底是一个reference类型指向234567的内存地址还是一个数值为234567的整数。

实现准确式GC的一种方式是从外部记录下类型信息存成映射表,HotSpot、JRockit和J9都是这样做的。其中,HotSpot把这样的数据结构叫做OopMap。

使用这样的映射表一般有两种方式:

1、每次都遍历原始的映射表,循环的一个个偏移量扫描过去,这种用法也叫“解释式”;

2、为每个映射表生成一块定制的扫描代码(想像扫描映射表的循环被展开的样子),以后每次要用映射表就直接执行生成的扫描代码;这种用法也叫“编译式”。

在类加载完成的时候,HotSpot就把对象内什么偏移量上是什么类型的数据计算出来,在JIT编译过程中,也会在特定的位置记录下栈和寄存器中哪些位置是引用,这样,GC在扫描时就可以直接得知这些信息了。

3.2 safe point

每个被JIT编译过后的方法也会在一些特定的位置记录下OopMap,记录了执行到该方法的某条指令的时候,栈上和寄存器里哪些位置是引用。这样GC在扫描栈的时候查询这些OopMap就知道哪里是引用了。这些特定的位置主要在:

1、循环的末尾
2、方法临返回前 / 调用方法的call指令后
3、可能抛异常的位置

这种位置被称为“安全点”。

之所以要选择一些特定的位置来记录OopMap,是因为oopMap虽然能帮助Hotspot准确的完成GC Roots枚举,但是可能导致引用关系变化,或者说引起oopMap内容变化的指令非常多,如果为每一条这样的指令都生成对应的oopMap将需要大量额外空间,GC成本就会随之变高。选用安全点来记录能有效的缩小空间成本,而且仍然能达到区分引用的目的。

GC放生时,所有线程都需要跑到最近的安全点上再停顿下来,这样就限定了安全点的选定不能太少以至于让GC等待时间太长,也不能过于频繁以致于过分增大运行时的负荷。

使线程停顿有两种方案:抢先式中断和主动式中断。抢先式中断不需要线程代码主动配合,当GC发生时,首先把所有线程中断,如果发现线程中断的地方不在安全点上,就恢复线程,让他跑到安全点上。现在几乎没有虚拟机实现采用抢先式中断来暂停线程来响应GC。

而主动式中断的思想是当GC需要中断线程的时候,不直接对线程操作,仅仅简单的设置一个标志,各个线程执行时主动去轮询这个标志,发现中断标志为真时就自己中断挂起。轮询标志的地方和安全点是重合的,另外再加上创建对象需要分配的内存的地方。

4. 垃圾回收器

垃圾收集器是垃圾回收算法的具体实现,,不同商家、不同版本的JVM所提供的垃圾收集器可能会有很在差别,Hotspot的收集器主要有如下七种:

image

两个收集器之间的连线表示它们可以搭配使用,收集器所属的区域表示它们属于新生代收集器还是老年代收集器。

4.1 Serial收集器

单线程收集器,进行垃圾回收时只会启动一个线程,收集过程中需要暂停其它所有的工作线程,直到它收集结束。可以和serial old搭配使用:

image

serial收集器回收速度较慢且回收能力有限,频繁的STW更会导致较差的使用体验。但它简单高效,是Client模式下默认的垃圾收集器,适用场景为资源受限的环境,比如单核条件下。

如果要使用Serial收集器,可以通过-XX:+UseSerialGC参数指定。

4.2 ParNew收集器

ParNew收集器是Serial收集器的多线程版本,除了使用多线程进行垃圾收集工作,其他的控制参数(例如-XX:SurvivorRatio等)、收集算法、STW、对象分配规则、回收策略等均与Serial收集器一致。

image

ParNew也是独占式的回收器,在收集过程中应用程序会全部暂停。但是由于并行回收器使用多线程进行垃圾回收,因此在并发能力比较强的CPU上,它产生的停顿时间要短于串行回收器。

但是ParNew在单核/双核环境下,效率未必有Serial收集器工作效率高(多线程切换开销等因素限制),当然随着核数的增加,其性能也会得到较大的提升。它默认开启的收集线程数和CPU核心数相同,可以使用-XX:ParallelGCThreads参数限制收集线程数。

除serial外,ParNew是目前唯一能和CMS收集器配合工作的。CMS是HotSpot在JDK1.5推出的第一款真正意义上的并发收集器,第一次实现了让垃圾收集线程与用户线程同时工作,但CMS无法与JDK1.4已经存在的新生代收集器Parallel Scavenge配合工作,因为Parallel Scavenge(以及G1)都没有使用传统的GC收集器代码框架而是另外独立实现,而其余几种收集器则共用了部分的框架代码。

4.3 Parallel Scavenge收集器

Parallel Scavenge也是使用复制算法的收集器,但Parallel Scavenge的关注点不同,和ParNew相比,ParNew目标在于加速资源回收的速度,减少STW时间;Parallel Scavenge目标在于资源回收的吞吐量:

吞吐量 = 运行用户代码时间 / (运行用户代码时间 + 垃圾收集时间)

例如jvm运行100min,其中垃圾回收1min,则吞吐量为99%。

停顿时间越短就越适合需要与用户交互的程序,良好的响应速度能提升用户体验,而高吞吐量则可用高效率地利用CPU时间,尽快完成程序的运算任务,主要适合在后台运算而不需要太多交互的任务。

image

Parallel Scavenge收集器提供了两个参数用于精确控制吞吐量,分别是控制最大垃圾收集停顿时间的-XX:MaxGCPauseMillis参数以及直接设置吞吐量大小的-XX:GCTimeRatio参数。

MaxGCPauseMillis参数允许的值是一个大于0的毫秒数,收集器将尽可能地保证内存回收花费的时间不超过设定值。不过大家不要认为如果把这个参数的值设置得稍小一点就能使得系统的垃圾收集速度变得更快,GC停顿时间缩短是以牺牲吞吐量和新生代空间来换取的:系统把新生代调小一些,收集300MB新生代肯定比收集500MB快吧,这也直接导致垃圾收集发生得更频繁一些,原来10秒收集一次、每次停顿100毫秒,现在变成5秒收集一次、每次停顿70毫秒。停顿时间的确在下降,但吞吐量也降下来了。

GCTimeRatio参数的值应当是一个大于0且小于100的整数,也就是垃圾收集时间占总时间的比率,相当于是吞吐量的倒数。如果把此参数设置为19,那允许的最大GC时间就占总时间的5%(即1 /(1+19)),默认值为99,就是允许最大1%(即1 /(1+99))的垃圾收集时间。

由于与吞吐量关系密切,Parallel Scavenge收集器也经常称为“吞吐量优先”收集器。除上述两个参数之外,Parallel Scavenge收集器还有一个参数-XX:+UseAdaptiveSizePolicy值得关注。这是一个开关参数,当这个参数打开之后,就不需要手工指定新生代的大小(-Xmn)、Eden与Survivor区的比例(-XX:SurvivorRatio)、晋升老年代对象年龄(-XX:PretenureSizeThreshold)等细节参数了,虚拟机会根据当前系统的运行情况收集性能监控信息,动态调整这些参数以提供最合适的停顿时间或者最大的吞吐量,这种调节方式称为GC自适应的调节策略(GC Ergonomics)。

如果对收集器运作原来不太了解,手工优化存在困难的时候,使用Parallel Scavenge收集器配合自适应调节策略,把内存管理的调优任务交给虚拟机去完成将是一个不错的选择。只需要把基本的内存数据设置好(如-Xmx设置最大堆),然后使用MaxGCPauseMillis参数(更关注最大停顿时间)或GCTimeRatio(更关注吞吐量)参数给虚拟机设立一个优化目标,那具体细节参数的调节工作就由虚拟机完成了。自适应调节策略也是Parallel Scavenge收集器与ParNew收集器的一个重要区别。

4.4 Serial Old收集器

Serial Old是serial收集器的老年代版本,它同样是一个单线程收集器,使用“标记--整理”算法。这个收集器的意义在于给Client模式下的虚拟机使用。如果在Server模式下,那么它主要有两大用途:一种是在jdk1.5以及之前的版本中与Parallel Scavenge收集器搭配使用,另一种用途是作为CMS收集器的后预案,在并发收集发生Concurrent Mode Failure时使用。工作流程图如下:

image

4.5 Parallel Old收集器

Parallel Old是Parallel Scavenge收集器的老年代版本,使用多线程与“标记--整理”算法。这个收集器在jdk1.6中才开始提供的,直到Parallel Old 收集器出现后,“吞吐量优先”收集器终于有了比较名副其实的应用组合,在注重吞吐量以及CPU资源敏感的场合,都可以优先考虑Parallel Scavenge加 Parallel Old收集器。工作过程如下图:

image

4.6 CMS收集器

CMS收集器是一种以获取最短回收停顿时间为目标的收集器,是基于“”标记--清理”算法实现的,整个回收过程分为四个步骤:

1. 初始标记

2. 并发标记

3. 重新标记

4. 并发清理

其中初始标记和重新标记仍需要STW,初始标记仅仅是标记一下GC roots能直接关联的对象,速度很快,并发标记就是进行gc roots tracing的过程,重新标记阶段就是为了修正并发标记期间因为用户程序继续运行而导致标记产生变动的那一部分对象的标记记录,这个阶段的停顿时间一般会比初始标记阶段的时间稍长,但远远比并发标记阶段时间短。

由于在整个过程和中最耗时的并发标记和并发清除过程收集器线程都可以和用户线程一起工作,所以总体来说,CMS收集器的内存回收过程是与用户线程一起并发执行的,其主要优点是并发收集、低停顿。

image

CMS收集器的缺点主要有三个:

对CPU资源敏感:并发收集虽然不会暂停应用程序,但是会占用CPU资源从而降低应用程序的执行效率(CMS默认收集线程数量=(CPU数量 + 3) / 4)。可以通过参数-XX:ConcGCThreads设置并发的GC线程数,降低CPU敏感度。

产生浮动垃圾:在并发清除时,用户线程会产生新的垃圾,这一部分垃圾出现在标记过程之后,CMS无法在当次收集中处理掉它们,只好留待下一次GC时再清理掉,这一部分垃圾就称为浮动垃圾。由于在垃圾收集阶段用户线程还需要运行,那也就还需要预留有足够的内存空间给用户线程使用,因此CMS收集器不能像其他收集器那样等到老年代几乎完全被填满了再进行收集,需要预留一部分空间提供并发收集时的程序运作使用。

要是CMS运行期间预留的内存无法满足程序需要,就会出现一次“Concurrent Mode Failure”失败,这时虚拟机将启动后备预案:临时启用Serial Old收集器来重新进行老年代的垃圾收集,这样停顿时间就很长了。在jdk1.6中,CMS收集器的启动阈值是92%,意味着当老年代空间被使用92%时就自动触发CMS回收机制,这个阈值可以通过参数-XX:CMSInitiatingOccupancyFraction合理设置。

产生空间碎片:使用"标记-清除"算法,会产生大量不连续的内存碎片,从而导致在分配大内存对象时,无法找到足够的连续内存,不得不提前触发一次Full GC操作。

为了解决这个问题,CMS提供了参数-XX:+UseCMSCompactAtFullGCCollection,用于在CMS顶不住要进行FullGC时开启内存碎片的合并整理过程,整理过程是无法并发的,停顿时间不得不变长;虚拟机还提供了另一个参数-XX:CMSFullGCBeforeCompaction,这个参数用于设置执行多少次不压缩的fullGC后,跟着来一次带压缩的fullGC(默认值为0,表示每次进入fullGC都进行碎片整理)。

4.7 G1收集器

4.7.1 G1特点

G1(Garbage-First)收集器是面向服务器端应用的垃圾收集器。与其他GC收集器相比,G1具备如下特点:

并行与并发:G1能充分利用多CPU、多核环境下的硬件优势,使用多个CPU来缩短STW停顿的时间,部分其他收集器原本需要停顿java线程执行的GC动作,G1收集器仍然可以通过并发的方式让java程序继续执行。

分代收集:与其他收集器一样,分代概念在G1中依然得以保留。虽然G1可以不需要其他收集器配合就能够独立管理整个GC堆,但它能够采用不同的方式去处理新创建的对象和已经存活了一段时间、熬过多次GC的旧对象以获取更好的收集效果。

空间整合:与CMS的“标记--清理”算法不同,G1从整体来看是基于“标记--整理”算法实现的收集器,从局部(两个Region之间)上来看是基于“复制”算法实现的,但无论如何,这两种算法都意味着G1运行期间不会产生内存空间碎片,收集后能提供规整的可用内存。这个特性有利于程序长时间运行,分配大对象时不会因为无法找到连续内存空间而提前出发下一次GC。

image

可预测的停顿:这是G1相对于CMS的另一大优势,降低停顿时间是G1和CMS共同的关注点,但G1除了追求低停顿外,还能建立可预测的停顿时间模型,能让使用者明确指定在一个长度为M毫秒的时间片段内,消耗在垃圾收集上的时间不得超过N毫秒,这几乎已经是实时java(RTSJ)的垃圾收集器的特性了。

使用G1收集器时,java堆的内存布局就与其他收集器有很大差别,它将真个java堆划分为多个大小相等的独立区域(Region),虽然还保留新生代与老年代的概念,但新生代与老年代不再试物理隔离的了,他们都是一部分Region(不需要连续)的集合。

G1收集器之所以能建立可预测的停顿时间模型,是因为它可以有计划地避免在整个Java堆中进行全区域的垃圾收集。G1跟踪各个Region里面的垃圾堆积的价值大小(回收所获取的空间大小以及回收所需要的时间的经验值),在后台维护一个优先列表,每次根据允许的收集时间,优先回收价值最大的Region,这也是Garbage-First名称的由来。

4.7.2 region

G1将堆空间分成若干个大小相等的内存区域,称为region,每次分配对象空间将逐段地使用内存。启动时可以通过参数-XX:G1HeapRegionSize指定分区大小(1MB~32MB,且必须是2的幂),默认将整堆划分为2048个分区。

在每个分区内部又被分成了若干个大小为512Byte的卡片(Card),分配的对象会占用物理上连续的若干个卡片,当查找对分区内对象的引用时便可通过记录卡片来查找该引用对象。每次对内存的回收,都是对指定分区的卡片进行处理。

image

region不是孤立的,一个对象分配在某个region中,并非只能被本region中的其他对象引用,而是可能与整个java堆任意的对象发生引用关系。那这样做可达性判定确定对象是否存活的时候,难道要扫描整个java堆才能保证准确性吗?

在G1收集器region之间的对象引用,以及其他收集器中的新生代与老年代之间的对象引用,虚拟机都是使用Remembered Set(RSet)来避免全堆扫描的。G1中每个region都有一个与之对应的remembered Set。

image

虚拟机发现程序在对Reference类型的数据进行写操作时,会产生一个Write Barrier暂时终端写操作,检查Reference引用对象是否处于不同的Region之中(在分代的例子中就是检查是否老年代中的对象引用了新生代中的对象),如果是,便通过CardTable把相关引用信息记录到被引用对象所属的regipn的Remembered Set之中。当进行内存回收时,在GC根结点的枚举范围中加入Rememberd Set即可保证不对全堆扫描也不会有遗漏。

4.7.3 分代

image

G1将内存在逻辑上划分为年轻代和老年代,其中年轻代又划分为Eden空间和Survivor空间。但年轻代空间并不是固定不变的,当现有年轻代分区占满时,JVM会分配新的空闲分区加入到年轻代空间。

整个年轻代内存会在初始空间-XX:G1NewSizePercent(默认整堆5%)与最大空间-XX:G1MaxNewSizePercent(默认60%)之间动态变化,且由参数目标暂停时间-XX:MaxGCPauseMillis(默认200ms)、需要扩缩容的大小以及分区的RSet计算得到。当然,G1依然可以设置固定的年轻代大小(参数-XX:NewRatio、-Xmn),但同时暂停目标将失去意义。

4.7.4 本地分配缓冲

每个线程均可以"认领"某个分区用于线程本地的内存分配,因此,每个应用线程和GC线程都会独立的使用分区,进而减少同步时间,提升GC效率,这个分区称为本地分配缓冲区Local allocation buffer(Lab)。

其中,应用线程可以独占一个本地缓冲区(TLAB)来创建的对象,而大部分都会落入Eden区域(巨型对象或分配失败除外),因此TLAB的分区属于Eden空间;而每次垃圾收集时,每个GC线程同样可以独占一个本地缓冲区(GCLAB)用来转移对象,每次回收会将对象复制到Suvivor空间或老年代空间;对于从Eden/Survivor空间晋升(Promotion)到Survivor/老年代空间的对象,同样有GC独占的本地缓冲区进行操作,该部分称为晋升本地缓冲区(PLAB)。

一个大小达到甚至超过分区大小一半的对象称为巨型对象。当线程为巨型分配空间时,不能简单在TLAB进行分配,因为巨型对象的移动成本很高,因此,巨型对象会直接在老年代分配,所占用的连续空间称为巨型分区(Humongous Region)。G1内部做了一个优化,一旦发现没有引用指向巨型对象,则可直接在年轻代收集周期中被回收。

TLAB可以通过参数-XX:+/-UseTLAB启用。

4.7.5 收集集合 (CSet)

image

收集集合(CSet)代表每次GC暂停时需要回收的一系列目标分区,收集期间,CSet所有分区都会被释放,内部存活的对象都会被转移到分配的空闲分区中。年轻代收集CSet只容纳年轻代分区,而混合收集会在老年代候选回收分区中,筛选出回收收益最高的分区添加到CSet中。

候选老年代分区的CSet准入条件,可以通过活跃度阈值-XX:G1MixedGCLiveThresholdPercent(默认85%)进行设置,从而拦截那些回收开销巨大的对象;同时,每次混合收集可以包含候选老年代分区,可根据CSet对堆的总大小占比-XX:G1OldCSetRegionThresholdPercent(默认10%)设置数量上限。

由上述可知,G1的收集都是根据CSet进行操作的,年轻代收集与混合收集没有明显的不同,最大的区别在于两种收集的触发条件。

4.7.5.1 年轻代收集集合

当JVM分配对象到Eden区域失败时,便会触发一次STW式的年轻代收集。在年轻代收集中,Eden分区存活的对象将被拷贝到Survivor分区;原有Survivor分区存活的对象,将根据任期阈值(tenuring threshold)分别晋升到PLAB中,新的survivor分区和老年代分区。而原有的年轻代分区将被整体回收掉。

同时,年轻代收集还负责维护对象的年龄(存活次数),辅助判断老化(tenuring)对象晋升的时候是到Survivor分区还是到老年代分区。年轻代收集首先先将晋升对象尺寸总和、对象年龄信息维护到年龄表中,再根据年龄表、Survivor尺寸、Survivor填充容量-XX:TargetSurvivorRatio(默认50%)、最大任期阈值-XX:MaxTenuringThreshold(默认15),计算出一个恰当的任期阈值,凡是超过任期阈值的对象都会被晋升到老年代。

4.7.5.2 混合收集集合

年轻代收集不断活动后,老年代的空间也会被逐渐填充。当老年代占用空间超过整堆比IHOP阈值-XX:InitiatingHeapOccupancyPercent(默认45%)时,G1就会启动一次混合垃圾收集周期。为了满足暂停目标,G1可能不能一口气将所有的候选分区收集掉,因此G1可能会产生连续多次的混合收集与应用线程交替执行,每次STW的混合收集与年轻代收集过程相类似。

为了确定包含到年轻代收集集合CSet的老年代分区,JVM通过参数混合周期的最大总次数-XX:G1MixedGCCountTarget(默认8)、堆废物百分比-XX:G1HeapWastePercent(默认5%)。通过候选老年代分区总数与混合周期最大总次数,确定每次包含到CSet的最小分区数量;根据堆废物百分比,当收集达到参数时,不再启动新的混合收集。而每次添加到CSet的分区,则通过计算得到的GC效率进行安排。

G1也可以通过-Xms/-Xmx来指定堆空间大小。当发生年轻代收集或混合收集时,通过计算GC与应用的耗费时间比,自动调整堆空间大小。如果GC频率太高,则通过增加堆尺寸,来减少GC频率,相应地GC占用的时间也随之降低;目标参数-XX:GCTimeRatio即为GC与应用的耗费时间比,G1默认为9,而CMS默认为99,因为CMS的设计原则是耗费在GC上的时间尽可能的少。

另外,当空间不足,如对象空间分配或转移失败时,G1会首先尝试增加堆空间,如果扩容失败,则发起担保的Full GC。Full GC后,堆尺寸计算结果也会调整堆空间。

4.7.6 回收机制

如果不计算维护Remembered Set的操作,G1收集器的运作大致可划分为以下几个步骤:

1、初始标记(Initial Marking)
2、并发标记(Concurrent Marking)
3、最终标记(Final Marking)
4、筛选回收(Live Data Counting and Evacuation)

G1的前几个步骤和CMS有很多相似之处。初始标记仅仅只是标记一下GC Roots能直接关联到的对象,并且修改TAMS(Next Top at Mark Start)的值,让下一阶段用户程序并发运行时,能在正确可用的region中创建新对象,这阶段需要线程停顿,但耗时很短。

image

并发标记是从GC Roots开始对堆中对象进行可达性分析,找出存活对象,这阶段耗时较长,但可与用户线程并发执行。而最终标记阶段则是为了修正在并发标记期间因用户线程记性运作而导致标记产生变化的那一部分标记记录,虚拟机将这段时间对象变化记录在线程Remembered Set Logs里面,最终标记阶段需要把Remembered Set Log的数据合并到Remembered Set 中,这几段需要停顿线程,但是可并行执行。

最后在筛选回收阶段首先对各个region的回收价值和成本进行排序,根据用户所期望的GC停顿时间来制定回收计划,这个阶段理论上也可以做到与用户线程并发执行,但是因为只回收一部分region,时间是用户可控制的,而且停顿用户线程将大幅提高收集效率,如下图可以比较清除的看到G1收集器运作的步骤中并发和需要停顿的阶段。

image

由上的阐述,可以看到G1之前的收集器和G1的异同点,之前的收集器大多如下特点:

年轻代、老年代是独立且连续的内存块;

年轻代收集基本都采用Eden、survivor from和survivor to进行内存管理,使用复制算法作为收集算法;

老年代垃圾收集基本采用扫描整个老年代区域。

G1:

G1可以根据用户设置的暂停时间目标自动调整年轻代和总堆大小(和Parallel Scavenge类似),且暂停时间越短年轻代空间越小、总空间就越大;

G1的空间整合特点表示其采用了复制清除、标记整理算法;

G1只有逻辑上的分代概念,不存在物理隔离的年轻代和老年代,年轻代中也不需要物理隔离的survivor区,G1收集都是STW,并且但年轻代和老年代采用了混合收集的方式,每次收集可能同时收集年轻代和老年代。

jdk1.8下,当内存相对比较大时可以采用G1替代CMS,例如32G内存;jdk1.9下基本放弃了CMS,默认就是G1。

4.8 ZGC收集器

4.8.1 ZGC简介

Z垃圾收集器也称为ZGC,是一种可伸缩的低延迟垃圾收集器。其主要目标:

GC暂停时间不应超过10ms;

能够处理几百M到几个T大小的堆;

与使用G1相比,应用程序吞吐量减少不超过15%;

ZGC为GC特征优化和着色指针、负载障碍奠定了基础,其最初支持的平台为Linux/x64。ZGC中没有新生代和老年代的概念,只有一块一块的内存区域page,以page单位进行对象的分配和回收。每次进行GC时,都会对page进行压缩操作,所以完全避免了CMS算法中的碎片化问题。

ZGC支持NUMA,现代多CPU插槽服务器都是Numa架构,比如两个CPU插槽(24核),64G内存的服务器,每个CPU槽上的12个核都访问从属于它的32G内存,比访问另外32G远端内存要快得多。在创建对象时,根据当前线程在哪个CPU执行,优先在靠近这个CPU的内存进行分配,这样可以显著的提高性能。

4.8.2 着色指针

之前的收集器在标记阶段,例如CMS、G1都是在对象的对象头进行标记,而ZGC是标记对象的指针。着色指针是将信息存储在指针中,ZGC仅支持64位平台,因此指针为64位,其使用其中低42位表示对象的地址,42-45位用来做指针状态:

ZGC限制最大支持4Tb堆(42-bits),剩下22位可用,目前使用了4位finalizable、remapped、mark0和mark1:

finalizable:该对象只能通过终结器来访问

remapped:重映射位,参考指向对象的当前地址

marked0和marked1位 - 这些用于标记可到达的对象

ZGC在指针上做标记,在线程访问指针时加入Load Barrier,比如当对象正被GC移动时,这个屏障会先把指针更新为有效地址再返回,因此只有单个对象读取时有概率被阻塞,而不是STW。

着色指针在取消着色时,需要额外的工作(因为需要屏蔽信息位)。像SPARC这样的平台有内置硬件支持指针屏蔽所以不是问题,而对于x86平台来说,ZGC团队使用了简洁的多重映射技巧。

4.8.3 load barriers

因为在标记和移动过程中,GC线程和应用线程是并发执行的,所以存在这种情况:对象A内部的引用所指的对象B在标记或者移动状态,为了保证应用线程拿到的B对象是对的,那么在读取B的指针时会经过一个 “load barriers” 读屏障,这个屏障可以保证在执行GC时,数据读取的正确性。

也就是说,读屏障是每当应用程序线程读取引用时,立即检查引用的状态,并在将引用返回给应用程序之前执行一些工作。

4.8.4 ZGC回收过程

4.8.4.1 标记

标记用于确定可达对象,ZGC标记分三个阶段:

第一阶段是寻找GC Roots并标记其直接子节点,例如局部变量或静态字段等,这个阶段是STW的,由于GC Roots数量通常较小,因此该阶段耗时很短。如下标记了GC Roots的直接子节点1、2、4:

image

第二阶段是并发阶段,这个阶段从GC Roots开始递归的标记每个可达的对象。此外,当负载屏障检测到未标记的引用时,则将其添加到队列以进行标记。ZGC使用marked0和marked1元数据位进行标记。

image

最后阶段也是STW,用来处理一些边缘情况,比如弱引用。

4.8.4.2 重定位

标记完成之后是重定位,重定位涉及移动活动对象以释放部分堆内存。ZGC重定位也包括三个阶段。

第一阶段是并发查找需要重定位的page并将它们放入重定位集中,ZGC将整个堆分成许多page,此阶段会选择一组需要重定位活动对象的页面。

第二阶段,选择重定位集后,ZGC开始STW并重定位该集合中root对象,将它们的引用更新为新位置,STW的暂停时间取决于GC Roots的数量,停顿时间很短。

第三阶段,移动root后进行并发重定位,GC线程遍历重定位集并重新定位page中所有的对象,如果应用程序线程在GC重新定位对象期间加载其引用,则读屏障负责将重定位后的引用返回:

image

4.8.4.3 重映射

并发重定位会产生转发表,在转发表中记录了旧地址和新地址之间的映射:

image

并发标记完成后,开始进行引用更新,将引用地址更新为转发表中记录的新地址,更新完成后则释放转发表空间:

image

GC线程最终将对重定位集中的所有对象重定位,然而可能仍有引用指向这些对象的旧位置,如果此时程序线程需要使用这些引用,将无法访问我们想要的对象,ZGC使用负载屏障来解决这个问题,负载屏障将负责返回正确的引用。

剩余的旧引用,GC可以遍历对象图并重新映射这些引用到新位置,但是这一步代价很高昂。因此这一步与下一个标记阶段合并在一起。在下一个GC周期的标记阶段遍历对象对象图的时候,如果发现未重映射的引用,则将其重新映射,然后标记为活动状态。

4.8.5 ZGC性能

SPECjbb 2015[1]的常规性能测试,从吞吐量和延迟的角度来看,ZGC性能看起来不错。下面是ZGC和G1的max-jOPS以及critical-jOPS得分:

ZGC
       max-jOPS: 100%
  critical-jOPS: 76.1%

G1
       max-jOPS: 91.2%
  critical-jOPS: 54.7%

以下是ZGC和G1在同一基准下的GC停顿时间对别:

ZGC
                avg: 1.091ms (+/-0.215ms)
    95th percentile: 1.380ms
    99th percentile: 1.512ms
  99.9th percentile: 1.663ms
 99.99th percentile: 1.681ms
                max: 1.681ms

G1
                avg: 156.806ms (+/-71.126ms)
    95th percentile: 316.672ms
    99th percentile: 428.095ms
  99.9th percentile: 543.846ms
 99.99th percentile: 543.846ms
                max: 543.846ms

一般来说,ZGC保持一位数的毫秒暂停时间。

4.8.6 使用ZGC

按照惯例,JVM中的实验特性默认被构建系统禁用。ZGC是一个实验性的特性,因此它不会出现在JDK构建中,需要在运行时显式解锁。因此,要启用/使用ZGC,需要以下JVM选项:

-XX:+UnlockExperimentalVMOptions-XX:+UseZGC

如果您是第一次尝试ZGC,请首先使用以下GC选项:

-XX:+UnlockExperimentalVMOptions -XX:+UseZGC -Xmx<size> -Xlog:gc

有关更详细的日志记录,请使用以下选项:

-XX:+UnlockExperimentalVMOptions -XX:+UseZGC -Xmx<size> -Xlog:gc*

ZGC能和下列参数一起使用:

image

更多参数设置:https://wiki.openjdk.java.net/display/zgc/Main

4.9 理解GC日志

每一种收集器的日志形式都是由它们自身的实现决定的,但是虚拟机的设计者为了方便用户阅读,将每个收集器的日志都维持一定的共性。可以通过下列参数开启GC日志打印:

-Xms3m -Xmx24m -Xmn3m -XX:+HeapDumpOnOutOfMemoryError -XX:+PrintGCDetails -XX:+PrintGCTimeStamps

打印结果示例:

0.178: [GC (Allocation Failure) [PSYoungGen: 2048K->488K(2560K)] 2048K->891K(3584K), 0.0008194 secs] [Times: user=0.00 sys=0.00, real=0.00 secs] 
0.583: [GC (Allocation Failure) [PSYoungGen: 2536K->504K(2560K)] 2939K->1101K(3584K), 0.0018190 secs] [Times: user=0.00 sys=0.00, real=0.00 secs] 
0.585: [Full GC (Ergonomics) [PSYoungGen: 504K->497K(2560K)] [ParOldGen: 597K->541K(3072K)] 1101K->1038K(5632K), [Metaspace: 3743K->3743K(1056768K)], 0.0068327 secs] [Times: user=0.06 sys=0.00, real=0.01 secs] 
0.601: [GC (Allocation Failure) --[PSYoungGen: 1603K->1603K(2560K)] 22624K->22656K(24064K), 0.0009573 secs] [Times: user=0.00 sys=0.00, real=0.00 secs] 
0.602: [Full GC (Ergonomics) [PSYoungGen: 1603K->1493K(2560K)] [ParOldGen: 21053K->20885K(21504K)] 22656K->22379K(24064K), [Metaspace: 3758K->3758K(1056768K)], 0.0094440 secs] [Times: user=0.02 sys=0.00, real=0.01 secs] 
0.612: [GC (Allocation Failure) --[PSYoungGen: 1493K->1493K(2560K)] 22379K->22403K(24064K), 0.0008611 secs] [Times: user=0.00 sys=0.00, real=0.00 secs] 
0.613: [Full GC (Allocation Failure) [PSYoungGen: 1493K->1470K(2560K)] [ParOldGen: 20909K->20887K(21504K)] 22403K->22357K(24064K), [Metaspace: 3758K->3758K(1056768K)], 0.0090611 secs] [Times: user=0.06 sys=0.00, real=0.01 secs] 

4.9.1 回收发生时间

最前边的数字0.1780.583表示GC发生的时间,这个数字代表了自虚拟机启动以来经过的秒数。

4.9.2 [GC、[FULL

GC日志的开头[GC[FULL GC表示这次垃圾收集的停顿类型,如果有FULL,说明这次垃圾收集发生了STW。[GC和[FULL和GC发生的地点,例如在新生代还是老年代无关。例如下面新生代垃圾收集发生了fullGC:

0.585: [Full GC (Ergonomics) [PSYoungGen: 504K->497K(2560K)] [ParOldGen: 597K->541K(3072K)] 1101K->1038K(5632K), [Metaspace: 3743K->3743K(1056768K)], 0.0068327 secs] [Times: user=0.06 sys=0.00, real=0.01 secs] 

这一般是因为发生了分配担保失败,导致fullGC。

如果是调用了 System.gc(); 方法所触发的收集,那么这将显示[GC (System.gc())[Full GC (System.gc())

4.9.3 回收发生区域

[PSYoungGen[ParOldGen[Metaspace表示GC发生的区域。这里的命名是根据收集器而定的,Serial收集器新生代为为DefNew(default new generation)ParNew收集器新生代为为ParNew(parallel new generation),如果是Parallel Scavenge新生代为PSYoungGen

老年代、元空间(或永久代)同理,名称也是由收集器决定。

4.9.4 回收情况

收集区域后面的数字,例如:

[PSYoungGen: 2536K->504K(2560K)] 2939K->1101K(3584K)

2536K->504K(2560K)含义是GC前该内存区域已使用的容量 -> GC 后该内存区域已使用的容量(该内存区域的总容量)。而在方括号之外的2939K->1101K(3584K)则表示GC前堆已使用容量 -> GC 后堆已使用容量(Java 堆的总容量)

4.9.5 回收耗时

在Java堆的总容量之后,是一个时间,表示该内存区域GC所占用的时间,单位是秒,例如0.0018190

[PSYoungGen: 2536K->504K(2560K)] 2939K->1101K(3584K), 0.0018190 secs]

有些收集器会给出更具体的时间数据:

[Times: user=0.00 sys=0.00, real=0.00 secs]

这里的user、sys、real和Linux的time命令所输出的时间含义一致,分别代表用户态消耗的CPU时间、内核态消耗的CPU时间、操作从开始到结束所经过的墙钟时间。

CPU时间与墙钟时间的区别是,墙钟时间包括各种非运算的等待耗时,例如等待磁盘I/O、等待线程堵塞,而CPU时间不包括这些耗时,但当系统有多CPU或者多核的话,多线程操作会叠加这些CPU时间,所以看到user或sys时间超过real时间完全是正常的。

5. 内存分配与回收策略

java对象的内存分配就是在堆上分配,对象主要分配在新生代的Eden区上,如果启用了本地线程分配缓冲,将按线程优先在TLAB上分配,少数情况下也可能会直接分配在老年代中。分配的细节决定于当前使用的是哪种垃圾收集器组合,以及虚拟机中与内存相关的参数。

5.1 对象优先在Eden区分配

对象通常在新生代的Eden区进行分配,当Eden区没有足够空间进行分配时,虚拟机将发起一次Minor GC,与Minor GC对应的是Major GC、Full GC。

Minor GC:指发生在新生代的垃圾收集动作,非常频繁,速度较快。

Major GC:指发生在老年代的GC,出现Major GC,经常会伴随一次Minor GC,同时Minor GC也会引起Major GC,一般在GC日志中统称为GC,不频繁。

Full GC:指发生在老年代和新生代的GC,速度很慢,需要Stop The World。

查看如下打印为例:

0.220: [GC (Allocation Failure) [PSYoungGen: 2400K->512K(4608K)] 16736K->15276K(19968K), 0.0011852 secs] [Times: user=0.00 sys=0.00, real=0.00 secs] 
0.221: [Full GC (Ergonomics) [PSYoungGen: 512K->0K(4608K)] [ParOldGen: 14764K->15172K(15360K)] 15276K->15172K(19968K), [Metaspace: 3225K->3225K(1056768K)], 0.0062224 secs] [Times: user=0.00 sys=0.02, real=0.01 secs] 
0.228: [Full GC (Ergonomics) [PSYoungGen: 2048K->2048K(4608K)] [ParOldGen: 15172K->15172K(15360K)] 17220K->17220K(19968K), [Metaspace: 3226K->3226K(1056768K)], 0.0037570 secs] [Times: user=0.00 sys=0.00, real=0.00 secs] 
0.232: [Full GC (Allocation Failure) [PSYoungGen: 2048K->2048K(4608K)] [ParOldGen: 15172K->15153K(15360K)] 17220K->17201K(19968K), [Metaspace: 3226K->3226K(1056768K)], 0.0080365 secs] [Times: user=0.08 sys=0.00, real=0.01 secs] 

Heap
 PSYoungGen      total 4608K, used 2171K [0x00000000ffb00000, 0x0000000100000000, 0x0000000100000000)
  eden space 4096K, 53% used [0x00000000ffb00000,0x00000000ffd1ed28,0x00000000fff00000)
  from space 512K, 0% used [0x00000000fff00000,0x00000000fff00000,0x00000000fff80000)
  to   space 512K, 0% used [0x00000000fff80000,0x00000000fff80000,0x0000000100000000)
 ParOldGen       total 15360K, used 15153K [0x00000000fec00000, 0x00000000ffb00000, 0x00000000ffb00000)
  object space 15360K, 98% used [0x00000000fec00000,0x00000000ffacc7b0,0x00000000ffb00000)
 Metaspace       used 3258K, capacity 4500K, committed 4864K, reserved 1056768K
  class space    used 353K, capacity 388K, committed 512K, reserved 1048576K

可知初始分配内存都是在新生代,新生代分配失败后启动分配担保到老年代。其中新生代Eden区和survivor区默认8:1。

5.2 大对象直接进入老年代

需要大量连续内存空间的Java对象称为大对象,当没有连续的空间容纳大对象时,会提前触发垃圾收集以获取连续的空间来进行大对象的分配。虚拟机提供了-XX:PretenureSizeThreadshold参数来设置大对象的阈值,超过阈值的对象直接分配到老年代,这样做的目的是为了避免在Eden区和两个Survivor区之间发生大量的内存复制,因为新生代使用复制算法进行垃圾回收。

使用如下参数:

-verbose:gc -XX:+PrintGCDetails -Xms20M -Xmx20M -Xmn10M -XX:PretenureSizeThreshold=6M

打印结果示例:

Heap
 PSYoungGen      total 9216K, used 2669K [0x00000000ff600000, 0x0000000100000000, 0x0000000100000000)
  eden space 8192K, 32% used [0x00000000ff600000,0x00000000ff89b698,0x00000000ffe00000)
  from space 1024K, 0% used [0x00000000fff00000,0x00000000fff00000,0x0000000100000000)
  to   space 1024K, 0% used [0x00000000ffe00000,0x00000000ffe00000,0x00000000fff00000)
 ParOldGen       total 10240K, used 6144K [0x00000000fec00000, 0x00000000ff600000, 0x00000000ff600000)
  object space 10240K, 60% used [0x00000000fec00000,0x00000000ff200010,0x00000000ff600000)
 Metaspace       used 3230K, capacity 4496K, committed 4864K, reserved 1056768K
  class space    used 350K, capacity 388K, committed 512K, reserved 1048576K

可以看到6m的大对象直接进入了老年代。

5.3 长期存活的对象进入老年代

虚拟机既然采用分代收集的思想来管理内存,那么内存回收时就必须能够识别哪些对象应当放在新生代,哪些对象应该放在老年代。为了做到这一点,虚拟机给每个对象定义了一个对象年龄计数器。如果对象在Eden出生并经过第一次Minor GC后仍然存活,并且能够被Survivor容纳的话,将被移动到Survivor空间中,并将对象年龄设置为1。

对象在Survivor区每熬过一次Minor GC,年龄就增加一岁,当它的年龄增加到一定程度(默认15岁)时,就会被晋升到老年代中。对象晋升老年代的年龄阈值,可以通过参数-XX:MaxTenuringThreshold来设置。

例如使用如下参数:

-verbose:gc -XX:+PrintGCDetails -Xms20M -Xmx20M -Xmn10M -XX:MaxTenuringThreshold=1

打印结果示例:

Heap
 PSYoungGen      total 9216K, used 2669K [0x00000000ff600000, 0x0000000100000000, 0x0000000100000000)
  eden space 8192K, 32% used [0x00000000ff600000,0x00000000ff89b698,0x00000000ffe00000)
  from space 1024K, 0% used [0x00000000fff00000,0x00000000fff00000,0x0000000100000000)
  to   space 1024K, 0% used [0x00000000ffe00000,0x00000000ffe00000,0x00000000fff00000)
 ParOldGen       total 10240K, used 6144K [0x00000000fec00000, 0x00000000ff600000, 0x00000000ff600000)
  object space 10240K, 60% used [0x00000000fec00000,0x00000000ff200010,0x00000000ff600000)
 Metaspace       used 3234K, capacity 4496K, committed 4864K, reserved 1056768K
  class space    used 350K, capacity 388K, committed 512K, reserved 1048576K

可以看到1岁的对象直接被转移到了老年代中。

5.4 动态对象年龄判断

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

5.5 空间分配担保

在发生Minor GC之前,虚拟机会检查老年代连续的空闲区域是否大于新生代所有对象的总和,若成立,则说明Minor GC是安全的,否则,虚拟机需要查看HandlePromotionFailure的值,看是否运行担保失败,若允许,则虚拟机继续检查老年代最大可用的连续空间是否大于历次晋升到老年代对象的平均大小,如果大于,将尝试进行一次Minor GC,尽管这次minor GC是有风险的;如果小于,或者HandlePromotionFailure设置不允许冒险,那么这时也要改成一次Full GC。

在jdk1.6之后,HandlePromotionFailure的设置不会影响到虚拟机的空间分配担保策略,只要老年代的连续空间大于新生代对象总大小或者历次晋升的平均大小就会进行Minor GC,否则将进行Full GC。

参考:

《深入理解java虚拟机》
https://wiki.openjdk.java.net/display/zgc/Main
https://blog.csdn.net/Leeycw96/article/details/90704760
https://docs.oracle.com/javase/8/docs/technotes/guides/vm/gctuning/g1_gc.html#garbage_first_garbage_collection
https://blog.csdn.net/coderlius/article/details/79272773
登录 后评论
下一篇
云栖号资讯小编
27323人浏览
2020-07-13
相关推荐
基本垃圾回收算法
443人浏览
2013-06-11 22:19:07
JAVA垃圾回收机制概要
1416人浏览
2017-06-01 21:33:00
JAVA垃圾回收机制概要
1027人浏览
2017-08-18 21:44:00
JVM(2)--一文读懂垃圾回收
987人浏览
2018-08-12 00:30:00
0
0
0
1163