第三章 垃圾收集器与内存分配策略
3.1 概述
程序计数器、虚拟机栈、本地方法栈3个区域随线程生、随线程而灭,内存分配和回收都具备确定性,而垃圾收集器主要关注Java堆和方法区的内存。
3.2.1 引用计数算法
给对象添加一个引用计数器,每当有一个地方引用它时,计数器值就加1;当引用失效时,计数器值就减1;任何时刻计数器为0的对象就是不可能再被使用。
引用计数算法的实现简单,判断效率也很高,在大部分情况下它都是一个不错的算法,但是主流的Java虚拟机里面没有选用引用计数算法来管理内存,其中最主要的原因是它很难解决对象之间相互循环引用的问题。
3.2.2 可达性分析算法
主流的商用程序语言(Java、C#)的主流实现中,都是通过可达性分析来判断对象是否存活的。这个算法的基本思路就是通过一系列的称为“GC Roots”的对象作为起始点,从这些节点开始向下搜索,搜索所走过的路径称为引用链(Reference Chain),当一个对象到GC Roots没有任何引用链相连时(用图论的话来说,就是从GC Roots到这个对象不可达),则证明此对象是不可用的。
在Java语言中,可作为GC Roots的对象包括下面几种:
- 虚拟机栈(栈帧中的本地变量表)中引用的对象。
- 方法区中静态属性引用的对象。
- 方法区中常量引用的对象。
- 本地方法栈中JNI(即一般说的Native方法)引用的对象。
3.2.3 再谈引用
引用分为强引用(Strong 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()方法是对象逃脱死亡命运的最后一次机会,稍后GC将对F-Queue中的对象进行第二次小规模的标记,如果对象要在finalize()中成功拯救自己——只要重新与引用链上的任何一个对象建立关联即可,譬如把自己(this关键字)赋值给某个类变量或者对象的成员变量,那在第二次标记时它将被移除“即将回收”的集合;如果对象这时候还没有逃脱,那基本上它就真的被回收了。
3.2.5 回收方法区
永生代的垃圾收集主要回收两部分:废弃常量和无用的类。
- 废弃常量:没有任何对象引用常量池中的某个常量,也没有其他地方引用了这个字面量
- 无用的类:
- 该类所有的实例都已经被回收。
- 加载该类的ClassLoader已经被回收。
- 该类对应的java.lang.Class对象没有在任何地方被引用,无法在任何地方通过反射访问该类的方法。
虚拟机可以对满足上述3个条件的无用类进行回收,这里说的仅仅是“可以”,而并不是和对象一样,不使用了就必然会回收。
3.3 垃圾收集算法
3.3.1 标记—清除算法(Mark-Sweep)
“标记—清除”算法(最基础)分为“标记”和“清除”两个阶段:首先标记出所有需要回收的对象,在标记完成后统一回收所有被标记的对象。
主要不足有两个:一个是效率问题,两个过程效率都不高;另一个是空间问题,标记清除之后会产生大量不连续的内存碎片,空间碎片太多可能会导致以后在程序运行过程中需要分配较大对象时,无法找到足够的连续内存而不得不提前触发另一次垃圾收集动作。
3.3.2 复制算法
复制算法:它将可用内存按容量划分为大小相等的两块,每次只使用其中的一块。当这一块的内存用完了,就将还存活着的对象复制到另一块上面,然后再把已使用过的内存空间一次清理掉。
主要缺点有两个:
- 效率问题:在对象存活率较高时,复制操作次数多,效率较低。
- 空间问题:内存缩小了一半,需要额外空间做分配担保(老年代)。
From Survivor, To Survivor使用的就是复制算法。老年代不使用这种算法。
3.3.3 标记—整理算法(Mark-Compact)
标记—整理算法:标记过程仍然与“标记—清除”算法一样,但后续步骤不是直接对可回收对象进行清理,而是让所有存活的对象都向一端移动,然后直接清理掉端边界以外的内存。
3.3.4 分代收集算法(Generational Collection)
当前商业虚拟机基本上都采用这种算法,它并没有什么新的思想,只是根据对象存活周期的不同将内存划分为几块。
一般把Java堆分为新生代和老生代:
- 新生代:每次垃圾收集时都有大批对象死去,只有少量存活,所有选用复制算法。
- 老生代:对象存活率高,没有额外空间进行分配担保,必须使用“标记—清理”或者“标记—整理”算法来进行回收。
3.4 HotSpot的算法实现
3.4.1 枚举根节点
- 可作为GC Roots的节点主要在全局性的引用(例如常量或类静态属性)与执行上下文(例如栈帧中的本地变量表)中。
- 可达性分析对执行时间的敏感还体现在GC停顿上,因为这项分析工作必须在一个能确保一致性的快照中进行(一致性——指整个分析期间整个执行系统看起来就像被冻结在某个时间点上,不可以出现分析过程中对象引用关系还在不断变化的情况),该点不满足的话分析结果准确性就无法得到保证。这点是导致GC进行时必须停顿所有Java执行线程的其中一个重要原因。
3.4.2 安全点
安全点:在“特定的位置”记录了这些信息,这些位置称为安全点(Safepoint),即程序执行时并非在所有地方都能停顿下来开始GC,只有在到达安全点时才能暂停。
Safepoint的选定既不能太少以致于让GC等待时间太长,也不能过于频繁以致于过分增大运行时的负荷。所以,安全点的选定基本上是以程序“是否具有让程序长时间执行的特征”为标准进行选定的。
“长时间执行”的最明显的特征就是指令序列复用,例如方法调用、循环跳转、异常跳转等,所以具有这些功能的指令才会产生Safepoint。
对于Safepoint,另一个需要考虑的问题是如何在GC发生时让所有线程(这里不包括执行JNI调用的线程)都“跑”到最近的安全点上再停顿下来。这里有两种方案可供选择:抢先式中断(Preemptive Suspension)和主动式中断(Voluntary Suspension)
- 抢先式中断:不需要线程的执行代码主动去配合,在GC发生时,首先把所有线程全部中断,如果发现有线程中断的地方不在安全点上,就恢复线程,让它“跑”到安全点上。现在几乎没有虚拟机实现采用抢先式中断来暂停线程从而响应GC事件。
- 主动式中断:当GC需要中断线程的时候,不直接对线程操作,仅仅简单地设置一个标志,各个线程执行时主动去轮询这个标志,发现中断标志为真时就自己中断挂起。轮询标志的地方和安全点是重合的,另外再加上创建对象需要分配内存的地方。
3.4.3 安全区域
程序不执行就是没有分配CPU时间,典型的例子就是线程处于Sleep状态或者Blocked状态,这时候线程无法响应JVM的中断请求,“走”到安全的地方去中断挂起,JVM也显然不太可能等待线程重新分配CPU时间。这时候就需要安全区域来解决。
安全区域是指在一段代码片段之中,引用关系不会发生变化。在这个区域中的任意地方开始GC都是安全的。
在线程执行到Safe Region中的代码时,首先标识自己已经进入了Safe Region,那样,当在这段时间里JVM要发起GC时,就不用管标识自己为Safe Region状态的线程了。在线程要离开Safe Region时,它要检查系统是否已经完成了根节点枚举(或者时整个GC过程),如果完成了,那线程就继续执行,否则它就必须等待直到收到可以安全离开Safe Region的信号为止。
3.5 垃圾收集器
下图展示了7种作用于不同分代的收集器,如果两个收集器之间存在连线,就说明他们可以搭配使用。虚拟机所处的区域,则表示它是属于新生代收集器还是老年代收集器。
3.5.1 Serial收集器
Serial收集器是最基本、发展历史最悠久的收集器,曾经在JDK1.3之前是虚拟机新生代收集的唯一选择。
特点:
-
针对新生代;
-
采用复制算法;
-
单线程收集;
-
进行垃圾收集时,必须暂停所有工作线程,直到完成;
-
即会"Stop The World";
现在依然是虚拟机运行在Client模式下的默认新生代收集器。
优于其他收集器的地方: -
简单高效。
-
对单个CPU的环境来说,Serial收集器没有线程交互的开销,可以获得最高的单线程收集效率。
-
在用户的桌面应用场景中,可用内存一般不大(几十M至一两百M),可以在较短时间内完成垃圾收集(几十MS至一百多MS),只要不频繁发生,这是可以接受的。
3.5.2 ParNew收集器
ParNew收集器是Serial收集器的多线程版本。除了使用多条线程进行垃圾收集之外,其余行为包括Serial收集器可用的所有参数、收集算法、Stop The World、对象分配规则、回收策略等都与Serial收集器完全一样。
除了Serial收集器外,目前只有它能与CMS收集器配合工作。
3.5.3 Parallel Scavenge收集器
Parallel Scavenge是一个新生代收集器(吞吐量优先收集器),使用复制算法的并行的多线程收集器。
它的目标是达到一个可控制的吞吐量。所谓吞吐量就是CPU用于运行用户代码的时间与CPU总消耗时间的比值。
Parallel Scavenge收集器提供两个参数用于精确控制吞吐量,分别是控制最大垃圾收集停顿时间-XX:MaxGCPauseMillis参数以及直接设置吞吐量大小的-XX:GCTimeRatio参数。
- MaxGCPauseMillis参数允许的值是一个大于0的毫秒数,收集器将尽可能地保证内存回收花费的时间不超过设定值。
- GCTimeRatio参数的值应当是一个大于0且小于100的整数,也就是垃圾收集时间占总时间的比率,相当于是吞吐量的倒数。
Parallel Scavenge收集器还有一个参数-XX:+UseAdaptiveSizePolicy,这是一个开关参数,当它打开后,就不需要手工指定新生代的大小(-Xmn)、Eden与Survivor区的比例(-XX:SurvivorRatio)、晋升老年代对象大小(-XX:PretenureSizeThreshold)等细节参数,虚拟机会根据当前系统的运行情况收集性能监控信息,动态的调整这些参数以提供最合适的停顿时间或者最大吞吐量,这种调节方式称为GC自适应的调节策略。这也是Parallel Scavenge收集器与ParNew收集器的一个重要区别。
3.5.4 Serial Old收集器
Serial Old是Serial的老年代版本,同样是一个单线程收集器,使用“标记-整理”算法。这个收集器的主要意义也是在于给Client模式下的虚拟机使用。
在Server模式下,它有两大用途:
- 在JDK1.5以及之前的版本中与Parallel Scavenge收集器搭配使用。
- 作为CMS收集器的后备预案,在并发收集发生Concurrent Mode Failure时使用。
3.5.5 Parallel Old收集器
Parallel Old是Parallel Scavenge收集器的老年代版本,使用多线程和“标记-整理”算法。
在注重吞吐量以及CPU资源敏感的场合,都可以优先考虑Parallel Scavenge加Parallel Old收集器。
3.5.6 CMS收集器
CMS(Concurrent Mark Sweep)收集器是一种以获取最短回收停顿时间为目标的收集器,是基于“标记-清除”算法实现的。
特点:
- 针对老年代;
- 基于"标记-清除"算法(不进行压缩操作,产生内存碎片);
- 以获取最短回收停顿时间为目标;
- 并发收集、低停顿;
- 需要更多的内存(看后面的缺点);
- 是HotSpot在JDK1.5推出的第一款真正意义上的并发(Concurrent)收集器;
- 第一次实现了让垃圾收集线程与用户线程(基本上)同时工作;
运行过程:
- 初始标记(CMS initial mark)
- 并发标记(CMS concurrent mark)
- 重新标记(CMS remark)
- 并发清除(CMS concurrent sweep)
其中,初始标记、重新标记这两个步骤仍然需要“Stop The World”。初始标记仅仅只是标记一下GC Roots能直接关联到的对象,速度很快,并发标记阶段就是进行GC RootsTracing的过程,而重新标记阶段则是为了修正并发标记期间因用户程序继续运作而导致标记产生变动的那一部分对象的标记记录,这个阶段的停顿时间一般会比初始标记阶段稍长一些,但远比并发标记的时间短。
由于整个过程中耗时最长的并发标记和并发清除过程收集器线程都可以与用户线程一起工作,所以,从总体上来讲,CMS收集器的内存回收过程是与用户线程一起并发执行的。
缺点:
- CMS收集器对CPU资源非常敏感。CMS默认启动的回收线程数是(CPU数量+3)/ 4 。
- CMS收集器无法处理浮动垃圾(Floating Garbage),可能出现“Concurrent Mode
Failure”失败而导致另一次Full GC的产生。 产生大量的内存碎片。
3.5.7 G1收集器
G1是一款面向服务端应用的垃圾收集器。
特点:
- 并行与并发:Java线程执行的GC动作,G1收集器仍然可以通过并发的方式让Java程序继续执行。
- 分代收集:能够采用不同的方式去处理新创建的对象和已经存活了一段时间、熬过多次GC的旧对象以获取更好的收集效果。
- 空间整合:从整体来看是基于“标记-整理”算法实现的收集器,从局部(两个Region之间)上来看是基于“复制”算法实现的,运作期间不会产生内存空间碎片。
- 可预测的停顿:G1除了追求低停顿外,还能建立可预测的停顿时间模型。
G1收集器将整个Java堆划分为多个大小相等的独立区域(Region),虽然还保留新生代和老生代的概念,但是它们之间不再是物理隔离,它们都是一部分Region(不需要连续)的集合。
一个对象被不同区域引用的问题:
- 每个Region都有一个与之对应的Remembered Set;
- 每次对Reference类型数据写操作时,都会产生一个Write Barrier暂停中断操作;
- 然后检查将要写入的引用指向的对象是否和该Reference类型数据在不同的Region(其他收集器:检查老年代对象是否引用了新生代对象);
- 如果不同,通过CardTable把相关引用信息记录到引用指向对象的所在Region对应的Remembered Set中;
- 当进行垃圾收集时,在GC根节点的枚举范围加入Remembered Set,就可以保证不进行全局扫描,也不会有遗漏。
运作步骤:
- 初始标志(Initial Marking)
仅标记一下GC Roots能直接关联到的对象;
并且修改TAMS(Next Top at Mark Start),让下一阶段并发运行时,用户程序能在正确可用的Region中创建新对象;
需要"Stop The World",但速度很快;
- 并发标记(Concurrent Marking)
- 进行GC Roots Tracing的过程;
- 刚才产生的集合中标记出存活对象;
- 耗时较长,但应用程序也在运行;
- 并不能保证可以标记出所有的存活对象;
- 最终标记(Final Marking)
- 为了修正并发标记期间因用户程序继续运作而导致标记变动的那一部分对象的标记记录;
- 上一阶段对象的变化记录在线程的Remembered Set Log;
- 这里把Remembered Set Log合并到Remembered Set中;
- 需要"Stop The World",且停顿时间比初始标记稍长,但远比并发标记短;
- 采用多线程并行执行来提升效率;
- 筛选回收(Live Data Counting and Evacuation)
- 首先排序各个Region的回收价值和成本;
- 然后根据用户期望的GC停顿时间来制定回收计划;
- 最后按计划回收一些价值高的Region中垃圾对象;
- 回收时采用"复制"算法,从一个或多个Region复制存活对象到堆上的另一个空的Region,并且在此过程中压缩和释放内存;
- 可以并发进行,降低停顿时间,并增加吞吐量;
3.6 内存分配与回收策略
3.6.1 对象优先在Eden分配
大多数情况下,对象在新生代Eden区中分配。当Eden区没有足够空间进行分配时,虚拟机将发起一次Minor GC。
3.6.2 大对象直接进入老年代
大对象:需要大量连续内存空间的Java对象,最经典的大对象就是那种很长的字符串以及数组。
3.6.3 长期存活的对象将进入老年代
对象年龄计数器:如果对象在Eden出生并经过第一次Minor GC后仍然存活,并且能被Survivor容纳的话,将被移动到Survivor空间中,并且对象年龄设为1 。对象在Survivor区中每熬过“熬过”一次Minor GC,年龄就增加1岁,当它的年龄增加到一定程度(默认为15岁),就将会被晋升到老年代中。
3.6.4 动态对象年龄判断
虚拟机并不是永远地要求对象的年龄必须达到了MaxTenuringThreshold才能晋升老年代,如果在Survivor空间中相同年龄所有对象的大小的总和大于Survivor空间的一半,年龄大于或等于该年龄的对象就可以直接进入老年代。
3.6.5 空间分配担保
在发生Minor GC之前,虚拟机会先检查老年代最大可用的连续空间是否大于新生代所有对象总空间,如果这个条件成立,那么Minor GC可以确保是安全的。如果不成立,则虚拟机会查看HandlePromotionFailure设置值是否允许担保失败。如果允许,那么会继续检查老年代最大可用的连续空间是否大于历次晋升到老年代对象的平均大小,如果大于,将尝试着进行一次Minor GC,尽管这次Minor GC是有风险的;如果小于,或者HandlePromotionFailure设置不允许冒险,那这时也要改为进行一次Full GC。