前面已经介绍了 Java 的内存分区,本文将基于内存分区来介绍 Java 区别与其他语言一个很重要的机制 : 垃圾回收(GC)。
二、如何判断一个对象该被回收了有一个比较直观的想法就是:用一个引用计数器来记录该对象被引用的次数,多一个引用就+1,少一个就-1,当引用数等于 0 的时候就是要回收该对象了,很多语言都是用这个方法来实现,但是这个简单的想法无法解决的例子还是很多的,譬如:当我创建了一个 A 对象,A对象中引用了 B 对象,当我解除 对 A 对象都引用后,实际上我已经无法再找到 A ,但由于 A 和 B 互相引用,它们都引用数不是0,这样肯定是不合理的。
可达性分析算法
通过 GC Roots 作为起始点进行搜索,能够到达到的对象都是存活的,不可达的对象可被回收。也是 java 所采用的策略。
那这里需要知道常见的 GC Roots 指哪些:
1、在虚拟栈中使用到的参数、局部变量、临时变量
2、方法区中类静态属性引用的对象
3、方法区常量引用的对象
4、本地方法引用的对象
5、虚拟机内部引用的对象等等
只有被 GC Root 中的对象可达的对象,才被认为被引用,不会被回收
引用的类别:普通情况我们只将对象分为被引用和不被引用,但对于某些特殊情况,这样的设定显的狭隘了,Java 中将引用设定为四种:
- 强引用:即普遍意义上的引用,在任何情况下,只要有强引用,垃圾回收器永远不会对其进行回收
- 软引用:软引用原来描述一些还有用,但非必需的对象,只要被软引用的对象,当系统将要发生内存溢出异常前,会把这些软引用的对象进行回收
- 弱引用:也是用来描述那些非必要的对象,但比软引用更弱一点,弱引用对象在下一个垃圾回收的时候就要被回收
- 虚引用:最弱的引用,一个对象是否有虚引用,完全不会对其造成任何影响,设置虚引用的唯一目的就是该对象被回收的时候,会接受到一个系统通知
对方法区的回收
方法区一般存在的都是永久代,所以回收的价值不是很大,而对方法区的回收主要是针对两部分:
- 废弃的常量
- 不在使用的类型
如何判断一个常量是被废弃了?
假如在字符串常量池中存在字符串 "abc",如果当前没有任何 String 对象引用该字符串常量的话,就说明常量 "abc" 就是废弃常量,如果这时发生内存回收的话而且有必要的话,"abc" 就会被系统清理出常量池了。
如何判断一个类不在使用?
- 该类所有的实例都已经被回收,也就是堆中不存在该类的任何实例。
- 加载该类的 ClassLoader 已经被回收。
- 该类对应的 Class 对象没有在任何地方被引用,也就无法在任何地方通过反射访问该类方法。
算法分为“标记” 和 “清除”两个阶段:首先标记出要回收的对象,然后再统一回收所有被标记的对象,也可以反过来,标记不需要回收的对象,然后统一回收没有被标记的对象,这个算法是最基础的回收算法。它有两个缺点:
1、执行效率不是太稳定,有时候如果大量对象都需要被标记,则会花大量时间在标记对象上,而随着需要标记对象的增多,算法执行效率就会降低,反过来也是同理
2、会造成大量的内存碎片,这些碎片都是不连续的,会导致大对象无法找到合适的连续的内存空间,同时也对程序的吞吐量造成影响
标记-复制算法
为了解决 标记-清除 算法造成的大量内存碎片,提出这个标记-复制算法,开始的想法是:将内存分为两块,每次只使用其中的一块,当这一块的内存快用完了,将还存在的对象复制到另一块,然后将第一块的内存全部回收。这一个想法不会产生大量的内存碎片,只需要移动指针,按顺序分配内存即可。第二次回收反过来操作即可,这个算法的缺点就是将内存分为了两部分,每次只能使用其中的 1/2
现在商业的大多数虚拟机都用复制算法来回收新生代的内存,但不是分为两部分,而是分为一块大的 Eden 空间和两块小的 Survivor 空间,每次 Eden大小 :Survivor 空间的比例大小默认是 8 : 1,每次只使用一块 Eden 空间 和一块 Survivor 空间,当进行垃圾清理的时候,把 Eden 和 一块 Survivo 中存活的对象复制到另一块 Survivor 空间中,这个算法基于的前提是绝大数情况类都是 “朝生夕灭”,所以一块 Survivo 足够复制生存的类。那会出现 Survivo 内存不足的情况嘛?当然会
如果出现这种情况,虚拟机就会拿老年代的空间进行使用,来保证出现这种情况能正常的进行复制。
标记—整理算法
标记复制算法在有较多对象存活的情况下,需要进行大量的复制操作,并且需要额外的内存进行担保,防止出现极端情况,老年代部分生存率很高,用复制算法显然不合适。
针对老年代对象的存活特征,提出了 标记-整理算法:
标记-整理算法 其标记过程和 标记-清除算法是一致的,但后续的步骤不是对可回收对象进行回收,而是将所有对象朝内存空间一端进行移动,然后直接回收边界外的所有内存。这样就不会造成内存碎片,但移动却是一个极为负重的操作,这种移动必须全程暂停用户的应用程序才能进行。
这就需要考虑移动和不移动之间的区别:如果不移动,就会造成大量的内存碎片,而解决碎片化的问题,只能依赖更复杂的内存分配器和内存访问器去解决,从而使内存访问成为用程序最为频繁的操作。
基于上面两点,不同的收集器针对不同的情况,采取的不同的策略,如关注吞吐量(即垃圾回收器和用户程序效率的总和),移动会更为划算。
如果关注延迟,则不移动则更为划算。
当然还有一种混合法,做法是大部分时间都采用 标记-清除算法,只有在内存碎片严重影响程序的内存分配的时候,再用 标记-整理算法 回收一次
四、经典的垃圾收集器 CMS 收集器
由图中可以看到,CMS 收集器总共有四个阶段:
1、初始标记: 初始标记期间的主要任务标记与 GC ROOT 直接关联的对象,这一个阶段执行的非常快,这里程序必须暂停,即“Stop the word”
2、并发标记:这里主要是标记整个对象图的过程,即除了直接关联以外可达的对象,这个过程较长但不需要停顿用户线程,可以和用户线程并发执行,但由于此时点用户线程还在执行阶段,所以这阶段的标记并不能保证将全部需要标记的对象全部标记
3、重新标记:这个阶段需要就是为了修正并发标记期间,因为用户程序继续运作而导致标记产生变动的那一部分对象的标记记录,这个阶段也需要所有程序都必须暂停,这阶段执行的时间会比第一阶段 初始标记 的时间要长一点,但也远低于第二阶段所需要的时间。
需要注意:这里修正的变动只能是在第一阶段中 GC ROOT 关联对象的变化,如指向的对象消亡、或者直接关联链中又新增了其他关联对象,这些变化都会在第二阶段被记录,并且在第三阶段重新从根扫描
而全新被 GC ROOT 直接关联的对象,及其对象树,是无法被重新标记的,这种就叫做 浮动垃圾(下面也会仔细说明)
4、并发清除:这一阶段同样可以和用户线程并发执行,清理掉已经死亡的对象,不需要移动对象,即 标记—清除算法
5、重制线程:归还用完的线程资源
CMS收集器的优点很明显:它能和用户线程并发执行,停顿时间比较短
但他也有三点明显的缺点:
- CMS 对处理器的资源十分敏感:
在并发阶段,它虽然不会导致用户线程停顿很久,但却会因此占用一部分线程(或者说是处理器的计算能力)而导致程序变慢,降低了吞吐量,CMS默认启动的回收线程数目是(处理器核心的数目 + 3)/4,也就是说处理器核心在 4 个以上时,CMS 占用不到 25%,但如果处理器核心少于 4 个,CMS 对用户程序的影响就会很大。曾经有一个改善方法是,在并发阶段,让 CMS 和 用户程序交替执行,但这样也会增加垃圾回收的整体时间,总体下来可能变慢的时间不会有很好的改善,所以现在已经被废弃
- CMS 无法处理浮动垃圾
上面解释了浮动垃圾的定义,这些垃圾只能只能等待下一次 GC 进行清理,同样由于在 GC 清理和 用户程序是并发的,这也导致,我们在垃圾清理的时候必须留存内存给用户进程使用,所以 CMS 回收器不能等待老年代几乎被完全填满后才进行收集,必须预留一部分空间供并发收集的时候用户程序的使用,在 JDK5 的时候,默认老年代在使用 68% 后才启用,到了 JDK6 这个比例提高到了 92%,这样会减少 GC 使用的频率,但同样也会面临一个风险:在 GMS 运行的期间用户程序的内存不够了,这样会导致“并发失败”,这样不得不会临时启用 Serial Old 收集器来进行收集,这样就会让停顿的时间变长。
- CMS 是基于 标记-清除,所以会存在内存碎片
空间碎片过多的时候,会严重影响大对象的空间分配,当出现这样严重的问题的时候,会不得不启动一次 FULL GC,即标记-整理,这样空间碎片的问题解决了,但停顿的时间又会变长。
Garbage First (G1)收集器
包括 CMS 在内的收集器收集的目标不是整个堆,就是老年代或者新生代,但 G1收集器 跳出了这个逻辑,它可以面向堆的任何部分来组成回收集(CSet)进行回收,衡量标准不在说它属于哪一个分代,而是哪块内存中存在的垃圾数量数目最多,回收收益最大,这就是 G1 收集器的 Mixed GC 模式。
而 G1 之所以能这样设计,是由于它开创了 Region 模式: G1 不再坚持固定大小以及固定数量的分代区域划分,而是把连续的 Java 堆划分为多个大小相等的独立区域(Region),每一个 Region 都可以根据需要扮演新生代的 Eden 空间、Survivor空间、或者老年代空间。下图是 Region 和 CSet 的关系图:
可以看到回收集(CSet)可以是老年代,也可以说年轻代,而设置 CSet 的用处会在后面说明。
我们先来看 G1 如何保证收集的效率,由于 G1 收集器以 Region 作为最小单元来进行收集,即每次回收的内存大小都是 Region 大小的整数倍,G1 可以有计划跟踪每一块 Region 收集的价值(即垃圾的多少),利用这个指标来构建一个优先队列,每次根据用户所允许的垃圾回收的时间,来保证在限定时间里面获得最大价值的回收空间,保证了 G1 在限定时间里尽可能高的回收效率。但这个思路虽然是比较好的思路,但还需要解决很多重要的问题。如以下的问题:
1、将 Java 堆分割成多个 region 后,由于收集是以 region 为最小单位,而当我们去判断某一个 region 里的对象是否存活的时候,就需要考虑 region 的对象是否被其他 region 引用。
类似传统的 新生代 和 老年代,早期只针对新生代的收集器就需要考虑一个问题,新生代的对象可能被老年代的对象所引用,而针对新生代的遍历无法遍历到老年代,(注意这里是新生代被引用,如果是新生代引用老年代,可以根据三色标记法,追过去,而被引用,则在第一阶段统计被 GC Root 直接连接的对象就不可能统计到老年代对象),所以我们如果要去统计被老年代引用的新生代,没有特殊处理,就需要遍历老年代,所以出现了一种记忆集,记忆集的作用就是保存那些引用了新生代的老年代,在进行处理的时候,只需要遍历一下 新生代内存 和 记忆集内存 就可以将全部新生代都统计到。 这里 G1 也采用了类似的设定,每一个 region 都有一个记忆集,记录着别动 region 里面哪些对象指向着自己,这种设计会占用大量内存
2、在并发阶段如何保证收集线程与用户线程之间的互不干扰? 这同样是 CMS 收集器需要解决的问题,如何保证用户线程不打破原有的关系树(即不能把应该被保留的对象,让 GC 给回收了),这里有一个结论,如果同时满足两个条件,三色标记法才可能出现把黑色标记成白色,见《深入理解 Java 虚拟机》89页。(新生成的对象,即第一阶段没有直接标记的,只能等下一次 GC 回收,见上面 CMS,这里 G1 也是提前划分一定的空间给这部分新生成的,和 CMS 策略类似)
CMS 打破的是第一个条件
G1打破的是第二个条件 具体见书本
3、怎么样建立一个可靠的停顿预测模型 在 G1 收集器中,有一个参数是用户期望的垃圾回收时间,而 G1 是如何满足用户的期望的呢? G1 的停顿预测模型是以衰减均值为理论基础来实现的,在垃圾收集的过程中,G1 会记录每个 region 的回收损时,并按照“衰减平均值”来去表示它最近的“平均状态”,即最新的数据越能代表其回收价值 G1垃圾回收器的整体步骤(不去考虑用户线程运行时的动作(如设置屏障维护记忆集)):
- 初始标记:这一阶段和 CMS 类似,仅仅标记与 GC ROOT 能直接相关联的对象,停顿时间少
- 并发标记:从 GC ROOT 开始对堆中对象进行可达性分析,递归扫描整个堆的对象图,找出要回收的对象,这一阶段用时比较长,但可以和用户进程并发执行,另外还需处理用户进程对进程树的改变(上面有讲)
- 最终标记:这里就有一个短暂的暂停,用于处理并发阶段用户进程对对象树的改变
- 筛选回收:负责更新 Region 的统计数据,对各个 Region 的回收价值和成本进行排序,根据用户所期望的停顿时间来制定回收计划,可以自由选择任意多个 Region 构成回收集,然后把决定回收的那部分的存活对象复制到空的 Region 中,再清理掉整个旧的 Region 空间,这里设计到移动,所以必须暂停用户线程,由多条 G1 线程并行完成
总结:
G1是一款非常优秀的垃圾收集器,不仅适合堆内存大的应用,同时也简化了调优的工作。通过主要的参数初始和最大堆空间、以及最大容忍的GC暂停目标,就能得到不错的性能;同时,我们也看到G1对内存空间的浪费较高,但通过首先收集尽可能多的垃圾(Garbage First)的设计原则,可以及时发现过期对象,从而让内存占用处于合理的水平。
虽然G1也有类似CMS的收集动作:初始标记、并发标记、重新标记、清除、转移回收,并且也以一个串行收集器做担保机制,但单纯地以类似前三种的过程描述显得并不是很妥当。
- G1的设计原则是"首先收集尽可能多的垃圾(Garbage First)"。因此,G1并不会等内存耗尽(串行、并行)或者快耗尽(CMS)的时候开始垃圾收集,而是在内部采用了启发式算法,在老年代找出具有高收集收益的分区进行收集。同时G1可以根据用户设置的暂停时间目标自动调整年轻代和总堆大小,暂停目标越短年轻代空间越小、总空间就越大;
- G1采用内存分区(Region)的思路,将内存划分为一个个相等大小的内存分区,回收时则以分区为单位进行回收,存活的对象复制到另一个空闲分区中。由于都是以相等大小的分区为单位进行操作,因此G1天然就是一种压缩方案(局部压缩);
- G1虽然也是分代收集器,但整个内存分区不存在物理上的年轻代与老年代的区别,也不需要完全独立的survivor(to space)堆做复制准备。G1只有逻辑上的分代概念,或者说每个分区都可能随G1的运行在不同代之间前后切换;
- G1的收集都是STW的,但年轻代和老年代的收集界限比较模糊,采用了混合(mixed)收集的方式。即每次收集既可能只收集年轻代分区(年轻代收集),也可能在收集年轻代的同时,包含部分老年代分区(混合收集),这样即使堆内存很大时,也可以限制收集范围,从而降低停顿。
11