概述
垃圾收集(Garbage Collection)需要考虑以下三件事:
- 哪些内存需要回收:存活的对象也就是仍然会用到的对象不能回收,死去的对象也就是不可能再被用到的对象需要回收,如果判断对象是否存活有两种方法引用计数算法和可达性分析算法
- 什么时候回收:一般在堆的剩余空闲空间下降到一定比率开始垃圾收集
- 如何回收:使用三种基本的垃圾收集算法标记-复制算法、标记-清除算法、标记-整理算法,以及各种垃圾收集器GC对这三种算法的具体实现和改进
判断对象是否存活
引用计数算法(Reference Counting)
- 在对象中添加一个引用计数器,每当有一个地方引用它时,计数器值就加1,当引用失效时,计数器就减一,任何时刻计数器为零的对象就是不可能再被使用的
- 引用计数算法虽然占用了一些额外的内存空间来进行计数,但原理简单,判定效率也很高,但是引用计数存在循环引用的问题,需要配合大量额外处理才能保证正确工作
可达性分析算法(Reachability Analysis)
- 可达性分析算法的基本思路是通过一系列称为GC Roots的根对象作为起始节点集,从这些节点开始,根据引用关系向下搜索,搜索过程所走过的路径称为引用链(Reference Chain),如果某个对象到GC Roots间没有任何引用链相连,或者用图论的话来说就是从GC Roots到这个对象不可达,则证明此对象是不可能再被使用的
- 可达性分析算法如何解决引用计数法中循环引用的问题呢,即使多个对象循环引用,如果这多个对象从GC Roots开始不可达,则直接回收这多个对象
- 如何判断对象循环引用呢,可以抽象成判断有向图是否有环,一般的深度优先遍历DFS(Depth First Search)算法是遍历一个节点标记一个节点为已经遍历了,存在环的条件是一个节点的下一个节点已经被遍历了(此图是有向图,如果无向图还得加个指向的节点不是刚刚遍历的节点),这个算法会有问题,在一个父节点有两个子结点的情况下,其中一个子节点又指向另一个子节点,此时会误判其有环,因为DFS是深度优先遍历,可能先把父节点和其中一个子结点全部遍历完了才遍历另一个子结点,解决这种误判有环的方法是引入三色标记算法,只有节点指向下一个节点是灰色的(此图是有向图,如果无向图还得加个指向的节点不是刚刚遍历的节点),才说明有环,因为黑色节点是该节点被遍历而且该节点所有子节点被遍历,所以在前面提到的情况,一个父节点两个子结点,其中一个子结点指向的是一个黑色子节点,所以仍然无环
GC Root
GC Root根对象是作为可达性分析算法的起始节点集
固定作为GC Root的对象:
- 在虚拟机栈(栈帧中的局部变量表)中引用的对象,譬如各个线程被调用的方法栈中使用到的参数、局部变量、临时变量
- 在方法区中类静态属性引用的对象(static关键字修饰的类变量),如Java类的引用类型静态变量
- 在方法区中常量引用的对象(final关键字修饰的类变量),譬如字符串常量池(String Table)里的引用
- 在本地方法栈中JNI(即Java Native 方法)引用的对象
- Java虚拟机内部的引用,如基本数据类型对应的Class对象,一些常驻的异常对象(如NullPointException、OutOfMemoryError)等,还有系统类加载器(Application ClassLoader)
- 所有被同步锁(synchronized关键字)持有的对象
- 反映Java虚拟机内部情况的JMXBean、JVMTI中注册的回调、本地代码缓存等
- 除了以上固定的GC Roots集合以外,还有其他对象临时性地加入,如跨代引用时需要将老年代的部分区域的对象加入GC Roots集合
三色标记
- 白色:在可达性分析算法搜索过程中,该对象未被垃圾收集器访问过
- 黑色:在可达性分析算法搜索过程中,该对象已被垃圾收集器访问过,并且这个对象的所有引用都扫描过
- 灰色:在可达性分析算法搜索过程中,该对象已被垃圾收集器访问过,并且这个对象的至少有一个引用没有被扫描过
垃圾收集算法
- 从如何判断对象是否存活的角度出发,垃圾收集算法可以划分为引用计数式垃圾收集(Reference Counting GC)也称为直接垃圾收集和追踪式垃圾收集(Tracing GC)也称为间接垃圾收集两大类
- 在Java虚拟机中没有涉及到引用计数式垃圾收集,所以以下垃圾收集算法都属于追踪式垃圾收集算法
3.并发标记时出现的问题
在用户线程和收集器线程(进行三色标记时)并发时当以下两种一起出现时会发生对象被错误回收
- 赋值器插入了一条或多条从黑色对象到白色对象到新引用(因为黑色对象被扫描过不会再次扫描)
- 赋值器删除了全部从灰色对象到白色对象的直接或间接引用
4.解决方案
增量更新(Incremental Update)
破坏第一个条件,当黑色对象插入指向新当白色对象当引用关系时,就将这个要删除的引用记录下来,
等并发扫描结束之后,再以这些记录过的引用关系中的黑色对象为根,重新扫描一次
原始快照(Snapshot At The Beginning, SATB)
破坏第二个条件,在一开始记录一个灰色对象指向白色对象的引用关系快照,就算后面删除该引用关系,也依然会扫描
5.记忆集(Remembered Set)
为了解决对象跨代引用,因为垃圾收集最经常回收的是年轻代,而此时老年代可能有对象引用年轻代对象,如果不考虑这个引用就会错误回收对象,由于扫描整个老年代效率太低,所以引入记忆集,只扫描记忆集中的老年代的引用(注:对象跨代引用可以扩展为非收集区域的对象引用着收集区域的对象)
6.记忆集的三种实现
- 字长精度:每个记录精确到一个机器字长(32位4字节 64位8字节)
- 对象精度:每个记录精确到一个对象,该对象里有字段含有跨带制作
- 卡精度:每个记录精确到一块内存区域,该区域内有对象含有跨代指针
可以看出从字长精度到对象精度到卡精度,每个记录所管控到区域逐渐增大,卡精度所最常用到记忆集实现形式,卡精度使用卡表 (Card Table)的方式去实现记忆集
7.卡表 (Card Table)
卡表的最简单形式所一个字节数组,字节数组每一个元素都对应其标识的内存区域中一块特定大小的内存块,这块内存块叫卡页(Card Page),HotSpot使用的卡页大小是512字节,即字节数组中每个元素的大小为1字节但是对应着512字节的区域
卡页变脏: 卡页中有一个或更多对象的字段存在跨代指针,称为卡页变脏
8.写屏障(与内存屏障不同,相当于AOP切面环绕)
写前屏障:修改对象之前进行的操作
写后屏障:修改对象之后进行的操作
9.垃圾收集算法
标记复制(Mark Copying)
将内存分为均等大小的两块,每次回收先标记存活对象,将其复制到另一块内存,然后清除原先的整块内存
优点:没有内存碎片
缺点:
- 如果存活对象很多,复制消耗比较大(适用于新生代)
- 能使用的内存大小减半
标记清除(Mark Sweep)
标记要清除的对象,直接清除
优点:只回收要回收的对象
缺点:
- 如果要回收对象很多,回收消耗比较大
- 会出现内存碎片
标记整理(Mark Compact)
标记存活对象,将存活对象移到内存另一端,清除边界外的对象
优点:避免了内存碎片
缺点:移动存活对象并更新所有引用开销大(用于老年代)
10.GC类型
部分收集(Partial GC)
指目标不是完整收集整个Java堆的垃圾收集,又分为三种
- 新生代收集(Minor GC/Young GC):指目标只是新生代的垃圾收集
- 老年代收集(Major GC/Old GC):指目标只是老年代的垃圾收集
- 混合收集(Mixed GC):指目标是收集整个新生代以及部分老年代的垃圾收集
整堆收集(Full GC)
收集整个Java堆和方法区的垃圾收集
各种垃圾收集器
1.Serial
- 新生代收集器
- 单线程:只会使用一个处理器或收集线程去完成垃圾收集工作,而且必须暂停用户线程的工作,直至收集结束
- 所有收集器中额外内存消耗(Memory Footprint)最小的
- 最高的单线程收集效率(里面包含了暂停用户线程的意思)
- 可以与老生代CSM、Serial Old(MSC)配合使用
- 基于标记复制算法实现
2.ParNew
- 新生代收集器
- 并行收集:使用多条线程进行垃圾收集,但是仍然会暂停用户线程的工作
- 可以与老生代CSM、Serial Old(MSC)配合使用
- 基于标记复制算法实现
- 基本上与Serial收集器类似
3.Parallel Scavenge
- 新生代收集器
- 关注的是达到一个可控制的吞吐量(Throughput),与CSM等收集器关注的尽可能缩短垃圾收集是用户线程的停顿时间不同
- 吞吐量 = (运行用户代码的时间)/ (运行用户代码时间 + 运行垃圾收集时间)
- 基于标记复制算法实现
- 并行收集:使用多条线程进行垃圾收集,但是仍然会暂停用户线程的工作
4.Serial Old(MSC)
- 老年代收集器
- 单线程收集器:只会使用一个处理器或收集线程去完成垃圾收集工作,而且必须暂停用户线程的工作,直至收集结束
- 可以与Serial、ParNew、Parallel Scavenge搭配使用
- 作为CMS收集器发送失败时的后备方案
5.Parallel Old
- 老年代收集器
- 只能与Parallel Scavenge配合工作
- 并行收集:使用多条线程进行垃圾收集,但是仍然会暂停用户线程的工作
- 吞吐量优先
6.CMS(Concurrent Mark Sweep)
- 老年代收集器
- 低延时优先
- 有可以不暂停用户线程的阶段
垃圾收集过程:
- 初始标记(CMS initial mark):
标记一下GC Roots能直接关联到的对象,速度很快,但是需要暂停用户线程 - 并发标记(CMS concurrent mark):
从GC Roots的直接关联对象开始遍历整个对象图的过程,过程耗时较长但不需要停顿用户线程 - 重新标记(CMS remark):
修正并发标记期间,因用户程序继续运作而导致标记变动的那一部分对象的标记记录
(CMS使用增量更新解决这个问题),也需要停顿用户线程 - 并发清除(CMS concurrent sweep):
清除删除掉标记阶段判断已经死亡的对象,与用户线程并发
CMS优点:并发收集、低停顿
CMS缺点:
- 对处理器资源非常敏感,CMS默认启动的回收线程数是(处理器核心数量+3)/4,如果处理器核心不足4个,则需要的运算能力很大
- 无法处理浮动垃圾,浮动垃圾指的是在并发标记和并发清除阶段新创建的垃圾对象
- CMS是基于标记清除算法实现的收集器,容易产生空间碎片,当无法找到足够大的连续空间来分配当前对象时会触发一次Full GC
7.G1(Garbage First)
- 面向堆内存任何部分组成回收集,不再属于哪个分代,而是哪块内存中存放的垃圾数量最多,回收收益最大,就回收哪块内存,属于Mixed GC模式
- 不再坚持固定大小以及固定数量的分代区域划分,而是把Java堆划分为多个大小相等的独立区域(Region),每个Region都可以根据需要扮演新生代的Eden空间、Survivor空间或老年代空间, Region中还有一类特殊的Humongous区域,专门用来存储大对象
- G1是部分收集,每次收集不一定要回收所有Region,它将Region作为单次回收的最小单元,在后台维护一个优先级列表,每次根据用户需要的收集停顿时间,优先处理回收价值收益最大(即Region中垃圾对象比例较大的)那些Region
- G1收集器通过原始快照SATB算法解决并发标记阶段用户线程改变对象引用关系的问题
垃圾收集过程:
- 初始标记(Initial Marking):
标记一下GC Roots能直接关联到的对象,速度很快,但是需要暂停用户线程 - 并发标记(Concurrent Marking):
从GC Roots的直接关联对象开始遍历整个对象图的过程,过程耗时较长但不需要停顿用户线程 - 最终标记(Final Marking)
对用户线程做一个短暂暂停,用于处理并发阶段结束后仍遗留下来的最后那少量的SATB记录 - 筛选回收(Live Data Counting and Evacuation)
负责关系Region的统计数据,对各个Region的回收价值和成本进行排序,根据用户所期望的停顿时间来指定回收计划,可以自由选择任意多个Region构成回收集,然后把决定回收的那一部分Region的存活对象复制到空的Region中,再清除整个旧Region的全部空间。这里的操作涉及存活对象的移动,是必须暂停用户线程,由多条收集器线程并行完成
G1收集器优点:
- 在延迟可控的情况下获得尽可能高的吞吐量
- G1分Region的内存布局,从整体看来是基于标记整理算法的收集器,从局部(两个Region之间)上看又是基于标记复制算法的收集器
- 按收益动态确定回收集
G1收集器缺点:
- G1内存占用比较高,每个Region都必须有一份卡表,用于处理跨代指针(而CMS只需要维护一份卡表)
- 程序运行时额外执行负载要比CMS高
G1收集器和CMS收集器共同点和对比
- 都用到了写屏障,CMS用写后屏障来更新维护卡表,G1也用写后屏障更新维护卡表
- G1为了实现原始快照搜索算法SATB,还需要使用写前屏障来跟踪并发时的指针变化情况
- 相比增量更新算法,原始快照搜索能够减少并发标记和重新标记阶段的消耗,避免CMS那样在最终标记阶段停顿时间过长的缺点,但是在用户程序运行中确实会产生由跟踪引用变化带来的额外负担
- 由于G1对写屏障的复杂操作要比CMS消耗更多运算资源,所以CMS的写屏障实现是直接的同步操作,而G1就不得不将其实现为类似消息队列的结构,把写前屏障和写后屏障中要做的事情都放到队列里,然后再异步处理
8.Shenandoah
- 与G1一样基于Region堆内存布局,有存放大对象的Humongous区域,默认回收策略是优先回收价值最大的Region
- 与G1不同的是,在回收阶段G1不能和用户线程并发,而Shenandoah可以
- Shenandoah不使用分代收集,就摒弃了G1中耗费大量内存和计算资源去维护的记忆集(卡表),改用连接矩阵(Connection Matrix)的全局数据结构来记录跨 Region 引用关系,连接矩阵是一个二维表格,Region N有对象指向 Region M,就在N行M列上打上一个标记
垃圾收集过程:
-
初始标记(Initial Marking):
与G1一样,首先标记与GC Roots直接关联的对象,不能与用线程并发,但是停顿时间与堆大小无关,只与GC Roots的数量相关 -
并发标记(Concurrent Marking):
与G1一样,遍历对象图,标记处全部可达的对象,这个阶段是与用户线程一起并发的,时间长短取决于堆中存活对象的数量以及对象图的结构复杂程度 -
最终标记(Final Marking)
与G1一样,处理剩余的SATB扫描,并在这个阶段统计出回收价值最高的Region,将这些Region构成一组回收集。最终标记阶段也要暂停用户线程 -
并发清理(Concurrent Cleanup)
从这一步开始就与G1不同了,用于清理那些整个区域连一个存活对象都没有找到的Region -
并发回收(Concurrent Evacuation)
并发回收阶段是Shenandoah与之前HotSpot中其他收集器的核心差异。在这个阶段,Shenandoah要把回收集里面的存活对象先复制一份到其他未被使用的Region之中,G1的筛选回收也会这样做,但是G1由于要移动对象,G1会暂停用户线程,但Shenandoah不会,因为Shenandoah引入了读屏障和Brooks Pointers转发指针来解决这个问题。并发回收阶段运行的时间长短取决于回收集的大小 -
初始引用更新(Initial Update Reference):
并发回收复制对象结束后,还需要把堆中所以指向旧对象的引用修正到复制后的新地址,这个操作称为引用更新。引用更新的初始化阶段实际上并未做什么具体的处理,设立这个阶段只是为了建立一个线程集合点,确保所有并发的回收阶段中进行的收集器线程都已经完全分配给它们的对象移动任务而已。初始引用更新时间很短,但会暂停用户线程 -
并发引用更新(Concurrent Update Reference):
真正进行引用更新操作,这个阶段是与用户线程一起并发的 -
最终引用更新(Final Update Reference):
解决堆中的引用更新后,还要修正存在于GC Roots中的引用,会暂停用户线程 -
并发清理(Concurrent Cleanup)
经过并发回收和引用更新后,那些需要收集的Region上已经没有存活对象,可以再调用一次并发清理来回收这些Region的内存空间
其中重点是并发标记、并发回收(复制存活对象)、并发引用更新三个阶段
Brooks Pointer转发指针:
在原有对象布局结构的最前面统一增加一个新的引用字段,在正常不处于并发对象移动时,该引用指向对象自己,在复制了新对象后就指向新对象
Brooks Pointer转发指针缺点:
为了实现Brooks Pointer,Shenandoah在读、写屏障中都加入了额外的转发处理
9.ZGC
- 与Shenandoah不同,ZGC的Region是动态创建和销毁以及有着动态的区域容量的,分为小、中、大型Region
- Shenandoah使用转发指针和读屏障来解决并发对象移动问题,ZGC使用读屏障和染色指针来解决这个问题
染色指针:
以前三色标记有直接记录在对象头上的(如Serial收集器),有把标记记录在与对象相互独立的数据结构上(如G1、Shenandoah使用BItMap记录),而ZGC把标记信息记录在指针上,这时,与其说可达性分析是遍历对象图来标记对象,还不如说是遍历引用图来标记引用,但是由于指针上有4位用来记录信息,则寻址能力下降了,ZGC能够管理的内存不超过4TB
染色指针优点
- 可以使得一个Region的存活对象被移走之后,这个Region立即就能够被释放和重用掉,不必等待整个堆中所有指向该Region的引用都被修正后才能清理
- 可以减少写屏障使用数量,写屏障目的通常是为了记录对象引用的变动情况,如果将这些信息直接维护在指针中,显然可以省去一些专门记录操作,ZGC只使用了读屏障,没有使用写屏障(一部分是染色指针的功劳,一部分是ZGC现在还不支持分代收集,天然就没有跨代引用的问题)
- 染色指针可以作为一种可扩展的存储结构用来记录更多与对象标记、重定位过程相关的数据,以便进一步提高性能
垃圾收集过程:
- 初始标记(Initial Marking):
与G1、Shenandoah一样,首先标记与GC Roots直接关联的对象,不能与用线程并发,但是停顿时间与堆大小无关,只与GC Roots的数量相关 - 并发标记(Concurrent Marking):
与G1、Shenandoah一样,遍历对象图,标记处全部可达的对象,这个阶段是与用户线程一起并发的,时间长短取决于堆中存活对象的数量以及对象图的结构复杂程度 - 并发预备重分配(Concurrent Prepare for Relocate):
需要根据特定的查询条件统计得出本次收集过程要清理那些Region,将这些Region组成重分配集,重分配集和G1收集器的回收集是有区别的,ZGC划分Region的目的并非像G1那样做收益优先的增量回收,相反ZGC每次回收都会扫描所以Region,用范围更大的扫描成本换取省去G1中记忆集的维护成本,因此ZGC的重分配集只是决定了里面的存活对象会被复制到其他的Region中,里面的Region会被释放,而并不能说回收行为就只是针对这个集合里面的Region进行,因为标记过程是针对全堆的 - 并发重分配(Concurrent Relocate):
重分配是ZGC执行过程中的核心阶段,要把重分配集中的存活对象复制到新的Region中和G1的筛选回收、Shenandoah的并发回收差不多,ZGC还要为重分配集中的每个Region维护一个转发表,记录从旧对象到新对象的转向关系,得益于染色指针的支持,ZGC收集器能仅从引用上就明确得知一个对象是否处于重分配集中,如果用户线程此时并发访问了位于重分配集中的对象,这次访问将会被预置的读屏障锁截获,然后立即根据Region上转发表记录将访问转发到新复制的对象上,并同时修正更新该引用的值,使其直接指向新对象,ZGC将这种行为称为指针的自愈能力。由于有这种能力,一旦重分配集中某个Region的存活对象都复制完毕后,这个Region可以立即释放用于新对象的分配(但是转发表还得留着不能释放掉),一旦旧指针被使用,将会主动查询转发表,并修正引用的值 - 并发重映射(Concurrent Remap)
修正整个堆中指向重分配集中旧对象的所有引用,和Shenandoah的并发引用更新阶段一样,但是ZGC的并发重映射并不是一个迫切要完成的任务,因为染色指针可以自愈,所以ZGC很巧妙地把并发重映射要做的工作合并到了下一次垃圾收集循环的并发标记阶段去了
10.Epsilon
是一个不以垃圾收集为卖点的垃圾收集器。一个垃圾收集器处理垃圾收集这个本职工作之外,还要负责堆的管理与布局、对象的分配、与解释器的协作、与编译器的协作、与监控子系统的协作等职责,从JDK10开始,为了隔离垃圾收集器与Java虚拟机解释、编译、监控等子系统的关系,RedHat提出垃圾收集器统一接口,而Epslion是这个接口的有效性验证和参考实现,而且Epslion在微服务、无服务化也有用处,如果应用只需要运行数分钟甚至数秒,只要Java虚拟机能正确分配内存,在堆耗尽之前就会退出,那显然运行负载极小、没有任何回收行为的Epsilon是很恰当的选择
一些概念
引用
- 强引用(Strongly Reference): 指的是程序代码中普遍存在的引用赋值,即类似 Object obj = new Object(),通常我们使用的都是强引用,无论任何情况下,只要强引用关系还存在,垃圾收集器就永远不会回收掉被引用的对象
- 软引用(Soft Reference): 用来描述一些有用,但非必须的对象,只被软引用关联着的对象,在系统将要发生内存溢出异常前,会把这些对象放入被回收的范围中进行回收
- 弱引用(Weak Reference): 用来描述那些非必需的对象,但是强度比软引用更弱, 只被弱引用关联的对象只能生存到下一次垃圾收集的时候,也就是说弱引用关联的对象逃不过一次垃圾收集
- 虚引用(Phantom Reference): 是最弱的一种引用关系,一个对象是否有虚引用的存在,完全不会对其生存时间构成影响,也无法通过虚引用获得一个对象对象实例,为一个对象设置虚引用关联的唯一目的只是为了能在这个对象被收集器回收时收到一个系统通知
finalize
- 要真正宣告一个对象死亡,至少要经历两次标记过程,如果对象在进行可达性分析后发现没有与GC Roots相连接的引用链,那它将会被第一次标记,随后进行一次筛选,筛选的条件是此对象是否有必要执行finalize()方法,没有必要执行的情况是对象没有覆盖(重写)finalize()方法,或者finalize()方法已经被虚拟机调用过,这两种情况都视为没有必要执行,没有必要执行则直接回收对象
- 有必要执行finalize()方法则该对象被放置在一个名为F-Queue的队列中,由低调度优先级的Finalizer线程去执行队列中对象的finalize()方法,每隔一段时间垃圾收集器会对F-Queue对象中进行第二次标记,如果执行了finalize()方法后仍然没有被其他对象引用,则回收该对象
- finalize()方法一般用于关闭外部资源,不过finalize()方法能做的所有工作,使用try-finally或者其他方式都可以做得更好、更及时,所以finalize()方法一般不使用
回收方法区
- 方法区的垃圾收集主要回收两部分内容: 废弃的常量和不再使用的类型(类信息)
- 废弃的常量回收和堆中对象回收非常类似,只要判断常量有无被引用即可
- 判断一个类型是否属于不再被使用的类需要同时满足三个条件,第一个是该类的所有的实例都已经被回收,也就是Java堆中不存在该类及其任何派生子类的实例,第二个是加载该类的类加载器已经被回收,这个条件除非是经过精心设计的可替换类加载器的场景,如OSGi(开放服务网关协议 Open Service Gateway Initiative)、JSP的重加载等,否则通常很难达成,第三个是该类对应的java.lang.Class对象没有在任何地方被引用,无法在任何地方通过反射访问该类的方法
- 满足上面三个条件的类型被允许回收,但GC垃圾收集器不一定会回收
- 在大量使用反射、动态代理、CGLib等字节码框架,动态生成JSP以及OSGi这类频繁自定义类加载器的场景中,通常都需要Java虚拟机具备类型卸载的能力,以保证不会对方法区造成过大的内存压力
参考
《深入理解Java虚拟机》