JVM知识分享(PPT在资源里)

一、前言

1.自动内存管理

有句经典的话是这样说,Java与C++之间有一堵由内存动态分配和垃圾收集技术所围成的高墙,墙外面的人想进去,墙里面的人却想出来。对于Java程序员来说,在虚拟机自动内存管理机制的帮助下,不再需要为每一个new操作去写配对的delete/free代码,不容易出现内存泄漏和内存溢出问题,看起来由虚拟机管理内存一切都很美好。不 过,也正是因为Java程序员把控制内存的权力交给了Java虚拟机,一旦出现内存泄漏和溢出方面的问 题,如果不了解虚拟机是怎样使用内存的,那排查错误、修正问题将会成为一项异常艰难的工作。

今天主要分享的JVM垃圾回收相关的知识,既然有自动内存管理,那就有垃圾回收,关于垃圾回收其实就是三件事,也是我今天要分享的三件事。

二、正文

1、哪些内存需要回收?

2.、什么时候回收?

3、如何回收?

第一件事,哪些内存需要回收?

其实答案很简单,就是垃圾即不存活的对象需要回收。那是不是很简单?就这结束了?

其实这件事有意思的点在于,如果判断对象是垃圾?

Java用的是什么?可达性分析算法。此算法的思路通过一系列称为"GCRoots"的根对象作为起始节点集,从这些节点开始,根据引用关系向下搜索,搜索过程所走过的路径称为"引用链"(ReferenceChain),如果某个对象到GCRoots间没有任何引用链相连,或者用图论的话来说就是从GCRoots到这个对象不可达时,则证明此对象是不可能再被使用的。

除了可达性分析算法,有其他常见算法?引用计数法。在对象中添加一个引用计数器,每当有一个地方

引用它时,计数器值就加一;当引用失效时,计数器值就减一;任何时刻计数器为零的对象就是不可

能再被使用的。

为什么选择可达性分析算法?或者换句话说,可达性分析算法有什么好处?引用计数法有什么缺点?

对于此问题的答案,八股文的回答循环引用,无法解决。如果两个对象再无任何引用,但是它们因为互相引用着对方,导致它们的引用计数都不为零,引用计数算法也就无法回收它们。那Python呢?用的是引用计算算法,是不是也有此问题无法解决?其实通过更高级的引用计数实现可以解决。

其实基于引用计数与基于trace这两大类别的自动内存管理方式最大的不同之处在于:引用计数只需要局部信息,而基于trace需要全局信息。

在内存充裕的前提下,tracingGC的整体开销比引用计数方式更低一些,所以吞吐量(throughput)高一些。因为引用计数方式通常需要统计冗余的局部信息,而tracingGC则可以通过全局信息一口气批量判断对象的生死;如果是带整理的tracingGC,则其内存分配通常也会更快。

不过tracingGC通常会比引用计数方式的延迟(latency)大一些,而且内存越紧张的时候tracingGC的效率反而越低,所以在内存不太充裕的地方使用引用计数仍然是个合理的选择(例如iOS5上的ARC)。我这边引用的是R哥的回答,感兴趣的话,可以自己查相关资料,详细了解。

既然Java用的是可达性算法,那必然会有两个缺点?是那两个缺点呢?一是要延迟释放内存,而不是在一个对象引用计数为0时立即释放内存。二是stoptheworld,根节点枚举这一步骤时都是必须暂停用户线程的,从JDK1.3开始,一直到现在最新的JDK13,HotSpot虚拟机开发团队为消除或者降低用户线程因垃圾收集而导致停顿的努力一直持续进行着,从Serial收集器到Parallel收集器,再到ConcurrentMarkSweep(CMS)和GarbageFirst(G1)收集器,最终至现在垃圾收集器的最前沿成果Shenandoah和ZGC等,我们看到了一个个越来越构思精巧,越来越优秀,也越来越复杂的垃圾收集器不断涌现,用户线程的停顿时间在持续缩短,ZGC据说停顿不超过10ms,,但是仍然没有办法彻底消除。

那在Java技术体系里面,哪些对象可以作为GCRoots的呢?基本上是分两类,一类是固定的,另一类是动态的。

固定的有在虚拟机栈(栈帧中的本地变量表)中引用的对象,譬如各个线程被调用的方法堆栈中使用到的参数、局部变量、临时变量等;

在方法区中类静态属性引用的对象,譬如Java类的引用类型静态变量。

在方法区中常量引用的对象,譬如字符串常量池(StringTable)里的引用。

在本地方法栈中JNI(即通常所说的Native方法)引用的对象。·Java虚拟机内部的引用,如基本数据类型对应的Class对象,一些常驻的异常对象(比如NullPointExcepiton、OutOfMemoryError)等,还有系统类加载器。·所有被同步锁(synchronized关键字)持有的对象。

反映Java虚拟机内部情况的JMXBean、JVMTI中注册的回调、本地代码缓存等。

除了这些固定的GCRoots集合以外,根据用户所选用的垃圾收集器以及当前回收的内存区域不同,还可以有其他对象"临时性"地加入,共同构成完整GCRoots集合。譬如后文将会提到的分代收集和局部回收(PartialGC),如果只针对Java堆中某一块区域发起垃圾收集时(如最典型的只针对新生代的垃圾收集),必须考虑到内存区域是虚拟机自己的实现细节(在用户视角里任何内存区域都是不可见的),更不是孤立封闭的,所以某个区域里的对象完全有可能被位于堆中其他区域的对象所引用,说白了就是年轻代可能引用老年代的,这时候就需要将这些关联区域的对象也一并加入GCRoots集合中去,才能保证可达性分析的正确性。

在CMS之前,最多有并行回收,没有并发回收,即进行垃圾收集时,必须暂停其他所有工作线程,直到它收集结束。这里解释下在垃圾收集器语境里,并行和并发的概念。

并行(Parallel):并行描述的是多条垃圾收集器线程之间的关系,说明同一时间有多条这样的线程在协同工作,通常默认此时用户线程是处于等待状态。

并发(Concurrent):并发描述的是垃圾收集器线程与用户线程之间的关系,说明同一时间垃圾收集器线程与用户线程都在运行。由于用户线程并未被冻结,所以程序仍然能响应服务请求,但由于垃圾收集器线程占用了一部分系统资源,此时应用程序的处理的吞吐量将受到一定影响。

例如原来的单线程回收(Serial+ParNew),到多线程并行回收(Parallel Scavenge+Parallel Old),他们回收就比较简单,垃圾线程运行时,StopTheWorld,用户线程被冻结,只不过一个是单线程,一个是多线程。"StopTheWorld"这个词语也许听起来很酷,但这项工作是由虚拟机在后台自动发起和自动完成的,在用户不可知、不可控的情况下把用户的正常工作的线程全部停掉,这对很多应用来说都是不能接受的,例如这次channelmanger的问题。早期HotSpot虚拟机的设计者们表示完全理解,但也同时表示非常委屈:"你妈妈在给你打扫房间的时候,肯定也会让你老老实实地在椅子上或者房间外待着,如果她一边打扫,你一边乱扔纸屑,这房间还能打扫完?"这也是CMS研究那么长的时间原因。那CMS之后包括CMS,G1是怎么实现可达性分析的?

这就设计到三色标记了。所谓三色标记,是把遍历对象图过程中遇到的对象,按照"是否访问过"这个条件标记成以下三种颜色。

白色:表示对象尚未被垃圾收集器访问过。显然在可达性分析刚刚开始的阶段,所有的对象都是白色的,若在分析结束的阶段,仍然是白色的对象,即代表不可达。

黑色:表示对象已经被垃圾收集器访问过,且这个对象的所有引用都已经扫描过。黑色的对象代表已经扫描过,它是安全存活的,如果有其他对象引用指向了黑色对象,无须重新扫描一遍。黑色对象不可能直接(不经过灰色对象)指向某个白色对象。

灰色:表示对象已经被垃圾收集器访问过,但这个对象上至少存在一个引用还没有被扫描过。

三色标记大家已经了解,但是三色标记会产生两个问题,是那两个问题?

一种错标,是把原本消亡的对象错误标记为存活,如图所示。另一种是漏标,如图所示。错标其实还好是可以容忍的,只不过产生了一点逃过本次收集的浮动垃圾而已,下次收集清理掉就好,这也是CMS,G1会产生浮动垃圾的原因。漏标,是无法接受的,会产生非常严重的后果。那错标怎么解决呢?首先,要知道什么条件下回产生错标问题。关于这问题,已经证明在理论上证明了,当且仅当以下两个条件同时满足时,会产生"对象消失"的问题,即原本应该是黑色的对象被误标为白色:

一是赋值器插入了一条或多条从黑色对象到白色对象的新引用;

二是赋值器删除了全部从灰色对象到该白色对象的直接或间接引用。

因此,我们要解决并发扫描时的对象消失问题,只需破坏这两个条件的任意一个即可。由此分别产生了两种解决方案:增量更新(Incremental Update)和原始快照(Snapshot At The Beginning,SATB)。

首先是增量更新,增量更新要破坏的是第一个条件,当黑色对象插入新的指向白色对象的引用关系时,就将这个新插入的引用记录下来,等并发扫描结束之后,再将这些记录过的引用关系中的黑色对象为根,重新扫

描一次。

CMS关注新增,但是会把新增对象的前一个引用变为灰色,重新对所有子对象进行遍历,有一定性能损耗。G1引用的打破,不需要重新遍历对象,因此效率较高。但是维护快照,以及其保守策略增加了一定的内存成本。当然这里面有很多细节,例如G1的增量更新是存在那里?感兴趣的可以自行查资料。

下面讲第二件事,什么时候回收?

主要分两个,一是主动显示回收,System.gc()(通知jvm进行一次垃圾回收,具体执行还要看JVM。另一个是自动隐式回收,当任何某一个区域不够时,就会进行GC。可以看下图,图里有个Virtual,暂时可以忽略,在JVM内部,如果Xms小于Xmx,堆的大小并不会直接扩展至其上限,也就是说保留的空间(reserved)大于实际能够使用的空间(committed)。当内存需求不断增大时,JVM会扩展,所以Virtual区是暂时不使用区域。但是咱们一直设置的最大堆大小和最小堆大小是一样的,所以是没有Virtual区的。

从抽象逻辑上方法区确实是堆的一部分, 虚拟机规范里面说了方法区属于堆,但是它有一个别名叫作"非堆"(Non-Heap)。

虚拟机规范:Chapter 2. The Structure of the Java Virtual Machine

从物理实现上,简单来看,Hotspot实现方法区,1.8以前是永久代,1.8是matespace。1.6是堆+永久代,1.7堆+永久代,1.8 堆+metaspace。最大区别metaspace是直接内存,永久代是jvm内存,1.7移动interned Strings 和 class statics 到堆,详情见:JEP 122: Remove the Permanent Generation

Eden满了,不能分配新的对象的了,会进行新生代收集,称之为Minor GC/Young GC。

老年代满了,会进行老年代收集,称之为Major GC/Old GC。目前只有CMS收集器会有单独收集老年代的行为。另外请注意"Major GC"这个说法现在有点混淆,在不同资料上常有不同所指, 读者需按上下文区分到底是指老年代的收集还是整堆收集。

G1收集器会有混合收集,收集整个新生代以及部分老年代,称之Mixed GC。

整堆收集(Full GC):收集整个Java堆和方法区的垃圾收集。

现在可以打开普罗米修斯看下相关指标,

https://metrics.xtadmins.com/d/vLl76-x4k/jvm-micrometer?orgId=1

最后讲第三件,如何回收?

前面讲了Java主流的虚拟机用的都是"追踪式垃圾收集"(Tracing GC),下面介绍的所有算法均属于追踪式垃圾收集的范畴。

标记-清除算法

最早出现也是最基础的垃圾收集算法是"标记-清除"(Mark-Sweep)算法,在1960年由Lisp之父John McCarthy所提出。如它的名字一样,算法分为"标记"和"清除"两个阶段:首先标记出所有需要回收的对象,在标记完成后,统一回收掉所有被标记的对象,也可以反过来,标记存活的对象,统一回收所有未被标记的对象。标记过程就是对象是否属于垃圾的判定过程,这在前一节讲述垃圾对象标记判定算法时其实已经介绍过了。

之所以说它是最基础的收集算法,是因为后续的收集算法大多都是以标记-清除算法为基础,对其缺点进行改进而得到的。它的主要缺点有两个:第一个是执行效率不稳定,如果Java堆中包含大量对象,而且其中大部分是需要被回收的,这时必须进行大量标记和清除的动作,导致标记和清除两个过程的执行效率都随对象数量增长而降低;第二个是内存空间的碎片化问题,标记、清除之后会产生大量不连续的内存碎片,空间碎片太多可能会导致当以后在程序运行过程中需要分配较大对象时无法找到足够的连续内存而不得不提前触发另一次垃圾收集动作。

标记-复制算法

标记-复制算法常被简称为复制算法。为了解决标记-清除算法面对大量可回收对象时执行效率低的问题,1969年Fenichel提出了一种称为"半区复制"(Semispace Copying)的垃圾收集算法,它将可用内存按容量划分为大小相等的两块,每次只使用其中的一块。当这一块的内存用完了,就将还存活着的对象复制到另外一块上面,然后再把已使用过的内存空间一次清理掉。如果内存中多数对象都是存活的,这种算法将会产生大量的内存间复制的开销,但对于多数对象都是可回收的情况,算法需要复制的就是占少数的存活对象,而且每次都是针对整个半区进行内存回收,分配内存时也就不用考虑有空间碎片的复杂情况,只要移动堆顶指针,按顺序分配即可。这样实现简单,运行高效,不过其缺陷也显而易见,这种复制回收算法的代价是将可用内存缩小为了原来的一半,空间浪费未免太多了一点。

现在的商用Java虚拟机大多都优先采用了这种收集算法去回收新生代,IBM公司曾有一项专门研究对新生代"朝生夕灭"的特点做了更量化的诠释------新生代中的对象有98%熬不过第一轮收集。因此并不需要按照1∶1的比例来划分新生代的内存空间。

在1989年,Andrew Appel针对具备"朝生夕灭"特点的对象,提出了一种更优化的半区复制分代策略,现在称为"Appel式回收"。HotSpot虚拟机的Serial、ParNew等新生代收集器均采用了这种策略来设计新生代的内存布局[1]。Appel式回收的具体做法是把新生代分为一块较大的Eden空间和两块较小的Survivor空间,每次分配内存只使用Eden和其中一块Survivor。发生垃圾搜集时,将Eden和Survivor中仍然存活的对象一次性复制到另外一块Survivor空间上,然后直接清理掉Eden和已用过的那块Survivor空间。HotSpot虚拟机默认Eden和Survivor的大小比例是8∶1,也即每次新生代中可用内存空间为整个新生代容量的90%(Eden的80%加上一个Survivor的10%),只有一个Survivor空间,即10%的新生代是会被"浪费"的。当然,98%的对象可被回收仅仅是"普通场景"下测得的数据,任何人都没有办法百分百保证每次回收都只有不多于10%的对象存活,因此Appel式回收还有一个充当罕见情况的"逃生门"的安全设计,当Survivor空间不足以容纳一次MinorGC之后存活的对象时,就需要依赖其他内存区域(实际上大多就是老年代)进行分配担保(Handle Promotion)。

标记-整理算法

标记-复制算法在对象存活率较高时就要进行较多的复制操作,效率将会降低。更关键的是,如果不想浪费50%的空间,就需要有额外的空间进行分配担保,以应对被使用的内存中所有对象都100%存活的极端情况,所以在老年代一般不能直接选用这种算法。

针对老年代对象的存亡特征,1974年Edward Lueders提出了另外一种有针对性的"标记-整理"(Mark-Compact)算法,其中的标记过程仍然与"标记-清除"算法一样,但后续步骤不是直接对可回收对象进行清理,而是让所有存活的对象都向内存空间一端移动,然后直接清理掉边界以外的内存,"标记-整理"算法的示意图如图3-4所示。

如果移动存活对象,尤其是在老年代这种每次回收都有大量对象存活区域,移动存活对象并更新所有引用这些对象的地方将会是一种极为负重的操作,而且这种对象移动操作必须全程暂停用户应用程序才能进行[1],这就更加让使用者不得不小心翼翼地权衡其弊端了,像这样的停顿被最初的虚拟机设计者形象地描述为"Stop The World"[2]。但如果跟标记-清除算法那样完全不考虑移动和整理存活对象的话,弥散于堆中的存活对象导致的空间碎片化问题就只能依赖更为复杂的内存分配器和内存访问器来解决。譬如通过"分区空闲分配链表"来解决内存分配问题(计算机硬盘存储大文件就不要求物理连续的磁盘空间,能够在碎片化的硬盘上存储和访问就是通过硬盘分区表实现的)。内存的访问是用户程序最频繁的操作,甚至都没有之一,假如在这个环节上增加了额外的负担,势必会直接影响应用程序的吞吐量。

基于以上两点,是否移动对象都存在弊端,移动则内存回收时会更复杂,不移动则内存分配时会更复杂。从垃圾收集的停顿时间来看,不移动对象停顿时间会更短,甚至可以不需要停顿,但是从整个程序的吞吐量来看,移动对象会更划算。此语境中,吞吐量的实质是赋值器(Mutator,可以理解为使用垃圾收集的用户程序,本书为便于理解,多数地方用"用户程序"或"用户线程"代替)与收集器的效率总和。即使不移动对象会使得收集器的效率提升一些,但因内存分配和访问相比垃圾收集频率要高得多,这部分的耗时增加,总吞吐量仍然是下降的。HotSpot虚拟机里面关注吞吐量的Parallel Scavenge收集器是基于标记-整理算法的,而关注延迟的CMS收集器则是基于标记-清除算法的,这也从侧面印证这点。

另外,还有一种"和稀泥式"解决方案可以不在内存分配和访问上增加太大额外负担,做法是让虚拟机平时多数时间都采用标记-清除算法,暂时容忍内存碎片的存在,直到内存空间的碎片化程度已经大到影响对象分配时,再采用标记-整理算法收集一次,以获得规整的内存空间。前面提到的基于标记-清除算法的CMS收集器面临空间碎片过多时采用的就是这种处理办法

既然要讲G1,那得先讲CMS,G1是从CMS改进过来的。CMS是一个划时代的产物,虽然他有很多缺点,但是他开辟了并发回收的先河。在讲CMS垃圾回收之前,需要强调下,CMS只进行老年代的回收,新生代还是Serial或者ParNew,打开普罗指示一下。

CMS(Concurrent Mark Sweep)

CMS(Concurrent Mark Sweep)收集器是一种以获取最短回收停顿时间为目标的收集器。目前很大一部分的Java应用集中在互联网网站或者基于浏览器的B/S系统的服务端上,这类应用通常都会较为关注服务的响应速度,希望系统停顿时间尽可能短,以给用户带来良好的交互体验。CMS收集器就非常符合这类应用的需求。

从名字(包含"Mark Sweep")上就可以看出CMS收集器是基于标记-清除算法实现的,它的运作过程相对于前面几种收集器来说要更复杂一些,整个过程分为四个步骤,包括:

1)初始标记(CMS initial mark)

2)并发标记(CMS concurrent mark)

3)重新标记(CMS remark)

4)并发清除(CMSconcurrent sweep)

其中初始标记、重新标记这两个步骤仍然需要"Stop The World"。初始标记仅仅只是标记一下GC Roots能直接关联到的对象,速度很快;并发标记阶段就是从GC Roots的直接关联对象开始遍历整个对象图的过程,这个过程耗时较长但是不需要停顿用户线程,可以与垃圾收集线程一起并发运行;而重新标记阶段则是为了修正并发标记期间,因用户程序继续运作而导致标记产生变动的那一部分对象的标记记录(详见关于增量更新的讲解),这个阶段的停顿时间通常会比初始标记阶段稍长一些,但也远比并发标记阶段的时间短;最后是并发清除阶段,清理删除掉标记阶段判断的已经死亡的对象,由于不需要移动存活对象,所以这个阶段也是可以与用户线程同时并发的。

由于在整个过程中耗时最长的并发标记和并发清除阶段中,垃圾收集器线程都可以与用户线程一起工作,所以从总体上来说,CMS收集器的内存回收过程是与用户线程一起并发执行的。通过图可以比较清楚地看到CMS收集器的运作步骤中并发和需要停顿的阶段。

首先,CMS收集器对处理器资源非常敏感。事实上,面向并发设计的程序都对处理器资源比较敏感。在并发阶段,它虽然不会导致用户线程停顿,但却会因为占用了一部分线程(或者说处理器的计算能力)而导致应用程序变慢,降低总吞吐量。CMS默认启动的回收线程数是(处理器核心数量+3)/4,也就是说,如果处理器核心数在四个或以上,并发回收时垃圾收集线程只占用不超过25%的处理器运算资源,并且会随着处理器核心数量的增加而下降。但是当处理器核心数量不足四个时,CMS对用户程序的影响就可能变得很大。如果应用本来的处理器负载就很高,还要分出一半的运算能力去执行收集器线程,就可能导致用户程序的执行速度忽然大幅降低。为了缓解这种情况,虚拟机提供了一种称为"增量式并发收集器"(Incremental Concurrent Mark Sweep/i-CMS)的CMS收集器变种,所做的事情和以前单核处理器年代PC机操作系统靠抢占式多任务来模拟多核并行多任务的思想一样,是在并发标记、清理的时候让收集器线程、用户线程交替运行,尽量减少垃圾收集线程的独占资源的时间,这样整个垃圾收集的过程会更长,但对用户程序的影响就会显得较少一些,直观感受是速度变慢的时间更多了,但速度下降幅度就没有那么明显。实践证明增量式的CMS收集器效果很一般,从JDK7开始,i-CMS模式已经被声明为"deprecated",即已过时不再提倡用户使用,到JDK9发布后i-CMS模式被完全废弃。

然后,由于CMS收集器无法处理"浮动垃圾"(FloatingGarbage),有可能出现"Con-current Mode Failure"失败进而导致另一次完全"Stop The World"的Full GC的产生。在CMS的并发标记和并发清理阶段,用户线程是还在继续运行的,程序在运行自然就还会伴随有新的垃圾对象不断产生,但这一部分垃圾对象是出现在标记过程结束以后,CMS无法在当次收集中处理掉它们,只好留待下一次垃圾收集时再清理掉。这一部分垃圾就称为"浮动垃圾"。同样也是由于在垃圾收集阶段用户线程还需要持续运行,那就还需要预留足够内存空间提供给用户线程使用,因此CMS收集器不能像其他收集器那样等待到老年代几乎完全被填满了再进行收集,必须预留一部分空间供并发收集时的程序运作使用。在JDK5的默认设置下,CMS收集器当老年代使用了68%的空间后就会被激活,这是一个偏保守的设置,如果在实际应用中老年代增长并不是太快,可以适当调高参数-XX:CMSInitiatingOccu-pancyFraction的值来提高CMS的触发百分比,降低内存回收频率,获取更好的性能。到了JDK6时,CMS收集器的启动阈值就已经默认提升至92%。但这又会更容易面临另一种风险:要是CMS运行期间预留的内存无法满足程序分配新对象的需要,就会出现一次"并发失败"(Concurrent Mode Failure),这时候虚拟机将不得不启动后备预案:冻结用户线程的执行,临时启用Serial Old收集器来重新进行老年代的垃圾收集,但这样停顿时间就很长了。所以参数-XX:CMSInitiatingOccupancyFraction设置得太高将会很容易导致大量的并发失败产生,性能反而降低,用户应在生产环境中根据实际应用情况来权衡设置。

还有最后一个缺点,在本节的开头曾提到,CMS是一款基于"标记-清除"算法实现的收集器,这意味着收集结束时会有大量空间碎片产生,空间碎片过多时,将会给大对象分配带来很大麻烦,所以CMS一定会触发FullGC。往往会出现老年代还有很多剩余空间,但就是无法找到足够大的连续空间来分配当前对象,而不得不提前触发一次FullGC的情况。为了解决这个问题,CMS收集器提供了一个-XX:+UseCMS-CompactAtFullCollection开关参数(默认是开启的,此参数从JDK9开始废弃),用于在CMS收集器不得不进行FullGC时开启内存碎片的合并整理过程,由于这个内存整理必须移动存活对象,(在Shenandoah和ZGC出现前)是无法并发的。这样空间碎片问题是解决了,但停顿时间又会变长,因此虚拟机设计者们还提供了另外一个参数-XX:CMSFullGCsBefore-Compaction(此参数从JDK9开始废弃),这个参数的作用是要求CMS收集器在执行过若干次(数量由参数值决定)不整理空间的FullGC之后,下一次进入FullGC前会先进行碎片整理(默认值为0,表示每次进入FullGC时都进行碎片整理),

CMS老年代垃圾回收停顿时间不可控,对应新生代的时间停顿时间也不可控,CMS还是JVM中唯一一款抢占式垃圾回收器,在CMS设计中,老年代的回收即可以主动进行,有可以被动进行。主动进行回收指的是老生代按照固定的时间间隔,有可能会和被动回收冲突,导致CMS也比较复杂。

Garbage First收集器

可控的停顿时间是交互式应用的刚性需求,为什么CMS的停顿时间不可控呢?该如何设计来尽量避免停顿时间不可控?

1)新生代回收中停顿时间不可控的原因主要是新生代大小是固定的,在回收过程中无洗根据应用使用内存的情况来调整新生代大小。如果新生代的大小能够根据应用使用内存的情况做出调整,那么从理论上讲就能解决停顿时间不可控的问题。

2)老生代回收中的初始标记和再标记的引起的停顿时间不可控,主要是对象个数不同导致的,可以通过并发处理来限制处理对象的个数。

3)解决碎片化唯一的方法是不再采用空闲列表的分配方法,从而避免因为碎片化而导致的垃圾回收。

插入一个知识(假设Java堆中内存是绝对规整的,所有被使用过的内存都被放在一边,空闲的内存被放在另一边,中间放着一个指针作为分界点的指示器,那所分配内存就仅仅是把那个指针向空闲空间方向挪动一段与对象大小相等的距离,这种分配方式称为"指针碰撞"(Bump The Pointer)。但如果Java堆中的内存并不是规整的,已被使用的内存和空闲的内存相互交错在一起,那 就没有办法简单地进行指针碰撞了,虚拟机就必须维护一个列表,记录上哪些内存块是可用的,在分配的时候从列表中找到一块足够大的空间划分给对象实例,并更新列表上的记录,这种分配方式称为"空闲列表"(Free List)。)

作为CMS收集器的替代者和继承人,设计者们希望做出一款能够建立起"停顿时间模型"(Pause PredictionModel)的收集器,停顿时间模型的意思是能够支持指定在一个长度为M毫秒的时间片段内,消耗在垃圾收集上的时间大概率不超过N毫秒这样的目标,这几乎已经是实时Java(RTSJ)的中软实时垃圾收集器特征了。

在G1收集器出现之前的所有 其他收集器,包括CMS在内,垃圾收集的目标范围要么是整个新生代(Minor GC),要么就是整个老 年代(M ajor GC),再要么就是整个Java堆(Full GC)。而G1跳出了这个樊笼,它可以面向堆内存任 何部分来组成回收集(Collection Set,一般简称CSet)进行回收,衡量标准不再是它属于哪个分代,而 是哪块内存中存放的垃圾数量最多,回收收益最大,这就是G1收集器的Mixed GC模式。

G1开创的基于Region的堆内存布局是它能够实现这个目标的关键。虽然G1也仍是遵循分代收集理论设计的,但其堆内存的布局与其他收集器有非常明显的差异:G1不再坚持固定大小以及固定数量的分代区域划分,而是把连续的Java堆划分为多个大小相等的独立区域(Region),每一个Region都可以根据需要,扮演新生代的Eden空间、Survivor空间,或者老年代空间。收集器能够对扮演不同角色的Region采用不同的策略去处理,这样无论是新创建的对象还是已经存活了一段时间、熬过多次收集的旧对象都能获取很好的收集效果。

Region中还有一类特殊的Humongous区域,专门用来存储大对象。G1认为只要大小超过了一个Region容量一半的对象即可判定为大对象。每个Region的大小可以通过参数-XX:G1HeapRegionSize设定,取值范围为1MB~32MB,且应为2的N次幂。而对于那些超过了整个Region容量的超级大对象,将会被存放在N个连续的HumongousRegion之中,G1的大多数行为都把HumongousRegion作为老年代的一部分来进行看待,如图3-12所示。

虽然G1仍然保留新生代和老年代的概念,但新生代和老年代不再是固定的了,它们都是一系列区域(不需要连续)的动态集合。G1收集器之所以能建立可预测的停顿时间模型,是因为它将Region作为单次回收的最小单元,即每次收集到的内存空间都是Region大小的整数倍,这样可以有计划地避免在整个Java堆中进行全区域的垃圾收集。更具体的处理思路是让G1收集器去跟踪各个Region里面的垃圾堆积的"价值"大小,价值即回收所获得的空间大小以及回收所需时间的经验值,然后在后台维护一个优先级列表,每次根据用户设定允许的收集停顿时间(使用参数-XX:MaxGCPauseMillis指定,默认值是200毫秒),优先处理回收价值收益最大的那些Region,这也就是"GarbageFirst"名字的由来。这种使用Region划分内存空间,以及具有优先级的区域回收方式,保证了G1收集器在有限的时间内获取尽可能高的收集效率。

可以看下metric的图。

G1收集器的运作过程大致可划分为以下四个步骤:

初始标记(Initial Marking):仅仅只是标记一下GCRoots能直接关联到的对象,并且修改TAMS指针的值,让下一阶段用户线程并发运行时,能正确地在可用的Region中分配新对象。这个阶段需要停顿线程,但耗时很短,而且是借用进行Minor GC的时候同步完成的,所以G1收集器在这个阶段实际并没有额外的停顿。

并发标记(Concurrent Marking):从GCRoot开始对堆中对象进行可达性分析,递归扫描整个堆里的对象图,找出要回收的对象,这阶段耗时较长,但可与用户程序并发执行。当对象图扫描完成以后,还要重新处理SATB记录下的在并发时有引用变动的对象。

最终标记(Final Marking):对用户线程做另一个短暂的暂停,用于处理并发阶段结束后仍遗留下来的最后那少量的SATB记录。

筛选回收(Live Data Counting and Evacuation):负责更新Region的统计数据,对各个Region的回收价值和成本进行排序,根据用户所期望的停顿时间来制定回收计划,可以自由选择任意多个Region构成回收集,然后把决定回收的那一部分Region的存活对象复制到空的Region中,再清理掉整个旧Region的全部空间。这里的操作涉及存活对象的移动,是必须暂停用户线程,由多条收集器线程并行完成的。

刚才讲了G1的大致垃圾回收过程,大家有没有问题呢?我这变先提出三个小问题:

1、4个阶段停了3个阶段,会不会更慢?

它并非纯粹地追求低延迟,官方给它设定的目标是在延迟可控的情况下获得尽可能高的吞吐量,所以才能担当起"全功能收集器"的重任与期望[4]。

2、G1有了mixed gc后,还会不会有full gc?

会,如果内存回收的速度赶不上内存分配的速度, G1收集器也要被迫冻结用户线程执行,导致Full GC而产生长时间"Stop The World"。

3、G1是不是优于其他垃圾回收器?

不过,G1相对于CM S仍然不是占全方位、压倒性优势的,从它出现几年仍不能在所有应用场景中代替CM S就可以得知这个结论。比起CM S,G1的弱项也可以列举出不少,如在用户程序运行过程 中,G1无论是为了垃圾收集产生的内存占用(Footprint)还是程序运行时的额外执行负载 (Overload)都要比CM S要高。

就内存占用来说,虽然G1和CM S都使用卡表来处理跨代指针,但G1的卡表实现更为复杂,而且 堆中每个Region,无论扮演的是新生代还是老年代角色,都必须有一份卡表,这导致G1的记忆集(和 其他内存消耗)可能会占整个堆容量的20%乃至更多的内存空间;相比起来CM S的卡表就相当简单, 只有唯一一份,而且只需要处理老年代到新生代的引用,反过来则不需要,由于新生代的对象具有朝生夕灭的不稳定性,引用变化频繁,能省下这个区域的维护开销是很划算的[6]。

在执行负载的角度上,同样由于两个收集器各自的细节实现特点导致了用户程序运行时的负载会 有不同,譬如它们都使用到写屏障,CM S用写后屏障来更新维护卡表;而G1除了使用写后屏障来进行 同样的(由于G1的卡表结构复杂,其实是更烦琐的)卡表维护操作外,为了实现原始快照搜索(SATB)算法,还需要使用写前屏障来跟踪并发时的指针变化情况。相比起增量更新算法,原始快照 搜索能够减少并发标记和重新标记阶段的消耗,避免CM S那样在最终标记阶段停顿时间过长的缺点, 但是在用户程序运行过程中确实会产生由跟踪引用变化带来的额外负担。由于G1对写屏障的复杂操作 要比CM S消耗更多的运算资源,所以CM S的写屏障实现是直接的同步操作,而G1就不得不将其实现 为类似于消息队列的结构,把写前屏障和写后屏障中要做的事情都放到队列里,然后再异步处理。

以上的优缺点对比仅仅是针对G1和CM S两款垃圾收集器单独某方面的实现细节的定性分析,通常 我们说哪款收集器要更好、要好上多少,往往是针对具体场景才能做的定量比较。按照笔者的实践经 验,目前在小内存应用上CM S的表现大概率仍然要会优于G1,而在大内存应用上G1则大多能发挥其 优势,这个优劣势的Java堆容量平衡点通常在6GB至8GB之间,当然,以上这些也仅是经验之谈,不 同应用需要量体裁衣地实际测试才能得出最合适的结论,随着HotSpot的开发者对G1的不断优化,也 会让对比结果继续向G1倾斜。

今天大致讲了下垃圾回收的相关知识,对于一般使用面试来说够用了,但是还有很多细节很复杂,感兴趣的可以继续深入了解下。

  1. 是不是任何时候都可以STW?怎么STW?

  2. 衰减预测模型的大致原理是什么?

  3. 当一个region的剩余空间不够创建一个对象时,怎么办?

  4. ···

等等,最后我们再一起简单看下metric指标和GC日志,今天的分享到此,感谢大家聆听。

参考资料:

深入理解Java虚拟机:JVM高级特性与最佳实践(第3版)周志明

深入探索JVM垃圾回收:ARM服务器垃圾回收的挑战和优化 彭成寒

垃圾回收机制中,引用计数法是如何维护所有对象引用的? - 知乎

Chapter 2. The Structure of the Java Virtual Machine

Garbage First Garbage Collector Tuning

JEP 122: Remove the Permanent Generation

图片上自带的水印

相关推荐
代码栈上的思考3 小时前
JVM中内存管理的策略
java·jvm
thginWalker5 小时前
深入浅出 Java 虚拟机之进阶部分
jvm
沐浴露z6 小时前
【JVM】详解 线程与协程
java·jvm
thginWalker8 小时前
深入浅出 Java 虚拟机之实战部分
jvm
程序员卷卷狗2 天前
JVM 调优实战:从线上问题复盘到精细化内存治理
java·开发语言·jvm
Sincerelyplz2 天前
【JDK新特性】分代ZGC到底做了哪些优化?
java·jvm·后端
初学小白...3 天前
线程同步机制及三大不安全案例
java·开发语言·jvm
凤山老林3 天前
还在用JDK8?JDK8升级JDK11:一次价值千万的升级指南
java·开发语言·jvm·spring boot·后端·jdk
2501_938790073 天前
详解 JVM 中的对象创建过程:类加载检查、内存分配、初始化的完整流程
jvm
宸津-代码粉碎机3 天前
Java内部类内存泄露深度解析:原理、场景与根治方案(附GC引用链分析)
java·开发语言·jvm·人工智能·python