Skip to the content.

首页

垃圾收集


对象存活判断


回收方法区

Java虚拟机规范并不要求虚拟机在方法区实现垃圾收集,方法区的垃圾收集主要回收废弃的常量和不再使用的类型。判断一个类型是否可以回收需要满足三个条件:该类所有实例都已被回收、加载该类的类加载器已经被回收、该类的Class对象没有在任何地方被引用


垃圾收集算法

分代收集理论

垃圾收集器将Java堆划分出不同的区域(一般至少分为新生代和老年代),然后将回收对象根据其年龄(熬过垃圾收集过程的次数)分配到不同的区域之中存储。建立在两个假设之上:绝大多数的对象都是朝生夕灭、熬过越多次垃圾收集过程的对象就越难以消亡。

记忆集

Remembered Set,建立在新生代上的全局数据结构,将老年代划分为若干小块,标记老年代哪一块内存存在跨代引用。Minor GC时只有包含了跨代引用的小块内存里的对象才会被加入GC Roots进行扫描,虽然会增一些维护记忆集的开销,但是收集时不用扫描整个老年代。

标记-清除算法

首先标记所有需要回收的对象,在标记完成后统一回收所有标记的对象,也可以反过来标记存活的对象。缺点主要有:执行效率不稳定、内存空间碎片化。

标记-复制算法

将内存分为大小相等的两块,每次只使用其中一块,当这一块内存用完了,就将还存活的对象复制到另一块上面。为了解决标记-清除算法面对大量可回收对象时(新生代)执行效率低的问题,只需要复制占少数的存活对象,每次都是针对整个半区进行内存回收,实现简单运行高效,缺点是浪费了一半的空间。

标记-整理算法

对于对象存活率高的区域(老年代),标记过程完成后,让所有存活的对象都向内存空间一端移动,然后直接清理掉边界之外的内存。移动存活对象并更新所有对象的引用必须在Stop The World阶段进行,相比清除算法,会增加程序的停顿时间,但会提高内存分配和访问的效率,使得总吞吐量提高。


HotSpot算法实现

根节点枚举

所有收集器在枚举根节点时都必须暂停用户线程,使用一组称为OopMap的数组结构获取所有执行上下文和全局的引用中哪些地方存放着对象引用

安全点

Safepoint,HotSpot只有在安全点才会生成OopMap,因为只有当前程序执行到安全点时才能够停顿下来开始垃圾收集,。在方法调用、循环跳转、异常跳转等指令位置才会产生安全点。HotSpot使用内存陷阱保护方式,当需要暂停用户线程时,将内存页设置为不可读,那线程执行指令时就会产生一个自陷异常信号,然后在异常处理器中挂起线程,只需要一条汇编指令就可以完成安全点轮询和触发线程中断。

安全区域

安全点机制可以保证分配到处理器时间的用户线程进入安全点,处于Sleep或Blocked状态的用户线程需要引入安全区域来解决。安全区域即:能够保证某一段代码片段之中引用关系不会发生变化,因为在这个区域的任意地方开始垃圾收集都是安全的。用户线程执行到安全区域时会标记自己进入安全区域,当要离开安全区域时需要检查当前垃圾收集(需要暂停用户线程的阶段)是否已经完成,未完成则会一直等待直到收到可以离开安全区域的信号为止。

Stop the world

当所有线程都进入安全点或安全区域时,堆的状态才是确定的,可以执行垃圾收集、内存分析命令(jstack、jmap、jstat等)、取消偏向锁等。

记忆集和卡表

记忆集是一种从非收集区域指向收集区域的指针集合的抽象数据结构,最简单的实现是非收集区域所有含跨代引用的对象数组。卡表是最常用的一种记忆集的实现形式,卡表内每一个记录代表了一块内存区域。HotSpot使用字节数组(类似bitmap)的方式实现,每一个字节对应着其标识的内存区域中的一个卡页(512字节大小的内存块),每个卡页内有一个或更多的对象的字段存在跨代指针,则将对应的卡表中的数组元素的值标识为1,没有则标识为0,垃圾收集时找出含有跨代指针的卡页加入GC Roots中一并扫描。

写屏障(非内存屏障)

对卡表的更新维护需要在对象赋值的时间点,在编译执行的场景需要使用机器码层面的手段,“写屏障”。虚拟机层会为“引用类型字段赋值”动作生成一个环形通知,供程序执行额外的动作,垃圾收集器使用“写后屏障”完成卡表状态更新。

并发的可达性分析

三色标记法

漏标

并发标记过程中,用户线程修改了引用关系,导致原本应该是黑色的对象被标记为了白色,需要满足两个条件:插入了一条或多条从黑色对象到该白色对象的新引用、删除了全部从灰色对象到该白色对象的直接或间接引用。


垃圾收集器

并行和并发

Serial

新生代垃圾收集器,采用复制算法,单线程工作,同时收集过程中必须暂停其他所有工作线程。优点是简单、额外内存消耗最小。

Serial Old

Serial的老年代版本,单线程工作,采用标记-整理算法。作为CMS收集器并发收集发生Concurrent Mode Failure时使用

ParNew

Serial的多线程并行版本,单核心处理器场景下效率不如Serial,默认开启的线程数与处理器核心数相同(可使用-XX:ParallelGCThreads参数设定)。仅作为与CMS搭配使用的新生代垃圾收集器,关注的是尽可能的缩短垃圾收集时用户线程停顿的时间

Parallel Scavenge

新生代垃圾收集器、基于标记-复制算法、并行收集,关注提高吞吐量(吞吐量即处理器用于运行用户代码与处理器总消耗时间的比值)。支持的参数有:

Parallel Old

Parallel Scavenge的老年代版本,并行收集,基于标记-整理算法,与Parallel Scavenge配合使用,适用于“吞吐量优先”场景。

CMS

Concurrent Mark Sweep,以获取最短回收停顿时间为目标的老年代垃圾收集器,并发收集,基于标记-清除算法,JDK 9之后不再推荐使用。过程分为四个步骤:

  1. 初始标记:需要STW,单线程,只标记GC Roots直接关联到的对象,速度很快;
  2. 并发标记:不需要STW,多线程,遍历整个对象图,时间长;
  3. 重新标记:需要STW,多线程,增量更新,时间比初始标记稍长,但远远短于并发标记;
  4. 并发清理:不需要STW,多线程,清理删除掉已死亡对象,时间很长。

缺点:

G1,Garbage First

面向局部收集,基于Region的内存布局,“停顿时间模型”的收集器。通过设置合理的期望停顿时间,可以使用G1在不同应用场景下取得关注吞吐量和关注延迟之间的最佳平衡。停顿时间一般在一两百或两三百毫秒会是比较合理的。G1在大内存的应用上能发挥其优势,与CMS优劣势的Java堆容量平衡点通常在6GB~8GB之间。

Region

将连续的Java堆划分为多个大小相等的独立区域(Region),每个Region可以是Eden空间、Survivor空间、老年代空间以及特殊的Humongous区域(视作老年代的一部分),新生代和老年代不再是固定的了,而是一个系列区域的(不需要连续)的动态集合。大小超过了一个Region容量一半的对象被判定为大对象,超过了整个Region容量大小的超级大对象会被存放再N个连续的H Region中。通过参数(-XX:G1HeapRegionSize)设定每个Region的大小,取值范围为1MB~32MB,且要求为2的N次幂。

Collection Set,CSet

垃圾收集的目标面向回收集。Region是单次收集的最小单元,G1收集器跟踪每个Region中垃圾堆积的“价值”大小,在后台维护一个优先级列表,每次根据用户设定的允许的收集停顿时间(参数-XX:MaxGCPauseMills,默认200毫秒),优先处理回收收益最大的那些Region。

实现细节

收集步骤

  1. 初始标记:需要STW,仅仅是标记一下GC Roots直接引用的对象,并且修改TAMS指针的值,停顿时间非常短;
  2. 并发标记:不需要STW,扫描对象图,扫描完成后还会重新处理原始快照记录的引用,耗时较长;
  3. 最终标记:需要STW,处理完最后仍遗留下来的少部分原始快照记录,时间较短;
  4. 筛选回收:需要STW,因为涉及存活对象的移动,多线程并行处理,更新Region的统计数据,根据用户期望的停顿时间,选择多个Region构成回收集,将回收集内Region中的存活对象复制到空的Region,再清理掉整个旧Region的全部空间。

Shenandoah

可以视作对G1的改进;基于Region,优先处理回收价值最大的Region;默认不使用分代收集,不区分新生代和老年代;支持并发的整理算法;使用“连接矩阵”(可以理解为二维表格,全局数据结构)来记录跨Region的引用关系,替代了记忆集。

工作过程

  1. 初始标记:与G1一样,需要STW,首先标记与GC Roots直接关联的对象,停顿时间只与GC Roots的数量有关;
  2. 并发标记:与G1一样,不需要STW,扫描对象图;
  3. 最终标记:与G1一样,需要STW,处理剩余的原始快照扫描,并选择回收价值最高的Region构成一组回收集
  4. 并发清理:清理不存在存活对象的Region;
  5. 并发回收:不需要STW,将回收集内存活的对象复制到空的Region中,因为用户线程仍然在对对象进行读写访问,Shenandoah使用了读屏障和“Brooks Pointers”转发指针解决;
  6. 初始引用更新:对象复制结束后,需要将堆中所有指向旧对象的引用修正到复制后的新地址,实际上未作具体处理,只是为了确保所有的收集器线程都完成了对象移动任务,会产生一个非常短的停顿;
  7. 并发引用更新:不需要STW,真正的开始引用更新,不是沿着对象图操作,而是按照内存物理地址的顺序,线性的搜索引用类型并处理;
  8. 最终引用更新:需要STW,堆中的引用更新完成后,还需要修正GC Roots中的引用,停顿时间只与GC Roots的数量相关;
  9. 并发清理:并发引用更新完成后,再次执行并发清理,回收回收集中所有的Region。

并发整理算法实现

ZGC

使用参数(-XX:+UseZGC)开启,支持TB级别的堆大小(ZGC已经支持到16TB),目标是在尽可能不影响吞吐量的前提下在任意堆容量下的停顿时间都不超过10毫秒。基于Region、(暂时)不设分代、使用了读屏障、染色指针和内存多重映射等实现可并发的标记-整理算法、以低延迟作为首要目标、支持“NUMA-Aware”的内存分配

Region/Page/ZPage

动态的创建和销毁、动态的区域容量大小。

并发整理算法实现

染色指针是一种将少量额外信息存储在指针上的技术。64位系统中实际上指针只有低48位可以用来寻址,ZGC将这其中的高4位用于存储四个标志信息(三色标记状态、是否进入重分配集、是否只能通过finalize()访问),即一个指针只有44位的地址空间,故ZGC可管理的最大内存为16TB,且不再支持指针压缩。由于染色指针重新定义了指针,需要使用虚拟内存映射技术去支持正常的寻址操作,使用多重映射的方式,将多个虚拟内存地址(一个染色指针不同的视图对应到不同的虚拟地址)映射到同一个物理内存地址上。

工作过程

  1. 并发标记:同G1、Shenandoah,且前后也有类似初始标记、最终标记的短暂停顿,但是ZGC是标记指针,更新染色指针的Marked 0、Marked 1标志位
  2. 并发预备重分配:将需要清理的Region组成重分配集(Relocation Set),并设置引用重分配集中的存活对象的染色指针的Remapped标记位;ZGC每次都会扫描所有Region,这样可以省去记忆集的维护成本;并且类卸载及弱引用的处理也是在此阶段完成;
  3. 并发重分配:将重分配集中存活的对象复制到新的Region上,并为重分配集中的每个Region维护一个转发表,用于记录旧对象到新对象的转向关系;根据染色指针可以从引用上就明确得知一个对象是否处于重分配集中,当用户线程并发访问这些对象时,“读屏障”会根据Region上的转发表将访问转发到新复制的对象上,并同时修正更新引用值,这就是ZGC中指针的“自愈”能力;Region中的存活对象赋值完毕后,这个Region可以立即释放用作新对象的分配,但是转发表需要保留;
  4. 并发重映射:修正整个堆中指向重分配集中旧对象的所有引用,因为指针的自愈能力存在,所以重映射并不是必须迫切完成的任务,因此会在下一次垃圾收集过程的并发标记阶段去完成,以此节省了一次遍历对象图的开销,之后就可以释放掉上一次的转发表了。

内存分配与回收策略

对象优先在Eden分配

大多数情况下,对象在新生代的Eden区中分配,当Eden区没有足够空间进行分配时,虚拟机会发起一次Minor GC,当已有的存活对象无法放入Survivor空间时,会通过分配担保机制提前转移到老年代去。

大对象直接进入老年代

大对象需要大量连续内存空间,为了避免在Eden区及两个Survivor区之间来回复制,超过一定阈值的大对象直接在老年代分配。

长期存活对象将进入老年代

新生代的对象每熬过一次Minor GC,其年龄就增加一岁,当达到阈值后就会被晋升到老年代中。

动态年龄判断

并不要求新生代对象的年龄一定达到阈值才能晋升到老年代,如果Survivor空间中相同年龄的所有对象大小的总和超过了空间的一半,则年龄大于等于该年龄的对象就可以直接进入老年代。

空间分配担保

发生Minor GC之前,虚拟机必须先检查老年代最大可用的连续空间是否大于新生代所有对象的总空间或者历次晋升到老年代对象的平均大小,如果大于则允许尝试进行一次Minor GC,如果小于则需要改为进行一次Full GC。