我们知道,java为了让程序员更专注于代码的实现,而不用过多的考虑内存释放的问题,采用了自动的垃圾回收机制,也就是我们熟悉的GC。
有了垃圾回收机制后,程序员只需要关心内存的申请即可,内存的释放由系统自动识别完成。换句话说,自动的垃圾回收的算法就会变得非常重要了,如果因为算法的不合理,导致内存资源一直没有释放,同样也可能会导致内存溢出的。
当然,除了Java语言,C#、Python等语言也都有自动的垃圾回收机制。
自动化的管理内存资源,垃圾回收机制必须要有一套算法来进行计算,其中有一个最重要的问题就是判断哪些是有效的对象,哪些是无效的对象,对于无效的对象就要进行回收处理。常见计算无效对象的方法有两种,分别是:引用计数算法、可达性分析算法。
引用计数是历史最悠久的一种算法,最早George E. Collins在1960的时候首次提出,50年后的今天,该算法依然被很多编程语言使用。
假设有一个对象A,任何一个对象对A的引用,那么对象A的引用计数器+1,当引用失败时,对象A的引用计数器就-1,如果对象A的计数器的值为0,就说明对象A没有引用了,可以被回收。
优点:
缺点:
循环引用:即AB两个对象相互依赖从而形成一个闭环的现象。
相关代码示范如下:
public class CircularReference {
public static void main(String[] args) {
A a = new A();
B b = new B();
a.setB(b);
b.setA(a);
System.out.println(a.toString());
System.out.println(b.toString());
}
}
class A {
private B b;
public B getB() {
return b;
}
public void setB(B b) {
this.b = b;
}
}
class B {
private A a;
public A getA() {
return a;
}
public void setA(A a) {
this.a = a;
}
}
debug的结果如下:
可以看到,a和b两个对象存在着循环引用,哪怕是后续将a,b都置为null,它们之间的循环关系依然存在,这样就会导致a,b永远不会被回收。
通过一系列称为GC Roots的根对象作为起始节点集,从这些节点开始,根据引用关系向下搜索,搜索过程所走过的路径称为“引用链”(Reference Chain),如果某个对象到GC Roots间没有任何引用链相连,就说明从GC Roots到这个对象不可达时,则证明此对象是不可能再被使用的,就是可以回收的对象。
如图所示:对象1-4因为有1在连接GC root,因此这些对象是可达的,而5-8没有任何对象与GC root相连,因此他们虽然互相有引用,但仍然是不可达的。
在JVM虚拟机中,可作为GC Roots的对象包括以下几种:
不可达的对象不是立马被回收,而是暂时处于“缓刑”阶段,要真正宣告一个对象死亡,至少要经历两次标记过程:
jvm在回收对象之前会先检查这个对象是否重写了finalize方法,以及这个finalize方法是否已经执行过,待这两个检查都执行完之后才会去清理这个对象,因此我们可以在对象回收之前即在finalize方法中处理一些东西,比如关闭文件、套接字和数据库连接等。
但是,子类重写finalize方法也不可避免的带来一些问题:
在jdk1.2之前,对象的引用只有两种状态:被引用和未被引用,1.2之后对对象的引用类型进行扩展,出现了强软弱虚四大引用类型,具体的可以参考我之前的文章:
Java的四大引用之强软弱虚
标记清除算法,是将垃圾回收分为2个阶段,分别是标记和清除。
标记:从根节点开始标记引用的对象。
清除:未被标记引用的对象就是垃圾对象,可以被清理。
标记清除法可以说是最基础的收集算法,因为后续的收集算法大多都是以标记-清除算法为基础,对其缺点进行改进而得到的。
缺点:
标记压缩算法是在标记清除算法的基础之上,做了优化改进的算法。和标记清除算法一样,也是从根节点开始,对对象的引用进行标记,在清理阶段,并不是简单的清理未标记的对象,而是将存活的对象压缩到内存的一端,然后清理边界以外的垃圾,从而解决了碎片化的问题。
该算法解决了标记清除算法的碎片化的问题,同时,标记压缩算法多了一步,对象移动内存位置的步骤,其效率也有一定的影响。
缺点:
● 效率不高:不仅要标记存活对象,还要整理所有存活对象的引用地址,在效率上不如复制算法。
复制算法的核心就是,将原有的内存空间一分为二,每次只用其中的一块,在垃圾回收时,将正在使用的对象复制到另一个内存空间中,然后将该内存空间清空,交换两个内存的角色,完成垃圾的回收。如果内存中的垃圾对象较多,需要复制的对象就较少,这种情况下适合使用该方式并且效率比较高,反之,则不适合。
优点:
缺点:
分代收集算法的思想是按对象的存活周期不同将内存划分为几块一般是把 Java 堆分为新生代和老年代(还有一个永久代,是 HotSpot 特有的实现,其他的虚拟机实现没有这一概念,永久代的收集效果很差,一般很少对永久代进行垃圾回收),这样就可以根据各个年代的特点采用最合适的收集算法,比如在jvm中,年轻代适合使用复制算法,老年代适合使用标记清除或标记压缩算法,具体的还要看相关的垃圾回收器采用哪种算法。
垃圾回收的相关概念:
1、部分收集(Partial GC)
2、整堆收集(Full GC)
接下来我将通过一个案例讲解一个对象object在分代垃圾回收中轨迹。
注意:在以上的新生代中,我们有提到对象的age,对象存活于survivor状态下,不会立即晋升为老年代对象,以避免给老年代造成过大的影响,它们必须要满足以下条件才可以晋升:
前面我们讲了垃圾回收的算法,还需要有具体的实现。在jvm中,实现了多种垃圾收集器,常见的十种垃圾回收器分别是:Serial、ParNew、ParallelScavenge、SerialOld、ParallelOld、CMS、G1、ZGC、Shenandoah、Epsilon。其中Epsilon是jdk11提出debug使用的,它只负责控制内存分配,但是不执行任何垃圾回收工作,因此本篇文章不再赘述。
这些垃圾回收器按照垃圾分代可分为以下几类:
按照串行和并行划分可分成以下几类:
串行:Serial、SerialOld
并行:ParNew、ParallelScavenge、ParallelOld、CMS
串行垃圾收集器,是指使用单线程进行垃圾回收,垃圾回收时,只有一个线程在工作,并且java应用中的所有线程都要暂停,等待垃圾回收的完成。这种现象称之为STW(Stop-The-World)。
并行垃圾收集器在串行垃圾收集器的基础之上做了改进,将单线程改为了多线程进行垃圾回收,这样可以缩短垃圾回收的时间。(这里是指,并行能力较强的机器)当然了,并行垃圾收集器在收集的过程中也会暂停应用程序,这个和串行垃圾回收器是一样的,只是并行执行,速度更快些,暂停的时间更短一些。
Serial收集器是最基本、历史最悠久的垃圾收集器了。JDK1.3之前回收新生代唯一的选择。
Serial收集器作为HotSpot中client模式下的默认新生代垃圾收集器,它采用复制算法、串行回收和"stop-the-World"机制的方式执行内存回收。
除了年轻代之外,Serial收集器还提供用于执行老年代垃圾收集的Serial Old收集器。Serial Old收集器同样也采用了串行回收和"Stop the World"机制,只不过内存回收算法使用的是标记压缩算法,与Serial一样,Serial Old也是是运行在Client模式下默认的老年代的垃圾回收器。
Serial Old在Server模式下主要有两个用途:
Serial收集器是一个单线程的收集器,但它的“单线程”的意义并不仅仅说明它只会使用一个CPU或一条收集线程去完成垃圾收集工作,更重要的是在它进行垃圾收集时,必须暂停其他所有的工作线程,直到它收集结束(Stop The World)
优势:简单而高效(与其他收集器的单线程比),对于限定单个CPU的环境来说,Serial收集器由于没有线程交互的开销,专心做垃圾收集自然可以获得最高的单线程收集效率。运行在Client模式下的虚拟机是个不错的选择。
在HotSpot虚拟机中,使用-XX:+UseSerialGC参数可以指定年轻代和老年代都使用串行收集器。等价于新生代用Serial GC,且老年代用Serial Old GC
总结:
Serial/SerialOld垃圾回收时图谱:
如果说Serial GC是年轻代中的单线程垃圾收集器,那么ParNew收集器则是Serial收集器的多线程版本。Par是Parallel的缩写,New:只能处理的是新生代
ParNew 收集器除了采用并行回收的方式执行内存回收外,两款垃圾收集器之间几乎没有任何区别。ParNew收集器在年轻代中同样也是采用复制算法、"Stop-the-World"机制,他是很多JVM运行在Server模式下新生代的默认垃圾收集器。
与ParallelScavenge的区别就是,ParNew能更好的和CMS配合使用,ParNew的响应时间优先,ParallelScavenge的吞吐量优先。
ParNew的优势:
在开发时我们可以通过选项"-XX:+UseParNewGC"手动指定使用ParNew收集器执行内存回收任务,需要注意的是它表示年轻代使用并行收集器,并不会影响老年代,除此之外,我们还可以通过-XX:ParallelGCThreads限制线程数量,默认开启和CPU数据相同的线程数。
不一定。
ParNew 收集器运行在多CPU的环境下,由于可以充分利用多CPU、多核心等物理硬件资源优势,可以更快速地完成垃圾收集,提升程序的吞吐量。
但是在单个CPU的环境下,ParNew收集器不比Serial 收集器更高效。虽然Serial收集器是基于串行回收,但是由于CPU不需要频繁地做任务切换,因此可以有效避免多线程交互过程中产生的一些额外开销。
HotSpot的年轻代中除了拥有ParNew收集器是基于并行回收的以外,Parallel Scavenge收集器同样也采用了复制算法、并行回收和"Stop the World"机制。
那么Parallel 收集器的出现是否多此一举?
和ParNew收集器不同,ParallelScavenge收集器的目标则是达到一个可控制的吞吐量(Throughput),它也被称为吞吐量优先的垃圾收集器。
自适应调节策略也是Parallel Scavenge与ParNew一个重要区别。
高吞吐量则可以高效率地利用CPU时间,尽快完成程序的运算任务,主要适合在后台运算而不需要太多交互的任务。因此,常见在服务器环境中使用。例如,那些执行批量处理、订单处理、工资支付、科学计算的应用程序。
Parallel 收集器在JDK1.6时提供了用于执行老年代垃圾收集的Parallel Old收集器,用来代替老年代的Serial Old收集器。Parallel Old收集器采用了标记压缩算法、并行回收和"Stop-the-World"机制。
Parallel垃圾回收时图谱:
在程序吞吐量优先的应用场景中,Parallel 收集器和Parallel Old收集器的组合,在Server模式下的内存回收性能很不错。在Java8中,默认是此垃圾收集器。
相关参数配置;
需要注意的是默认-XX:+UseParallelGC和-XX:+UseParallelOldGC参数开启一个,另一个也会被开启,即他们俩会互相激活。
在JDK1.5时期,Hotspot推出了一款在强交互应用中几乎可认为有划时代意义的垃圾收集器:CMS(Concurrent-Mark-Sweep)收集器,这款收集器是HotSpot虚拟机中第一款真正意义上的并发收集器,它第一次实现了让垃圾收集线程与用户线程同时工作。
CMS收集器的关注点是尽可能缩短垃圾收集时用户线程的停顿时间。停顿时间越短(低延迟)就越适合与用户交互的程序,良好的响应速度能提升用户体验。
目前很大一部分的Java应用集中在互联网站或者B/S系统的服务端上,这类应用尤其重视服务的响应速度,希望系统停顿时间最短,以给用户带来较好的体验。CMS收集器就非常符合这类应用的需求。
CMS的垃圾收集算法采用标记-清除算法,并且也会"Stop-the-World"。
不幸的是,CMS作为老年代的收集器,却无法与JDK1.4中已经存在的新生代收集器Parallel Scavenge配合工作,所以在JDK1.5中使用CMS来收集老年代的时候,新生代只能选择ParNew或者Serial收集器中的一个。
CMS垃圾回收图谱:
CMS整个过程比之前的收集器要复杂,整个过程分为6个主要阶段,即初始标记阶段、并发标记阶段、并发预清理阶段、重新标记阶段、并发清除阶段和并发重置阶段:
尽管CMS收集器采用的是并发回收(非独占式),但是在其初始化标记和再次标记这两个阶段中仍然需要执行“Stop-the-World”机制暂停程序中的工作线程,不过暂停时间并不会太长,因此可以说明目前所有的垃圾收集器都做不到完全不需要“stop-the-World”,只是尽可能地缩短暂停时间。
由于最耗费时间的并发标记与并发清除阶段都不需要暂停工作,所以整体的回收是低停顿的。
另外,由于在垃圾收集阶段用户线程没有中断,所以在CMS回收过程中,还应该确保应用程序用户线程有足够的内存可用。因此,CMS收集器不能像其他收集器那样等到老年代几乎完全被填满了再进行收集,而是当堆内存使用率达到某一阈值时,便开始进行回收,以确保应用程序在CMS工作过程中依然有足够的空间支持应用程序运行。要是CMS运行期间预留的内存无法满足程序需要,就会出现一次“Concurrent Mode Failure” 失败,这时虚拟机将启动后备预案:临时启用Serial Old收集器来重新进行老年代的垃圾收集,这样停顿时间就很长了。
CMS收集器的垃圾收集算法采用的是标记清除算法,这意味着每次执行完内存回收后,由于被执行内存回收的无用对象所占用的内存空间极有可能是不连续的一些内存块,不可避免地将会产生一些内存碎片。那么CMS在为新对象分配内存空间时,将无法使用指针碰撞(Bump the Pointer)技术,而只能够选择空闲列表(Free List)执行内存分配。
答案其实很简单,因为当并发清除的时候,用压缩算法整理内存的话,原来的用户线程使用的内存还怎么用呢?要保证用户线程能继续执行,前提的它运行的资源不受影响嘛。因此标记压缩算法更适合“Stop the World” 这种场景下使用。
优点:并发收集、低延迟
缺点:
-XX:+UseConcMarkSweepGC手动指定使用CMS收集器执行内存回收任务。开启该参数后会自动将-xx:+UseParNewGC打开。即:ParNew(Young区用)+CMS(Old区用)+ Serial Old的组合。
注意:CMS会在若干次垃圾回收之后进行一次碎片化的整理。
原因就在于应用程序所应对的业务越来越庞大、复杂,用户越来越多,没有GC就不能保证应用程序正常进行,而经常造成STW的GC又跟不上实际的需求,所以才会不断地尝试对GC进行优化。G1(Garbage-First)垃圾回收器是在Java7 update4之后引入的一个新的垃圾回收器,是当今收集器技术发展的最前沿成果之一。
与此同时,为了适应现在不断扩大的内存和不断增加的处理器数量,进一步降低暂停时间(pause time),同时兼顾良好的吞吐量。
官方给G1设定的目标是在延迟可控的情况下获得尽可能高的吞吐量,所以才担当起“全功能收集器”的重任与期望。
因为G1是一个并行回收器,它把堆内存分割为很多不相关的区域(Region)(物理上不连续的)。使用不同的Region来表示Eden、幸存者0区,幸存者1区,老年代等。
G1 GC有计划地避免在整个Java堆中进行全区域的垃圾收集。G1跟踪各个Region里面的垃圾堆积的价值大小(回收所获得的空间大小以及回收所需时间的经验值),在后台维护一个优先列表,每次根据允许的收集时间,优先回收价值最大的Region。
由于这种方式的侧重点在于回收垃圾最大量的区间(Region),所以我们给G1一个名字:垃圾优先(Garbage First)。
G1(Garbage-First)是一款面向服务端应用的垃圾收集器,主要针对配备多核CPU及大容量内存的机器,以极高概率满足GC停顿时间的同时,还兼具高吞吐量的性能特征。
在JDK1.7版本正式启用,移除了Experimenta1的标识,是JDK9以后的默认垃圾回收器,取代了CMS回收器以及Parallel+Parallel Old组合。被Oracle官方称为“全功能的垃圾收集器”。
与此同时,CMS已经在JDK9中被标记为废弃(deprecated)。在jdk8中还不是默认的垃圾回收器,需要使用-XX:+UseG1GC来启用。
使用G1收集器时,它将整个Java堆划分成约2048个大小相同的独立Region块,每个Region块大小根据堆空间的实际大小而定,整体被控制在1MB~32MB之间,且为2的N次幂,即1MB,2MB,4MB,8MB等等。可以通过-XX:G1HeapRegionSize设定。所有的Region大小相同,且在JVM生命周期内不会被改变。
虽然还保留有新生代和老年代的概念,但新生代和老年代不再是物理隔离的了,它们都是一部分Region(不需要连续)的集合。通过Region的动态分配方式实现逻辑上的连续。
一个region有可能属于Eden,Survivor或者Old/Tenured内存区域。但是一个region只可能属于一个角色。图中的E表示该region属于Eden内存区域,S表示属于survivor内存区域,O表示属于Old内存区域。图中空白的表示未使用的内存空间。
G1垃圾收集器还增加了一种新的内存区域,叫做Humongous内存区域,如图中的H块。主要用于存储大对象,如果一个对象超过0.5个region,G1收集器就认为这是一个巨型对象,这些巨型对象,默认直接会被分配在老年代,但是如果它是一个短期存在的巨型对象,就会对垃圾收集器造成负面影响。为了解决这个问题,G1划分了一个Humongous区,它用来专门存放巨型对象。如果一个H区装不下一个巨型对象,那么G1会寻找连续的H分区来存储。为了能找到连续的H区,有时候不得不启动Full GC。G1的大多数行为都把H区作为老年代的一部分来看待。
每个Region都是通过指针碰撞来分配空间:
与其他GC收集器相比,G1使用了全新的分区算法,其特点如下所示:
并行与并发
分代收集
空间整合
可预测的停顿时间模型(即:软实时soft real-time)
这是G1相对于CMS的另一大优势,G1除了追求低停顿外,还能建立可预测的停顿时间模型,能让使用者明确指定在一个长度为M毫秒的时间片段内,消耗在垃圾收集上的时间不得超过N毫秒。
相较于CMS,G1还不具备全方位、压倒性优势。比如在用户程序运行过程中,G1无论是为了垃圾收集产生的内存占用(Footprint)还是程序运行时的额外执行负载(Overload)都要比CMS要高。
从经验上来说,在小内存应用上CMS的表现大概率会优于G1,而G1在大内存应用上则发挥其优势。平衡点在6-8GB之间。
JVM启动时,G1先准备好Eden区,程序在运行过程中不断创建对象到Eden区,当Eden空间耗尽时,G1会启动一次年轻代垃圾回收过程。年轻代垃圾回收只会回收Eden区和Survivor区。
首先G1停止应用程序的执行(Stop-The-World),G1创建回收集(Collection Set),回收集是指需要被回收的内存分段的集合,年轻代回收过程的回收集包含年轻代Eden区和Survivor区所有的内存分段。
然后开始如下回收过程:
在GC年轻代的对象时,我们如何找到年轻代中对象的根对象呢?
根对象可能是在年轻代中,也可以在老年代中,那么老年代中的所有对象都是根么?
如果全量扫描老年代,那么这样扫描下来会耗费大量的时间。于是,G1引进了RSet的概念。它的全称是Remembered Set(记忆集),其作用是跟踪指向某个堆内的对象引用。
每个Region初始化时,会初始化一个RSet,该集合用来记录并跟踪其它Region指向该Region中对象的引用,每个Region默认按照512Kb划分成多个Card,所以RSet需要记录的东西应该是 xx Region的 xx Card。
当越来越多的对象晋升到老年代o1d region时,为了避免堆内存被耗尽,虚拟机会触发一个混合的垃圾收集器,即Mixed GC,该算法并不是一个Old GC,除了回收整个Young Region,还会回收一部分的Old Region。这里需要注意:是一部分老年代,而不是全部老年代。可以选择哪些Old Region进行收集,从而可以对垃圾回收的耗时时间进行控制。也要注意的是Mixed GC并不是Full GC。
并发标记结束以后,老年代中百分百为垃圾的内存分段被回收了,部分为垃圾的内存分段被计算了出来。默认情况下,这些老年代的内存分段会分8次(可以通过-XX:G1MixedGCCountTarget设置)被回收。
混合回收的回收集(Collection Set)包括八分之一的老年代内存分段,Eden区内存分段,Survivor区内存分段。混合回收的算法和年轻代回收的算法完全一样,只是回收集多了老年代的内存分段。具体过程请参考上面的年轻代回收过程。
由于老年代中的内存分段默认分8次回收,G1会优先回收垃圾多的内存分段。垃圾占内存分段比例越高的,越会被先回收。并且有一个阈值会决定内存分段是否被回收,-XX:G1MixedGCLiveThresholdPercent,默认为65%,意思是垃圾占内存分段比例要达到65%才会被回收。如果垃圾占比太低,意味着存活的对象占比高,在复制的时候会花费更多的时间。
混合回收并不一定要进行8次。有一个阈值-XX:G1HeapWastePercent,默认值为10%,意思是允许整个堆内存中有10%的空间被浪费,意味着如果发现可以回收的垃圾占堆内存的比例低于10%,则不再进行混合回收。因为GC会花费很多的时间但是回收到的内存却很少。
Mixed GC什么时候触发? 由参数 -XX:InitiatingHeapOccupancyPercent=n 决定。默认:45%,该参数的意思是:当老年代大小占整个堆大小百分比达到该阀值时触发。
Mixed GC发生时,会执行如下步骤:
全局并发标记:
拷贝存活对象:
Evacuation阶段是全暂停的。该阶段把一部分Region里的活对象拷贝到另一部分Region中,从而实现垃圾的回收清理。
G1的初衷就是要避免Full GC的出现。但是如果上述方式不能正常工作,G1会停止应用程序的执行(Stop-The-World),使用单线程的内存回收算法进行垃圾回收,性能会非常差,应用程序停顿时间会很长。
要避免Full GC的发生,一旦发生需要进行调整。什么时候会发生Full GC呢?比如堆内存太小,当G1在复制存活对象的时候没有空的内存分段可用,则会回退到Full GC,这种情况可以通过增大内存解决。
导致G1 Full GC的原因可能有两个:
G1的设计原则就是简化JVM性能调优,开发人员只需要简单的三步即可完成调优:
G1中提供了三种垃圾回收模式:Young GC、Mixed GC和Full GC,在不同的条件下被触发。
面向服务端应用,针对具有大内存、多处理器的机器。(在普通大小的堆里表现并不惊喜)
最主要的应用是需要低GC延迟,并具有大堆的应用程序提供解决方案;如:在堆大小约6GB或更大时,可预测的暂停时间可以低于0.5秒;(G1通过每次只清理一部分而不是全部的Region的增量式清理来保证每次GC停顿时间不会过长)。
用来替换掉JDK1.5中的CMS收集器;在下面的情况时,使用G1可能比CMS好:
HotSpot垃圾收集器里,除了G1以外,其他的垃圾收集器使用内置的JVM线程执行GC的多线程操作,而G1 GC可以采用应用线程承担后台运行的GC工作,即当JVM的GC线程处理速度慢时,系统会调用应用程序线程帮助加速垃圾回收过程。
年轻代大小
暂停时间目标不要太过严苛
Shenandoah作为第一款不由Oracle(包括以前的Sun)公司的虚拟机团队所领导开发的HotSpot垃圾收集器,不可避免地会受到一些来自“官方”的排挤。 Oracle明确拒绝在OracleJDK 12中支持Shenandoah收集器,并执意在打包OracleJDK时通过条件编译完全排除掉了Shenandoah的代码,换句话说, Shenandoah是一款只有OpenJDK才会包含,而OracleJDK里反而不存在的收集器, “免费开源版”比“收费商业版”功能更多,这是相对罕见的状况。如果读者的项目要求用到Oracle商业支持的话,就不得不把Shenandoah排除在选择范围之外了。
最初Shenandoah是由RedHat公司独立发展的新型收集器项目,在2014年RedHat把Shenandoah贡献给了OpenJDK,并推动它成为OpenJDK 12的正式特性之一,也就是后来的JEP 189。这个项目的目标是实现一种能在任何堆内存大小下都可以把垃圾收集的停顿时间限制在十毫秒以内的垃圾收集器,该目标意味着相比CMS和G1, Shenandoah不仅要进行并发的垃圾标记,还要并发地进行对象清理后的整理动作。
Shenandoah和G1非常类似,更像是对G1的升级改造,它们两者有着相似的堆内存布局,在初始标记、并发标记等许多阶段的处理思路上都高度一致,甚至还直接共享了一部分实现代码。
虽然Shenandoah也是使用基于Region的堆内存布局,同样有着用于存放大对象的Humongous Region,默认的回收策略也同样是优先处理回收价值最大的Region……但在管理堆内存方面,它与G1有三个明显的改进之处:
支持并发的整理算法, G1的回收阶段是可以多线程并行的,但却不能与用户线程并发,这点作为Shenandoah最核心的功能。
Shenandoah(目前)是默认不使用分代收集的,换言之,不会有专门的新生代Region或者老年代Region的存在,没有实现分代,并不是说分代对Shenandoah没有价值,这更多是出于性价比的权衡,基于工作量上的考虑而将其放到优先级较低的位置上。
Shenandoah摒弃了在G1中耗费大量内存和计算资源去维护的记忆集,改用名为“连接矩阵”(Connection Matrix)的全局数据结构来记录跨Region的引用关系,降低了处理跨代指针时的记忆集维护消耗,也降低了伪共享问题的发生概率。连接矩阵可以简单理解为一张二维表格,如果Region N有对象指向Region M,就在表格的N行M列中打上一个标记,如图所示,如果Region 5中的对象Object C引用了Region 3的Object B, Object B又引用了Region 1的Object A,那连接矩阵中的5行3列、 3行1列就应该被打上标记。在回收时通过这张表格就可以得出哪些Region之间产生了跨代引用。
Shenandoah收集器的工作过程大致可以划分为以下九个阶段(在最新版本的Shenandoah 2.0中,进一步强化了“部分收集”的特性,初始标记之前还有Initial Partial、 Concurrent Partial和Final Partial阶段,它们可以不太严谨地理解为对应于以前分代收集中的Minor GC的工作):
以上对Shenandoah收集器这九个阶段的工作过程的描述可能拆分得略为琐碎,我们只需要关注其中三个最重要的并发阶段(并发标记、并发回收、并发引用更新),就能比较容易理清Shenandoah是如何运作的了。
Shenandoah用以支持并行整理的核心概念——Brooks Pointer:
Rodney A.Brooks在论文《Trading Data Space for Reduced Time and Code Space in Real-Time Garbage Collection on Stock Hardware》中提出了使用转发指针(Forwarding Pointer,也常被称为Indirection Pointer)来实现对象移动与用户程序并发的一种解决方案。此前,要做类似的并发操作,通常是在被移动对象原有的内存上设置保护陷阱(Memory Protection Trap),一旦用户程序访问到归属于旧对象的内存空间就会产生自陷中段,进入预设好的异常处理器中,再由其中的代码逻辑把访问转发到复制后的新对象上。虽然确实能够实现对象移动与用户线程并发,但是如果没有操作系统层面的直接支持,这种方案将导致用户态频繁切换到核心态,代价是非常大的,不能频繁使用。
Brooks Pointers示意图:
Brooks提出的新方案不需要用到内存保护陷阱,而是在原有对象布局结构的最前面统一增加一个新的引用字段,在正常不处于并发移动的情况下,该引用指向对象自己。
从结构上来看, Brooks提出的转发指针与某些早期Java虚拟机使用过的句柄定位有一些相似之处,两者都是一种间接性的对象访问方式,差别是句柄通常会统一存储在专门的句柄池中,而转发指针是分散存放在每一个对象头前面。
有了转发指针之后,有何收益暂且不论,所有间接对象访问技术的缺点都是相同的,也是非常显著的——每次对象访问会带来一次额外的转向开销,尽管这个开销已经被优化到只有一行汇编指令的程度,例如以下所示:
mov r13,QWORD PTR [r12+r14*8-0x8]
不过,毕竟对象定位会被频繁使用到,这仍是一笔不可忽视的执行成本,只是它比起内存保护陷阱的方案已经好了很多。转发指针加入后带来的收益自然是当对象拥有了一份新的副本时,只需要修改一处指针的值,即旧对象上转发指针的引用位置,使其指向新对象,便可将所有对该对象的访问转发到新的副本上。这样只要旧对象的内存仍然存在,未被清理掉,虚拟机内存中所有通过旧引用地址访问的代码便仍然可用,都会被自动转发到新对象上继续工作。如图所示。
Brooks Pointers示意图:
需要注意, Brooks形式的转发指针在设计上决定了它是必然会出现多线程竞争问题的,如果收集器线程与用户线程发生的只是并发读取,那无论读到旧对象还是新对象上的字段,返回的结果都应该是一样的,这个场景还可以有一些“偷懒”的处理余地;但如果发生的是并发写入,就一定必须保证写操作只能发生在新复制的对象上,而不是写入旧对象的内存中。读者不妨设想以下三件事情并发进行时的场景:
如果不做任何保护措施,让事件2在事件1、事件3之间发生的话,将导致的结果就是用户线程对对象的变更发生在旧对象上,所以这里必须针对转发指针的访问操作采取同步措施,让收集器线程或者用户线程对转发指针的访问只有其中之一能够成功,另外一个必须等待,避免两者交替进行。实际上Shenandoah收集器是通过比较并交换(Compare And Swap, CAS)操作来保证并发时对象的访问正确性的。
转发指针另一点必须注意的是执行频率的问题,尽管通过对象头上的Brooks Pointer来保证并发时原对象与复制对象的访问一致性,这件事情只从原理上看是不复杂的,但是“对象访问”这四个字的分量是非常重的,对于一门面向对象的编程语言来说,对象的读取、写入,对象的比较,为对象哈希值计算,用对象加锁等,这些操作都属于对象访问的范畴,它们在代码中比比皆是,要覆盖全部对象访问操作, Shenandoah不得不同时设置读、写屏障去拦截。
之前介绍其他收集器时,或者是用于维护卡表,或者是用于实现并发标记,写屏障已被使用多次,累积了不少的处理任务了,这些写屏障有相当一部分在Shenandoah收集器中依然要被使用到。除此以外,为了实现Brooks Pointer, Shenandoah在读、写屏障中都加入了额外的转发处理,尤其是使用读屏障的代价,这是比写屏障更大的。代码里对象读取的出现频率要比对象写入的频率高出很多,读屏障数量自然也要比写屏障多得多,所以读屏障的使用必须更加谨慎,不允许任何的重量级操作。Shenandoah是本书中第一款使用到读屏障的收集器,它的开发者也意识到数量庞大的读屏障带来的性能开销会是Shenandoah被诟病的关键点之一,所以计划在JDK 13中将Shenandoah的内存屏障模型改进为基于引用访问屏障(Load Reference Barrier) 的实现,所谓“引用访问屏障”是指内存屏障只拦截对象中数据类型为引用类型的读写操作,而不去管原生数据类型等其他非引用字段的读写,这能够省去大量对原生类型、对象比较、对象加锁等场景中设置内存屏障所带来的消耗。
Shenandoah性能测试网上报告不一,在此笔者选择展示了一份RedHat官方在2016年所发表的Shenandoah实现论文中给出的应用实测数据,测试内容是使用ElasticSearch对200GB的维基百科数据进行索引。
如图所示。从结果来看,应该说2016年做该测试时的Shenandoah并没有完全达成预定目标,停顿时间比其他几款收集器确实有了质的飞跃,但也并未实现最大停顿时间控制在十毫秒以内的目标,而吞吐量方面则出现了很明显的下降,其总运行时间是所有测试收集器中最长的。读者可以从这个官方的测试结果来对Shenandoah的弱项(高运行负担使得吞吐量下降)和强项(低延迟时间)建立量化的概念,并对比一下稍后介绍的ZGC的测试结果。
ZGC(The Z Garbage Collector)是一款在JDK 11中新加入的具有实验性质的低延迟垃圾收集器,是由Oracle公司研发的。
ZGC的目标是希望在尽可能对吞吐量影响不太大的前提下,实现在任意堆内存大小下都可以把垃圾收集的停顿时间限制在10毫秒以内的低延迟。
它的设计目标包括:
ZGC的内存布局与G1一样,也采用基于Region的堆内存布局,但不同的是,ZGC的Page(ZGC中称之为页面,道理和Region一样)具有动态性——动态创建和销毁,以及动态的区域容量大小。在x64硬件平台下,ZGC的Page可以具有大、中、小三类容量:
需要注意的是:每个大页面中只会存放一个大对象,这也预示着虽然名字叫作“大型Page”,但它的实际容量完全有可能小于中型Page,最小容量可低至4MB。
大型Page在ZGC的实现中是不会被重分配(重分配是ZGC的一种处理动作)的,因为复制一个大对象的代价非常高昂。
ZGC的运作过程大致可划分为以下四个大的阶段。全部四个阶段都是可以并发执行的,仅是两个阶段中间会存在短暂的停顿小阶段,这些小阶段,例如初始化GC Root直接关联对象的Mark Start,与之前G1和Shenandoah的Initial Mark阶段并没有什么差异,笔者就不再单独解释了。 ZGC的运作过程具体如图所示。
染色指针是一种直接将少量额外的信息存储在指针上的技术,可是为什么指针本身也可以存储额外信息呢?在64位系统中,理论可以访问的内存高达16EB(2的64次幂)字节。实际上,基于需求(用不到那么多内存)、性能(地址越宽在做地址转换时需要的页表级数越多)和成本(消耗更多晶体管)的考虑,在AMD64架构中只支持到52位(4PB)的地址总线和48位(256TB)的虚拟地址空间,所以目前64位的硬件实际能够支持的最大内存只有256TB。此外,操作系统一侧也还会施加自己的约束, 64位的Linux则分别支持47位(128TB)的进程虚拟地址空间和46位(64TB)的物理地址空间, 64位的Windows系统甚至只支持44位(16TB)的物理地址空间。
尽管Linux下64位指针的高18位不能用来寻址,但剩余的46位指针所能支持的64TB内存在今天仍然能够充分满足大型服务器的需要。因此, ZGC的染色指针技术继续盯上了这剩下的46位指针宽度,将其高4位提取出来存储四个标志信息。通过这些标志位,虚拟机可以直接从指针中看到其引用对象的三色标记状态、是否进入了重分配集(即被移动过)、是否只能通过finalize()方法才能被访问到,如图所示。当然,由于这些标志位进一步压缩了原本就只有46位的地址空间,也直接导致ZGC能够管理的内存不可以超过4TB(2的42次幂) 。
使用染色指针的好处:
虽然染色指针有4TB的内存限制,不能支持32位平台,不能支持压缩指针(-XX:
+UseCompressedOops)等诸多约束,但它带来的收益也是非常可观的。染色指针主要有三大优势:
上面经常提到读屏障,那么到底什么是读屏障呢?
读屏障是JVM向应用代码插入一小段代码的技术。当应用线程从堆中读取对象引用时,就会执行这段代码。需要注意的是,仅“从堆中读取对象引用”才会触发这段代码。
读屏障示例:
Object o = obj.FieldA // 从堆中读取引用,需要加入屏障
Object p = o // 无需加入屏障,因为不是从堆中读取引用
o.dosomething() // 无需加入屏障,因为不是从堆中读取引用
int i = obj.FieldB //无需加入屏障,因为不是对象引用
ZGC中读屏障的代码作用:在对象标记和转移过程中,用于确定对象的引用地址是否满足条件,并作出相应动作。
下图1和下图2是ZGC与Parallel Scavenge、 G1三款收集器通过SPECjbb 2015的测试结果。在ZGC的“弱项”吞吐量方面,以低延迟为首要目标的ZGC已经达到了以高吞吐量为目标Parallel Scavenge的99%,直接超越了G1。如果将吞吐量测试设定为面向SLA(Service Level Agreements)应用的“Critical Throughput”的话, ZGC的表现甚至还反超了Parallel Scavenge收集器。
ZGC的吞吐量测试:
而在ZGC的强项停顿时间测试上,它就毫不留情地与Parallel Scavenge、 G1拉开了两个数量级的差距。不论是平均停顿,还是95%停顿、 99%停顿、 99.9%停顿,抑或是最大停顿时间, ZGC均能毫不费劲地控制在十毫秒之内,以至于把它和另外两款停顿数百近千毫秒的收集器放到一起对比,就几乎显示不了ZGC的柱状条(图3-24a),必须把结果的纵坐标从线性尺度调整成对数尺度(图2-b,纵坐标轴的尺度是对数增长的)才能观察到ZGC的测试结果。
ZGC的停顿时间测试:
在jdk11下,只能在linux 64位的平台上使用ZGC,如果想要在Windows下使用ZGC就需要升级jdk到14了,下面参数是以jdk11为例:
不同厂商、不同版本的虚拟机实现差距比较大,目前市面上主流的虚拟机仍然是HotSpot,它在JDK7/8后所有收集器及组合如下图所示:
垃圾收集器 | 分类 | 作用区域 | 使用算法 | 特点 | 适用场景 | 整理过程 |
---|---|---|---|---|---|---|
Serial | 串行 | 新生代 | 复制算法 | 响应速度优先 | 适用于单CPU环境下的client模式 | STW后,单线程将存活对象复制到另一块内存中 |
Serial Old | 串行 | 老年代 | 标记压缩算法 | 响应速度优先 | 适用于单CPU环境下的Client模式 | STW后,单线程清除对象,并将存活对象压缩 |
ParNew | 并行 | 新生代 | 复制算法 | 响应速度优先 | 多CPU环境Server模式下与CMS配合使用 | STW后,多线程将存活对象复制到另一块内存中 |
Parallel | 并行 | 新生代 | 复制算法 | 吞吐量优先 | 适用于后台运算而不需要太多交互的场景 | STW后,多线程将存活对象复制到另一块内存中 |
Parallel Old | 并行 | 老年代 | 标记压缩算法 | 吞吐量优先 | 适用于后台运算而不需要太多交互的场景 | STW后,多线程清除对象,并将存活对象压缩 |
CMS | 并行 | 老年代 | 标记清除算法 | 响应速度优先 | 适用于互联网或B/S业务 | 第一次标记GC Roots时会STW,然后并发标记所有对象,再并行补充标记这一段时间发生变动的对象,最后并发清理所有需要清理的对象 |
G1 | 并发并行 | 全堆 | 标记压缩算法、复制算法 | 响应速度优先 | 面向服务端应用 | 年轻代:并发扫描根,更新纠正根,计算regio的card确定存活对象,复制存活对象。老年代:标记根节点可达对象(STW),并发标记对象,补充修正一次再清理(清理时会STW) |
Shenandoah | 并发并行 | 全堆 | 标记压缩算法、复制算法 | 响应速度优先 | 面向服务端应用 | STW后标记GC roots,并发标记对象,补充修正一次,并发回收对象,并发更新对象的引用,并发更新GC Roots(STW),并发清理对象 |
ZGC | 并发并行 | 全堆 | 标记压缩算法、复制算法 | 响应速度优先、高吞吐 | 更适用于大级别堆 | STW后利用染色指针技术标记GC roots,并发标记对象,补充修正一次,计算需要回收的region,将要回收的region的存活对象复制到新的region,修正染色指针的指向(自愈,如果没有修正,第一次访问时会被自动修正) |
Java垃圾收集器的配置对于JVM优化来说是一个很重要的选择,选择合适的垃圾收集器可以让JVM的性能有一个很大的提升。那么我们应该如何选择合适的垃圾收集器?
最后需要注意的是:每款垃圾回收器都有自己的优势和缺点,要根据自己项目的需要和要求选择适合自己的收集器,调优只是在特定场景特定需求下进行的,今天的最优解不一定是明天的最优解,也不存在一劳永逸的收集器。