JVM 系列文章之 GC 算法浅析

Catalogue
  1. 1. Java的堆结构
  2. 2. 标记 - 清除算法
  3. 3. 复制算法
  4. 4. 标记 - 整理算法
  5. 5. 分代收集
  6. 6. 简单对比三种基本算法
  7. 7. JVM堆内存设置参数
    1. 7.1. 常用堆参数
  8. 8. 小结
  9. 9. 参考资料 & 鸣谢

Java的堆结构

再介绍垃圾回收算法之前,先来看看 Java中的堆,Java里的堆指的是用于存放 Java 对象的内存区域。JVM的堆被同一个JVM实例中所有的Java线程共享,它通常由某种自动管理机制所管理,这种机制通常叫做”垃圾回收”

堆的内存模型大致如下:
heap

堆大小 = 新生代 + 老年代。其中堆的大小可以通过参数 -Xms,-Xmx来指定。

默认的,新生代(Young) 与老年代(Old)的比例的值是 1:2 (该值可以通过参数 -XX: NewRatio来指定),即: 新生代(Young) = 1/3的堆空间大小,老年代(Old) = 2/3的堆空间大小。

其中,新生代(Young)被细分为 Eden 和 两个 Survivor区域,这两个 Survivor区域分别被命名为 from 和 to,以示区分。

默认的,Eden:from:to = 8:1:1 (可以通过参数 -XX: SurvivorRatio来设定),即: Eden = 8/10 的新生代空间大小,from = to = 1/10 的新生代空间大小。

JVM每次只会使用 Eden和其中的一块 Survivor区域来为对象服务,所以无论什么时候,总有一块 Survivor区域是空闲着的,新生代实际可用的内存空间为 90% 的新生代空间。

标记 - 清除算法

在GC算法中,最简单的就是 “标记-清除”(Mark-Sweep)算法。它的原理比较简单,首先根据可达性分析算法对不可达对象进行标记,在标记完成后统一回收所有被标记的对象。标记-清除算法的执行过程如下图:
mark_sweep
标记-清除算法有两个缺点:

  • 效率问题,标记和清除两个过程的效率都不高
  • 空间问题,标记清除之后产生大量不连续的内存碎片,如果这时候有大对象需要连续的内存空间进行分配时,很可能会因为没有足够的连续内存空间而又触发一次 GC

基于Mark-Sweep的GC 多用于老年代

复制算法

复制算法的思路是它将可用内存按容量划分为大小相等的两块,每次只用其中的一块。当这块内存用完了,就将还存活的对象复制到另外一块上面,然后再把已使用过的内存空间一次清理掉。

这样每次都是对半区进行内存回收,内存分配时也就不用考虑内存碎片等复杂情况,只要移动堆顶指针,按顺序分配内存即可。但是这种算法是用空间换时间,代价是将内存缩小为原来的一半,代价很高。而新生代的对象一般是存活时间较短的对象,GC频率较高,占内存较少,因此新生代一般都采用基于复制的GC。复制算法过程如下:
copy

HotSpot 虚拟机将新生代内存分为 一块较大的 Eden空间和两块较小的 Survivor空间,Eden和Survivor的大小比例是8:1。每次新生代中可用内存空间为整个新生代容量的 90%。我们没有办法保证每次回收都只有不多于 10%的对象存活,当 Survvivor 空间不够用时,需要依赖老年代进行分配担保

标记 - 整理算法

复制收集算法在对象存活率较高时就要进行较多的复制操作,效率会变低,它比较适合收集新生代对象,至于老年代这种一般不选用复制算法。根据老年代的特点,可以使用 “标记-整理”算法或者”标记-清除”算法

标记 - 整理算法可以解决内存碎片的问题,而且思路也比较简单,它的思想就是,让所有存活的对象都向一端移动,然后直接清理掉边界以外的内存,如下图所示:

mark-compact

分代收集

当前商业虚拟机的垃圾收集都采用”分代收集”,将堆分为新生代和老年代,根据各个年代的特点采用最适当的收集算法:

  • 新生代
    • 复制收集算法
  • 老年代
    • 标记 - 清理算法
    • 标记 - 整理算法

简单对比三种基本算法

下面的分析参照 R大对于GC算法的分析:http://hllvm.group.iteye.com/group/topic/44381#post-272188

分代式 GC里,老年代常用 mark-sweep(标记 - 清除算法),或者是 mark-sweep /mark-compact 的混合方式,一般情况下用 mark-sweep,统计估算碎片量达到一定程度时用 mark-compact(标记 - 整理)。这是因为传统上大家认为老年代的对象可能会长时间存活且存活率高,或者是比较大,这样拷贝起来不划算,还不如采用就地收集的方式。 Mark-Sweep,Mark-compact,copying这三种基本算法里,只有mark-sweep是不移动对象的(也就是不拷贝的),所以老年代常选用 mark-sweep。当然针对不同的垃圾收集器,GC 算法是有区别的

以下是三种算法的比较:

mark-sweep mark-compact copying
速度 中等 最慢 最快
空间开销 少(但会堆积碎片) 少(但不会堆积碎片) 通常需要活对象的2倍大小(不堆积碎片)
移动对象?

关于时间开销

  • mark-sweep: mark阶段与活对象的数量成正比,sweep阶段与整堆大小成正比
  • mark-compact: mark阶段与活对象的数量成正比,compact阶段与活对象的大小成正比
  • copy:与活对象的大小成正比

如果把 mark,sweep,compact,copying这几种动作的耗时放在一起看,大致有这样的关系:

compaction >= copying > marking > sweeping
marking + sweeping > copying

总结一下:

在分代式假设中,年轻代中对象在 minor GC 时的存活率应该很低,这样用copying算法就是最合算的,因为其时间开销与活对象的大小成正比,如果没多少活对象,它就非常快。而且 young GC 本身应该比较小,就算需要2倍空间也只会浪费不太多的空间

而老年代被 GC 时对象存活率可能会很高,而且假定可用剩余空间不太多,这样copying 算法就不太合适,于是更可能选用另两种算法,特别是不用移动对象的 Mark-Sweep算法

不过 HotSpot VM中除了CMS收集器之外的其他收集器都是会移动对象的,也就是要么是 copying,要么是mark-compact的变种

JVM堆内存设置参数

  • -XX:+<option> 启用选项 例如:-XX:+PrintGCDetails启动打印GC信息的选项,其中+号表示true,开启的意思
  • -XX:-<option>不启用选项 ,例如:-XX:-PrintGCDetails关闭启动打印GC信息的选项,其中-号表示false,关闭的意思
  • -XX:<option>=<number>
  • -XX:<option>=<string>

常用堆参数

  • -Xms: 初始堆大小
  • -Xmx: 最大堆大小,默认为物理内存的1/4
  • -Xmn: 新生代大小,通常为 Xmx的 1/3或1/4。新生代 = Eden + 2个Survivor空间。实际可用空间为 = Eden + 1个 Survivor,即90%
  • -XX:NewSize = n:设置新生代大小
  • -XX:NewRatio = n: 设置新生代和老年代的比值,如 n = 3,表示新生代:老年代 = 1:3。
  • -XX:SurvivorRatio: 新生代中 Eden与Survivor的比值,默认值为 8。即Eden占新生代空间的 8/10,另外两个 Survivor各占 1/10
  • -XX:PermSize: 永久代(方法区)的初始大小,(前提是永久代存在的情况下,在JDK 1.8及以后,永久代被移除了)
  • -XX:MaxPermSize:永久代(方法区)的最大值
  • -XX:+PrintGCDetails:打印 GC 信息
  • -XX:+HeapDumpOnOutOfMemoryError:让虚拟机在发生内存溢出时 Dump 出当前的内存堆转储快照,以便分析用

更多JVM参数选项设置,请参考Oracle官方网站给出的相关信息: http://www.oracle.com/technetwork/java/javase/tech/vmoptions-jsp-140102.html

小结

以上主要参考了《深入理解Java虚拟机》这本书以及R大对于GC算法的分析,本人对于JVM是渣渣级选手,如有问题之处,欢迎指出

另外关于垃圾算法更加详解的解释,三种算法的具体实现参考 中村成洋的《垃圾回收的算法与实现》

参考资料 & 鸣谢

Bagikan Komentar