2.垃圾收集器与内存分配策略
Java 的程序计数器、虚拟机栈、本地方法栈这 3 个区域随线程而生,随线程而灭,内存分配和回收都具备确定性。而 Java 堆和方法区则不一样,这部分内存的分配和回收是动态的,垃圾收集器所关注的是这部分的内存。
思维导图
判断对象存活
引用计数算法
给对象添加一个引用计数器,每当有一个地方引用它时,计数器值加一;当引用失效,计数器值减一。无法解决对象之间循环引用的问题
可达性分析算法
通过一系列的 “GC Roots” 的对象作为起点,从这些节点往下搜索,搜索走过的路径称为引用链,当一个对象到GC Roots没有任何引用链(也就是GC Roots到这个对象不可达),则证明此对象是不可用的。
GC Roots 对象的选择:
- 虚拟机栈(栈帧中的本地变量表)中引用的对象
- 方法区中类静态属性引用的对象
- 方法区中常量引用的对象
- 本地方法栈中 JNI(即一般说的Native方法)引用的对象
再谈引用
强引用(Strong Reference)
类似于“Object object = new Object()”,只要强引用还在,垃圾收集器永远不会回收掉被引用的对象
软引用(Soft Reference)
软引用是用来描述一些还有用但并非必需的对象。系统在内存不足时,才会对软引用对象进行回收;若还没有足够的内存,抛出内存溢出异常。
弱引用 (Weak Reference)
弱引用也是用来描述非必需对象的,但强度更弱。只被弱引用关联的对象在GC时,无论当前内存是否足够,都会被回收掉
虚引用(Phantom Reference)
最弱的一种引用关系。一个对象是否有虚引用,不会对其生存时间造成影响,也无法通过虚引用来取得一个对象实例。唯一目的是这个对象被回收时收到一个系统通知。
finalize() 方法逃脱回收
要真正宣告一个对象死亡,至少要经历两次标记过程:
- 如果对象在进行可达性分析后发现没有与GC Roots相连接的引用链,那它将会被第一次标记并且进行一次筛选,筛选的条件是此对象是否有必要执行finalize方法(如没有重写finalize方法或者已经被调用过则认为没有必要执行);如果有必要执行则将该对象放置在F-Queue队列中,并在稍后由一个由虚拟机自己建立的、低优先级的 Finalizer 线程去执行它
- 稍后GC将对F-Queue中的对象进行第二次标记,如果对象还是没有被引用,则会被回收。
所以,可以在 finalize() 方法与引用链的对象建立关联,如将自己(this关键字)赋值给某个类变量或对象的成员变量,那么第二次标记时它将会被移除出“即将回收”的集合。
回收方法区
永久代的垃圾收集主要回收两部分内容:废弃常量和无用的类。
一个无用的类可以(并不像对象,无用就必然回收)被回收需要满足以下三个条件:
- 该类的所有实例都已经被回收;
- 加载该类的 ClassLoader 已经被回收;
- 该类对象的
java.lang.Class
对象没有在任何地方被引用,无法在任何地方通过反射访问该类的方法;
HotSpot 提供了 -Xnoclassgc
进行控制,使用 -verbose:class
以及-XX:+TraceClassLoading
(这两个可以在Product版的虚拟机使用),-XX:+TraceClassUnLoading
(需要FastDebug版的虚拟机支持)查看类加载和卸载信息
垃圾收集算法
标记-清除(Mark-Sweep)算法
分为标记和清除阶段,首先标记出所有需要回收的对象,标记完成后统一回收所有被标记的对象。
不足:
- 效率问题,标记和清除过程效率不高
- 空间问题,造成大量不连续的内存碎片
复制(Copying)算法
思想:将可用的容量划分为大小相同的两块,每次只使用其中一块,当一块内存用完,将还存活的对象复制到另一块上,然后在把已使用的内存空间一次性清理掉。
好处:不用考虑内存碎片,只要移动堆顶指针,按顺序分配内存,简单,高效;
不足:内存缩小为原来的一半。
HotSpot 实现
考虑到大部分对象存活时间很短,所以将内存分为一块较大的Eden空间和两块较小的Survivor空间,每次使用Eden和一块Survivor。回收时,将Eden和Survivor还存活的对象一次性复制到另外一块Survivor空间上,清理掉Eden和刚才使用的Survivor空间。默认Eden和Survivor大小比例是 8:1,也就是新生代可用内存空间为整个新生代的90%。
当Survivor空间不够用时,需要依赖其它内存(老年代)进行分配担保(Handle Promotion),即如果另外一块Survivor空间没有足够的空间存放上一次GC后的存活对象,这些对象将直接通过分配担保机制进入老年代。
标记-整理(Mark-Compact)算法
根据老年代特点,提出该算法,标记过程和“标记-清除”算法一样,但后续并不是直接对可回收对象进行清理,而是让所有存活的对象都向一端移动,然后清理直接清理掉端边界以外的内存。
分代收集(Generational Collection)算法
根据对象存活周期的不同将内存划分为几块。将堆分为新生代和老年代,在新生代中,每次垃圾收集时都发现有大批对象死去,只有少量存活,选用复制算法;而老年代则采用“标记-整理”或者“标记-清除”算法。
HotSpot 的算法实现
枚举根节点
由于要确保在一致性(不可以出现分析过程中对象引用关系还在不断变化的情况 )的快照中进行可达性分析,从而导致GC进行时必须要停顿所有Java执行线程,这也是 “Stop The World” 的一个重要原因。
虚拟机应当有办法直接知道哪些地方存放着对象引用。在HotSpot里通过一组被称为OopMap数据结构来达到目的。
安全点
HotSpot 只在特定的位置记录了 OopMap,这些位置称为安全点(SafePoint),程序执行时并非在所有地方都能停顿下来开始GC,只有到达安全点时才能暂停。
对于安全点基本上是以程序“是否具有让程序长时间执行的特征”(比如方法调用、循环跳转、异常跳转等)为标准进行选定的。
还需要考虑如何在GC时让所有线程都跑到最近的安全点上,有两种方案:
- 抢先式中断(在GC发生时,中断所有线程, 如果有线程不在安全点,就恢复线程,让它跑到安全点,几乎没有虚拟机使用抢先式中断)
- 主动式中断(当GC需要中断线程时,仅设置一个标志位,各个线程主动去轮询这个标志,为真时自己中断挂起,轮询标志的地方与安全点是重合的)
安全区域
如果程序没有分配CPU时间(如线程处于Sleep或Blocked),此时就需要安全区域(Safe Region),其是指在一段代码片段之中,引用关系不会发生变化,在这个区域的任何位置,GC 都是安全的。
线程执行到安全区域时,首先标识自己已经进入了安全区域,这样JVM在GC时就不管这些线程了;当线程要离开安全区域时,它要检查系统是否已经完成根节点枚举(或GC),如果完成,线程就继续执行,否则等待可以离开安全区域的信号。
垃圾收集器
收集算法是内存回收的方法论,而收集器则是内次回收的具体实现。
这里讨论JDK 1.7 Update 14之后的HotSpot虚拟机(此时G1仍处于实验状态),包含的虚拟机如下图所示(存在连线的表示可以搭配使用):
Serial 收集器
- 最基本、发展历史最悠久,在JDK 1.3之前是新生代收集的唯一选择
- 是一个单线程(并非指一个收集线程,而是会暂停所有工作线程)的收集器
- 现在依然是虚拟机运行在Client模式下的默认新生代收集器,主要就是因为它简单而高效(没有线程交互的开销)
ParNew 收集器
- 其实就是Serial收集器的多线程版本
- ParNew收集器在单CPU环境中绝对不会有比Serial收集器更好的效果
- 是许多运行在 Server 模式下虚拟机首选的新生代收集器,重要原因就是除了Serial收集器外,只有它能与 CMS 收集器配合工作
[并行(Parallel):指多条垃圾收集线程并行工作,但此时用户线程仍处于等待状态;并发(Concurrent):指用户线程与垃圾收集线程同时执行,用户线程在继续执行而垃圾收集程序运行在另外一个CPU上]
Parallel Scavenge 收集器
- 新生代收集器,使用复制算法,并行的多线程收集器
- 与其他收集器关注于尽可能缩短垃圾收集时用户线程停顿时间不同,它的目标是达到一个可控制的吞吐量(Throughput),吞吐量=运行用户代码时间/(运行用户代码+垃圾收集时间)
- 高吞吐量可以高效率利用CPU时间,适合在后台运算而不需要太多交互的任务
-XX:MaxGCPauseMillis
参数可以设置最大停顿时间,而停顿时间缩短是以牺牲吞吐量和新生代空间来换取的- 另外它还支持GC自适应的调节策略(GC、 Ergonomics)
Serial Old 收集器
- 是Serial收集器的老年代版本,同样是单线程,使用标记-整理算法
- 主要是给 Client 模式下的虚拟机使用的
- 在Server模式下:
- 主要是给 JDK 1.5 及之前配合 Parallel Scavenge 收集器使用
- 作为 CMS 收集器的后备预案,在并发收集发生 Concurrent Mode Failure时使用
Parallel Old 收集器
- 是Parallel Scavenge的老年代版本,使用多线程和标记-整理算法
- 是JDK 1.6中才开始提供的;
CMS 收集器
- CMS(Concurrent Mark Sweep)收集器是一种以获取最短回收停顿时间为目标的收集器,特别适合互联网站或者B/S的服务端;
- 它是基于标记-清除 算法实现的,主要包括4个步骤:
- 初始标记(CMS initial mark),STW,只是初始标记一下GC Roots能直接关联到的对象,速度很快
- 并发标记(CMS concurrent mark),非STW,执行GC RootsTracing,耗时比较长
- 重新标记(CMS remark),STW,修正并发标记期间因用户程序继续导致变动的那一部分对象标记
- 并发清除(CMS concurrent sweep),非STW,耗时较长
- 还有3个明显的缺点:
- CMS收集器对CPU非常敏感(占用部分线程及CPU资源,影响总吞吐量)
- 无法处理浮动垃圾(默认达到92%就触发垃圾回收)
- 大量内存碎片产生(可以通过参数启动压缩)
G1 收集器
- G1(Garbage-First)收集器是一款面向服务端应用的垃圾收集器,后续会替换掉CMS垃圾收集器;
- 特点:
- 并行与并发:充分利用多核多 CPU 缩短 Stop-The-World 时间
- 分代收集:独立管理整个 Java 堆,但针对不同年龄的对象采取不同的策略
- 空间整合:基于标记-整理
- 可预测的停顿:将堆分为大小相等的独立区域,避免全区域的垃圾收集
- G1 将整个堆分为大小相等的独立区域(Region),新生代和老年代不再物理隔离,只是部分 Region (不需要连续)的集合。G1跟踪各个Region垃圾堆积的价值大小,在后台维护一个优先列表,根据允许的收集时间优先回收价值最大的 Region。在 G1 中, Region 之间的对象引用以及其他收集器中的新生代与老年代之间的对象引用,采用 Remembered Set 来避免全堆扫描;
- 不计算维护 Remembered Set 的操作,G1 运行分为几个步骤:
- 初始标记(Initial Marking),标记一下GC Roots能直接关联的对象并修改 TAMS (Next Top at Mark Start)值,需要 STW 但耗时很短
- 并发标记(Concurrent Marking),从GC Root从堆中对象进行可达性分析找存活的对象,耗时较长但可以与用户线程并发执行
- 最终标记(Final Marking),为了修正并发标记期间产生变动的那一部分标记记录,这一期间的变化记录在 Remembered Set Log 里,然后合并到 Remembered Set 里,该阶段需要 STW 但是可并行执行
- 筛选回收(Live Data Counting and Evacuation),对各个 Region 回收价值排序,根据用户期望的 GC 停顿时间制定回收计划来回收
GC日志
- 最前面的数字代表 GC 发生的时间(虚拟机启动以后的秒数)
- “[GC” 和 “[Full GC” 说明停顿类型,有 Full 代表的是 Stop-The-World 的
- “[DefNew”、“[Tenured” 和 “[Perm” 表示 GC 发生的区域,与使用的 GC 收集器密切相关,例如"[DefNew"即 “Default New Generation”,使用的是 Serial 收集器;如果是 Parallel Scavenge 收集器,新生代被称为 “PSYoungGen”
- 方括号内部的 “3324K -> 152K(3712K)” 含义是 “GC前该内存已使用容量 -> GC后该内存区域已使用容量(该区域总容量)”
- 方括号之外的 “3324K -> 152K(11904K)” 含义是 “GC前Java堆已使用容量 -> GC后Java堆已使用容量(Java堆总容量)”
- 再往后 “0.0025925 secs” 表示该内存区域GC所占用的时间,单位秒
垃圾收集器参数
内存分配与回收策略
使用 Client 模式虚拟机运行,即验证 Serial / Serial Old 收集器(ParNew / Serial Old 收集器类似)。
对象优先在新生代分配
新生代 GC(Minor GC):指发生在新生代的垃圾收集动作,Minor GC 非常频繁,一般回收速度也比较快
老年代 GC(Major GC 或 Full GC):指发生在老年代的 GC,出现了 Major GC,经常会伴随至少一次的 Minor GC(但并非绝对,在 Parallel Scavenge 收集器的收集策略里就有进行 Major GC 的策略选择过程)。Major GC 一般比 Minor GC 慢十倍以上。
大对象直接进入老年代
所谓大对象就是指,需要大量连续内存空间的 Java 对象,最典型的大对象就是那种很长的字符串及数组,虚拟机提供了一个 -XX:PretenureSizeThreshold
参数,令大于这个设置值的对象直接在老年代中分配。这样做的目的是避免在 Eden 区及两个 Survivor 区之间发生大量的内存拷贝。
长期存活的对象将进入老年代
虚拟机既然采用了分代收集的思想来管理内存,那内存回收时就必须能识别哪些对象应当放在新生代,哪些对象应放在老年代中。
虚拟机给每个对象定义了一个对象年龄(Age)计数器。如果对象在 Eden 出生并经过第一次 Minor GC 后仍然存活,并且能被 Survivor 容纳的话,将被移动到 Survivor 空间中,并将对象年龄设为1。对象在 Survivor 区中每熬过一次 Minor GC,年龄就增加 1岁,当它的年龄增加到一定程度(默认为 15 岁)时,就会被晋升到老年代中。对象晋升老年代的年龄阈值,可以通过参数 -XX:MaxTenuringThreshold
来设置。
动态对象年龄判断
如果在 Survivor 空间中相同年龄所有对象大小总和大于 Survivor 空间的一半,大于或等于该年龄的对象直接进入老年代。
空间分配担保
发生 Minor GC 前,虚拟机会先检查老年代最大可用连续空间是否大于新生代所有对象总空间,如果不成立,虚拟机会查看 HandlePromotionFailure 设置值是否允许担保失败,如果允许继续检查老年代最大可用的连续空间是否大于历次晋升到老年代的平均大小,如果大于会尝试进行一次 Minor GC;如果小于或者不允许冒险,会进行一次 Full GC;
小结
本章介绍了垃圾回收算法、几款JDK 1.7中提供的垃圾收集器特点以及运作原理。内存回收与垃圾收集器在很多时候都是影响系统性能、并发能力的主要因素之一,然而没有固定收集器和参数组合,也没有最优的调优方法,需要根据实践了解各自的行为、优势和劣势。
参考资料
- 周志明. 深入理解Java虚拟机 : JVM高级特性与最佳实践 : Understanding the JVM : advanced features and best practices[M]. 机械工业出版社, 2013.