Java虚拟机系列之垃圾回收

342 阅读9分钟

判断对象存活方式

GC无非就是涉及3个场景:

  • 哪些内存需要回收
  • 什么时候回收
  • 如何回收

对于第一个问题,我们首先要搞清楚的对象是否要回收了,也就是判断对象是否还存活,判断方式有:

引用计数法

引用计数法就是给对象添加一个引用计数器,每当有一个地方引用它时,就加1,当不再引用时,就减1。

这种方法实现简单,而且判定效率也不错。但是有个问题是,它无法判断的相互引用的场景,如:

a.test = b; b.test = a

而且Java虚拟机也不是使用引用计数法

可达性分析算法

可达性分析是通过一系列的GC root(全局性引用,如常量或类静态属性)的对象作为起始点,从这些起始点开始向下搜索,所走过的路径称为引用链,如果从GC root到对象存在引用链,那么对象就是存活的,否则对象就可以回收,如图

引用类型

判断是否存活跟引用有关,而引用可以分为4中类型

  • 强引用:如 Object a = new Object(),只要强引用存在,就不会发生垃圾回收。
  • 软引用:SoftReference类实现,软引用关联的对象,在系统将要发生内存溢出时,会将这些对象放入回收队列中进行第二次回收。
  • 弱引用:WeakReference类实现,比软引用更弱,弱引用关联的对象,只能活到下一次垃圾回收之前。
  • 虚引用:PhantomReference类实现,虚引用唯一的目的就是在对象被垃圾回收时能收到一个系统通知,对对象的生存时间无印象。

F-Queue队列

即时不可达的对象也不是马上就进行回收的,需经过两次标记过程:

第一次标记并筛选对象是否有必要执行finalize()方法,无必要(没实现这个方法)就直接回收,有必要就把对象放入F-Queue队列里,然后由Finalizer线程触发虚拟机执行finalize(),所以对象如在F-Queue队列再次内引用,就可以获得存活的机会。如在finalize()里被引用。

垃圾回收算法

标记清除法

最基础的算法就是标记-清除法,该算法分为标记、清除两个阶段。首先标记所有需要回收的对象,在标记完成后进行统一回收。

这个算法实现简单,但是存在大量的内存碎片,并且标记和清除的效率也不高

复制算法

将可用内存划分为两部分,每次只使用其中一块,当一块快用完了,就将还存活的对象复制到另一块中上面,然后把已使用过的内存清理掉。

这个算法实现简单,运行高效,但是将内存分为两块,缩小了内存使用范围

标记-整理算法

标记-整理算法的标记阶段和标记-清除算法一样,但后续的整理阶段是将存活的对象移动到内存的一端,然后直接清理端边界以外的内存。整理就是为了避免内存碎片。

分代回收算法

分代回收就是按给堆的区域使用不同的回收算法,如新生代适合使用复制算法,老年代适合使用标记-清除算法或标记-整理算法。

垃圾收集器

这里主要看HotSpot实现的垃圾收集器,如图

每个年代都有各自适用的收集器,如新生代使用Serial、ParNew、Parallel和G1(G1是两个年代适用的),老年代适用CMS、Serial Old和Parallel Old。图中的连线代表可以配合适用,没有连线的代表不能配合适用。

这里要先讲一个叫安全点(Safepoint)的东西,程序并非在所有的地方都能停下来进行GC,而是在“特定的位置”(安全点)到达时才能暂停。安全点的选定不能太少让GC等待时间太长,也不能太多频繁GC。

Serial收集器

这个收集器是单线程收集器,只会使用一个CPU或一个线程进行垃圾回收,,在进行垃圾回收时,必须暂停其他所有的工作线程。Stop The World可能会让应用难以接受,比如每运行一个小时,就要暂停5分钟进行垃圾回收,估计要砸电脑了。

ParNew收集器

这个收集器其实是Serial收集器的多线程版本,在垃圾回收时使用多线程执行,其余的控制参数、收集算法、STW、对象分配规则和回收策略跟Serial完全一样,并无太多的创新之处。

Parallel Scavenge收集器

Parallel Scavenge收集器是一个新生代收集器,使用复制算法,也是多线程收集器,这些和ParNew一样,区别在于:Parallel Scavenge的目的是要达到一个可控制的吞吐量,高吞吐量可以高效地利用CPU时间,尽快完成程序的运算任务。该收集器提供 -XX:MaxGCPauseMillis和 -XX:GCTimeRatio参数精确地控制吞吐量

Serial Old收集器

Serial Old收集器是Serail收集器的老年代版本,同样是单线程收集器,使用标记-整理算法,这个收集器的目的是给Client模式下的虚拟机使用的。

Parallel Old收集器

这个收集器是Parallel Scavenge收集器的老年代版本,使用多线程和标记-整理算法。它存在的意义是在新生代选择Parallel Scavenge时,可以多一种选择,如上面的收集器配合使用图,当新生代选择Parallel Scavenge时,老年代只能使用Serial Old,而Serial Old在服务器端应用性能上不佳。而且Parallel Scavenge不能和CMS配合使用。

CMS收集器

CMS(Concurrent Mark Sweep)收集器是一种以获取最短回收停顿时间为目标的收集器,现在广泛应用于互联网的服务器上。CMS收集器是基于标记-清除算法实现的,整个过程分为4步:

  • 初始标记:仅标记一下GC root能直接关联的对象,速度很快
  • 并发标记:进行GC root跟踪过程
  • 重新标记:修正并发标记期间因用户程序继续运行而导致标记发生变动的那一部分对象的标记记录
  • 并发清除:与用户线程一起并发执行

CMS有明显的3个缺点:

  • 对CPU资源非常敏感。当CPU在4个以上时,并发回收时垃圾收集线程不少于25%的CPU资源,并且随着CPU数量增加而下降。当CPU在不足4个时,CMS对用户程序的映像就可能变得很大,可能要分出一半的运算能力去GC而导致性能忽然下降50%。
  • 无法处理浮动垃圾。伴随用户程序运行时而产生的垃圾,只能等下一次GC时再清理掉,这部分就是浮动垃圾。
  • 产生大量的空间碎片。CMS是基于标记-清除算法的,这种算法前面提到,会产生大量的空间碎片,为了解决这个问题,CMS提供了-XX:+UseCMSCompactAtFullCollection参数,用于在CMS顶不住要进行FullGC时进行内存碎片的合并整理。

G1收集器

G1是一款面向服务器应用的收集器,旨在替换CMS收集器。G1有以下特点:

  • 并行与并发:使用多个CPU缩短STW时间,并且通过并发的方式让Java程序继续运行
  • 分代收集:对旧对象收集效果更好
  • 空间整合:G1整体看是基于标记-整理算法,局部看是基于复制算法,保证运行期间不产生内存空间碎片
  • 可预测的停顿:G1除了追求低停顿时间,还建立可预测的停顿时间模型。

G1将这个Java堆分为大小相等的独立区域(Region)虽然保留了新生代和老年代的概念,但物理上不隔离。G1的运行过程,可分为:

  • 初始标记:仅标记一下GC root能直接关联的对象
  • 并发标记:从GC root进行可达性分析,找出存活对象
  • 最终标记:修正并发标记期间因用户程序继续运行而导致标记发生变动的那一部分对象的标记记录
  • 筛选回收:对各个Region的回收价值和成本进行排序,根据用户所期望的GC停顿时间来指定回收计划

内存分配与回收策略

对象的内存分配,就是在堆上分配,分配是按照一定的分配规则进行。

  • 对象优先在Eden分配:对象在新生代Eden区中分配,当Eden区没有足够的空间进行分配是,JVM发生一次Minor GC。存活的对象进入Survivor空间,大的存活对象通过分配担保机制进入老年代。Eden区和Survivor的空间比例是8:1,可通过-XX:SurvivorRatio调整。

  • 大对象直接进入老年代:大对象如很长的字符串和数组,JVM提供了-XX:PretenureSizeThreshold参数,另大于这个设置值的对象直接进入老年代分配,避免在Eden和Survivor区直接发生大量的内存复制。

  • 长期存活的对象将进入老年代:JVM给每个对象一个年龄Age计数器,每一次Minor GC仍然存活的对象,其年龄就加1,当年龄达到一定程度(默认为15),就会晋升到老年代。可通过-XX:MaxTenuringThreshold设置。

  • 动态年龄判定:如果在Survivor区中相同年龄的所有对象大小总和大于Survivor空间的一半,年龄大于或等于该年龄的对象,就直接进入老年代。

  • 空间分配担保:在Minor GC之前,JVM会检查以下老年代的连续空间是否大于新生代所有对象的总空间,如果大于,则Minor GC是安全可行的;如果小于,JVM会检查HandlePromotionFilure值是否允许担保失败,如果允许,那么JVM再检查老年代的连续空间是否大于以前晋升的老年代对象的平均大小,如果大于,则尝试Minor GC;如果小于,或者HandlePromotionFilure值不允许,则进行一次Full GC。