回顾上文对于GC算法的大概介绍,希望大家可以深入思考下因为这会让你对具体运用的理解更加容易,赶快上车~
在JAVA的 众多八股文中你可能能都听过Serial(New/Old),ParNew,Parallel(New/Old),G1....
有些文章说的比较抽象,但是如果结合实际的设计理解起来还是很容易的,我们不妨先换个角度,就是让你设计个垃圾回收器你会先考虑什么?
1.实现复杂度,这是最先想到的
2.具体场景,其实没有好与不好也没有强与不强,其实都是具体场景下促成,但是其实无外乎下面几点时要考虑到的。(这里插一句,比如技术好就可以进大厂拿高薪吗?大厂的员工就一定很厉害吗?未必,其实只不过都是特定场景下特定的人的共同作用下促成的,并没有什么好不好也没有什么强不强,都是相对的罢了。这里要时刻勉励自己吧,强不强没有绝对的也不是谁说了算的,应该以发展的眼光看待自身吧。这里又感慨了~)
①对于CPU资源的消耗
②对于用户线程的影响
③回收效率
在HotSpot虚拟机中,将堆内存分为新生代、老年代、永久代(1.8以后移除,可以具体参考内存结构)其实这里面换一个角度,JVM只不过是按照基础的操作系统的进程分段换份自己实现了一套自己的内存结构,包括虚拟机栈,本地方法栈,堆内存,程序计数器,堆外内存这些都是金鱼操作系统进程的映射,但是并不是说JVM堆就对应JVM进程的堆了其他结构也不是一一对应的因为JAVA的运行数据是要受虚拟机管理的,比如GC等所以实际上是一种映射。
话说回来,为什么要分代回收,因为实例的生命周期是千差万别的,尤其是对于堆来说对象的生命周期肯定不相同(题外话,JAVA中的对象都是分配在堆上吗?思考下吧~)所以根据回收算法不同产生了针对不同分代的垃圾回收器。
直接先上个图吧,不想听我叭叭的可以直接看图(但是其实并没有这么简单)
Serial
是单线程,不论新老,单线程也不一定不好,这伪并发场景下线程的时间片切换实际上也是个消耗,因为实际上JVM的线程实际上也是1:1映射了操作系统线程所以某些场景下实际上更快,新生代采用复制算法,老年代采用标记-压缩算法。整个过程STW,这里为什么这样选可以看前文算法特点的分析~
ParNew
是针对新生代的,采用复制算法,但是采用多线程并发执行,同样也会整个过程STW。与CMS可以搭配使用,因为在那个年代只有他可以...因为前者都是单线程嘛这搭配起来也不合理
Parallel
这个系列以吞吐量为主,啥是吞吐量?打个比方CPU运算,每次回收都会占用CPU资源,GC占用太多的话用户线程分到的资源就少,单位时间处理能力就变弱了也就是吞吐量变低了。
综上实际上它采用的策略是精确控制吞吐量通过参数配置的形式,同时有GC动态调整策略也就是根据虚拟机近期的运行状态调整它的STW时间和GC线程运行状态,因为本质这东西是多线程的是会消耗比较大的CPU资源的,方式就是通过自身算法收集单位时间虚拟机运行状态完成的。
新生代采用复制算法老年代是标记-压缩算法,这个相传比较适合计算场景,因为相对来说对吞吐量是有控制的所以对于CPU密集型程序是先对友好的。但是我猜想实际效果也不会太好因为调节了回收参数也就意味着放弃了一部分GC机会个人猜想可能出现回收不干净不及时的问题(没论证过...瞎猜的有兴趣的可以实践一下)
CMS
这是在早期比较流行的一个适合Server的回收器,旨在提供最短的停顿时间,因为相对来说Server需要的是更快的接发请求基本也都集中在IO上,也不会有大量的CPU的密集运算。
原理是采用标记-压缩的方式
CMS收集器工作的整个流程分为以下4个步骤:
- 初始标记(CMS initial mark):仅仅只是标记一下GC Roots能直接关联到的对象,速度很快,需要“Stop The World”。
- 并发标记(CMS concurrent mark):进行GC Roots Tracing的过程,在整个过程中耗时最长。
- 重新标记(CMS remark):为了修正并发标记期间因用户程序继续运作而导致标记产生变动的那一部分对象的标记记录,这个阶段的停顿时间一般会比初始标记阶段稍长一些,但远比并发标记的时间短。此阶段也需要“Stop The World”。
- 并发清除(CMS concurrent sweep)
上面这东西网上一搜相关的八股就能搜到,但是实际上联系下标记压缩的过程,第一阶段也就对应了初始标记,把每个GCROOT标记出来,然后更改分配预计到达空间,分配更改到新指针,并发标记时间上是从GCROOT下面的引用对象开始向下递归(也就是沿用上面的逻辑)。之后的重新标记肯定也就是访问屏障区解决漏标多标问题的,最后是并发清楚。
联想起算法来很好理解,因为开始GCROOT肯定要STW的之后向下递归肯定没必要了,之后的重标STW估计是为了不要出现访问不完的情况引入的最后再并发清除未引用的队列。
但是CMS在若干次清除后也会进行碎片整理,这个整理过程实际上小号就比较大了。
但是这个算法本身要维护一个空闲列表并且会碎片化在产生了大对象时可能会多次GC,其实这里面也会先去堆伸缩自己去扩容堆内存的大小。
G1
这和CMS的目标是一致的,G1采用了很大的优化虽然也有新老年的概念但是整体分为多个Region,整体上是标记-压缩算法,在单个Region中是复制算法。
当然这么做会带来比较大问题,比如多个对象在不同的Region中但是互相引用,怎么快速找到对方进行可达性分析呢?不可能全堆扫描吧,所以出现了以下机制:检查Reference引用的对象是否处于不同的Region之中(在分代的例子中就是检查是否老年代中的对象引用了新生代中的对象),如果是,便通过CardTable把相关引用信息记录到被引用对象所属的Region的Remembered Set之中。当进行内存回收时,在GC根节点的枚举范围中加入Remembered Set即可保证不对全堆扫描也不会有遗漏。其实Remembered Set在别的回收器中也存在,只不过G1中是绑定到Region的。
①初始标记: 标记从根节点直接可达的对象。这个阶段会伴随一次新生代GC,它会产生全局停顿。
②根区域扫描: 由于初始标记必然会伴随一次新生代的GC,所以在初始化标记后,eden被清空,并且存活对象被移入survivor区。这个阶段,将扫面survivor区直接可达的老年代对象,并标记这些直接可达的对象。根区域扫描不能和新生代GC同时执行。
③并发标记: 和CMS类似,扫面查找整个对存活的对象,这是一个并发的过程,可以被一次新生代GC打断。
④重新标记: 由于并发标记过程中,应用仍在执行,因此标记结果需要修正,所以对上一次的标记结果进行补充,在G1中,这个过程使用STAB算法完成。即G1会在标记之初为存活对象创建一个快照,有助于加速重新标记速度。
⑤独占清理: 这个阶段会引起停顿。
⑥并发清理阶段: 识别并清理完全空闲的区域。它是并发的清理,不会引起停顿。
这里暂时不细致分析,因为G1真实场景还是挺复杂的,有兴趣的可以自行了解一下或者等我后续更新~
其实上面这些垃圾回收器说白了各有优缺,有能力的完全可以自己设计一个回收算法,因为本来也是这么演进的嘛。
最后分代年龄的问题,也就是怎么界定新/老年以及怎么移动,以及细致的分代划分东西挺多,那就连同GO单独写个第三节吧~