概述
在 Java 运行时,其中程序计数器,虚拟机栈,本地方法栈3个区域随线程而生,随线程而灭,栈中的栈帧随着方法的进入和退出而执行着出栈入栈的操作,每一个栈帧中分配多少内存基本是在类结构确定下来就已知了,因此这几个区域的内存分配和回收都具备确定性,在这几个区域内不需要过多考虑回收问题,因为方法结束或者线程结束时,内存自然就跟着回收了,而 Java 堆和方法区则不一样,一个接口中的多个实现类需要的内存可能不一样,一个方法中的方法中多个分支需要的内存也可能不一样,我们只有在程序运行期间才能知道会创建哪些对象,这部分内存的分配和回收都是动态的,垃圾收集器所关注的是这部分内存
哪些内存需要回收
在堆里面存放着 Java 中几乎所有的对象实例,垃圾收集器在对堆进行回收前,第一件事情就是要确定这些对象之中哪些还存活着,哪些已经死去
引用计数法
在 Java 堆中给每个对象添加一个引用计数器,每当有一个地方引用它时,计数器 + 1,当引用失效时,计数器 -1,任何时刻计数器为0时表明对象不可能被使用
- 优点:实现简单,判定效率高,交织在程序运行中,对程序不被长时间打断的实时环境比较有利
- 缺点:难以检测出对象之间的循环引用
可达性分析算法
通过一系列称为 "GC Roots"的对象作为起始点,从这些节点开始向下搜索,搜索的路径称为引用链,当一个对象到 GC Roots 没有任何引用链相连时,证明此对象是不可用
如下图,Object5,Object6,Object7 虽然互相连接,但它们到 GC Roots 是不可达的,因此会被判定为可收回的对象
GC Roots 对象
- 虚拟机栈 (栈帧中本地变量表) 中引用的对象
- 方法区中类静态属性引用的对象
- 方法区中常量引用的对象
- 本地方法栈 JNI 引用的对象
引用分类
JDK1.2 之后,Java 对引用的概念进行了扩充,将引用分为强引用,软引用,弱引用,虚引用
- 强引用 代码中普遍存在,如 Object obj = new Object() 这类的引用,只要强引用还存在,垃圾收集器就不会回收被引用的对象
- 软引用 描述一些还有用但并非必须的对象,可用 SoftReference 类实现,对于软引用关联的对象,在系统将要发生内存溢出之前,将会把这些对象列入回收范围之中进行二次回收,如果这次回收还没有足够的内存,才会抛内存溢出异常
- 弱引用 也是描述非必须对象,但是它强度比软引用还弱些,可用 WeakReference 类实现,被弱引用关联的对象只能生存在下一次垃圾收集发生之前,当垃圾收集工作时,无论当前是否足够,都会回收被只被弱引用关联的对象
- 虚引用 起到对象被回收时收到一个系统通知的目的,可以通过 PhantomReference 类实现
垃圾收集算法
主要介绍算法的思想以及发展过程
标记—清除算法
算法分为标记和清除两个阶段,首先标记所需要回收的对象,在标记完成后统一回收所有被标记的对象
- 优点:实现简单,不需要进行对象的移动
- 缺点:标记,清除效率低,产生大量不连续的内存碎片,提高了垃圾回收的频率
复制算法
这种收集算法解决标记清除算法存在的效率问题,它将内存划分为相同的两个内存块,每次只使用一半空间,JVM 生成的新对象放在一半空间中,当一般空间用完时进行 GC,将可达对象复制到另一半区间,然后将用过的内存空间一次清理掉(新生代收集采用的算法,只不过 按 8:1:1 分配)
- 优点:内存分配时不用考虑内存碎片问题,按顺序分配内存即可,实现简单,高效
- 缺点:可用内存大小缩小为原来一半,空间利用率低
标记—整理算法
复制算法在对象存活率高的时候要进行较多的复制操作,效率会变低,更关键是,如果不想浪费一半的空间,需要额为的空间进行分配担保,以应对被内存中对象存活率高的极端情况,所有一半老年代不采用这个算法
和标记—清除算法一样,但后续步骤不是直接对可回收对象进行清理,而是让所用存活对象向一端移动,耳环清理端边界以外的内存
- 优点:解决了标记—清理存在的内存碎片问题
- 缺点:仍然需要局部对象的移动,移动程度降低效率
分代收集算法
根据对象存活周期的不同将内存分为几块,一般将 Java 堆分为新生代和老年代,新生代中,每次垃圾收集时发生大量对象死亡,只有少量存活,选用复制算法,只需要付出少量复制成本就可以完成收集,而老年代因为对象存活率高,没有额外的空间担保,必须采用 标记—清理 或 标记—整理 算法
HotSpot 的算法实现
枚举根节点
在可达性分析算法中,可作为 GC Roots 的节点主要在全局性的引用(例如常量或类静态属性)与执行上下文(例如栈帧的本地变量表)中,实现有如下问题:
- 在很多应用中仅仅方法区就有数百兆,如果逐个检查这里面的引用,会消耗大量时间
- 对可达性分析还体现在 GC 停顿上,因为分析工作必须在一个确保一致性的快照中进行,不可以出现分析过程中对象引用关系还在不断变化的情况
目前的虚拟机使用时准确式 GC,所以当执行系统停顿下来,并不需要一个不漏的检测所有与执行上下文和全局的引用位置,虚拟机知道哪些地方存放对象引用,在 HotSpot 实现中,通过一组称为 OopMap 的数据结构达到目的,在类加载完成的时候,Hotspot 将对象内什么偏移量量上是什么类型的数据计算出来,在 JIT 编译的过程中,也会在特定的位置记录下栈和寄存器中哪些位置的引用
安全点
通过 OopMap 的帮助,HotSpot 能快速准确的完成 GC Roots 的枚举,但也带来一个问题:可能导致引用关系变化,或者说 OopMap 内容变化的指令非常多,如果每一条指令都生成对于的 OopMap ,那将会大量的额外空间,GC 成本变高
通常 HotSpot 的确没有为每条指令生成 OopMap,只有特定的位置记录了这些信息,这些位置称为安全点,即程序执行时并非在所有地方能停顿下来开始GC,只有到达安全点才能暂停,一般以"程序是否具有长时间执行特性"为标准进行选定,例如方法调用,循环跳转,异常跳转等
对于 SafePoint,另一个需要考虑的问题是如何在 GC 发生时让所有线程都跑到最近的安全点在停下来,通常有两种分案,抢占式中断和主动式中断,其中抢占式不需要线程配合,在 GC 发生伤害,首先将所有线程全部中断,如果发现线程中断地方不在安全点,就恢复线程,让它跑到安全点
而主动式中断的思想是当GC 需要中断线程的时候,不直接对线程操作,仅仅简单设置一个标志,每个线程执行时主动轮询这个标志,发现中断标志为真时候直接中断挂起
安全区域
使用 SafePoint 似乎已经很好解决了 GC 的问题,但实际情况并不如此,如果程序不执行的时候,如线程处于 Sleep状态,此时线程无法响应主动到安全点挂起,对于这种情况,就需要安全区域来解决
安全区域是指一段代码片段之中,引用关系不会发生变化,在这个区域的任意地方开始 GC 都是安全的,当线程进入 SafeRegion中代码,会标识自己进入了安全区域,这段时间里 JVM 要发生 GC 时,就不用管带有 SafeRegion 的标识线程了
垃圾收集器
垃圾收集器是内存回收的具体实现,
图中有7 中不同的垃圾收回器,它们分别用于不同分代的垃圾回收
- 新生代回收器:Serial,ParNew,Parallel Scavenge
- 老年代回收器:Serial Old,Parallel Old, CMS
- 整堆回收器:G1
单线程收集器
Serial 收集器, Serial Old 收集器
单线程的收集器,在垃圾收集的过程中,必须暂停使用其他所有的工作线程,直到它收集结束,由虚拟机在后台自动发起和自动完成的,在新生代采用的复制算法,而老年代则使用标记—整理算法
多线程收集器
ParNew 收集器
ParNew 收集器是 Serial 收集器的多线程版本,同样运行在新生代区,采用多条线程进行垃圾收集
Parallel Scavenge 收集器,Parallel Old收集器
和 ParNew
回收一样,Parallel Scavenge
回收器也是运行在 新生代区域,属于 多线程 的回收器。但不同的是,ParNew
回收器是通过控制 垃圾回收 的 线程数 来进行参数调整,而 Parallel Scavenge
回收器更关心的是 程序运行的吞吐量。即一段时间内,用户代码 运行时间占 总运行时间 的百分比
-
-XX:GCTimeRatio : 设置吞吐量大小。
-
-XX:MaxGCPauseMillis : 设置最大垃圾收集停顿时间
CMS 收集器
CMS 收集器是一种以获取最短回收停顿时间为目标的收集器,CMS 收集器只允许在老年代区域,基于"标记—清除"算法实现,它的运行过程分为4个步骤
- 初始化标记
- 并发标记
- 重新标记
- 并发清除
其中初始化标记,重新标记需要"Stop the world",并发标记和并发清除可以与用户线程一起工作
初始化标记仅仅只是标记一下 GC Roots 能直接关联的对象,速度很快
并发标记阶段是进行 GC Roots Tracing 的过程
重新标记修正并发标记期间因为用户程序运作而导致标记产生变动那一部分对象的标记记录,这个阶段停顿时间一般比初始化标记长些,但远比并发标记时间短
由于整个过程中并发标记和并发清除过程收集器线程都可以与用户线程一起工作,所以总体来说 CMS 收集器的内存回收过程与用户线程一起并发执行
- 优点:并发收集,低停顿
- 缺点
- CMS 收集器对 CPU 资源十分敏感,在并发阶段,它虽然不会导致用户线程停顿,但是会占用一部分线程而导致应用程序变慢,总吞吐量下降
- CMS 收集器无法处理浮动垃圾,由于 CMS 并发清理阶段用户还在运行,伴随而来新的垃圾将不断产生,这一部分垃圾出现在标记之后,CMS 无发在当次收集中处理它们,只好留在下一次 GC 时再清理,这部分垃圾称为"浮动垃圾"
- CMS 收集器中 内存回收 和 用户线程 是同时进行的,内存在被 回收 的同时,也在被 分配。当 老生代 中的内存使用超过一定的比例时,系统将会进行 垃圾回收;当 剩余内存 不能满足程序运行要求时,系统将会出现
Concurrent Mode Failure
,临时采用Serial Old
算法进行 清除,此时的 性能将会降低 - CMS 基于"标记—清除"算法实现,将产生大量临时空间碎片
G1 收集器
G1收集器是一款面向服务器端应用的垃圾收集器,与 CMS 收集器相比,G1具备如下特点:
- 并发与并发:G1能充分利用多 CPU,多核环境下的硬件优势,使用多个 CPU 来缩短 Stop-The-World 挺顿的时间,部分其他收集器原来需要停顿 Java线程执行的 GC 动作,G1 收集器可以通过并发方式让 Java 程序继续执行
- 分代收集:与其他收集器一样,分代概念在 G1 仍然保留,虽然 G1 可以不需要其他收集器配合就能独立管理整个 GC 堆,但它能够采用不同的方式去处理新创建和已经存活一段时间,经历过多次 GC 的旧对象
- 空间整合:与 CMS 的"标记—清理"不同,G1 从整体来看是基于"标记—整理"算法实现,从局部(两个 Region 之间)来看是基于"复制"算法实现,但无论如何都不会产生空间碎片,收集后能提供完整的可用内存
- 可预测的停顿:这是 G1 的另一大优势,在追求停顿外,还能建立可预测的停顿时间模型,能使使用者明确指定在一个长度为 M 毫秒的时间片段中,消耗在垃圾收集的时间不超过 N 毫秒
G1 的分代思想
与其他收集器不同,G1收集器将 Java 堆的划分多个大小相等的独立区域,虽然还保留新生代和老年代的概念,但新生代和老年代不在是物理隔离,他们都是一部分 Region 的集合
G1 的可预测停顿
G1之所以能建立可预测的时间模型,是因为它可以计划的避免在整个 Java 堆中进行全区域的垃圾收集,G1 跟踪各个 Region 里面的垃圾堆积的价值大小(回收所获得空间大小以及回收所需时间的经验值),在后台维护一个优先列表,每次根据允许的时间回收价值最大的区域,保证了收集效率最大化
在 G1 收集器中,Region 之间的对象引用以及其他收集器中新生代和老年代之间的对象引用,虚拟机都是通过使用 Remembered Set 来避免全堆扫描的,G1 中每个 Region 都有一个与之对应的 Remembered Set,虚拟机发现程序在堆 Reference 类型的数据进行写操作时,会生成一个 Write Barrier 暂时中断写操作,检查 Reference 引用的对象是否处于不同的 Region 之间,如果是通过 CardTable 将引用信息记录对象所有的区域
G1 收集器的运作步骤
- 初始标记:标记一下 GC Roots 能直接关联到的对象并修改 TAMS 的值,让下一阶段用户程序并发运行时能在正确的区域中创建对象,这个阶段需要停顿,但耗时少
- 并发标记:从 GC Roots 开始对堆中对象进行可达性分析,找出存活的对象,耗时较长,但可与用户线程并发执行
- 最终标记:修正在并发阶段因用户程序继续运作而导致标记产生变化的部分,虚拟机将这段时间变化记录在线程 Remember Set Logs 里面,最终标记阶段将次数据整合到 Remembered Set 中
- 筛选回收:首先对各个 Region 的回收价值和成本进行排序,根据用户期望的 GC 停顿时间来制定停顿计划,但是因为只会回收一部分的区域,时间是用户可控的,而且停顿用户线程将大幅提高收集效率
内存分配与回收策略
对象的内存分配,主要是在堆上进行,对象主要分配在新生代的 Eden 区上,如果启动本地线程分配缓冲,将按线程优先在 TLAB 分配,少数情况下分配在老年代中,分配的规则并不是固定,其细节取决于当前使用的是哪一种垃圾收集器组合,还有虚拟机与内存相关的参数设置
对象优先在 Eden 分配
大多数情况下,对象在新生代 Eden 区分配,具体过程如下
- 当 Eden 区没有足够空间分配,虚拟机进行一次 Minor GC
- 将存活的对象移到 survivor 区,如果 survivor 空间不足,启动分配担保进入老年代
- 老年代空间不足是,触发 Full GC,此时内存仍然不够,抛出异常
- 否则在 Eden 重新分配内存
大对象直接进入老年代
所谓的大对象是指需要大量连续内存空间的 Java 对象,最典型就是很长的字符串或者数组,大对象对虚拟机来说是一个坏消息,经常出现大对象将容易导致内存还有不少空间时就提前触发垃圾回收来获取足够的空间来安置它们
长期存活的对象将进入老年代
虚拟机采用了分代收集的思想来管理内存,当内存回收时必须能识别哪些对象应该放在新生代,哪些对象放在老年代,为了做到这一点,虚拟机给每一个对象定义了一个对象年龄计数器
- 如果对象在 Eden 出生并经过第一次 Minor GC 后仍存活,并且能被 Survivor 容纳,对象将移动到 Survivor 空间中,并且对象年龄 +1,
- 对象在 Survivor 区中每熬过一次 Minor GC,年龄就 + 1,
- 当年龄增加到一定程度(默认15),将会被晋升到老年代中
动态对象年龄判定
为了更好的适应不同程序的内存状况,虚拟机并不是永远要求对象年龄达到 MaxTenuringThreshold 才能晋升,如果在 Survivor 空间中相同年龄所有对象大小总和大于 Survivor 空间的一半,年龄大于或等于该年龄的对象就可以直接进入老年代,无须等到要求的年龄
空间分配担保
- 在发生 Minor GC 之前,虚拟机会先检查老年代最大可用的连续区间是否大于新生代所有对象总空间,如果条件成立,那么 Minor GC 可以确保是安全,
- 如果不成立,则虚拟机会查看是否允许担保失败,如果允许,那么会继续检查老年代最大可用连续区间是否大于历次晋升到老年代的平均大小,如果大于,则尝试进行一次 Minor GC
- 如果小于,或者不允许担保失败,则会进行一次 Full GC