垃圾回收( Garbage Collection 如下简称 GC)诞生于1960年 MIT 的 Lisp 语言,有半个多世纪的历史。在Java 中,JVM 会对内存进行自动分配与回收,其中 GC 的主要做用就是清楚再也不使用的对象,自动释放内存。php
GC 相关的研究者们主要是思考这3件事情。html
本文也大体按照这个思路,为你们描述垃圾回收的相关知识。由于会有不少内存区域相关的知识,但愿读者先学习完精美图文带你掌握 JVM 内存布局再来阅读本文。java
在这里先感谢周志明大佬的新鲜出炉的大做:《深刻理解Java 虚拟机》- 第3版拜读以后对JVM有了更深的理解,强烈推荐你们去看。算法
本文的主要内容以下(建议你们在阅读和学习的时候,也大体按照如下的思路来思考和学习):缓存
如何判断这个对象须要回收?即GC 的存活标准?多线程
这里又可以引出如下的知识概念:并发
有了对象的存活标准以后,咱们就须要知道GC 的相关算法(思想)oracle
知道了算法以后,天然而然咱们到了JVM中对这些算法的实现和应用,即各类垃圾收集器(Garbage Collector)jvm
一句话:GC 主要关注 堆和方法区
在精美图文带你掌握 JVM 内存布局一文中,理解介绍了Java 运行时内存的分布区域和特色。布局
其中咱们知道了程序计数器、虚拟机栈、本地方法栈3个区域是随线程而生,随线程而灭的。栈中的栈帧随着方法的进入和退出而有条不紊地执行着出栈和入栈操做。每个栈帧中分配多少内存基本上是在类结构肯定下来时就已知的(尽管在运行期会由JIT编译器进行一些优化,但在本章基于概念模型的讨论中,大致上能够认为是编译期可知的),所以这几个区域的内存分配和回收都具有肯定性,在这几个区域内就不须要过多考虑回收的问题,由于方法结束或者线程结束时,内存天然就跟随着回收了。
而堆和方法区则不同,一个接口中的多个实现类须要的内存可能不同,一个方法中的多个分支须要的内存也可能不同,咱们只有在程序处于运行期间时才能知道会建立哪些对象,这部份内存的分配和回收都是动态的。GC 关注的也就是这部分的内存区域。
知道哪些区域的内存须要被回收以后,咱们天然而然地想到了,如何去判断一个对象须要被回收呢?(回收对象...没对象的我听着怎么有点怪怪的😂)
对于如何判断对象是否能够回收,有两种比较经典的判断策略。
在对象头维护着一个 counter 计数器,对象被引用一次则计数器 +1;若引用失效则计数器 -1。当计数器为 0 时,就认为该对象无效了。
主流的Java虚拟机里面没有选用引用计数算法来管理内存,其中最主要的缘由是它很难解决对象之间相互循环引用的问题。发生循环引用的对象的引用计数永远不会为0,结果这些对象就永远不会被释放。
从GC Roots 为起点开始向下搜索,搜索所走过的路径称为引用链。当一个对象到GC Roots 没有任何引用链相连时,则证实此对象是不可用的。不可达对象。
Java 中,GC Roots 是指:
Java对引用的概念进行了扩充,将引用分为强引用(Strong Reference)、软引用(Soft Reference)、弱引用(Weak Reference)、虚引用(Phantom Reference)4种,这4种引用强度依次逐渐减弱。
这样子设计的缘由主要是为了描述这样一类对象:当内存空间还足够时,则能保留在内存之中;若是内存空间在进行垃圾收集后仍是很是紧张,则能够抛弃这些对象。不少系统的缓存功能都符合这样的应用场景。
也就是说,对不一样的引用类型,JVM 在进行GC 时会有着不一样的执行策略。因此咱们也须要去了解一下。
MyClass obj = new MyClass(); // 强引用 obj = null // 此时‘obj’引用被设为null了,前面建立的'MyClass'对象就能够被回收了 复制代码
只要强引用存在,垃圾收集器永远不会回收被引用的对象,只有当引用被设为null的时候,对象才会被回收。可是,若是咱们错误地保持了强引用,好比:赋值给了 static 变量,那么对象在很长一段时间内不会被回收,会产生内存泄漏。
软引用是一种相对强引用弱化一些的引用,可让对象豁免一些垃圾收集,只有当 JVM 认为内存不足时,才会去试图回收软引用指向的对象。JVM 会确保在抛出 OutOfMemoryError 以前,清理软引用指向的对象。软引用一般用来实现内存敏感的缓存,若是还有空闲内存,就能够暂时保留缓存,当内存不足时清理掉,这样就保证了使用缓存的同时,不会耗尽内存。
SoftReference<MyClass> softReference = new SoftReference<>(new MyClass()); 复制代码
弱引用的强度比软引用更弱一些。当 JVM 进行垃圾回收时,不管内存是否充足,都会回收只被弱引用关联的对象。
WeakReference<MyClass> weakReference = new WeakReference<>(new MyClass()); 复制代码
弱引用能够引伸出来一个知识点, WeakHashMap&ReferenceQueueReferenceQueue 是GC回调的知识点。这里由于篇幅缘由就不细讲了,推荐引伸阅读:ReferenceQueue的使用
虚引用也称为幽灵引用或者幻影引用,它是最弱的一种引用关系。一个对象是否有虚引用的存在,彻底不会对其生存时间构成影响,也没法经过虚引用来取得一个对象实例。为一个对象设置虚引用关联的惟一目的就是能在这个对象被收集器回收时收到一个系统通知。
PhantomReference<MyClass> phantomReference = new PhantomReference<>(new MyClass(), new ReferenceQueue<>()); 复制代码
有了判断对象是否存活的标准以后,咱们再来了解一下GC的相关算法。
标记-清除算法在概念上是最简单最基础的垃圾处理算法。
该方法简单快速,可是缺点也很明显,一个是效率问题,标记和清除两个过程的效率都不高;另外一个是空间问题,标记清除以后会产生大量不连续的内存碎片,空间碎片太多可能会致使之后在程序运行过程当中须要分配较大对象时,没法找到足够的连续内存而不得不提早触发另外一次垃圾收集动做。
后续的收集算法都是基于这种思路并对其不足进行改进而获得的。
复制算法改进了标记-清除算法的效率问题。
它将可用内存按容量划分为大小相等的两块,每次只使用其中的一块。当这一块的内存用完了,就将还存活着的对象复制到另一块上面,而后再把已使用过的内存空间一次清理掉。
这样使得每次都是对整个半区进行内存回收,内存分配时也就不用考虑内存碎片等复杂状况,只要移动堆顶指针,按顺序分配内存便可,实现简单,运行高效。
缺点也是明显的,可用内存缩小到了原先的一半。
如今的商业虚拟机都采用这种收集算法来回收新生代,IBM公司的专门研究代表,新生代中的对象98%
是“朝生夕死”的,因此并不须要按照1:1的比例来划份内存空间,而是将内存分为一块较大的Eden空间和两块较小的Survivor空间,每次使用Eden和其中一块Survivor。
在前面的文章中咱们提到过,HotSpot默认的Eden:survivor1:survivor2=8:1:1
,以下图所示。
固然,98%的对象可回收只是通常场景下的数据,咱们没有办法保证每次回收都只有很少于10%的对象存活,当Survivor空间不够用时,须要依赖其余内存(这里指老年代)进行分配担保(Handle Promotion)。
内存的分配担保就比如咱们去银行借款,若是咱们信誉很好,在98%的状况下都能按时偿还,因而银行可能会默认咱们下一次也能按时按量地偿还贷款,只须要有一个担保人能保证若是我不能还款时,能够从他的帐户扣钱,那银行就认为没有风险了。内存的分配担保也同样,若是另一块Survivor空间没有足够空间存放上一次新生代收集下来的存活对象时,这些对象将直接经过分配担保机制进入老年代。
前面说了复制算法主要用于回收新生代的对象,可是这个算法并不适用于老年代。由于老年代的对象存活率都较高(毕竟大多数都是经历了一次次GC千辛万苦熬过来的,身子骨很硬朗😎)
根据老年代的特色,提出了另一种标记-整理(Mark-Compact)算法,标记过程仍然与“标记-清除”算法同样,但后续步骤不是直接对可回收对象进行清理,而是让全部存活的对象都向一端移动,而后直接清理掉端边界之外的内存。
有没有注意到了,咱们前面的表述当中就引入了新生代、老年代的概念。准确来讲,是先有了分代收集算法的这种思想,才会将Java堆分为新生代和老年代。这两个概念之间存在着一个前后因果关系。
这个算法很简单,就是根据对象存活周期的不一样,将内存分块。在Java 堆中,内存区域被分为了新生代和老年代,这样就能够根据各个年代的特色采用最适当的收集算法。
就如咱们在介绍上面的算法时描述的,在新生代中,每次垃圾收集时都发现有大批对象死去,只有少许存活,那就选用复制算法,只须要付出少许存活对象的复制成本就能够完成收集。而老年代中由于对象存活率高、没有额外空间对它进行分配担保,就必须使用 “标记—清理” 或者 “标记—整理” 算法 来进行回收。
这里从新回顾一下精美图文带你掌握 JVM 内存布局里面JVM建立一个新对象的内存分配流程图。这张图也描述了GC的流程。
在学习垃圾收集器知识点以前,须要向读者大大们科普一些GC的术语😊,方便大家后面理解。
部分收集(Partial GC):指目标不是完整收集整个Java堆的垃圾收集,其中又分为:
知道了算法以后,天然而然咱们到了JVM中对这些算法的实现和应用,即各类垃圾收集器(Garbage Collector)。
首先要认识到的一个重要方面是,对于大多数JVM,须要两种不一样的GC算法,一种用于清理新生代,另外一种用于清理老年代。
意思就是说,在JVM中你一般会看到两种收集器组合使用。下图是JVM 中全部的收集器(Java 8 ),其中有连线的就是能够组合的。
为了减少复杂性,快速记忆,我这边直接给出比较经常使用的几种组合。其余的要么是已经废弃了要么就是在现实状况下不实用的。
新生代
老年代
JVM options
Serial
Serial Old
-XX:+UseSerialGC
Parallel Scavenge
Parallel Old
-XX:+UseParallelGC -XX:+UseParallelOldGC
Parallel New
CMS
-XX:+UseParNewGC -XX:+UseConcMarkSweepGC
G1
G1
-XX:+UseG1GC
接下去咱们开始具体介绍上各个垃圾收集器。这里须要提一下的是,我这边是将垃圾收集器分红如下几类来说述的:
理由无他,我以为这样更符合理解的思路,你更好理解。
Serial 翻译过来能够理解成单线程。单线程收集器有Serial 和 Serial Old 两种,它们的惟一区别就是:Serial 工做在新生代,使用“复制”算法,Serial Old 工做在老年代,使用“标志-整理”算法。因此这里将它们放在一块儿讲。
串行收集器收集器是最经典、最基础,也是最好理解的。它们的特色就是单线程运行及独占式运行,所以会带来很很差的用户体验。虽然它的收集方式对程序的运行并不友好,但因为它的单线程执行特性,应用于单个CPU硬件平台的性能能够超过其余的并行或并发处理器。
“单线程”的意义并不只仅是说明它只会使用一个处理器或一条收集线程去完成垃圾收集工做,更重要的是强调在它进行垃圾收集时,必须暂停其余全部工做线程,直到它收集结束(STW阶段)。
STW 会带给用户恶劣的体验,因此从JDK 1.3开始,一直到如今最新的JDK 13,HotSpot虚拟机开发团队为消除或者下降用户线程因垃圾收集而致使停顿的努力一直持续进行着,从Serial收集器到Parallel收集器,再到Concurrent Mark Sweep(CMS)和Garbage First(G1)收集器,最终至如今垃圾收集器的最前沿成果Shenandoah和ZGC等。
虽然新的收集器不少,可是串行收集器仍有其适合的场景。迄今为止,它依然是HotSpot虚拟机运行在客户端模式下的默认新生代收集器,有着优于其余收集器的地方,那就是简单而高效。对于内存资源受限的环境,它是全部收集器里额外内存消耗最小的,单线程没有线程交互开销。(这里实际上也是一个时间换空间的概念)
经过JVM参数
-XX:+UseSerialGC
可使用串行垃圾回收器(上面表格也有说明)
按照程序发展的思路,单线程处理以后,下一步很天然就到了多核处理器时代,程序多线程并行处理的时代。并行收集器是多线程的收集器,在多核CPU下可以很好的提升收集性能。
这里咱们会介绍:
这里仍是提供太长不看版白话总结,方便理解。由于我知道有些人刚开始学习JVM 看这些名词都会以为头晕。
- ParNew收集器 就是 Serial收集器的多线程版本,基于“复制”算法,其余方面彻底同样,在JDK9以后差很少退出历史舞台,只能配合CMS在JVM中发挥做用。
- Parallel Scavenge 收集器 和 ParNew收集器相似,基于“复制”算法,但前者更关注可控制的吞吐量,而且可以经过
-XX:+UseAdaptiveSizePolicy
打开垃圾收集自适应调节策略的开关。- Parallel Old 就是 Parallel Scavenge 收集器的老年代版本,基于**“标记-整理”算法**实现。
ParNew收集器除了支持多线程并行收集以外,其余与Serial收集器相比并无太多创新之处,但它倒是很多运行在服务端模式下的HotSpot虚拟机,尤为是JDK 7以前的遗留系统中首选的新生代收集器,其中有一个与功能、性能无关但其实很重要的缘由是:除了Serial收集器外,目前只有它能与CMS收集器配合工做。
可是从G1 出来以后呢,ParNew的地位就变得微妙起来,自JDK 9开始,ParNew加CMS收集器的组合就再也不是官方推荐的服务端模式下的收集器解决方案了。官方但愿它能彻底被G1所取代,甚至还取消了『ParNew + Serial Old』 以及『Serial + CMS』这两组收集器组合的支持(其实本来也不多人这样使用),并直接取消了-XX:+UseParNewGC
参数,这意味着ParNew 和CMS 今后只能互相搭配使用,再也没有其余收集器可以和它们配合了。能够理解为今后之后,ParNew 合并入CMS,成为它专门处理新生代的组成部分。
Parallel Scavenge收集器与ParNew收集器相似,也是使用复制算法的并行的多线程新生代收集器。但Parallel Scavenge收集器关注可控制的吞吐量(Throughput)
注:吞吐量是指CPU用于运行用户代码的时间与CPU总消耗时间的比值,即 吞吐量 = 运行用户代码时间 /( 运行用户代码时间 + 垃圾收集时间 )
Parallel Scavenge收集器提供了几个参数用于精确控制吞吐量和停顿时间:
参数
做用
--XX: MaxGCPauseMillis
最大垃圾收集停顿时间,是一个大于0的毫秒数,收集器将回收时间尽可能控制在这个设定值以内;但须要注意的是在一样的状况下,回收时间与回收次数是成反比的,回收时间越小,相应的回收次数就会增多。因此这个值并非越小越好。
-XX: GCTimeRatio
吞吐量大小,是一个(0, 100)之间的整数,表示垃圾收集时间占总时间的比率。
XX: +UseAdaptiveSizePolicy
这是一个开关参数,当这个参数被激活以后,就不须要人工指定新生代的大小(-Xmn)、Eden与Survivor区的比例(-XX:SurvivorRatio)、晋升老年代对象大小(-XX:PretenureSizeThreshold)等细节参数了,虚拟机会根据当前系统的运行状况收集性能监控信息,动态调整这些参数以提供最合适的停顿时间或者最大的吞吐量。这种调节方式称为垃圾收集的自适应的调节策略(GC Ergonomics)
Parallel Old是Parallel Scavenge收集器的老年代版本,多线程,基于“标记-整理”算法。这个收集器是在JDK 1.6中才开始提供的。
因为若是新生代选择了Parallel Scavenge收集器,老年代除了Serial Old(PS MarkSweep)收集器外别无选择(Parallel Scavenge没法与CMS收集器配合工做),Parallel Old收集器的出现就是为了解决这个问题。Parallel Scavenge和Parallel Old收集器的组合更适用于注重吞吐量以及CPU资源敏感的场合。
CMS(Concurrent Mark Sweep,并发标记清除) 收集器是以获取最短回收停顿时间为目标的收集器(追求低停顿),它在垃圾收集时使得用户线程和 GC 线程并发执行,所以在垃圾收集过程当中用户也不会感到明显的卡顿。
从名字就能够知道,CMS是基于“标记-清除”算法实现的。它的工做过程相对于上面几种收集器来讲,就会复杂一点。整个过程分为如下四步:
1)初始标记 (CMS initial mark):主要是标记 GC Root 开始的下级(注:仅下一级)对象,这个过程会 STW,可是跟 GC Root 直接关联的下级对象不会不少,所以这个过程其实很快。
2)并发标记 (CMS concurrent mark):根据上一步的结果,继续向下标识全部关联的对象,直到这条链上的最尽头。这个过程是多线程的,虽然耗时理论上会比较长,可是其它工做线程并不会阻塞,没有 STW。
3)从新标记(CMS remark):顾名思义,就是要再标记一次。为啥还要再标记一次?由于第 2 步并无阻塞其它工做线程,其它线程在标识过程当中,颇有可能会产生新的垃圾。
这里举一个很形象的例子:就好比你和你的小伙伴(多个GC线程)给一条长走廊打算卫生,从一头打扫到另外一头。当大家打扫到走廊另外一头的时候,可能有同窗(用户线程)丢了新的垃圾。因此,为了打扫干净走廊,须要你示意全部的同窗(用户线程)别再丢了(进入STW阶段),而后你和小伙伴迅速把刚刚的新垃圾收走。固然,由于刚才已经收过一遍垃圾,因此此次收集新产生的垃圾,用不了多长时间(即:STW 时间不会很长)。
4)并发清除(CMS concurrent sweep):
❔❔❔ 提问环节:为何CMS要使用“标记-清除”算法呢?刚才咱们不是提到过“标记-清除”算法,会留下不少内存碎片吗?
确实,可是也没办法,若是换成“标记 - 整理”算法,把垃圾清理后,剩下的对象也顺便整理,会致使这些对象的内存地址发生变化,别忘了,此时其它线程还在工做,若是引用的对象地址变了,就天下大乱了。
对于上述的问题JVM提供了两个参数:
参数
做用
--XX: +UseCMS-CompactAtFullCollection
(默认是开启的,此参数从JDK 9开始废弃)用于在CMS收集器不得不进行FullGC时开启内存碎片的合并整理过程,内存整理的过程是没法并发的,空间碎片问题没有了,但停顿时间不得不变长。
--XX: CMSFullGCsBeforeCompaction
(此参数从JDK 9开始废弃)这个参数的做用是要求CMS收集器在执行过若干次(数量由参数值决定)不整理空间的Full GC以后,下一次进入Full GC前会先进行碎片整理(默认值为0,表示每次进入Full GC时都进行碎片整理)
另外,因为最后一步并发清除时,并不阻塞其它线程,因此还有一个反作用,在清理的过程当中,仍然可能会有新垃圾对象产生,只能等到下一轮 GC,才会被清理掉。
JDK 9发布之日,G1宣告取代Parallel Scavenge加Parallel Old组合,成为服务端模式下的默认垃圾收集器。
鉴于 CMS 的一些不足以外,好比: 老年代内存碎片化,STW 时间虽然已经改善了不少,可是仍然有提高空间。G1 就横空出世了,它对于堆区的内存划思路很新颖,有点算法中分治法“分而治之”的味道。具体什么意思呢,让咱们继续看下去。
G1 将连续的Java堆划分为多个大小相等的独立区域(Region),每个Region均可以根据须要,扮演新生代的Eden空间、Survivor空间,或者老年代空间。每一个Region的大小能够经过参数-XX:G1HeapRegionSize
设定,取值范围为1MB~32MB,且应为2的N次幂。
Region中还有一类特殊的Humongous区域,专门用来存储大对象。G1认为只要大小超过了一个Region容量一半的对象便可断定为大对象。对于那些超过了整个Region容量的超级大对象,将会被存放在N个连续的Humongous Region之中。
Humongous,简称 H 区,是专用于存放超大对象的区域,一般
>= 1/2 Region Size
,
G1的大多数行为都把Humongous Region做为老年代的一部分来进行看待。
认识了G1中的内存规划以后,咱们就能够理解为何它叫作"Garbage First"。全部的垃圾回收,都是基于 region 的。G1根据各个Region回收所得到的空间大小以及回收所需时间等指标在后台维护一个优先列表,每次根据容许的收集时间,优先回收价值最大(垃圾)的Region,从而能够有计划地避免在整个Java堆中进行全区域的垃圾收集。这也是 "Garbage First" 得名的由来。
G1从总体来看是基于“标记-整理”算法实现的收集器,但从局部(两个Region之间)上看又是基于“标记-复制”算法实现,不管如何,这两种算法都意味着G1运做期间不会产生内存空间碎片,垃圾收集完成以后能提供规整的可用内存。这种特性有利于程序长时间运行,在程序为大对象分配内存时不容易因没法找到连续内存空间而提早触发下一次GC。
❔❔❔ 提问环节:一个对象和它内部所引用的对象可能不在同一个 Region 中,那么当垃圾回收时,是否须要扫描整个堆内存才能完整地进行一次可达性分析?
这里就须要引入 Remembered Set 的概念了。
答案是不须要,每一个 Region 都有一个 Remembered Set (记忆集),用于记录本区域中全部对象引用的对象所在的区域,进行可达性分析时,只要在 GC Roots 中再加上 Remembered Set 便可防止对整个堆内存进行遍历。
再提一个概念,Collection Set :简称 CSet,记录了等待回收的 Region 集合,GC 时这些 Region 中的对象会被回收(copied or moved)。
若是不计算维护 Remembered Set 的操做,G1 收集器的工做过程分为如下几个步骤:
从上述阶段的描述能够看出,G1收集器除了并发标记外,其他阶段也是要彻底暂停用户线程的,换言之,它并不是纯粹地追求低延迟,官方给它设定的目标是在 延迟可控的状况下得到尽量高的吞吐量。
在分配通常对象时,当全部eden region使用达到最大阈值而且没法申请足够内存时,会触发一次YGC。每次YGC会回收全部Eden以及Survivor区,而且将存活对象复制到Old区以及另外一部分的Survivor区。
下面是一段通过抽取的GC日志:
GC pause (G1 Evacuation Pause) (young) ├── Parallel Time ├── GC Worker Start ├── Ext Root Scanning ├── Update RS ├── Scan RS ├── Code Root Scanning ├── Object Copy ├── Code Root Fixup ├── Code Root Purge ├── Clear CT ├── Other ├── Choose CSet ├── Ref Proc ├── Ref Enq ├── Redirty Cards ├── Humongous Register ├── Humongous Reclaim ├── Free CSet 复制代码
由这段GC日志咱们可知,整个YGC由多个子任务以及嵌套子任务组成,且一些核心任务为:Root Scanning,Update/Scan RS,Object Copy,CleanCT,Choose CSet,Ref Proc,Humongous Reclaim,Free CSet。
推荐阅读: 深刻理解G1的GC日志这篇文章经过G1 GC日志介绍了GC的几个步骤。对上面英文单词概念不清楚的能够查阅。
当愈来愈多的对象晋升到老年代Old Region 时,为了不堆内存被耗尽,虚拟机会触发一个混合的垃圾收集器,即Mixed GC,是收集整个新生代以及部分老年代的垃圾收集。除了回收整个Young Region,还会回收一部分的Old Region ,这里须要注意:是一部分老年代,而不是所有老年代,能够选择哪些Old Region 进行收集,从而能够对垃圾回收的耗时时间进行控制。
Mixed GC的整个子任务和YGC彻底同样,只是回收的范围不同。
注:G1 通常来讲是没有FGC的概念的。由于它自己不提供FGC的功能。若是 Mixed GC 仍然效果不理想,跟不上新对象分配内存的需求,会使用 Serial Old GC 进行 Full GC强制收集整个 Heap。
相比CMS,G1总结有如下优势:
关于G1实际上还有不少的细节能够讲,这里但愿读者去阅读《深刻理解Java虚拟机》或者其余资料来延伸学习,查漏补缺。
相关参数:
参数
做用
-XX:+UseG1GC
采用 G1 收集器
-XX:G1HeapRegionSize
每一个Region的大小
更多的参数和调优参考详见: 分析和性能来调整和调优 G1 GC
本系列关于JVM 垃圾回收的知识就到这里了。
由于篇幅的关系,也受限于能力水平,本文不少细节没有涉及到,只能算是为学习JVM的同窗打开了一扇的门(一扇和日常看到的文章相比要大那么一点点的门,写了这么久容许我自恋一下吧😂😂)。但愿不过瘾的同窗能本身更加深刻的学习。