垃圾回收算法与垃圾回收器
学习垃圾回收的意义
垃圾回收成为系统达到更高并发量的瓶颈时,需要对这些“自动化”的技术实施必要的监控和调节。
如何判断对象的存活
引用计数算法(JVM中没有使用)
Reference Counting,给对象添加一个引用计数器,当对象增加一个引用时计数器加 1,引用失效时计数器减 1。引用计数为 0 的对象可被回收。(Python等脚本领域在用,但主流虚拟机没有使用)
优点:快,方便,实现简单。
缺陷:对象相互引用时(A.instance=B同时B.instance=A),很难判断对象是否该回收。
可达性分析算法(JVM中使用)
Reachability Analysis,这个算法的基本思路就是通过一系列的称为“GC Roots”的对象作为起始点,从这些节点开始向下搜索,搜索所走过的路径称为引用链(Reference Chain),当一个对象到GC Roots没有任何引用链相连时,则证明此对象是不可用的。
作为GC Roots的对象包括下面几种:
- 当前虚拟机栈和本地方法栈中的引用的对象
- 方法区中类静态变量和常量引用的对象
- MinorGC 中,引用新生代的对象的老年代对象
关于finalize()
即使在可达性分析算法中不可达的对象,也不是“非死不可”的。
对象在被标记为不可达之后,如果对象覆盖了finalize()方法并且该对象还没有调用过finalize(),那么这个对象会被放入F-Queue队列中,并在稍后一个由虚拟机建立的、低优先级的Finalize线程中去执行对象的finalize()方法。稍后GC会对F-Queue的对象进行再一次的标记,如果对象的finalize方法中,将对象重新和GC Roots建立了关联,那么在第二次标记中就会被移除出“即将回收”的集合。
但是,finalize线程的优先级很低,GC并不保证会等待对象执行完finalize方法之后再去回收,因而想通过finalize方法区拯救对象的做法,并不靠谱。鉴于finalize()方法这种执行的不确定性,基本上用不到finalize方法。
四种引用(Reference)
引用的传统定义:如果reference类型的数据中存储的数值代表的是另一块内存的起始地址,就称这个数据为一个引用。
传统定义的不足:一个对象要么被引用,要么不被引用,只有2种状态。
而我们希望:有一类对象,当内存空间足够,则能保留;如果内存空间进行垃圾回收后还很紧张,则抛弃。
所以,诞生了其它的引用。
强引用(Strong Reference)
一般的Object obj = new Object() ,就属于强引用。
只要强引用还在,垃圾回收器永远不会回收它。当内存不足时宁愿抛出 OOM 错误,使得程序异常停止。
软引用(Soft Reference)
一些有用但非必需的对象。对于软引用关联着的对象,在系统将要OOM之前,将会把这些对象列进回收范围之中进行第二次回收。如果这次回收还没有足够的内存,才会OOM。
软引用非常适合于创建缓存。当系统内存不足的时候,缓存中的内容是可以被释放的。例如,一个程序用来处理用户提供的图片。如果将所有图片读入内存,这样虽然可以很快的打开图片,但内存空间使用巨大,一些使用较少的图片浪费内存空间,需要手动从内存中移除。如果每次打开图片都从磁盘文件中读取到内存再显示出来,虽然内存占用较少,但一些经常使用的图片每次打开都要访问磁盘,代价巨大。这个时候就可以用软引用构建缓存。
弱引用(Weak Reference)
也用来描述非必需对象,强度比软引用更弱。垃圾回收器在扫描到该对象时,无论内存充足与否,都会回收该对象的内存。
实际运用:WeakHashMap、ThreadLocal
虚引用(Phantom Reference)
最弱的一个。虚引用不会影响对象的生存时间,也不能获得对象的实例。唯一作用是在对象被回收时获得一个通知。
垃圾回收算法
标记-清除算法(Mark-Sweep)
过程:
- 首先标记所有需要回收的对象
- 统一回收被标记的对象
优点:
- 利用率百分之百
缺点:
- 效率问题,标记和清除效率都不高
- 标记清除之后会产生大量不连续的内存碎片,空间碎片太多可能会导致以后在程序运行过程中需要分配较大对象时,无法找到足够的连续内存而不得不提前触发另一次垃圾收集动作。
复制算法(Copying)
将可用内存按容量划分为大小相等的两块,每次只使用其中的一块。当这一块的内存用完了,就将还存活着的对象复制到另外一块上面,然后再把已使用过的内存空间一次清理掉。这样使得每次都是对整个半区进行内存回收,内存分配时也就不用考虑内存碎片等复杂情况,只要按顺序分配内存即可,实现简单,运行高效。只是这种算法的代价是将内存缩小为了原来的一半。
注意:内存移动是必须实打实的移动(复制),不能使用指针玩。
专门研究表明,新生代中的对象98%是“朝生夕死”的,所以一般来说回收占据10%的空间够用了,所以并不需要按照1:1的比例来划分内存空间,而是将内存分为一块较大的Eden空间和两块较小的Survivor空间,每次使用Eden和其中一块Survivor。当回收时,将Eden和Survivor中还存活着的对象一次性地复制到另外一块Survivor空间上,最后清理掉Eden和刚才用过的Survivor空间。如果另一块Survivor空间不够,存活对象会通过分配担保机制进入老年代。
HotSpot虚拟机默认Eden和Survivor的大小比例是8:1,也就是每次新生代中可用内存空间为整个新生代容量的90%(80%+10%),只有10%的内存会被“浪费”。
标记-整理算法(Mark-Compact)
过程:
- 标记出所有需要回收的对象
- 让所有存活的对象都向一端移动
- 直接清理掉端边界以外的内存
优点:
- 利用率百分之百
- 没有内存碎片
缺点:
- 标记和清除的效率都不高
- 效率相对标记-清除要低
Appel 式回收
一种更加优化的复制回收分代策略:具体做法是分配一块较大的 Eden 区和两块较小的 Survivor 空间。
专门研究表明,新生代中的对象 98%是“朝生夕死”的,所以并不需要按照 1:1 的比例来划分内存空间,而是将内存分为一块较大的 Eden 空间和两块较小的 Survivor 空间,每次使用 Eden 和其中一块 Survivor。当回收时,将 Eden 和 Survivor 中还存活着的对象一次性地复制到另外一块 Survivor 空间上,最后清理掉 Eden 和刚才用过的 Survivor 空间。
HotSpot 虚拟机默认 Eden 和 Survivor 的大小比例是 8:1,也就是每次新生代中可用内存空间为整个新生代容量的 90%(80%+10%),只有 10%的内存会被“浪费”。当然,98%的对象可回收只是一般场景下的数据,我们没有办法保证每次回收都只有不多于 10%的对象存活,当 Survivor 空间不够用时,会将存活周期长的对象直接放入老生代。
Minor GC 条件
Eden 区满。
空间分配担保
在发生Minor GC之前,虚拟机会先检查老年代最大可用的连续空间是否大于新生代所有对象总空间,如果这个条件成立,那么Minor GC可以确保是安全的(因为新生代的内容可能会全部移到老年代)。如果不成立,则虚拟机会查看HandlePromotionFailure设置值是否允许担保失败。如果允许,那么会继续检查老年代最大可用的连续空间是否大于历次晋升到老年代对象的平均大小,如果大于,将尝试着进行一次Minor GC,尽管这次Minor GC是有风险的,如果担保失败则会进行一次Full GC;如果小于,或者HandlePromotionFailure设置不允许冒险,那这时也要改为进行一次 Full GC。
对象年龄动态判定
如果在Survivor空间中,同年龄所有对象大小的综合大于Survivor空间的一半,年龄大于或等于该年龄的对象就可以直接进入老年代
Survivor 不够怎么办
从 Eden 存活下来的和原来在 Survivor 空间中不够老的对象占满 Survivor 后, 就会提升到老年代。这样容易引发一个问题,即过早提升 (premature promotion)。表现出来是:Minor GC后,老年代增加了许多占用。
过早提升 在短期看来不会有问题, 可是常常过早提升 , 会导致大量短期对象被提升到老年代, 终于导致老年代空间不足, 引发另一个 JVM 内存问题,即提升失败 (promotion failure):老年代空间不足以容乃 Minor GC 中提升上来的对象。提升失败 发生就会让 JVM 进行一次 Full GC。
当出现“Minor GC后,老年代增加了许多占用”的情况后,为了减少 Full GC 的频率,可以尝试增加新生代大小或Survivor 区大小。
垃圾回收器
分代回收(Generation Collection):
在新生代中,每次垃圾收集时都发现有大批对象死去,只有少量存活,那就选用复制算法,只需要付出少量存活对象的复制成本就可以完成收集。
而老年代中因为对象存活率高、没有额外空间对它进行分配担保,就必须使用“标记—清除”或者“标记—整理”算法来进行回收。
在这里的并发和并行:
并行:垃圾收集的多线程的同时进行。 用户线程等待。
并发:垃圾收集的线程和用户的线程同时进行(并行或者交替)。用户线程不必等待。
Serial/Serial Old
最古老的,单线程,独占式,成熟,适合单CPU 服务器
-XX:+UseSerialGC 新生代和老年代都用串行收集器
-XX:+UseParNewGC 新生代使用ParNew,老年代使用Serial Old
-XX:+UseParallelGC 新生代使用ParallerGC,老年代使用Serial Old
ParNew
和Serial比,唯一的区别:多线程的,停顿时间比Serial少。
-XX:+UseParNewGC ,新生代使用ParNew,老年代使用Serial Old
除了性能原因外,主要是因为除了 Serial 收集器,只有它能与 CMS 收集器配合工作。
-XX:+UseConcMarkSweepGC ,新生代使用ParNew,老年代的用CMS
Parallel Scavenge/Parallel Old
关注吞吐量(Throughput)的垃圾回收器,高吞吐量则可以高效率地利用CPU时间,尽快完成程序的运算任务,主要适合在后台运算而不需要太多交互的任务。
所谓吞吐量就是CPU用于运行用户代码的时间与CPU总消耗时间的比值,即吞吐量=运行用户代码时间/(运行用户代码时间+垃圾收集时间)。
-XX:MaxGCPauseMills=n 控制最大垃圾回收停顿时间,回收器尽可能保证回收时间不超过n毫秒。并不是越小越好,会牺牲吞吐量和新生代空间。
-XX:GCTimeRatio=n 设置吞吐量大小,0<n<100,默认值是99,允许最大1%(1/(1+99))的垃圾收集时间。
-XX:+UseAdaptiveSizePolicy设置以后,虚拟机会根据当前系统运行情况调整 -Xmn 、-XX:SurvivorRatio、
-XX:PretenureSizeThreshold 等参数以提供最合适的停顿时间(如果设置了-XX:MaxGCPauseMills=n)或最大吞吐量(如果设置了-XX:GCTimeRatio=n )。这种调节方式叫GC自适应的调节策略(GC Ergonomics)。
CMS(Concurrent Mark Sweep)
一种以获取最短回收停顿时间为目标的收集器。目前很大一部分的Java应用集中在互联网站或者B/S系统的服务端上,这类应用尤其重视服务的响应速度,希望系统停顿时间最短,以给用户带来较好的体验。CMS收集器就非常符合这类应用的需求。
-XX:+UseConcMarkSweepGC ,一般新生代使用ParNew,老年代的用CMS
从名字(包含“Mark Sweep”)上就可以看出,CMS收集器是基于“标记—清除”算法实现的,它的运作过程相对于前面几种收集器来说更复杂一些。
垃圾回收过程
整个过程分为4个步骤,包括:
- 初始标记(initial mark):仅仅只是标记一下 GC Roots 能直接关联到的对象,速度很快,需要停顿(STW -Stop the World)。
- 并发标记(concurrent mark):从GC Root 开始对堆中对象进行可达性分析,找到存活对象,它在整个回收过程中耗时最长,不需要停顿。
- 重新标记(remark):为了修正并发标记期间因用户程序继续运作而导致标记产生变动的那一部分对象的标记记录,需要停顿(STW)。这个阶段的停顿时间一般会比初始标记阶段稍长一些,但远比并发标记的时间短。
- 并发清除(concurrent sweep):不需要停顿。
优点:
由于整个过程中耗时最长的并发标记和并发清除过程收集器线程都可以与用户线程一起工作,所以,从总体上来说,CMS收集器的内存回收过程是与用户线程一起并发执行的。
缺点:
- 对CPU资源敏感:因为并发阶段多线程占据CPU资源,如果CPU资源不足,效率会明显降低。
- 无法处理浮动垃圾:由于CMS并发清理阶段用户线程还在运行着,伴随程序运行自然就还会有新的垃圾不断产生,这一部分垃圾出现在标记过程之后,CMS无法在当次收集中处理掉它们,只好留待下一次GC时再清理掉。这一部分垃圾就称为“浮动垃圾”。由于浮动垃圾的存在,因此需要预留出一部分内存,意味着 CMS 收集不能像其它收集器那样等待老年代快满的时候再回收。在1.6的版本中老年代空间使用率阈值为92%,如果预留的内存不够存放浮动垃圾,就会出现 Concurrent Mode Failure,这时虚拟机将临时启用 Serial Old 来替代 CMS。
-XX:CMSInitiatingOccupancyFraction 设置老年代空间使用率阈值,超过这个值就要GC。 - 会产生空间碎片:标记 - 清除算法会导致产生空间碎片。
-XX:+UseCMSCompactAtFullCollection 默认开启,在将要FullGC之前进行碎片整理。
-XX:CMSFullGCsBeforeCompaction 设置执行多少次不整理的Full GC后,来一个带整理的。默认值0,每次Full GC都会进行碎片整理。
为什么 CMS 采用标记-清除,在实现并发的垃圾回收时,如果采用标记整理算法,那么还涉及到对象的移动(对象的移动必定涉及到引用的变化,这个需要暂停业务线程来处理栈信息,这样使得并发收集的暂停时间更长),所以使用简单的标记-清除算法才可以降低 CMS 的 STW 的时间。
G1(Garbage-First)
内部布局改变:
G1 把堆划分成多个大小相等的独立区域(Region),新生代和老年代不再物理隔离。
过程(全局并发标记,global concurrent marking):
-
初始标记:仅仅只是标记一下GC Roots 能直接关联到的对象,并且修改TAMS(Nest Top at Mark Start)的值,让下一阶段用户程序并发运行时,能在正确可以的Region中创建对象,此阶段需要停顿线程(STW),但耗时很短。
-
并发标记:从GC Root 开始对堆中对象进行可达性分析,找到存活对象,此阶段耗时较长,但可与用户程序并发执行。
-
最终标记:为了修正在并发标记期间因用户程序继续运作而导致标记产生变动的那一部分标记记录,虚拟机将这段时间对象变化记录在线程的 Remembered Set Logs 里面,最终标记阶段需要把 Remembered Set Logs 的数据合并到 Remembered Set 中。这阶段需要停顿线程(STW),但是可并行执行。
-
筛选回收:首先对各个 Region 中的回收价值和成本进行排序,根据用户所期望的 GC 停顿时间来制定回收计划。此阶段其实也可以做到与用户程序一起并发执行,但是因为只回收一部分 Region,时间是用户可控制的,而且停顿用户线程将大幅度提高收集效率。
特点:
- 并行与并发:缩短STW时间。
- 分代回收:新生代、老年代处理不同。
- 空间整合:不会产生内存碎片
算法:标记—整理 (整体来看) 和复制(局部来看)。 - 可预测的停顿:G1收集器之所以能建立可预测的停顿时间模型,是因为它可以有计划地避免在整个Java堆中进行全区域的垃圾收集。G1跟踪各个Region里面的垃圾堆积的价值大小(回收所获得的空间大小以及回收所需时间的经验值),在后台维护一个优先列表,每次根据允许的收集时间,优先回收价值最大的Region(这也就是Garbage-First名称的来由)。这种使用Region划分内存空间以及有优先级的区域回收方式,保证了G1收集器在有限的时间内可以获取尽可能高的收集效率。
主要参数:
垃圾回收重要参数(-XX:)
三色标记
三色标记最大的好处是可以异步执行,从而可以以中断时间极少的代价或者完全没有中断来进行整个 GC。
将对象用三种颜色标记,分别是白色、灰色和黑色。
黑色:根对象,或者该对象与它引用的对象都被扫描过。
灰色:对本身被扫描,但是还没扫描完该对象引用的对象。
白色:未被扫描对象,如果扫描完所有对象之后,最终为白色的为不可达对象,既垃圾对象。
假如没有“三色”,只有两色,黑色和白色,那么另一个线程碰到黑色并不知道它引用的对象是否扫描完。
并发下的漏标问题
CMS解决方案
Incremental Update 算法
当一个黑色对象引用指向白色对象,将黑色对象重新标记为灰色,让垃圾回收器重新扫描。
关注的是引用的增加。
G1解决方案
SATB(snapshot-at-the-beginning)
刚开始做一个快照,把灰色对象指向白色对象的引用推到 GC 的堆栈,白色对象就不会漏标。
关注的是引用的删除。
对应 G1 的垃圾回收过程中的最终标记( Final Marking):
对用户线程做另一个短暂的暂停,用于处理并发阶段结后仍遗留下来的最后那少量的 SATB 记录(漏标对象)。
跨代引用问题
对新生代的垃圾回收,还要扫描老年代,看是否有对象引用。
解决方式:
RSet(记忆集)
RSet 是记录跨代引用的集合,本身是一个 Hash 表。
RSet 的价值在于使得垃圾收集器不需要扫描整个堆,只需要扫描 RSet 即可。
对于 CMS,只有一个 RSet,是一种 point-out,记录我指向谁。Key 是指向新生代对象的地址,Value是一个集合,里面的元素是Card Table的Index。
对于 G1,一个 Region 一个 RSet,是一种 point-in,记录谁指向我。Key是别的Region的起始地址,Value是一个集合,里面的元素是Card Table的Index。
所以当跨代引用特别多的时候,G1 更占内存。
CardTable(卡表)
CardTable 的作用是快速定位跨代引用,本身是一个字节数组。
如果一个老年代的 CardTable 中有对象指向新生代, 就将它设为 Dirty。在进行Minor GC的时候,只需要扫描 CardTable上是 Dirty 的内存区域即可。将脏卡中的对象加入到 Minor GC 的GC Roots 里。当完成所有脏卡的扫描之后,Java虚拟机便会将所有脏卡的标识位清零。
字节数组 CardTable 的每一个元素都对应着其标识的内存区域中一块特定大小的内存块,这个内存块被称作“卡页”(Card Page)。
Young GC 阶段:
阶段1:根扫描
静态和本地对象被扫描
阶段2:更新RS
处理CardTable,更新RS
阶段3:处理RS
检测老年代指向新生代的对象
阶段4:对象拷贝
拷贝存活的对象到survivor/old区域
阶段5:处理引用队列
软引用,弱引用,虚引用处理
方法区中的垃圾回收
方法区中的类信息的回收
方法区中的类信息的回收要满足:
1、该类所有的实例都已经被回收,也就是堆中不存在该类的任何实例。
2、加载该类的 ClassLoader 已经被回收。
3、该类对应的 java. lang class 对象没有在任何地方被引用,无法在任何地方通过反射访问该类的方法。
各种常量池里的对象的回收
各种常量池逻辑上属于方法区,物理上属于堆,但不属于新生代与老年代。
但会随着新生代的回收而回收。
参考:周志明——《深入理解Java虚拟机》