JVM垃圾回收

JVM垃圾回收

判断对象是已死

判断对象是否已死就是要找出哪些对象是已经死掉的,以后不会再用到的,就像地上有废纸、饮料瓶和百元大钞,扫地前要先判断出地上废纸和饮料瓶是垃圾,百元大钞不是垃圾。判断对象是否已死有引用计数算法和可达性算法。

引用计数算法

这种方法看起来非常简单,但目前许多主流的虚拟机都没有选用这种算法来管理内存,原因就是当某些对象之间互相引用时,无法判断出这些对象是否已死,如下图,对象1和对象2都没有被堆外的变量所引用,而是被对方互相引用,这是它们虽然没有用处了,但是计数器仍然是1,无法判断它们是死对象,垃圾回收器也无法回收了。

可达性算法

了解可达性分析算法之前先了解一个概念——GC Roots,垃圾收集的起点,可以作为GC Root的有虚拟机栈中本地变量表中的引用对象、方法区中静态属性引用对象、方法区中常量引用的对象、本地方法栈中JNI(Native方法)引用的对象。
当一个对象到GCRoots没有任何引用链相连(GC Roots到这个对象不可达)时,就说明此对象是不可用的,是死对象。如下图:object1、object2、object3、object4和GC Roots之间有可达路劲,这些对象不会被回收,但object5、object6、object7到GC Roots之间没有可达路径,这些对象就被判了死刑。

上面被判了死刑的对象(object5、object6、object7)并不是必死无疑的,还有挽救的余地。进行可达性分析后对象和GC Roots之间没有引用链相连时,对象会被进行一次标记,接着会判断如果对象没有覆盖Object的finalize()方法或者finalize()方法已经被虚拟机调用过,那么它们就会被清楚;如果对象覆写了finalize()方法且还没有被嗲偶哦那个,则会执行finalize()方法中的内容,所以finalize()方法中如果重新与GC Roots引用链上的对象关联就可以拯救自己,但是一般不建议这么做。

方法区回收

上面说的都是对堆内存中对象的判断,方法区中主要回收的是废弃的常量和无用的类。判断常量是否废弃可以判断是否有地方引用这个常量,如果没有引用则为废弃的常量。
判断类是否废弃需要同时满足如下条件:

  1. 该类所有的实例已经被回收(堆中不存在任何该类的实例)
  2. 加载该类的ClassLoader已经被回收
  3. 该类对应的java.lang.Class对象再任何地方没有被引用(无法通过反射访问该类的方法)

常用的垃圾回收算法

常用的垃圾回收算法有三种:标记-清除算法、复制算法、标记-整理算法。

标记-清除算法

标记-清除算法:分为标记和清除两个阶段,首先标记出所有需要回收的对象,标记完成后统一回收所有被标记的对象,如下图。

缺点:标记和清除两个过程效率都不高;标记清除之后会产生大量不连续的内存碎片。

复制算法

把内存分为大小相等的两块,每次存储只用一块,当这一块用完了,就把存活的对象全部复制到另一块上,同时把使用过的这块内存空间全部清理掉,往复循环,如下图。

缺点:实际可使用的内存空间缩小为原来的一半。

标记-整理算法

标记-整理算法是先对可用的对象进行标记,然后所有被标记的对象向一段移动,最后清除

可用对象边界以外的内存,如下图。

分代收集算法

把堆内存分为新生代和老年代,新生代又分为Eden区、From Survivor和 To Survivor。一般新生代中的对象的对象基本上都是朝生夕灭的,每次只有少量对象存活,因此采用复制算法,只需要复制哪些少量存活的对象就可完成垃圾收集;老年代中的对象存活率较高,就采用标记-清除和标记-整理算法来回收。

在这些区域的垃圾回收大概由如下几种情况:

新生代是minor gc,老年代是full gc。每次full gc时,同时会触发一次minor gc。

大多数情况下,新的对象都分配在Eden区,当Eden区没有空间进行分配时,将进行一次Minor GC,清理Eden区中无用对象。清理后,Eden和From Survivor中存活对象如果小于To Survivor的可用空间则进入To Survivor,否则直接进入老年代;Eden和From Survivor中还存活能够进入To Survivor的对象年龄增加1岁(虚拟机为每个对象定义了一个年龄计数器,每执行一次Minor GC年龄加1),当存活对象的年龄达到一定程度(默认15岁)后进入老年代,可以通过-XX:MaxTenuringThreshold来设置年龄的值。

当进行了Minor GC后,Eden还不足以为新对象分配空间(那这个新对象肯定很大),新对象直接进入老年代。

占To Survivor空间一半以上且年龄相等的对象,大于等于该年龄的对象直接进入老年代,比如Survivor空间是10M,有几个年龄为4的对象占用总空间已经超过5M,则年龄大于等于4的对象都直接进入老年代,不需要等到MaxTenuringThreshold指定的岁数。

在进行Minor GC之前,会判断老年代最大连续可用空间是否大于新生代所有对象总空间,如果大于,说明Minor GC是安全的,否则会判断是否允许担保失败,如果允许,判断老年代最大连续可用空间是否大于历次晋升到老年代的对象的平均大小,如果大于,则执行Minor GC,否则执行Full GC。

当在java代码里直接调用System.gc()时,会建议JVM进行Full GC,但一般情况下都会触发Full GC,一般不建议使用,尽量让虚拟机自己管理GC的策略。

永久代(方法区)中用于存放类信息,jdk1.6及之前的版本的永久代中还存储常量、静态变量等,当永久代的空间不足时,也会触发Full GC,如果经过Full GC还无法满足营救带存放永久代存放新数据的需求,就会抛出永久代的内存溢出异常。

大对象(需要大量连续内存的对象)例如很长的数组,会直接进入老年代,如果老年代没有足够的连续大空间来存放,则会进行Full GC。

Minor GC和Full GC

在说这两种回收的区别之前,我们先来说一个概念,“Stop-The-World”。
如字面意思,每次垃圾回收的时候,都会将整个JVM暂停,回收完成后再继续。如果一边增加废弃对象,一边进行垃圾回收,完成工作似乎就变得遥遥无期了。
而一般来说,我们把新生代的回收称为Minor GC,Minor意思是次要的,新生代的回收一般回收很快,采用复制算法,造成的暂停时间很短。而Full GC 一般是老年代回收,并伴随至少一次的Minor GC,新生代和老年代都回收,而老年代采用标记-整理算法。这种GC每次都比较慢,造成的暂停时间比较长,通常是MInor GC时间的10倍以上。
所以很明显,我们需要尽量通过Minor GC来回收内存,而尽量少的触发Full GC。毕竟系统运行一会儿就要因为GC卡住一段时间,再加上其他的同步阻塞,整个系统给人的感觉就是又卡又慢。

选择垃圾收集的时间

当程序进行时,各种数据、对象、线程、内存等时刻都在发生变化,当下达垃圾收集命令就立刻进行吗?肯定不是。这里有两个概念:安全点和安全区。
安全点:从线程角度看,安全点可以理解为实在代码执行过程中的一些特殊位置,当线程执行到安全点的时候,说明虚拟机当前的状态是安全的,如果有需要,可以在这里暂停用户线程。当垃圾收集时,如果需要暂停当前的用户线程,但用户线程没在安全电商,则应该等待这些线程执行到安全点再暂停。举个例子,妈妈在扫地,儿子在吃西瓜(瓜皮会扔地上),妈妈扫到儿子跟前时,儿子说:“妈妈等一下,让我吃完这块再扫。”儿子吃完这块西瓜把瓜皮扔地上后就是一个安全点,妈妈可以继续扫地(垃圾收集器可以继续收集垃圾)。理论上,解释器的每条字节码的边界上都可以放一个安全点,实际上,安全点基本上以“是否具有让程序长时间执行的特征”为标准进行选定。
安全区:安全点是相对于运行中的线程来说的,对于sleep和blocked等状态的线程,收集器不会等待这些线程被分配CPU时间,这时候只要线程处于安全区中,就可以算是安全的,安全区就是再一段代码片段中,引用关系不会发生变化,可以看作是被扩展、拉长了的安全点。还以上面的例子说明,妈妈再扫地,儿子在吃膝盖(瓜皮会扔到地上),妈妈扫到儿子跟前时,儿子说:“妈妈你继续扫地吧,我还等吃10分钟呢!”儿子吃瓜的这段时间就是安全区,妈妈可以继续扫地(垃圾收集器可以继续收集垃圾)。

常见垃圾收集器

现在常见的垃圾收集器有如下几种
新生代收集器:Serial、ParNew、Parallel Scavenge
老年代收集器:Serial Old、CMS、Parallel Old
堆内存垃圾收集器:G1
每种垃圾收集器之间有连线,表示它们可以搭配使用。

Serial收集器

Serial是一款用于新生代的单线程收集器,采用复制算法进行垃圾收集。Serial进行垃圾收集时,不仅只用一条线程执行垃圾收集工作,它在收集的同时,所有的用户线程必须暂停(Stop The World)。就比如妈妈在家打扫卫生的时候,肯定不会边打扫边让儿子往地上乱扔纸屑,否则一边制造垃圾,一边清理垃圾、这活啥时候也干不完。
如下是Serial收集器和Serial Old收集器结合进行垃圾收集的示意图,当用户线程都执行到安全点时,所偶有线程暂停执行,Serial收集器以单线程,采用复制算法进行垃圾收集工作,收集完之后,用户线程继续开始执行。

适用场景:Client模式(桌面应用);单核服务器。可以用-XX:+UserSerialGC来选择Serial作为新生代收集器。

ParNew 收集器

ParNew就是一个Serial的多线程版本,其他与Serial并不区别。ParNew在单核CPU环境并不会比Serial收集器达到更好的效果,它默认开启收集线程和CPU数量一致,可以通过-XX:ParallelGCThreads来设置垃圾收集的线程数。
如下是ParNew收集器和Serial Old收集器结合进行垃圾收集的示意图,当用户线程都执行到安全点时,所有线程暂停执行,ParNew收集器以多线程,采用复制算法进行垃圾收集工作,收集完之后,用户线程继续开始执行。

适用场景:多核服务器;与CMS收集器搭配使用。当使用-XX:+UserConcMarkSweepGC来选择CMS作为老年代收集器时,新生代收集器默认就是ParNew,也可以用-XX:+UserParNewGC来指定使用ParNew作为新生代收集器。

Parallel Scavenge收集器

parallel也是一款用于新生代的多线程收集器,与ParNew的不同之处是,ParNew的目标是尽可能缩短垃圾收集时用户线程的停顿时间,Parallel Scavenge的目标是达到一个可控制的吞吐量。吞吐量就是CPU执行用户线程的时间与CPU执行总时间的比值【吞吐量=运行用户代码时间/(运行用户代码时间+垃圾收集时间)】,比如虚拟机一共运行了100分钟,其中垃圾收集花费了1分钟,那吞吐量就是99%。比如下面两个场景,垃圾收集器每100秒收集一次,每次停顿10秒,和垃圾收集器每50秒收集一次,每次停顿时间7秒,虽然后者每次停顿时间变短了,但是总吞吐量变低了,CPU总体利用率变低了。

可以通过-XX:MaxGCPauseMillis来设置收集器尽可能在多长时间内完成内存回收,可以通过-XX:GCTimePatio来精确控制吞吐量。

如下是Parallel收集器和Parallel Old收集器结合进行垃圾收集的示意图,在新生代,当用户线程都执行到安全点时,所有线程暂停执行,ParNew收集器以多线程,采用复制算法进行垃圾收集工作,收集完之后,用户线程继续开始执行;在老年代,当用户线程都执行到安全点时,所欲线程暂停执行,Parallel Old收集器以多线程,采用标记整理算法进行垃圾收集工作。

适用场景:注重吞吐量,高效利用CPU,需要高效运算且不需要太多交互。可以使用-XX:UserParallelGC来选择Parallel Scavenge作为新生代收集器,jdk1.7、jdk1.8默认使用Parallel Scavenge作为新生代收集器。

Serial Old收集器

Serial Old收集器是Serial的老年代版本,同样是一个单线程收集器,采用标记-整理算法。如下图是Serial收集器和Serial Old收集器结合进行垃圾收集的示意图:

适用场景:Client模式(桌面应用);单核服务器;与Parallel Scavenge收集器搭配;作为CMS收集器的后备预案。

CMS收集器

CMS收集器是一种以最短回收停顿时间为目标的收集器,以“最短用户线程时间”著称。

整个垃圾收集过程分为4个步骤

  1. 初始标记:标记一下GC Roots能直接关联的对象,速度较快

  2. 并发标记:进行GC Roots Tracing,标记出全部的垃圾对象,耗时较长

  3. 重新标记:修正并发标记阶段引用用户程序继续运行而导致变化的对象的标记记录,耗时较短

  4. 并发清除:用标记-清除算法清除对象,耗时较长
    整个过程耗时最长的并发标记和并发清除都是和用户线程一起工作,所以从总体上来说,CMS收集器垃圾收集可以看做是和用户线程并发执行的。

CMS收集器也存在一些缺点:

  1. 对CPU资源敏感:默认分配的垃圾收集线程数为(CPU数+3)/4,随着CPU数量下降,占用CPU资源越多,吞吐量越小
  2. 无法处理浮动垃圾:在并发清理阶段,由于用户线程还在运行,还会不断产生新的垃圾,CMS收集器无法在档次收集中清除这部分垃圾。同时由于在垃圾收集阶段用户线程也在并发执行,CMS收集器不能像其他收集器那样等老年代填满时再进行收集,需要预留一部分空间提供用户线程运行适用。当CMS运行时,预留的内存空间无法满足用户线程的需要,就会出现“Concurrent Mode Failure”的错误,这时将会启动后备预案,临时用Serial Old来重新进行老年代的垃圾收集。
  3. 因为CMS是基于标记-清除算法,所以垃圾回收后会产生空间碎片,可以通过-XX:UserCMSCompactAtFullCollection开启碎片整理(默认开启),在CMS进行Full GC之前,会进行内存碎片整理。还可以用-XX:CMSFullGCsBeforeCompaction设置执行多少次不压缩(不进行碎片整理)的Full GC之后,跟着来一次带压缩(碎片整理)的Full GC。
    适用场景:重视服务器响应速度,要求系统停顿时间最短。可以使用-XX:+User
    ConMarkSweepGC来选择CMS作为老年代收集器。

Parallel Old收集器

Parallel Old收集器是Parallel Scavenge的老年代版本,是一个多线程收集器,采用标记-整理算法。可以与Parallel Scavenge收集器搭配,可以充分利用多核CPU的计算能力。

适用场景:与Parallel Scavenge收集器搭配适用;注重吞吐量。jdk7、jdk8默认使用该收集器作为老年代收集器,使用-XX:+UserParallelOldGC来指定使用Parallel Old收集器。

G1收集器

G1收集器是jdk1.7才正式引用的商用收集器,现在已经称为jdk1.9默认的收集器。前面几款收集器的范围都是新生代或者老年代,G1进行垃圾收集的范围是整个堆内存,它采用“化整为零”的思路,把整个堆内存划分为多个大小相等的独立区域(Region),在G1收集器中还保留着新生代和老年代的概念,它们分别都是Region,如下图:

每一个方块就是一个区域,每个区域可能是Eden、Survivor、老年代,每种区域的数量也不一定。JVM启动时会自动设置每个区域的大小(1M~32M,必须是2的次幂),最多可以设置2048个区域(即支持的最大堆内存为32M*2048=64G),假如设置-Xmx8g -Xms8g,则每个区域大小为8g/2048=4M。

为了在GC Roots Tracing的时候避免扫描全堆,在每个Region中,都有一个Remembered Set来实时记录该区域的引用数据类型与其他区域数据的引用关系(在前面的几款分代收集中,新生代、老年代中也有一个Remembered Set来实时记录与其他区域的引用关系),在标记时直接参考这些引用关系就可以知道这些对象是否应该被清除,而不用扫描全堆的数据。

G1收集器可以“建立可预测的停顿时间模型”,它维护了一个列表用于记录每个Region回收价值大小(回收后获得的空间大小以及回收所需时间的经验值),这样可以保证G1收集器在有限的时间内可以获得最大的回收率。

如下图所示,G1收集器手机过程有初始标记、并发标记、最终标记、筛选回收和CMS收集器前几步的收集过程很相似:

  1. 初始标记:标记出GC Root直接关联的对象,这个阶段速度较快,需要停止用户线程,单线程执行
  2. 并发标记:从GC Root开始对堆中的对象进行可达性分析,找出存活对象,这个阶段耗时较长,单可以和用户线程并发执行
  3. 最终标记:修正在并发标记阶段引用程序执行而产生变动的标记记录
  4. 筛选回收阶段会对各个Region的回收价值和成本进行排序,根据用户所期望的GC停顿时间来指定回收计划(用最少的时间来回收包含垃圾最多的区域,这就是Garbage First的由来——第一时间清理垃圾最多的区块),这里为了提高回收效率,并没有采用和用户线程并发执行的方式,而是停顿用户线程。
    适用场景:要求尽可能可控GC停顿时间;内存占较大的应用。可以用-XX:+UserG1GC使用G1收集器,jdk1.9默认使用G1收集器。
posted @ 2020-08-28 17:49  cqy19951026  阅读(101)  评论(0编辑  收藏  举报