java基础-垃圾回收

概述:
想必垃圾回收是选择java语言进行开发的最大动力了吧。其他的各种java的好特性在其他的语言中大多也都有实现,甚至胜之一筹的也有很多。
在当初C语言的天下,分配内存和回收内存肯定是编程最痛苦的事情没有之一,而java自带了垃圾回收方便了无数的程序员。从最开始的低效不停的迭代到现在的高效、并发并行甚至是停顿时间可控,使得java能够更稳定高效的执行,同时也让GC程序成为一个极其高深的考点,如果能把GC搞的比较通顺,面试肯定是个大加分项。

整个gc部分考点分布很多,从jvm内存模型,到垃圾对象查找算法,再到垃圾清除算法,最后深入到各种垃圾收集器的实现、特性和实用场景。一般问题都会是这么顺下来的,到了垃圾收集器的各种实现上如果能完美答辩,说明对这块内容比较熟悉那么就可以加分了。

jvm内存模型已经有讲述,可以自行查看java基础-jvm内存
jvm内存的分区中,虚拟机栈、本地方法栈、程序计数器,这些线程私有的区域是为运行期而分出来的。而堆和方法区(或元数据区)还有堆的细分为新生代和老年代,新生代又细分为伊甸区和交换区,这么划分主要也都是为了GC回收而分的。

垃圾对象查找算法都有哪些?
1.引用计数法,实例化一个对象上添加一个计数器,每次被引用都加1,若该计数器不为0则为有用对象,但是自引用或者环形引用也是垃圾对象但是清理不到。
2.可达性分析,从根节点出发,寻找引用的对象,找到的对象再往下查找如此递推,这些查找到的对象进行标记,没有被标记到的对象就是垃圾对象等待清理。这个方法是现在GC中使用的算法。那么从哪里开始查找,这个也比较好理解,那就是最根源的调用和引用的地方:a.方法区中的引用;b.虚拟机栈和本地方法栈中的引用,也就这两个最根源了。

垃圾清理算法都有哪些?
标记清除,标记完删除对应对象释放内存,简单但是有内存碎片。
标记整理(标记压缩),标记完删除对应对象释放内存并将后面的对象向前移动,没有内存碎片,但是修改数据多,效率较低,一般老年代都使用这个算法。
复制算法,需要两块内存,同一时间只有一块内存正在被使用,回收时将非垃圾复制整理到新内存中,然后释放原来内存块,切换工作内存块。分代回收的新生代一般都是用复制算法,从伊甸区复制到交换区,然后两个交换区互相替换使用,回收次数超过一定值就放在老年代。

GC主流收集器都有哪些?实现原理是什么?特性是什么?实用场景是什么?
Serial串行收集器
运行方式:串行
适用于分代:新生代、老年代
垃圾清理算法:老年代-标记整理,新生代-复制算法
一旦触发就会停顿

ParNew收集器
运行方式:并行
适用于分代:新生代
垃圾清理算法:新生代-复制算法
一旦触发就会停顿,但是由于是多个线程并行运行,所以停顿时间可以缩短

Parallel收集器
运行方式:并行
适用于分代:新生代、老年代
垃圾清理算法:老年代-标记整理,新生代-复制算法
一旦触发就会停顿,但是由于是多个线程并行运行,所以停顿时间可以缩短,更加关注吞吐量。

CMS收集器(Concurrent Mark Sweep并发标记清除)
运行方式:并行
适用于分代:老年代,年轻代收集搭配 ParNew或Serial
垃圾清理算法:老年代-标记清除
收集分为四个阶段:初始标记-单线程且停顿、并发标记-单线程与用户线程并行不停顿、重新标记-多线程且停顿、并发清理-单线程与用户线程并行不停顿。所以有两个阶段是要停顿的,另外两个阶段不停顿是与用户线程同时进行的。更加关注低延时。
由于使用的是标记清除算法,会有大量内存碎片,所以当内存快满的时候,会产生回收失败(concurrent mode failure),回收失败后就会用Serial Old回收器进行垃圾回收,这样可以对内存进行一次整理。也可以使用参数控制其回收完成后进行一次内存整理,这样就牺牲了它的时间延迟低的特性了。

G1收集器(Garbage First)
运行方式:并行
适用于分代:新生代、老年代
分区的不同,G1分区与原来不同,它是把内存统一分成很多个Region,对每个Region进行管理。保留新生代、老年代的说法,只不过是有的Region是新生代有的是老年代或是大对象区。
新生代的收集类似ParNew,需要收集就会停顿下来多线程使用复制算法进行收集,老年代的手机类似于CMS。
说G1详细回收过程需要两个新概念:
1.Rset记忆集合(remember set)每一个region都有一个这个集合,用于记录本区中的对象被哪个区引用过,这样当要标记本区垃圾的时候就可以直接从这个set中去找对应区来计算是否有引用,防止全局扫描。
2.dirty card queue,在对象引用赋值的时候,会将引用card放在这个队列中而不是直接去更新Rset,这样做是为了效率考虑。
G1回收过程为:年轻代GC->年轻代GC+并发标记->混合回收->Full GC
1.年轻代GC,扫描根节点->处理dirty card queue->更新Rset->复制对象->更新引用,前面三个步骤是为了标记垃圾对象的准备。复制对象方式与传统的年轻代相同,将交换区的非垃圾复制到空闲区或老年代、伊甸区的非垃圾复制到交换区,然后更新引用即可。
2.年轻代GC+并发标记,当达到一定阈值后触发,过程类似于CMS收集器,young Gc(STW)->初始化标记(STW)->根区域扫描->并发标记->再次标记(STW)->独占清理(STW,这个不是真的进行清理,而是计算需要清理的比例,对region进行排序)
3.混合回收,拿着2步骤的结果分多次进行回收(默认分8次,根据2步骤独占清理的时候进行的排序就行分批回收),这个里面就不管分代了,对所有region进行回收操作是STW的。由于是对一部分分区进行回收,所以这个停顿时间比较短并且可以根据配置进行控制。
4.当内存占用达到一定值了,没有办法依靠前面这几个步骤进行回收,就会停顿下来进行一次串行的Full GC,进而获取更多空间,这个Full GC效率极低,所以G1出现Full GC是需要最大关注的事情。

ZGC
很新的GC了,喊的很牛逼,但是真没见过有用的。
它的四个目标:支持大内存(实际上内存小了也不建议用它)、减少STW(一般10ms以内)、对吞度量影响小(减少吞度量15%以内,减少延时又没那么影响吞吐量这是很牛逼的)、为未来GC打下基础。
首先它和G1一样,是分区的,而不同的事它的分区大小是不同(2M、32M、N*2M),这样分区更灵活。回收的过程与G1也很相似(其实我感觉G1才是为未来GC打下基础的啊)。
主要特性:
1.和G1不同,这个是分页的,而且分页不均等。
2.NUMA,简答说就是CPU使用离自己最近的独立的内存,当然这个需要响应的支持才起作用。
3.颜色指针,所有对象都对应一个指针,而不只是对象头的那点信息,这个指针能够标识出来对象在回收过程中是否被移动了,若移动了需要修正对应的引用。
4.读屏障,之前的GC回收过程为了修正对象移动的引用修改一般采用的是写屏障,而这里使用彩色指针与读屏障结合使用,就减少了很多的开销。

各回收器的搭配?
Serial/Serial Old、
ParNew/Serial Old、
Parallel Scavenge/Serial Old、
Serial/CMS、
ParNew/CMS、
Parallel Scavenge/Parallel Old、
G1
理解:
1.Serial是最原始的收集器,所以相对来说比较万能,除G1以外的所有收集器都可以进行搭配。
Serial/Serial Old、
ParNew/Serial Old、
Parallel Scavenge/Serial Old
2.CMS只能做老年代收集可以和Serial搭配
Serial/CMS
3.而ParNew是Serial的并发升级版,所以可以替换Serial
ParNew/Serial Old(前面有过了)
ParNew/CMS
4.Parallel收集器可以回收新生代可以回收老年代,所以有自己的搭配
Parallel Scavenge/Parallel Old
5.G1是个相对独立的收集器,由于分区方式都不同,所以没有能和它搭配使用的,就自己独立使用。

Stop The World的解释?
为什么有STW?在垃圾回收的时候,若不停顿所有线程那么就会有一些问题,比如标记垃圾的时候,这个对象还有引用,但是回收的时候引用已经没有了,那么漏了,这个叫浮动垃圾,不会导致错误,下次垃圾回收的时候可以回收掉。再比如刚创建一个对象还没有进行引用关联呢,创建完成之前标记完了,创建之后开始垃圾回收和引用关联一起发生了,那么这个对象就会被回收掉而引用指向了一个不存在的对象,这个就会报错,而STW主要就是为了避免这样的垃圾回收错误。
那么就可以尝试理解一下CMS为啥只有两个阶段STW了,初始标记的停顿,为了标记GC Root及下一级,如果不停顿会导致新生成的下一级关联不上Root对象。然后并发标记就是用上一阶段的结果一直向下进行查找和标记,这个过程中是和用户线程并行的,所以会产生新的Root对象和新的引用未被标记上。接下来重新标记并行进行并且STW也是防止未关联的对象漏标记的错误,而这时候需要标记的对象数量大大减少了,因为第二阶段大多数都已经标记过了,所以可以大大缩减STW时间,这个阶段标记的垃圾绝对是真实的垃圾可以放心清除,在下一个阶段就可以和用户线程并行的进行删对象了。由于是和用户线程并行的,所以只能使用标记清理算法,不能进行移动对象因为用户线程在不停的分配和使用内存呢。
哪些回收器什么时候发生STW?Serial、ParNew、Parallel只要进行垃圾回收,就要开始停顿,CMS在1、3阶段停顿,G1在新生代区回收就会停顿老年代回收类似CMS的1,3阶段停顿,但是它每个区比较小所以停顿时间很短。
垃圾回收的安全点?垃圾回收的时候,并不是一触发垃圾回收,所以线程咔嚓一下就停在那不动了,举个例子:有连续两条命令前一个是创建对象并获得对象地址,后一个是将地址引用赋值到一个变量,那么线程停在了创建对象和赋值引用之间,这个对象就会被当做垃圾回收掉。而垃圾回收完恢复后赋值引用就是个野指针了啊。和STW的例子是不是差不多。为了解决这个问题,在编译的时候就会把一些叫做safepoint的代码放在命令中,触发垃圾回收的时候,线程运行到安全点后就暂停了,所有线程都到达安全点后才开始进行垃圾回收的。这里有两个问题,第一个问题安全点的稀疏问题,太多会影响编译后程序大小和运行时安全点检查效率,太少垃圾回收的时候等待执行到安全点的时间会太长,所以jvm已经做了折中处理(这个我们也没啥办法改,只能用这啊),第二个问题就是实际上STW的时间到底是多少,这个没有统一时间,因为每个线程停止的时间点它就不一样,有的先停有的后停。这两个问题都不是我们能处理的问题,但是至少值得我们进行思考。

GC收集器的选择
首先思考几个问题:
1.GC时间消耗主要在哪里?我们可以综合所有的GC回收过程中需要STW的部分,Serial、ParNew、Parallel他们是开始回收都要STW,而CMS、G1的回收过程中需要停顿的基本都是标记的时候,所以标记的过程肯定要停顿,而且也是主要消耗时间的地方。
2.为啥Serial、ParNew、Parallel不只是在标记的时候才停顿回收的时候并行呢?因为他们都是分代收集的而且有压缩算法,不停顿的话每个带比较大,动态处理引用变化的开销可能比STW还要大。
3.传统GC中Full GC 肯定比Young GC慢么?比较老的java开发基本都是这个印象,young gc是很快的,其实这个不是因为它快,而是因为它对应回收的空间小。有部分特殊应用内存配置可能需要老年代很小而年轻代很大,那可就不定了。前面说过时间消耗大多数都消耗在标记上了,而标记的过程就是遍历要收集的那块内存上的所有对象的引用关系,所以空间大对象多消耗的时间自然就多,如果是几百兆那是毫秒级的,如果是几个G那就是几十上百毫秒级的所以不分是Full GC还是youngGc。所以G1之后都采用分区,单个区比较小,而且有Rset记录跨区引用,也减少了遍历时间,所以延迟时间更小更可控。
4.如果让你优化GC那么首先优化的哪里?因为GC的时间消耗在了遍历引用上,那么如果引用在最开始就记录和组织好,也就是省去了标记环节了,不用标记就可以直接删对象那不就几乎不用停顿了么。但是这所有的引用维护起来需要创建更多的结构对象或者占用更大的内存,维护这些对象或内存的开销和程序的复杂度也是一直不采用这种方式原因了,自始至终都没有用过这方案,都是在回收的时候STW进行计算的原因。
简单的GC选择方式:
管理几百兆内存就用SerialGC
2G内存以内就用ParallelGC
8G内存以内就用CMS+ParNew
百G内存以内就用G1
百G以上考虑ZGC

参考资料:
JVM七大垃圾回收器上篇
JVM七大垃圾回收器下篇
代表Java未来的ZGC深度剖析,牛逼!

posted @ 2021-02-23 18:03  Q-JayLee  阅读(150)  评论(0)    收藏  举报