第3章 垃圾收集器与内存分配策略
第3章 垃圾收集器与内存分配策略
3.1 概述
- 垃圾收集需要完成三件事情:1)哪些内存需要回收?2)什么时候回收?3)如何回收?
- 程序计数器、虚拟机栈、本地方法栈这3个区域随线程而生,随线程而灭,栈中的栈帧随着方法的进入和退出有条不紊地执行着出栈和入栈操作。每一个栈帧中分配多少内存基本上是在类结构确定时就已知的。这几个区域的内存分配和回收都具备确定性,无需过多考虑如何回收的问题,当方法结束或者线程结束时,内存自然也跟随着回收了。
- Java 堆和方法区这两个区域有着显著的不确定性,这部分的内存分配和回收是动态的,垃圾收集器所关注的也是这部分内存。
3.2 对象已死?
3.2.1 引用计数算法
- 在对象中添加一个引用计数器,每当有一个地方引用它时,计数器值就加一;当引用失效时,计数器值就减1;任何时刻计数器为零的对象就是不可能再被使用的。
- 引用计数算法(Reference Counting)虽然占用了一些额外的内存空间来进行计数,但它的原理简单,判定效率也很高,在大部分情况下它都是一个不错的算法。但是,至少主流的Java虚拟机里面没有选用引用计数算法来管理内存,主要原因是,这个看似简单的算法有很多例外情况要考虑,必须要配合大量额外处理才能保证正确地工作,例如单纯地引用计数很难解决对象之间相互循环引用的问题。
- Java 虚拟机并不是通过引用计数算法来判断对象是否存活的。
3.2.2 可达性分析算法
- 当前主流的商用程序语言(Java、C#,甚至包括前面提到的古老的Lisp)的内存管理子系统, 都是称通过可达性分析(Reachability Analysis)算法来判定对象是否存活的。
- 其基本思路是通过一系列称为“GC Roots”的根对象作为起始节点集,从这些节点开始,根据引用关系向下搜索,搜索过程所走过的路径称为“引用链”(Reference Chain),如果某个对象到 GC Roots 间没有任何引用链相连,或者用图论的话来说就是从 GC Roots 到这个对象不可达时,则证明此对象是不可能再被使用的。
- 在 Java 技术体系中,固定可作为 GC Roots 的对象包括下面几种:
- 在虚拟机栈(栈帧中的本地变量表)中引用的对象。
- 在方法区中类静态属性引用的对象。
- 在方法区中常量引用的对象。
- 在本地方法栈中JNI(即通常所说的 Native 方法)引用的对象。
- Java 虚拟机内部的引用
- 所有被同步锁(synchronized 关键字)持有的对象。
- 反映 Java 虚拟机内部情况的JMXBean、JVMTI中注册的回调、本地代码缓存等。
3.2.3 再谈引用
- 无论是通过引用计数算法判断对象的引用数量,还是通过可达性分析算法判断对象是否引用链可达,判定对象是否存活都和“引用”离不开关系。
- Java对引用的概念进行了扩充,将引用分为强引用(Strongly Reference)、软引用(Soft Reference)、弱引用(Weak Reference)、虚引用(Phantom Reference)4种,这4种引用强度依次逐渐减弱。
- 强引用是最传统的“引用”的定义,是指在程序代码之中普遍存在的引用赋值,类似“Object obj=new Object()”这类引用。无论任何情况下,只要强引用还存在,垃圾收集器永远不会回收掉被引用的对象。
- 软引用是用来描述一些还有用,但非必须的对象。只被软引用关联着的对象,在系统将要发生内存溢出异常前,会把这些对象列进回收范围之中进行第二次回收。如果这次回收还没有足够的内存,才会抛出内存溢出异常。
- 弱引用也是用来描述非必需对象的,但是它的强度比软引用更弱一些,被弱引用关联的对象只能生存到下一次垃圾收集发生为止。当垃圾收集器开始工作,无论当前内存是否足够, 都会回收掉只被弱引用关联的对象。
- 虚引用也称为“幽灵引用”或者“幻影引用”,它是最弱的一种引用关系。一个对象是否有虚引用的存在,完全不会对其生存时间构成影响,也无法通过虚引用来取得一个对象实例。为一个对象设置虚引用关联的唯一目的只是为了能在这个对象被收集器回收时收到一个系统通知。
3.2.4 生存还是死亡?
要真正宣告一个对象死亡,至少要经历两次标记过程:
- 第一次标记:如果对象在进行可达性分析后发现没有与 GC Roots 相连接的引用链,那它将会被第一次标记并且进行一次筛选, 筛选的条件是此对象是否有必要执行 finalize() 方法。假如对象没有覆盖finalize() 方法,或者 finalize() 方法已经被虚拟机调用过,那么虚拟机将这两种情况都视为“没有必要执行”。
- 第二次标记:如果这个对象被判定为有必要执行 finalize() 方法,那么该对象将会放置在一个叫做 F-Queue 的队列之中,并在稍后由一条由虚拟机自动建立的、低调度优先级的 Finalizer 线程去执行它们的 finalize() 方法。finalize() 方法是对象逃脱死亡命运的最后一次机会,稍后收集器将对 F-Queue 中的对象进行第二次小规模的标记,如果对象要在 finalize() 中成功拯救自己——只要重新与引用链上的任何一个对象建立关联即可,譬如把自己(this关键字)赋值给某个类变量或者对象的成员变量,那在第二次标记时它将被移出“即将回收”的集合;如果对象这时候还没有逃脱,那基本上它就真的要被回收了。
- 任何一个对象的 finalize() 方法都只会被系统自动调用一次, 如果对象面临下一次回收,它的 finalize() 方法不会被再次执行。
3.2.5 回收方法区
- 《Java 虚拟机规范》中提到过可以不要求虚拟机在方法区实现垃圾收集,方法区中进行垃圾收集的“性价比”一般比较低。
- 方法区的垃圾收集主要回收两部分内容:废弃的常量和不再使用的类型。
- 回收废弃常量与回收 Java 堆中的对象非常类似。没有其他地方引用这个字面量,如果在这时发生内存回收,而且垃圾收集器判断却有必要的话,这个常量就会被系统清理出常量池。
- 判定一个类型是否属于“不再被使用的类”的条件苛刻许多,需要同时满足下面3个条件:
- 该类所有的实例都已经被回收,也就是 Java 堆中不存在该类及其任何派生子类的实例。
- 加载该类的类加载器已经被回收。
- 该类对应的java.lang.Class对象没有在任何地方被引用,无法在任何地方通过反射访问该类的方法。
3.3 垃圾收集算法
从如何判定对象消亡的角度出发,垃圾收集算法可以划分为“引用计数式垃圾收集”(Reference Counting GC)和“追踪式垃圾收集”(Tracing GC)两大类,这两类也常被称作“直接垃圾收集”和“间接垃圾收集”。 本节介绍的所有算法均属于追踪式垃圾收集的范畴。
3.3.1 分代收集理论
- 当前商业虚拟机的垃圾收集器,大多数都遵循了“分代收集”(Generational Collection)的理论进行设计,它建立在两个分代假说之上:
- 1)弱分代假说(Weak Generational Hypothesis):绝大多数对象都是朝生夕灭的。
- 2)强分代假说(Strong Generational Hypothesis):熬过越多次垃圾收集过程的对象就越难以消亡。
- 以上两个分代假说共同奠定了常用垃圾收集器的一致的设计原则:收集器应该将 Java 堆划分出不同的区域,然后将回收对象依据其年龄(年龄即对象熬过垃圾收集过程的次数)分配到不同的区域之中存储。它同时兼顾了垃圾收集的时间开销和内存的空间的有效利用。
- Java 堆一般会被分为新生代(Young Generation)和老年代(Old Generation)两个区域。在新生代中,每次垃圾收集时都发现有大批对象死去,而每次回收后存活的少量对象,将会逐步晋升到老年代中存放。
- 分代收集还存在一个困难,即对象不是孤立的,对象之间会存在跨代引用。为了解决这个问题,需要对分代收集理论添加第三条经验法则:
- 3)跨代引用假说(Intergenerational Reference Hypothesis):跨代引用相对于同代引用来说仅占极少数。
- 根据前两条假说逻辑推理得出隐含结论:存在互相引用关系的两个对象,是应该倾向于同时生存或者同时消亡的。
3.3.2 标记 - 清除算法
- 算法分为“标记”和“清除”两个阶段:首先标记出所有需要回收的对象,在标记完成后,统一回收掉所有被标记的对象,也可以反过来,标记存活的对象,统一回收所有未被标记的对象。标记过程就是对象是否属于垃圾的判定过程。
- 它的主要缺点有两个:
- 1)执行效率不稳定,如果 Java 堆中包含大量对象,而且其中大部分是需要回收的,这时必须进行大量标记和清除的动作,导致标记和清除两个过程的执行效率都随对象数量增长而降低。
- 2)内存空间的碎片化问题,标记、清除之后会产生大量不连续的内存碎片,空间碎片太多可能会导致当以后在程序运行过程中需要分配较大对象时无法找到足够的连续内存而不得不提前触发另一次垃圾收集动作。
3.3.3 标记 - 复制算法
- 为了解决“标记-清除”算法面对大量可回收对象时执行效率低的问题。
- 提出了一种称为“半区复制”(Semispace Copying)的垃圾收集算法,它将可用内存按容量划分为大小相等的两块,每次只使用其中的一块。当这一块的内存用完了,就将还存活着的对象复制到另外一块上面,然后再把已使用过的内存空间一次清理掉。
- 如果内存中多数对象都是存活的,这种算法将会产生大量的内存间复制的开销,但对于多数对象都是可回收的情况,算法需要复制的就是占少数的存活对象,而且每次都是针对整个半区进行内存回收,分配内存时也就不用考虑有空间碎片的复杂情况,只要移动堆顶指针,按顺序分配即可。这样实现简单,运行高效。
- 其缺点也很明显,这种复制回收算法的代价是将可用内存缩小为了原来的一半,空间浪费未免太高了一点。
3.3.4 标记 - 整理算法
- “标记-复制”算法在对象存活率较高时就要进行较多的复制操作,效率将会降低。更关键的是,如果不想浪费50%的空间,就需要有额外的空间进行分配担保,以应对被使用的内存中所有对象都100%存活的极端情况,所以在老年代一般不能直接选用这种算法。
- 针对老年代对象的存亡特征,提出了另外一种有针对性的“标记-整理”(Mark-Compact)算法,其中的标记过程与“标记-清除”算法一样,但后续步骤不是直接对可回收对象进行清理,而是让所有存活的对象都向内存空间一端移动,然后直接清理掉边界以外的内存。
- “标记-清除”算法与“标记-整理”算法的本质差别在于前者是一种非移动式的回收算法,而后者是移动式的。
- 是否移动回收后的存活对象是一项优缺点并存的风险决策,移动则内存回收时会更复杂,不移动则内存分配时会更复杂。从垃圾收集的停顿时间来看,不移动对象停顿时间会更短,甚至可以不需要停顿,但从整个程序的吞吐量来看,移动对象会更划算。
3.4 HotSpot 的算法细节实现
3.4.1 根节点枚举
3.4.2 安全点
3.4.3 安全区域
3.4.4 记忆集与卡表
3.4.5 写屏障
3.4.6 并发的可达性分析
3.5 经典垃圾收集器
3.5.1 Serial 收集器
3.5.2 ParNew 收集器
3.5.3 Parallel Scavenge 收集器
3.5.4 Serial Old 收集器
3.5.5 Parallel Old 收集器
3.5.6 CMS 收集器
3.5.7 Garbage First 收集器
3.6 低延迟垃圾收集器
3.6.1 Shenandoah 收集器
3.6.2 ZGC 收集器
3.7 选择合适的垃圾收集器
3.7.1 Epsilon 收集器
3.7.2 收集器的权衡
3.7.3 虚拟机及垃圾收集器日志
3.7.4 垃圾收集器参数总结
3.8 实战:内存分配与回收策略
- Java 技术体系的自动内存管理,最根本的目标是自动化地解决两个问题:自动给对象分配内存以及自动回收分配给对象的内存。
- 对象的内存分配,从概念上讲,应该都是在堆上分配(而实际上也有可能经过即时编译后被拆散为标量类型并间接地在栈上分配)。在经典分代的设计下,新生对象通常会分配在新生代中,少数情况下(例如对象大小超过一定阈值)也可能会直接分配在老年代。
3.8.1 对象优先在 Eden 分配
- 大多数情况下,对象在新生代 Eden 区中分配。当 Eden 区没有足够空间进行分配时,虚拟机将发起一次 Minor GC。
- 新生代 GC(Minor GC):指发生在新生代的垃圾收集动作,因为 Java 对象大多都具备朝生夕灭的特性,所以 Minor GC 非常频繁,一般回收速度也比较快。
- 老年代 GC(Major GC):指发生在老年代的 GC,出现了 Major GC,经常会伴随至少一次的 Minor GC。Major GC 的速度一般会比 Minor GC 慢10倍以上。
3.8.2 大对象直接进入老年代
- 大对象是指,需要大量连续内存空间的 Java 对象,最典型的大对象就是那种很长的字符串,或者元素数量很庞大的数组。
- 在 Java 虚拟机中要避免大对象的原因是,在分配空间时,它容易导致内存明明还有不少空间时就提前触发垃圾收集,以获取足够的连续空间才能安置好它们,而当复制对象时,大对象就意味着高额的内存复制开销。
- HotSpot 虚拟机提供了 -XX:PretenureSizeThreshold 参数,指定大于该设置值的对象直接在老年代分配,这样做的目的是避免在 Eden 区及两个 Survivor 区之间来回复制,产生大量的内存复制操作。
3.8.3 长期存活的对象将进入老年代
- 虚拟机给每个对象定义了一个对象年龄(Age)计数器,存储在对象头中。对象通常在 Eden 区里诞生,如果经过第一次 Minor GC 后仍然存活,并且能被 Survivor 容纳的话,该对象会被移动到 Survivor 空间中,并且将其对象年龄设为1岁。对象在 Survivor 区中每熬过一次 Minor GC,年龄就增加1岁,当它的年龄增加到一定程度(默认为15),就会被晋升到老年代中。
- 对象晋升老年代的年龄阈值,可以通过参数 -XX:MaxTenuringThreshold 设置。
3.8.4 动态对象年龄判定
- 虚拟机并不是永远要求对象的年龄必须达到 -XX:MaxTenuringThreshold 才能晋升老年代,如果在 Survivor 空间中相同年龄所有对象大小的总和大于 Survivor 空间的一半,年龄大于或等于该年龄的对象就可以直接进入老年代,无须等到 -XX:MaxTenuringThreshold 中要求的年龄。
3.8.5 空间分配担保
- 在发生 Minor GC 之前,虚拟机必须先检查老年代最大可用的连续空间是否大于新生代所有对象总空间,如果这个条件成立,那这一次 Minor GC 可以确保是安全的。如果不成立,则虚拟机会先查看 -XX:HandlePromotionFailure 参数的设置值是否允许担保失败(Handle Promotion Failure);如果允许,那会继续检查老年代最大可用的连续空间是否大于历次晋升到老年代对象的平均大小,如果大于,将尝试进行一次 Minor GC,尽管这次 Minor GC 是有风险的;如果小于,或者 -XX:HandlePromotionFailure 设置不允许冒险,那这时就要改为进行一次 Full GC。
- 冒险是指,新生代使用复制收集算法,但为了内存利用率,只使用其中一个 Survivor 空间来作为轮换备份,因此当出现大量对象在 Minor GC 后仍然存活的情况——最极端的情况就是内存回收后新生代中所有对象都存活,需要老年代进行分配担保,把 Survivor 无法容纳的对象直接送入老年代。
- 老年代要进行这样的担保,前提是老年代本身还有容纳这些对象的剩余空间,但一共有多少对象会在这次回收中活下来在实际完成内存回收之前是无法明确知道的,所以只好取之前每一次回收晋升到老年代对象容量的平均大小作为经验值,与老年代的剩余空间进行比较,决定是否进行 Full GC 来让老年代腾出更多空间。
- 假如某次 Minor GC 存活后的对象突增,远远高于历史平均值的话,依然会导致担保失败。如果出现了担保失败,那就只好老老实实地重新发起一次 Full GC,这样停顿时间就很长了。虽然担保失败时绕的圈子是最大的,但通常情况下都还是会将 -XX:HandlePromotionFailure 开关打开,避免 Full GC 过于频繁。
- JDK 6 Update 24 之后的规则变为只要老年代的连续空间大于新生代对象总大小或者历次晋升的平均大小,就会进行 Minor GC,否则将进行 Full GC。