对象存活判断

判断对象是否存活一般有引用计数法和可达性分析两种方式。

引用计数算法

为每个对象添加一个引用计数器,新增一个引用时计数器加1,引用释放时计数器减1,计数器为0时该对象可以被回收。

引用计数法实现简单且高效,但无法解决对象之间相互循环引用的问题。

可达性分析算法

通过一系列GC Roots作为起始点向下搜索,搜索所走过的路径称为引用链,当一个对象到GC Roots没有任何引用链相连时,则证明此对象是不可引用的。

arrive

可作为GC Roots的对象包括下面几种:

  • 虚拟机栈中引用的对象。
  • 方法区中类静态属性引用的对象。
  • 方法区中常量引用的对象。
  • 本地方法栈中JNI引用的对象。

finalize

如果对象在进行可达性分析后发现没有与GC Roots相连接的引用链,那它将会被第一次标记。如果没有覆盖finalize()方法或者该方法已经被虚拟机调用过,那么它将被回收;否则,会将这个对象放置在一个叫做F-Queue的队列中,要想不被回收,就要在finalize()中重新与引用链上的任何一个对象建立关联。

引用类型

对象的引用类型分为强引用、软引用、弱引用、虚引用,这四种引用强度依次减弱。

  • 强引用:类似Object obj = new Object()这类的引用,强引用关联的对象永远不会被回收。
  • 软引用:软引用是用来描述一些还有用但并非必需的对象。在系统将要发生内存溢出异常之前,将会把这些对象列进回收范围之中进行第二次回收,如果这次回收还没有足够的内存才会抛出内存溢出异常。简单的说,被软引用关联的对象只有在内存不够的情况下才会被回收。
  • 弱引用:强度比软引用更弱一些,无论当前内存是否足够,被弱引用关联的对象一定会被回收。
  • 虚引用:一个对象是否有虚引用的存在,完全不会对其生存时间构成影响,也无法通过虚引用取得一个对象。为一个对象设置虚引用关联的唯一目的就是能在这个对象被回收时收到一个系统通知。

垃圾收集算法

最基础的垃圾收集算法有三种:标记-清除算法、复制算法、标记-整理算法,我们常用的垃圾回收器一般都采用分代收集算法。

标记-清除算法

首先标记出所有需要回收的对象,在标记完成后统一回收所有被标记的对象。

不足:标记和清除的两个过程的效率都不高;会产生大量不连续的内存碎片,导致无法给大对象分配内存。

复制算法

将内存划分为大小相等的两块,每次只使用其中一块,当这一块内存用完了就将还存活的对象复制到另一块上面,然后再把使用过的内存空间进行一次清理。

现在的商业虚拟机都采用这种收集算法来回收新生代,但并不需要按照1:1的比例来划分内存空间,而是将内存分为一块较大的Eden空间和两块较小的Survivor空间,每次使用Eden和其中一块Survivor。当回收时,将Eden和Survivor中还活着的对象一次性地复制到另外一块Survivor空间上,最后清理掉Eden和刚才用过的Survivor空间。

如果每次回收有多于 10% 的对象存活,那么一块 Survivor 空间就不够用了,此时需要依赖于老年代进行分配担保,也就是借用老年代的空间存储放不下的对象。

标记-整理算法

复制算法在对象存活率较高时要进行较多的复制操作,也有可能需要额外的空间进行分配担保,所以在老年代一般不能直接选用这种算法。

标记-整理算法的标记过程仍与标记-清除算法一样,但后续步骤不是直接对可回收对象进行清理,而是让所有存活的对象都向一端移动,然后直接清理掉端边界以外的内存。

分代收集算法

现在的商业虚拟机采用分代收集算法,它根据对象存活周期将内存划分为新生代和老年代,新生代使用复制算法,老年代使用标记-清除或者标记-整理算法。

垃圾收集器

如果说收集算法是内存回收的方法论,垃圾收集器就是内存回收的具体实现。

Serial收集器

串行收集器是最古老,最稳定以及效率高的收集器,可能会产生较长的停顿,只使用一个线程去回收。

ParNew收集器

ParNew收集器其实就是Serial收集器的多线程版本。

Parallel Scavenge收集器

Parallel是一个多线程收集器。其它收集器关注点是尽可能缩短垃圾收集时用户线程的停顿时间,而Parallel Scavenge收集器的目标是达到一个可控制的吞吐量,它被称为“吞吐量优先”收集器。这里的吞吐量指 CPU 用于运行用户代码的时间占总时间的比值。

停顿时间越短就越适合需要与用户交互的程序,良好的响应速度能提升用户体验。而高吞吐量则可以高效率地利用 CPU 时间,尽快完成程序的运算任务,适合在后台运算而不需要太多交互的任务。缩短停顿时间是以牺牲吞吐量和新生代空间来换取的:新生代空间变小,收集时间缩短,但同时垃圾回收也变得频繁,导致吞吐量下降。

可以通过一个开关参数打开GC自适应的调节策略(GC Ergonomics),就不需要手工指定新生代的大小、Eden和Survivor区的比例、晋升老年代对象年龄等细节参数了。虚拟机会根据当前系统的运行情况收集性能监控信息,动态调整这些参数以提供最合适的停顿时间或者最大的吞吐量。

Serial Old收集器

Seriol Old是Serial收集器的老年代版本,同样是一个单线程收集器。

Parallel Old收集器

Parallel Old是Parallel Scavenge收集器的老年代版本,同样是一个多线程收集器。

CMS收集器

CMS(Concurrent Mark Sweep)收集器是一种以获取最短回收停顿时间为目标的收集器。目前很大一部分的Java应用都集中在互联网站或B/S系统的服务端上,这类应用尤其重视服务的响应速度,希望系统停顿时间最短,以给用户带来较好的体验。

从名字(包含“Mark Sweep”)上就可以看出CMS收集器是基于“标记-清除”算法实现的,它的运作过程相对于前面几种收集器来说要更复杂一些,整个过程分为以下几个阶段:

  1. Initial Mark:这个是CMS两次stop-the-wolrd事件的其中一次,这个阶段的目标是标记那些直接被GC root引用或者被年轻代存活对象所引用的所有对象。
  2. Concurrent Mark:在这个阶段收集器会根据上个阶段找到的GC Roots遍历查找,然后标记所有存活的对象,也就是进行GC Roots Tracing的过程。这个阶段会与用户的应用程序并发运行,因此在标记期间用户的程序可能会改变一些引用,并不是老年代所有的存活对象都会被标记。
  3. Concurrent Preclean:这也是一个并发阶段,与应用的线程并发运行。在并发运行的过程中,一些对象的引用可能会发生变化,但当种情况发生时,JVM会将包含这个对象的区域(Card)标记为Dirty,这也就是Card Marking。在这个阶段,能够从Dirty对象到达的对象也会被标记,这个标记做完之后,dirty card标记就会被清除了。
  4. Concurrent Abortable Preclean:这也是一个并发阶段,这个阶段是为了尽量承担stop-the-world中最终标记阶段的工作。
  5. Final Remark:这是第二个STW阶段,也是CMS中的最后一个,这个阶段的目标是标记老年代所有的存活对象,由于之前的阶段是并发执行的,gc线程可能跟不上应用程序的变化,为了完成标记老年代所有存活对象的目标,STW就非常有必要了。
  6. Concurrent Sweep:这个阶段清除那些不再使用的对象,回收它们的占用空间为将来使用。
  7. Concurrent Reset:这个阶段会重设CMS内部的数据结构,为下次的GC做准备。

由于整个过程中耗时最长的并发标记和并发清除过程中,收集器线程都可以与用户线程一起工作,所以总体上来说,CMS收集器的内存回收过程是与用户线程一起并发地执行。

CMS收集器具有以下几个缺点:

  • 在并发阶段,CMS虽然不会导致用户线程停顿,但是会因为占用了一部分线程(或者说CPU资源)而导致应用程序变慢,总吞吐量会降低。
  • CMS无法处理浮动垃圾。
  • 需要预留一部分空间提供并发收集时的程序运作使用,因此不能等到老年代完全被填满再进行收集,要是CMS运行期间预留的内存无法满足程序需要,就会出现“Concurrent Mode Failure”失败,这时将会启用Serial Old收集器重新进行老年代的垃圾收集,停顿时间就很长了。
  • CMS是基于“标记-清除”算法实现的收集器,会产生大量空间碎片。

浮动垃圾:由于CMS并发清理阶段用户线程还在运行着,伴随程序运行自然就还会有新的垃圾不断产生,这一部分垃圾出现在标记过程之后,CMS无法在当次收集中处理掉它们,只好留待下一次GC时再清理掉,这一部分垃圾就称为“浮动垃圾”。

G1收集器

G1(Garbage-First)收集器是一款面向服务端应用的垃圾收集器,在多CPU和大内存的场景下有很好的性能。HotSpot开发团队赋予它的使命是未来可以替换掉CMS收集器。G1收集器具有以下几个特点:

  • 垃圾收集线程和应用线程并发执行,和CMS一样。
  • 分代收集:不需要其它收集器配合就能独立管理整个GC堆,采用不同的方式去收集。
  • 空间整合:整体来看是基于“标记 - 整理”算法实现的收集器,从局部(两个Region之间)上来看是基于“复制”算法实现的,这意味着运行期间不会产生内存空间碎片。
  • 可预测的停顿:能让使用者明确指定在一个长度为M毫秒的时间片段内,消耗在GC上的时间不得超过N毫秒。

在G1算法中,采用了一种完全不同的方式组织堆内存,它将整个Java堆划分为多个大小相等的独立区域Region,每个Region是逻辑连续的一段内存,虽然还保留有新生代和老年代的概念,但新生代和老年代不再是物理隔离的了。

G1跟踪各个Region里面的垃圾堆积的价值大小(回收所获得的空间大小以及回收所需时间的经验值),在后台维护一个优先列表,每次根据允许的收集时间,优先回收价值最大的Region,这保证了G1收集器在有限的时间内可以获取尽可能高的收集效率。

由于难免存在一个Region中的对象引用另一个Region中对象的情况,为了达到可以以Region为单位进行垃圾回收的目的,G1收集器使用了RememberedSet这种技术。G1中每个Region都有一个与之对应的RememberedSet ,在各个Region上记录自家的对象被外面对象引用的情况。当进行内存回收时,在GC根节点的枚举范围中加入RememberedSet即可保证不对全堆扫描也不会有遗漏。

G1收集器的运作大致可划分为以下几个步骤:

  • 初始标记
  • 并发标记
  • 最终标记
  • 筛选回收

前三个步骤与CMS收集器相似,最后的筛选回收阶段对各个Region的回收价值和成本进行排序,根据用户所期望的GC停顿时间来制定回收计划。

内存分配策略

  • 对象优先分配在Eden区:如果Eden区没有足够的空间时,虚拟机执行一次Minor GC。
  • 大对象直接进入老年代:大对象是指需要大量连续内存空间的对象,这样做的目的是避免在Eden区和两个Survivor区之间发生大量的内存拷贝(新生代采用复制算法收集内存)。
  • 长期存活的对象进入老年代:为对象定义年龄计数器,对象在Eden区出生并经过Minor GC依然存活,将被移动到Survivor区中,年龄增加1岁,增加到年龄阈值则移动到老年代中。
  • 动态判断对象的年龄:如果在Survivor区中相同年龄所有对象大小的总和大于Survivor空间的一半,则年龄大于或等于该年龄的对象可以直接进入老年代而无需达到年龄阈值。
  • 空间分配担保:新生代使用复制收集算法,但为了了内存利用率,只是用其中一个Survivor空间来作为轮换备份,因此当出现大量对象在Minor GC后仍然存活的情况,就需要老年代进行分配担保,把Survivor无法容纳的对象直接进入老年代。老年代要进行这样的担保,前提是老年代本身还有容纳这些对象的剩余空间,一共有多少对象会活下来在实际完成内存回收之前是无法明确知道的,所以在发生Minor GC之前,虚拟机先检查老年代最大可用的连续空间是否大于新生代所有对象总空间,如果大于则确保是安全的,如果不大于,则只好以晋升到老年代对象容量的平均大小值作为经验值,与老年代的剩余空间进行比较,决定是否进行Full GC来让老年代腾出更多的空间。

方法区的回收

垃圾收集主要针对于Java堆进行,方法区虽然也有垃圾收集,但性价比很低,主要回收两部分内容:废弃常量和无用的类。

无用的类需要满足下面三个条件:

  • 该类所有的实例都已经被回收,也就是堆中不存在该类的任何实例。
  • 加载该类的ClassLoader已经被回收。
  • 该类对应的Class对象没有在任何地方被引用,也就无法在任何地方通过反射访问该类方法。

参考资料