1 对象死亡判断
引用计数算法
单纯的引用计数算法:给对象中添加一个引用计数器,每当有一个地方引用,计数器值加一;引用失效减一;为零代表不能再被使用的。主流的 Java 虚拟机都没有选用引用计数器来管理内存的主要原因:看似简单的算法有许多额外情况要考虑,必须配合大量额外处理才能保证正确工作,譬如单纯的引用计数很难解决对象之间相互循环引用的问题。
循环引用:两个对象除了彼此外无其他引用,但都不可能再被访问,由于互相引用,也不能通过引用计数算法回收。
可达性分析算法
可达性分析算法的基本思路:如果某个对象到 GC Roots 间没有任何引用链相连/(用图论的话来说)从 GC Roots 到这个对象不可达时,则此对象不可能再被使用。
- 引用链:通过一系列称为“GC Roots”的根对象作为起始节点集,从这些节点开始,根据引用关系向下搜索,搜索过程所走过的路径称为“引用链”。
- 利用可达性分析算法判定对象是否可回收。
固定可作为 GC Roots 的对象包括以下几种:
- 虚拟机栈(栈帧中的本地变量表)中引用的对象,如各线程被调用方法堆栈中使用到的参数、局部变量、临时变量等。
- 方法区类静态属性引用的对象,譬如 Java 类的引用类型静态变量。
- 方法区常量引用的对象,譬如字符串常量池 (String Table) 里的引用。
- 本地方法栈中 JNI (即通常所说的 Native 方法) 引用的对象。
- Java 虚拟机内部的引用,如基本数据类型对应的 Class 对象,一些常驻的异常对象 (比如 NullPointExcepiton、OutOfMemoryError)等,还有系统类加载器。
- 所有被同步锁 (synchronized关键字) 持有的对象。
- 反映Java虚拟机内部情况的 JM XBean、JVM TI 中注册的回调、本地代码缓存等。
- 其他对象“临时性”地加入。某个区域里的对象完全有可能被位于堆中其他区域的对象所引用,这时候就需要将这些关联区域的对象也一并加入 GC Roots 集合中去,才能保证可达性分析的正确性。
判断
要真正宣告一个对象死亡,至少要经历两次标记过程:如果对象在进行可达性分析后发现没有与 GC Roots 相连接的引用链,那它将会被第一次标记,随后进行一次筛选,筛选的条件是此对象是否有必要执行 finalize() 方法。假如对象没有覆盖 finalize() 方法,或者 finalize() 方法已经被虚拟机调用过,那么虚拟机将这两种情况都视为“没有必要执行”。
finalize() 方法是对象逃脱死亡命运的最后一次机会。这种自救的机会只有一次,因为一个对象的 finalize() 方法最多只会被系统自动调用一次。finalize() 能做的所有工作,使用 try-finally 或者其他方式都可以做得更好、更及时,所以建议完全可以忘掉 Java 语言里面的这个方法。
回收方法区
方法区的垃圾回收内容:废弃的常量和不再使用的类型。
回收废弃常量与回收 Java 堆中的对象非常类似。已经没有任何字符串对象引用常量池中的该常量,且虚拟机中也没有其他地方引用这个字面量,它在内存回收时会被清理出常量池。判定一个类型是否属于“不再被使用”的条件就比较苛刻了。需要同时满足下面三个条件,才“被允许”回收:
仅仅是“被允许”,而并不是和对象一样,没有引用了就必然会回收。
- 类的所有实例被回收。堆中不会产生该类及其任何派生子类的实例。
- 类的类加载器被回收。除非是经过精心设计的可替换类加载器的场景,如 OSGi、JSP 的重加载等,否则该条件通常是很难达成的。
- 类对应的 java.lang.Class 对象没有在任何地方被引用,无法在任何地方通过反射访问该类的方法。
2 垃圾收集算法
分代假说
弱分代假说:绝大多数对象都是朝生夕灭的。
强分代假说:熬过越多次垃圾收集过程的对象就越难以消亡。把分代收集理论具体放到现在的商用Java 虚拟机里,设计者一般至少会把Java堆划分为新生代和老年代两个区域。在新生代中,每次垃圾收集时都发现有大批对象死去,而每次回收后存活的少量对象,将会逐步晋升到老年代中存放。它至少存在一个明显的困难:对象不是孤立的,对象之间会存在跨代引用。因此提出下一条经验法则。
跨代引用假说:跨代引用相对于同代引用来说仅占极少数。存在互相引用关系的两个对象,是应该倾向于同时生存或者同时消亡的。如果某个新生代对象存在跨代引用,由于老年代对象难以消亡,该引用会使得新生代对象在收集时同样得以存活,进而在年龄增长之后晋升到老年代中,这时跨代引用也随即被消除了。
标记-清除算法
标记:需要回收或者存活对象。
清除:根据标记对象判定清楚所有被标记或未标记对象。
下图是标记-清除算法示意图。
主要缺点:
- 执行效率不稳定。如果堆中包含大量对象,且大部分需要被回收,须进行大量标记和清除,导致这两个动作都会随对象数量增长而降低。
- 内存空间碎片化。标记、清除后会产生大量不连续碎片空间,导致大对象无法分配到合适内存而不得不提前触发另一次垃圾收集。
标记-复制算法
半区复制。将内存分为等大小两块,每次使用一块,使用完进行回收并将存够对象复制到另一块,对这一块一次清理。
下图是标记-复制算法示意图。
主要缺点:
- 如果内存多数对象存活会产生大量复制开销。
- 为了复制将可用内存缩小一半,空间浪费太多。
Appel 式回收—更优化的半区复制分代策略
- 原因:新生代对象“朝生夕灭”,98% 熬不过第一轮收集,不需要按 1:1 划分内存。
- 具体做法:把新生代分成一块较大的 Eden 空间和两块较小的 Survivor 空间,每次分配只使用Eden 和其中一块 Survivor。垃圾收集将这两块复制到剩下的 Survivor 空间,直接清理这两块。
- 分配担保:“逃生门”安全设计—Survivor 空间不足以容纳一次 Minor GC 之后存活的对象时,就需要依赖其他内存区域(实际上大多就是老年代)进行分配担保。Survivor 空间没有足够空间存放上一次新生代收集下来的存活对象,这些对象便将通过分配担保机制直接进入老年代。
HotSpot 中 Eden 和 Survivor 大小比例是 8:1,无法百分百保证只有不多于 10% 对象存活。
标记-整理算法
针对老年代对象存亡特征,提出标记-整理算法。其中的标记过程仍然与“标记-清除”算法一样,后续步骤不是直接对可回收对象进行清理,而是让所有存活的对象都向内存空间一端移动,然后直接清理掉边界以外的内存。标记-整理与标记-清除算法的本质差异:标记-整理是移动式的回收算法。标记-清除是非移动式的。
下图是标记-整理算法示意图。
针对标记-复制算法的缺点(较多复制、空间浪费),需要额外空间分配担保,应对内存对象 100% 存活的情况,所以老年代一般不选用标记-复制算法。
是否移动回收后的存活对象是一项优缺点并存的风险决策:
- 移动存活对象。对老年代这种每次回收都有大量对象存活区域,移动并更新所有这些对象是极为负重的操作。移动操作须全程暂停用户应用程序-“Stop The World”。
- 完全不考虑移动和整理存活对象。空间碎片化问题就只能依赖更复杂的内存分配器和内存访问器。
- 基于以上两点,是否移动对象都存在弊端,移动则内存回收时会更复杂,不移动则内存分配时会更复杂。
3 经典垃圾收集器
图展示了七种作用于不同分代的收集器,如果两个收集器之间存在连线,就说明它们可以搭配使用。不存在最好或者万能的收集器,我们选择的只是对具体应用最合适的。
总结
垃圾收集需要完成的三件事:哪些内存需要回收?什么时候回收?如何回收?