JVM 垃圾收集器和内存分配策略

2017年04月25日

GC需要完成的三件事情

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

在JVM的内存划分中,程序计数器,虚拟机栈,本地方法栈3个区域随线程而生,随线程而灭。因此这几个区域的内存分配和回收基本都具有确定性,这几个区域不用过多的考虑回收的问题,因为方法结束或者线程结束时,内存自然就回收了。所以这里讨论的主要是 Java堆方法区 的内存回收。

哪些内存需要回收

判断对象是否可回收的方式:

  • 引用计数器 (存在循环引用问题)
  • 可达性分析 基本思路是通过一系列称为“GC Roots”的对象作为起点,向下搜索,如果一个对象无法通过GC Roots 到达,则判定其为不可用对象,可进行回收。(主流实现中基本都是通过此算法来判断对象是否存活的)

可作为 GC Roots 的对象

  • 虚拟机栈(栈帧中的本地变量表)中引用的对象。
  • 方法区中静态属性引用的对象。
  • 方法区中常量引用的对象。
  • 本地方法栈中 JNI(即一般说的Native方法)引用的对象。

回收方法区

永久代的垃圾收集主要回收两部分的内容:废弃常量和无用的类。Java虚拟机规范中说过可以不要去虚拟机在方法区实现垃圾收集,所以是否会堆方法区进行垃圾回收,需要看具体的JVM实现。

废弃常量的回收

例如常量池中的字符串“abc”,如果没有任何String对西那个引用常量池中的“abc”常量的话,这个字符串就是可以在必要时被清理的。常量池中的其他数据也类似。

无用的类需同时满足的条件

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

垃圾收集算法

  • 标记-清除 算法 (缺点:1.效率不高,标记和清除两个过程的效率都不高,2. 产生大量不连续的内存碎片)
  • 复制 算法(从一块内存区域中将所有存活对象复制到另一块内存,然后将前一块内存全部回收),通常会使用 一块Eden区和两块Survivor区 ,Eden和Survivor大小为8:1,应该通常新生代中的对象98%都是“朝生夕死”
  • 标记-整理 算法 (基于标记-清除后,将对象整理,使之不产生内存碎片)

现在一般的商业虚拟机都采用“分代收集”的策略。即根据各个年代的特点采用最合适的收集算法,老年代使用 标记-清除 或者 标记-整理 (由于老年代的对象存活率极高,没有额外的空间进行分配担保);新生代使用 复制 算法(由于新生代只有少量对象存活,只需要付出少量的复制成本就可以完成收集)。

HotSpot的算法实现

枚举根节点(GC Roots)

为节省扫描所有可作为GC Roots的节点的时间,HotSpot使用一组OopMap的数据结构来达到这个目的,在类加载完时,HotSpot就把对象内什么偏移量上是什么类型的数据计算发出来,在JIT编译过成中,也会在特定的位置记录下栈和寄存器中哪些位置是引用。这样,GC在扫描时就可以直接得知这些信息了。(保存了所有的GC Roots引用)可达性分析时,需要暂停所有用户线程,这个事件称为 Stop The World。

安全点

HotSpot并非为每条指令都生成OopMap,只有在“特定的位置”记录这些信息,这些位置被称为“安全点”(SafePoint),安全点的选择既不能太少以至于让GC等待时间长,也不能过于频繁以至于过分增大运行是的负荷。所以,安全点的选择基本是以程序“是否具有让程序长时间执行的特征”为标准选定的。“长时间运行”的最明显特征就是指令序列的复用,例如方法调用,循环跳转,异常跳转等,所以具有这些功能的指令才会产生 安全点。这个点会记录下所有的引用。

两种线程中断方式:

  • 抢占式中断:GC时,先把所有线程中断,然后发现有线程中断的地方不在安全点上,就恢复它,直到它到达安全点。(目前几乎没有虚拟机实现如此来做)
  • 主动式中断:GC时设置中断标志,线程主动轮询标志,发现中断标志就将自己中断挂起。(基本都是这么来实现的)

为避免线程长时间没有分配到时间片(处于Sleep或者Blocked状态的线程),将安全点扩展为安全区域(即:在一段代码片段中,引用关系不会发生变化)

垃圾收集器

新生代收集器

  • Serial:单线程收集,复制算法(是HotSpot虚拟机运行在Client模式下的默认的新生代收集器)
  • ParNew:多线程收集,Serial的多线程版本(是许多运行在Server模式下的虚拟机中首选的新生代收集器,其中有一个与性能无关的重要原因是,除了Serial收集器外,目前只有它能和CMS收集器(Concurrent Mark Sweep)配合工作)
  • Parallel Scavenge:复制算法。比其他收集器更关注 吞吐量(运行用户代码时间/(运行用户代码时间+垃圾收集时间)),CMS等收集器的关注点是尽可能缩短垃圾收集时用户线程的停顿时间,而Parallel Scavenge收集器的目标是达到一个可控制的吞吐量。

Parallel Scavenge收集器除了会显而易见地提供可以精确控制吞吐量的参数,还提供了一个参数-XX:+UseAdaptiveSizePolicy,这是一个开关参数,打开参数后,就不需要手工指定新生代的大小(-Xmn)、Eden和Survivor区的比例(-XX:SurvivorRatio)、晋升老年代对象年龄(-XX:PretenureSizeThreshold)等细节参数了,虚拟机会根据当前系统的运行情况收集性能监控信息,动态调整这些参数以提供最合适的停顿时间或者最大的吞吐量,这种方式称为GC自适应的调节策略(GC Ergonomics)。自适应调节策略也是Parallel Scavenge收集器与ParNew收集器的一个重要区别。

另外值得注意的一点是,Parallel Scavenge收集器无法与CMS收集器配合使用,所以在JDK 1.6推出Parallel Old之前,如果新生代选择Parallel Scavenge收集器,老年代只有Serial Old收集器能与之配合使用。

老年代收集器

  • CMS (Concurrent Mark Sweep),基于标记-清除,它的主要优点在名字上已经体现出来了:并发收集、低停顿,因此CMS收集器也被称为并发低停顿收集器(Concurrent Low Pause Collector)
  • Serial Old (MSC):单线程,基于标记-整理
  • Parallel Old:Parallel Scavenge收集器的老年代版本

Parallel Old收集器是Parallel Scavenge收集器的老年代版本,使用多线程和“标记-整理”算法。前面已经提到过,这个收集器是在JDK 1.6中才开始提供的,在此之前,如果新生代选择了Parallel Scavenge收集器,老年代除了Serial Old以外别无选择,所以在Parallel Old诞生以后,“吞吐量优先”收集器终于有了比较名副其实的应用组合,在注重吞吐量以及CPU资源敏感的场合,都可以优先考虑Parallel Scavenge加Parallel Old收集器。Parallel Old收集器的工作流程与Parallel Scavenge相同。

CMS的缺点:

  • 对CPU资源非常敏感 其实,面向并发设计的程序都对CPU资源比较敏感。在并发阶段,它虽然不会导致用户线程停顿,但会因为占用了一部分线程(或者说CPU资源)而导致应用程序变慢,总吞吐量会降低。CMS默认启动的回收线程数是(CPU数量+3)/4,也就是当CPU在4个以上时,并发回收时垃圾收集线程不少于25%的CPU资源,并且随着CPU数量的增加而下降。但是当CPU不足4个时(比如2个),CMS对用户程序的影响就可能变得很大,如果本来CPU负载就比较大,还要分出一半的运算能力去执行收集器线程,就可能导致用户程序的执行速度忽然降低了50%,其实也让人无法接受。
  • 无法处理浮动垃圾(Floating Garbage) 可能出现“Concurrent Mode Failure”失败而导致另一次Full GC的产生。由于CMS并发清理阶段用户线程还在运行着,伴随程序运行自然就还会有新的垃圾不断产生。这一部分垃圾出现在标记过程之后,CMS无法再当次收集中处理掉它们,只好留待下一次GC时再清理掉。这一部分垃圾就被称为“浮动垃圾”。也是由于在垃圾收集阶段用户线程还需要运行,那也就还需要预留有足够的内存空间给用户线程使用,因此CMS收集器不能像其他收集器那样等到老年代几乎完全被填满了再进行收集,需要预留一部分空间提供并发收集时的程序运作使用。
  • 标记-清除算法导致的空间碎片 CMS是一款基于“标记-清除”算法实现的收集器,这意味着收集结束时会有大量空间碎片产生。空间碎片过多时,将会给大对象分配带来很大麻烦,往往出现老年代空间剩余,但无法找到足够大连续空间来分配当前对象。

G1收集器可同时作用于新生代和老年代。优势:

  • 并行与并发:充分利用多CPU,多核的优势。
  • 分代收集
  • 空间整合:基于标记-清理
  • 可预测的停顿。

GC 日志

内存分配于回收策略

  • 对象优先在Eden分配
  • 大对象直接进入老年代(可通过 -XX:PretenureSizeThreshold 来设置,对象大小超过这个设置的值,直接进入老年代)
  • 长期存活对象进入老年代:如果对象多次经过Minor GC都没有被回收,就将其放入老年代(默认15次,可通过 -XX:MaxTenuringThreshold 设置)
  • 动态对象年龄判定:如果在Survivor空间中相同年龄所有多想大小的总和大于Survivor空间的一半,年龄大于或者等于该年龄的对象直接进入老年代。
  • 空间分配担保:在Minor GC前虚拟机先检查老年代最大可用连续空间是否大于新生代所有对象空间,如果成立,则Minor GC可以确保是安全的。如果不成立。则查看 HandlePromotionFailure 设置的值是否允许担保失败。如果允许。那么会继续检查老年代最大可用的连续空间是否大于历次晋升到老年代对象的平均大小,如果大于,将进行Minor GC,尽管这词Minor GC是有风险的;如果小于,或者 HandlePromotionFailure 设置为不允许冒险,那此时将改为进行一次 Full GC。(风险是指 如果出现大量对象在Minor GC后仍然存活的情况,需要老年代进行分配担保,把Survivor无法容纳的对象直接静如老年代,通常将 HandlePromotionFailure 开关打开,避免Full GC 过于频繁)

其他

  • 对象被标记为不可达后,是否一定会被回收 : 不一定,可通过 finalize() 方法自救一次。
  • 引用类型