Java 垃圾回收
“Java 与 C++ 之间有一堵由内存动态分配和垃圾收集技术所围成的“高墙”,墙外面的人想进去,墙里面的人却想出来。”
—— 周志明 《深入理解Java虚拟机:JVM高级特性与最佳实践(第2版)》
以下内容整理自周志明《深入理解Java虚拟机:JVM高级特性与最佳实践(第2版)》
对象回收条件
引用计数算法(Reference Counting)
给对象添加一个引用计数器,每当有一个地方引用这个对象时,计数器的指加1;当引用失效时,计数器的指减1;任意时刻计数器为0的对象就是不可能再被使用的。
- 优点:实现简单,判定效率高
- 缺点:难以解决对象间循环引用问题
主流Java虚拟机均未选择引用计数算法来管理内存。
可达性分析算法(Reachability Analysis)
通过一系列 GC Roots 对象作为起始点,从这些节点开始向下搜索,搜索走过的路径称为引用链,当一个对象到GC Roots没有任何引用链相连,则此对象不可用。
在Java中,可作为GC Roots的对象如下:
- 虚拟机栈(栈帧中的本地变量表)中引用的对象
- 方法区中类静态属性引用的对象
- 方法区中常量引用的对象
- 本地方法栈中JNI(一般说的Native方法)引用的对象
在主流的商用程序语言的主流实现中,都是通过可达性分析来判断对象是否存活的。
可达性分析算法对执行时间的敏感性
- 从GC Roots查找引用链
- 可作为GC Roots的节点主要在全局性的引用与执行上下文中
- 如果逐个检查这里面的引用会消耗很多时间
- GC停顿
- 分析工作必须在一个能确保一致性的“快照”中进行
- GC进行时必须停顿所有Java执行线程(Sun将其称之为“ Stop The World ”)
关于引用
在JDK1.2前,Java中引用的定义为:如果reference类型的数据中存储的数值代表的是另一块内存的起始地址,就称这块内存代表着一个引用。
这样的定义很纯粹,但是太过狭隘,一个对象在这种定义下只有被引用和没有被引用两种状态。无法描述一些“食之无味,弃之可惜”的对象。
在JDK1.2后,Java对引用的概念进行了扩充,将引用分为以下四种,这四种引用强度依次减弱。
- 强引用 Strong Reference
- 程序代码中普遍存在的引用
- 被引用的对象永远不会被垃圾收集器回收
- 软引用 Soft Reference
- 有用但非必需的对象
- 在系统将要发生内存溢出异常之前会把其关联对象列入回收范围之内进行第二次回收
- 弱引用 Weak Reference
- 被弱引用关联的对象只能生存到下一次垃圾收集发生之前
- 虚引用 Phantom Reference
- 不会对对象的生存时间构成影响
- 设置的唯一目的是能在对象被回收时收到系统通知
垃圾收集算法
垃圾收集算法的实现涉及大量程序细节,且各个平台虚拟机操作内存的方法各不相同,因此以下整理的仅为几种算法的主要思想,并不关注其具体实现。
标记-清除算法(Mark-Sweep)
标记-清除算法是最基础的收集算法,之所以说是最基础的收集算法,是因为后续的收集算法都是基于这种思路并对其不足进行改进而得到的。该算法分为“标记”和“清除”两个阶段。
- 标记:标记出所有需要回收的对象
- 清除:在标记完成后统一回收所有被标记的对象
该算法的不足之处:
- 效率问题:标记和清除两个过程的效率都不高
- 空间问题:标记清除后会产生大量不连续的内存碎片
复制算法(Copying)
复制算法的出现是为了解决效率问题。它的大体思路为:将可用内存按容量分为大小相等的两块,每次只使用其中一块。当一块的内存用完了,就将还存活着的对象复制到另一块上,然后把已使用过的内存空间一次清理掉。
- 优点:每次仅对半区进行回收,按顺序分配内存即可,实现简单,运行高效
- 缺点:将内存缩小为了原来的一半,代价巨大
现在的商业虚拟机都采用这种收集算法来回收新生代。
研究表明,新生代中的对象98%是“朝生夕死”的,所以不需要按照1:1的比例划分内存空间,而是将内存分为一块较大的 Eden 空间和两块较小的 Survivor 空间。HotSpot虚拟机默认Eden和Survivor的大小比例是8:1。
在回收时,将Eden和Survivor中还存活着的对象一次性复制到另一块Survivor空间上,最后清理掉Eden和刚刚使用的Survivor空间。如果Survivor空间不够用,则需要依赖老年代内存进行分配担保(Handle Promotion)。
标记-整理算法(Mark-Compact)
复制算法一般不能用于老年代,原因如下:
- 老年代对象存活率较高,需要进行较多复制操作,降低效率
- 为了不浪费50%的空间,需要额外空间进行分配担保,以应对对象100%存活的极端情况
“标记-整理”算法考虑到老年代的特点,其中标记过程与“标记-清除”算法相同,但后续步骤是让所有对象向一端移动,然后直接在清理掉端边界以外的内存。
分代收集算法(Generational Collection)
当前商业虚拟机的垃圾收集都采用“分代收集”算法。
思想:把Java堆分为新生代和老年代,这样可以根据各个年代的特点采取适当的收集算法。
注:Minor GC 与 Full GC
- Minor GC
- 发生在新生代的垃圾收集动作
- 非常频繁,回收速度较快
- Full GC / Major GC
- 发生在老年代的GC
- 经常会伴随至少一次的Minor GC
- 速度一般会比Minor GC慢10倍以上
垃圾收集器
垃圾收集器是内存回收的具体实现。Java虚拟机规范中对垃圾收集器如何实现没有任何规定,因此不同厂商、版本的虚拟机提供的垃圾收集器区别很大。
以下收集器基于JDK1.7 Update 14后的HotSpot虚拟机。这个虚拟机包含的所有收集器如下图所示。
Serial 收集器
- 单线程收集器
- 简单高效
- 没有线程交互的开销,可以获得最高的单线程收集效率
- 最基本、发展历史最悠久
- 在JDK1.3.1之前是虚拟机新生代收集的唯一选择
- 目前为止依然是虚拟机运行在Client模式下的默认新生代收集器
ParNew 收集器
ParNew收集器是Serial收集器的多线程版本。在实现上,这两者也共用了相当多的代码。
ParNew收集器是运行在Server模式下的虚拟机中首选的新生代收集器 ,一个与性能无关的重要原因是,除了Serial收集器外,目前只有它能与CMS收集器配合工作。
Parallel Scavenge 收集器
Parallel Scavenge 收集器是使用复制算法、并行的多线程新生代收集器。
与ParNew收集器的不同之处:
关注点——吞吐量(Throughput)
吞吐量:CPU用于运行用户代码的时间与CPU总消耗时间的比值
吞吐量 = 运行用户代码时间 / (运行用户代码时间+垃圾收集时间)
高吞吐量意味着可以高效率地利用CPU时间,尽快完成运算任务
适合在后台运算不需要太多交互的任务
CMS等收集器的关注点是尽可能缩短垃圾收集时用户线程的停顿时间,停顿时间越短就越适
合需要与用户交互的程序,良好的响应速度能提升用户体验
GC自适应调节策略(GC Ergonomics)
- 提供参数 -XX:+UseAdaptiveSizePolicy
- MaxGCPauseMillis参数更关注最大停顿时间
- GCTimeRatio参数更关注吞吐量
- 参数打开后虚拟机会根据当前系统运行情况动态调整参数以提供最合适的停顿时间或最大吞吐量
- 提供参数 -XX:+UseAdaptiveSizePolicy
Serial Old 收集器
Serial Old 是 Serial 收集器的老年代版本。它是单线程收集器,使用“标记-整理”算法。主要意义是给Client模式下的虚拟机使用。
在Server模式下,它还有两大用途:
在JDK1.5及之前版本中与Parallel Scavenge收集器搭配使用
Parallel Scavenge 收集器架构中本身有PS MarkSweep收集器来进行老年代收集,并非直接
使用Serial Old收集器,但是PS MarkSweep收集器与Serial Old的实现非常接近,所以在官
方许多资料中都是都是直接以Serial Old代替PS作为CMS收集器的后备预案,在并发收集发生Concurrent Mode Failure时使用
Parallel Old 收集器
Parallel Old 自JDK1.6开始提供,是 Parallel Scavenge收集器的老年代版本,使用多线程和“标记-整理”算法。
在Parallel Old收集器出现前,如果新生代选择了Parallel Scavenge收集器,老年代除了Serial Old (PS MarkSweep)收集器外别无选择。但老年代Serial Old收集器无法充分利用多CPU的处理能力,使用了Parallel Scavenge收集器未必能在整体应用上获得吞吐量最大化的效果。
Paralle Old收集器出现后,“吞吐量优先”收集器有了比较名副其实的应用组合(Parallel Scavenge + Parallel Old)。
CMS 收集器
CMS (Cocurrent Mark Sweep) 收集器是一种以获取最短回收停顿时间为目标的收集器。
CMS收集器基于“标记-清除”算法实现,整个过程分为以下4个步骤:
- 初始标记( CMS initial mark )
- 标记GC Roots能直接关联到的对象,速度较快
- 并发标记( CMS concurrent mark )
- GC Roots Tracing的过程
- 重新标记( CMS remark )
- 修正并发标记期间因用户程序继续运作而导致标记产生变动的那一部分对象的标记记录
- 停顿时间比初始标记阶段稍长,远比并发标记时间短
- 并发清除( CMS concurrent sweep )
CMS收集器运行示意图
整个过程中耗时最长的并发标记和并发清除过程中,收集器线程都可以和用户线程一起工作。
- 优点
- 并发收集。从整体上说,CMS收集器内存回收过程是与用户线程一起并发执行的
- 低停顿
- 缺点
- 对CPU资源非常敏感
- 在并发阶段,会因为占用了一部分CPU资源导致应用程序变慢,总吞吐量降低
- 当CPU数量较少时,CMS对用户程序的影响可能很大
- 无法处理浮动垃圾(Floating Garbage)
- 浮动垃圾:CMS并发清理阶段,用户线程产生的未被标记的新的垃圾
- CMS无法在当次收集中处理浮动垃圾,只能留待下一次GC时再清理
- 可能出现“Concurrent Mode Failure”导致另一次Full GC的产生
- 基于“标记-清除”算法,收集结束时会产生大量空间碎片
- 对CPU资源非常敏感
G1 收集器
G1( Gabage-First )是一款面向服务端应用的垃圾收集器。
与其他GC收集器相比,G1具备如下特点:
- 并行与并发
- 充分利用多CPU、多核环境下的硬件优势,缩短Stop-The-World的时间
- 部分其他收集器原本需要停顿Java线程执行的GC动作,G1仍然可以通过并发方式让Java程序继续执行
- 分代收集
- 不需要其他收集器配合就能独立管理整个Java堆
- 能够采用不同方式处理新创建对象和已经存活一段时间的对象
- 空间整合
- 从整体上看是基于“标记-整理”算法实现的收集器
- 从局部(两个Region之间)上看是基于“复制”算法实现的
- 两种算法都意味着G1运作期间不会产生内存空间碎片
- 可预测的停顿
- 降低停顿时间是G1和CMS共同的关注点
- G1能建立可预测的停顿时间模型
使用G1收集器时,它将整个Java堆划分为多个大小相等的独立**区域 ( Region )**,虽然保留有新生代和老年代的概念,但它们之间不再是物理隔离的,都是一部分Region(不需要连续)的集合。
G1收集器可以有计划地避免在整个Java堆中进行全区域的垃圾收集。G1跟踪各个Region里面的垃圾堆积的的价值大小,在后台维护一个优先列表,每次根据允许的收集时间,优先回收价值最大的Region。
G1收集器运作大致可划分为以下几个步骤(不计算维护Remembered Set的操作):
- 初始标记 ( Initial Marking )
- 标记GC Roots能直接关联到的对象
- 修改TAMS ( Next Top at Mark Start ) 的值,让下一阶段用户程序能在正确可用Region中创建新对象
- 需要停顿线程,耗时短
- 并发标记 ( Concurrent Marking )
- 从GC Roots开始对堆中对象进行可达性分析,找出存活的对象
- 可与用户程序并发执行
- 最终标记 ( Final Marking )
- 修正在并发标记期间因用户程序继续运作而导致标记产生变动的那一部分标记记录
- 虚拟机将对象变化记录在线程Remembered Set Logs里,本阶段把其中数据合并到Remembered Set中
- 需要停顿线程,可并行执行
- 筛选回收 ( Live Data Counting and Evacuation )
- 对各个Region的回收价值和成本进行排序,根据用户期望GC停顿时间制定回收计划
- 可以做到与用户程序并发执行,但停顿用户线程将大幅提高收集效率,而且时间用户可控
G1 收集器运行示意图