前言
文章是看了《深入理解Java虚拟机》书后进行的整理和总结,算是一个读书笔记吧。
- 深入理解Java虚拟机一 虚拟机内存管理机制
- 深入理解Java虚拟机二 虚拟机类加载机制
- 深入理解Java虚拟机三 垃圾回收机制
一、如何确定对象已死
虚拟机的垃圾收集策略自动为我们管理虚拟机的内存空间,当某个对象“已死”,虚拟机就会在适当的时机将该对象占用的内存释放。如果你是一个思维灵活的人,那么你应该看出这句话的几个要点了。
- 如何判断对象是否“已死”,或者说已经不再使用了呢?
- 如何释放这些对象占用的内存?
- 虚拟机在适当的时机回收,那这个时机是怎样的呢?
1、引用计数算法
这是一种比较简单也容易理解的算法。给对象添加一个一个引用计数器,每当有一个地方引用它,计数就加1,当引用失效,计数就减1,任何时刻,计数为0的对象就不能再使用。
看起来这个方式似乎可行,但是在我们Java 程序中总会存在一种A引用B,B引用A的情况,而实际上两个对象也已经不会再被访问,但他们的引用计数却不是0。如果使用引用计数,这种循环引用的情况就不能解决,届时虚拟机也就会出现内存泄露。而实际上我们现有的虚拟机也的确不是使用这种方式。
2、可达性分析
java虚拟机主要使用一种可达性分析的算法来判断对象是否存活的。
这个算法的基本思路就是通过一系列称为“GC Roots”没有任何引用链相连时,也就是说从“GC Roots”节点不能到达这个对象时,就证明该对象是不可用的,可以回收。
在java语言中,可以做为“GC Roots”的节点有以下几种:
- 虚拟机栈中引用的对象
- 方法区中类静态属性引用的对象
- 方法区中的常量引用的对象
- 本地方法栈中JNI引用的对象
所以很多垃圾收集算法在进行垃圾收集开始的第一步就是标记这些GC Roots起点,然后开始标记从GC Roots可达的节点。
二、垃圾收集算法
知道了如何确定对象已死,才是垃圾收集的第一步,接下来是要明白,如何进行这些对象的内存释放。他肯定不是简简单单的标记,然后清理掉这些对象就可以了,这里是有很多事情需要考虑的。
- 比如只是单纯的清除,那内存形成大量的碎片怎么办?
- 如果对碎片进行整理,运行的程序就会停止,如何降低这种停止的时间或者频率?
这些都是垃圾收集需要考虑的要点。
1、标记-清除算法
这是一个最基础的算法,标记-清除(Mark-Sweep),如同它的名字一样,算法分为标记和清除两个阶段:
- 首先标记出所有需要回收的对象
- 在标记完成后统一回收所有被标记的对象
我们可以思考一下,这上面的两个过程中哪个过程是需要停止程序的,哪个过程是可以和程序并行的。
这个算法是有缺点的:
- 标记和清除的两个动作效率并不高,当然如果要处理的空间很小的话就另当别论了。
- 另一个是空间问题,标记清除因为没有整理的过程,所以导致产生很多不连续的内存碎片,如果要分配的对象稍大一些,可能都找不到一块连续的内存空间用来分配。
2、复制算法
为了解决效率以及内存碎片的问题,设计出了复制算法。
这个算法是把可用的空间分成两块相等的区域,当一块内存用完后,就将当前区域的存活对象复制到另一块上,然后直接清空当前这块内存区域,当另一块内存用完则重复这个步骤。
当存活对象较少时这个算法效率很高,同时也不会出现内存碎片,但还是有缺点:
- 内存利用率过低,最多只能使用一半的内存,另一半一直处于闲置状态。
3、标记-整理算法
复制算法在对象存活率高时就要进行很多的复制操作,效率就会降低,同时复制算法的内存利用率也很低,只有50%,所以该算法不能应用在所有场景。
而在需要提高内存利用率的场景来说,就出现了标记-整理算法(Mark-Compact)。
- 标记的过程仍然和标记-清除算法相同
- 标记完成后是让所有对象都向一端移动,然后直接清理到边界外的内存。
这种算法效率其实也不高,但胜在内存利用率高,不会产生内存碎片,但也有一个致命的缺点:
- 整理的过程是需要STW,即Stop The World,停止程序,而且如果存活对象很多,这个时间是相当长的。
4、分代收集算法
这个算法不能算是一个独立的算法,因为以上三个算法都有自己的缺点以及优势在,他是对以上算法的结合,取长补短,从而提升垃圾收集性能和稳定。
当前的虚拟机收集算法大多都是使用这种方式。
他将堆空间分为两块大的区域:
- 年轻代
IBM有过专门的研究表明,大多对象(年轻代对象)在产生后98%都是朝生夕死的,也就是说可以利用复制算法,因为存活对象较少,复制过程效率高,同时没有内存碎片。
同时因为存活的较少,我们完全可以不必做1:1的分配,这也就衍生出了年轻代空间的划分:Eden区和两块Survivor区,其中Eden区较大,新生的对象都放在这块区域,另外两块相同大小的Survivor区则较小。
当Eden区达到垃圾回收的条件时,则将eden区中存活的对象复制到Survivor0区,然后清空eden区,当eden区再次触发回收则将eden区和Survivor0中存活的对象复制到Survivor1区,如此往复几次,如果对象多能存活下来,则会被放入老年代。
通常eden和Survivor空间的比例是8比1,这样内存利用率也能提升,效率也能得到提升。大多垃圾收集器都是在年轻代使用这种算法。 - 老年代
老年代通常都是一块比较大的空间,老年代中对象的存活率又比较高,因为都是经过了几次垃圾回收还能存活下来的对象。所以在这里使用复制算法就不合理,能选的也就是标记-清除算法和标记-整理算法。事实上通常都是这两种算法的结合,因为这两种算法各自有一个特别的特点:标记-清除算法的清除过程是可以和程序并行的,标记-整理算法的整理过程是必须要STW的。
所有有时候老年代大多是用标记-清除算法,等到标记-清除算法没办法释放出足够连续空间时则会触发标记-整理算法,通常标记-整理的过程也就是我们所说的Full GC了。
三、垃圾收集器
垃圾收集算法是内存回收的方法论,而垃圾收集器就是内存回收的具体实现了。
上图展示了目前的7种垃圾收集器,横线的上半部分代表了年轻代的垃圾收集器,下半部分为老年代的垃圾收集器,而G1则跟其他收集器不同,他收集不再是这种分代的收集方式,而是对整个堆空间的管理。
不同垃圾收集器的连线代表是这两种收集器可以搭配使用。
1、Serial收集器
Serial收集器是一个最基本的收集器,从名字我们也能看出,他的收集过程是串行的,而且是单线程的。他收集时需要STW,这种收集器在年轻代就是Serial,通常使用复制算法,在老年代就是Serial Old,通常使用标记-整理算法。
通常由于这种垃圾收集器STW过长的原因,不会在服务端程序中使用,但是在Java客户端程序中应用反而比较多。
2、ParNew收集器
ParNew收集器是Serial收集器的多线程版本,这种收集器的过程和Serial相同,只是利用了多个线程来进行垃圾收集,如果服务器CPU性能好的确是能够提升性能,但如果机器只有一个CPU的话,ParNew也并不会有性能提升。
另外ParNew是除了Serial外唯一能和CMS搭配的年轻代垃圾收集器,而CMS是一个常用的老年代垃圾收集器。
3、Parallel Scavenge收集器
Parallel Scavenge 收集器与其他垃圾收集器关注点稍有不同,其他垃圾收起机器更关注STW的停顿时间,比如ParNew多线程也是为了降低停顿时间,CMS并行收集也是为了降低停顿时间。
而Parallel Scavenge更关注的是吞吐量,吞吐量是CPU用于运行用户程序的时间和与CPU总消耗时间的比值。
停顿时间越短则越适合需要与用户交互的程序,良好的响应速度提升用户体验,而高吞吐量则可以高效率的利用CPU时间,尽快完成程序的运算任务。
Parallel Scavenge收集器有一些调优参数可选,因为这个垃圾收集器并不常用,在这里也就不展开说了。
4、Parallel Old收集器
Parallel Old是Parallel Scavenge收集器的老年代版本,使用多线程和标记-整理算法。具体这里不也不细说了。他的思路也是和Parallel Scavenge收集器相同,以吞吐量为优先。
5、CMS收集器
CMS收集器是一种以获取最短回收停顿时间为目标的收集器。目前大多服务器应用都是用了该收集器。它的全名是Concurrent Mark Sweep,从名字就能看出这是一种使用标记-清除算法的垃圾收集器。它的运作过程就要比前面的几种收集器复杂一些:
- 初始标记
这个过程是需要STW的,但因为只需要标记出GC Roots能直接关联到的对象,所以,这个过程速度很快。 - 并发标记
这个阶段是和程序并行运行的,这个过程是并发标记那些能被GC Roots关联到的对象。 - 重新标记
重新标记是为了修正并发标记期间因用户程序继续运作而导致标记产生变动的对象的标记记录,这个过程也是STW的,但耗时也比较短。 - 并发清除
最后通过并发来清除那些不被使用的对象完成垃圾收集。
这里其实过程讲的比较粗糙,比如标记过程到底是如何运作的,如何处理那些在并发标记过程中产生的新的对象,或者产生变化的对象。这个可以了解三色标记法。
Java JVM 4:CMS 垃圾收集器 - 工作原理,浮动垃圾,三色标记法等
CMS垃圾收集器同样有一些缺点:
- 对CPU资源非常敏感,如果机器只有一个CPU,用CMS收集器可能还没Serial收集器来的快
- CMS无法处理浮动垃圾,因为CMS并发清理阶段,用户线程还在运行,还会有新的垃圾产生,而这部分垃圾CMS收集器是无法处理的,只能等到下一次GC。同时由于用户线程在运行也就导致可能存在在用户运行过程中不断产生垃圾,从而导致没有剩余的空间给用户线程使用,从而触发一次Full GC,而这次Full GC通常都是用Serial Old垃圾收集器来处理的。触发CMS收集的话必须要JVM还有一定的剩余内存空间,这个空间可能是70% 可能是90%。
- CMS收集器使用标记清除算法,会产生很多内存碎片,从而有大对象申请空间时不能分配而触发Full GC。这个过程可以通过一些参数选择来让CMS收集器进行一次碎片整理过程,只是会花费更多的时间。
5、G1垃圾收集器
G1垃圾收集器和上面的分代垃圾收集不同,它是将整个内存区域划分为多个大小相同的区域(Region),虽然有老年代和新生代的概念,但他们不再是物理隔离了,而是一部分Region的集合。
G1的垃圾收集避免了在Java堆中的全区域垃圾回收,它跟踪各个Region里面垃圾堆积的价值大小,比如回收消耗的时间以及回收能得到的空间大小,在后台维护了一个优先级列表,每次根据允许的收集时间,优先回收价值最大的Region,这保证了G1收集器能在有限的时间内可以获取尽可能高的收集效率。
G1垃圾收集器通常也是以下几个步骤:
- 初始标记
- 并发标记
- 最终标记
- 筛选回收
这个垃圾收集器是一个被寄予厚望,希望未来能够用来代替CMS垃圾收集器。
四、内存回收和分配策略
大多数情况下,对象在新声代Eden区中分配,当Eden区没有足够的空间时,会触发一次Minor GC,也就是年轻代的GC。
但有些大对象因为过大,会占用过多Eden空间,在分配时会直接分配到老年代。
如果有些对象在年轻代经过几次GC仍然存活,则会被放进老年代空间中。
而Minor GC过程中如果Survivor区无法容纳存活的对象,也会被放入老年代中,如果这时候老年代空间不足,就会触发Full GC。