首页
垃圾收集
对象存活判断
- 引用计数算法:在对象中添加一个引用计数器,计数器为零的对象就是不可能再被使用的,简单高效,但是难以解决相互引用的问题;
- 可达性分析算法:Java虚拟机使用的算法,将GC Roots作为起始结点集,如果某个对象到GC Roots之间不存在引用链,则此对象是不可能再被使用的。Java中可以固定作为GC Roots的对象包括:
- 虚拟机栈(栈帧中的本地变量表)中引用的对象;
- 本地方法栈中Native方法引用的对象;
- 方法区中类静态属性引用的对象,如类的引用类型静态变量;
- 方法区中常量引用的对象,如字符串常量池里的引用;
- 虚拟机内部的引用,如基本数据类型的Class对象、常驻的异常对象、系统类加载器等;
- 所有被同步锁持有的对象。
回收方法区
Java虚拟机规范并不要求虚拟机在方法区实现垃圾收集,方法区的垃圾收集主要回收废弃的常量和不再使用的类型。判断一个类型是否可以回收需要满足三个条件:该类所有实例都已被回收、加载该类的类加载器已经被回收、该类的Class对象没有在任何地方被引用。
垃圾收集算法
分代收集理论
垃圾收集器将Java堆划分出不同的区域(一般至少分为新生代和老年代),然后将回收对象根据其年龄(熬过垃圾收集过程的次数)分配到不同的区域之中存储。建立在两个假设之上:绝大多数的对象都是朝生夕灭、熬过越多次垃圾收集过程的对象就越难以消亡。
- Partial GC:目标不是完整收集整个Java堆的垃圾收集;
- Minor GC/Young GC:目标只是新生代的垃圾收集;
- Major GC/Old GC:目标只是老年代的收集,仅CMS有单独收集老年代的行为;
- Mixed GC:目标是整个新生代及部分老年代的垃圾收集,仅G1的收集行为;
- Full GC:收集整个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中一并扫描。
写屏障(非内存屏障)
对卡表的更新维护需要在对象赋值的时间点,在编译执行的场景需要使用机器码层面的手段,“写屏障”。虚拟机层会为“引用类型字段赋值”动作生成一个环形通知,供程序执行额外的动作,垃圾收集器使用“写后屏障”完成卡表状态更新。
并发的可达性分析
三色标记法
- 白色:表示对象未被垃圾收集器访问过,在可达性分析刚开始的阶段,所有对象都是白色,若在分析结束时的阶段,仍然是白色的对象则不可达。
- 黑色:表示对象已经垃圾收集器访问过,且这个对象所有的引用都已经扫描过。它是安全存活的,如果有其他对象指向黑色对象无须重复扫描,黑色对象也不可能直接指向某个白色对象。
- 灰色:表示对象已经被垃圾收集器访问过,但这个对象上至少存在一个引用还没被扫描过。根节点枚举阶段,根节点引用的对象都是灰色的。可达性分析结束的阶段不存在灰色对象。
漏标
并发标记过程中,用户线程修改了引用关系,导致原本应该是黑色的对象被标记为了白色,需要满足两个条件:插入了一条或多条从黑色对象到该白色对象的新引用、删除了全部从灰色对象到该白色对象的直接或间接引用。
- 增量更新:CMS,破坏第一个条件,当黑色对象插入新的指向白色对象的引用关系时,将这个新插入的引用记录下来,等并发扫描结束后,再将记录过的引用关系中的黑色对象作为根,重新扫描一次。即黑色对象一旦新插入指向白色对象的引用就会变回灰色对象。使用“写后屏障”实现。
- 原始快照:G1、Shenandoah,破坏第二个条件,当灰色对象要删除指向白色对象的引用关系时,将这个要删除的引用记录下来,在并发扫描结束后,再将这些记录过的引用关系中的灰色对象作为根,重新扫描一次。即无论引用关系是否删除都会按照开始扫描的那一刻的对象图像进行搜索。使用“写前屏障”实现。
垃圾收集器
并行和并发
- 并行:垃圾收集器工作时可以使用多条线程。
- 并发:垃圾收集器的工作线程可以与用户线程一同运行,垃圾收集不会导致用户线程的暂停。
Serial
新生代垃圾收集器,采用复制算法,单线程工作,同时收集过程中必须暂停其他所有工作线程。优点是简单、额外内存消耗最小。
Serial Old
Serial的老年代版本,单线程工作,采用标记-整理算法。作为CMS收集器并发收集发生Concurrent Mode Failure时使用。
- -XX:+UseSerialGC:虚拟机运行在客户端模式下的默认值,使用Serial + Serial Old的收集器组合。
ParNew
Serial的多线程并行版本,单核心处理器场景下效率不如Serial,默认开启的线程数与处理器核心数相同(可使用-XX:ParallelGCThreads参数设定)。仅作为与CMS搭配使用的新生代垃圾收集器,关注的是尽可能的缩短垃圾收集时用户线程停顿的时间。
Parallel Scavenge
新生代垃圾收集器、基于标记-复制算法、并行收集,关注提高吞吐量(吞吐量即处理器用于运行用户代码与处理器总消耗时间的比值)。支持的参数有:
- -XX:MaxGCPauseMills:最大停顿时间,会尽力保证不超过该值;
- -XX:GCTimeRatio:设置吞吐量大小,默认99,最大允许1%(即1/1(1+99));
-
-XX:+UseAdpativeSizePolicy:开关参数,默认开启,自适应调节策略,区别于ParNew的重要特性。无须人工指定垃圾收集的细节参数,只需设置基本内存参数(堆大小等)及优化目标(最大停顿时间或最大吞吐量),具体细节参数会由虚拟机根据运行情况动态调整。
- -XX:+UseParallelGC:JDK9之前虚拟机运行在服务端模式下的默认值,使用Parallel Scavenge + Serial Old的收集器组合。
Parallel Old
Parallel Scavenge的老年代版本,并行收集,基于标记-整理算法,与Parallel Scavenge配合使用,适用于“吞吐量优先”场景。
- -XX:+UseParallelOldGC:JDK9之前虚拟机运行在服务端模式下的默认值,使用Parallel Scavenge + Parallel Old的收集器组合。
CMS
Concurrent Mark Sweep,以获取最短回收停顿时间为目标的老年代垃圾收集器,并发收集,基于标记-清除算法,JDK 9之后不再推荐使用。过程分为四个步骤:
- 初始标记:需要STW,单线程,只标记GC Roots直接关联到的对象,速度很快;
- 并发标记:不需要STW,多线程,遍历整个对象图,时间长;
- 重新标记:需要STW,多线程,增量更新,时间比初始标记稍长,但远远短于并发标记;
- 并发清理:不需要STW,多线程,清理删除掉已死亡对象,时间很长。
缺点:
- 对处理器资源敏感,默认回收线程数为(处理器核心数 + 3)/ 4,占用处理理器资源,降低总吞吐量;
- 不能等待老年代被填满了再进行收集,因为并发标记和并发清理时运行的用户线程仍然在产生垃圾对象,所以需要预留空间供并发收集时的程序运作使用,通过参数*-XX:CMSInitiatingOccupancyFration)设定触发CMS启动的阈值;同时当运行期间预留的内存无法满足程序分配的需要,则会触发“并发失败”(Concurrent Mode Failure),虚拟机将冻结用户线程执行,临时启动Serial Old来重新收集,这样会导致很长的停顿时间;
- 可能存在大量空间碎片,如果无法找到足够的连续大空间来分配对象,则需要触发FullGC以对空间进行合并整理(FullGC也可以不整理空间)。
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。
实现细节
- 记忆集:每个Region都会维护自己的记忆集,本质上是一种哈希表,Key是别的Region的起始地址,Value是卡表索引号的集合,即“双向”的卡表结构,记录了本Region中所有对象引用的对象所在的Region。因为Region的数量很多,所以G1的内存占用更高,至少要耗费10%~20%的内存用于维持收集器工作。
- 与用户线程并发:Region中的一部分空间会被划分出来用于并发回收过程中的新对象分配,每个Region都有两个TAMS指针,新分配的对象地址必须在这两个指针位置之上,且默认是存活的不纳入回收范围。当预留的空间不足时G1也要被迫冻结用户执行导致Full GC(与CMS的并发失败类似)。
- 可靠的停顿预测:使用衰减平均值的理论预测停顿时间,G1收集器会记录每个Region的回收耗时、脏卡数量等各个可测量的步骤花费的成本并进行分析,Region的统计状态越新越能决定其回收的价值。
收集步骤
- 初始标记:需要STW,仅仅是标记一下GC Roots直接引用的对象,并且修改TAMS指针的值,停顿时间非常短;
- 并发标记:不需要STW,扫描对象图,扫描完成后还会重新处理原始快照记录的引用,耗时较长;
- 最终标记:需要STW,处理完最后仍遗留下来的少部分原始快照记录,时间较短;
- 筛选回收:需要STW,因为涉及存活对象的移动,多线程并行处理,更新Region的统计数据,根据用户期望的停顿时间,选择多个Region构成回收集,将回收集内Region中的存活对象复制到空的Region,再清理掉整个旧Region的全部空间。
Shenandoah
可以视作对G1的改进;基于Region,优先处理回收价值最大的Region;默认不使用分代收集,不区分新生代和老年代;支持并发的整理算法;使用“连接矩阵”(可以理解为二维表格,全局数据结构)来记录跨Region的引用关系,替代了记忆集。
工作过程
- 初始标记:与G1一样,需要STW,首先标记与GC Roots直接关联的对象,停顿时间只与GC Roots的数量有关;
- 并发标记:与G1一样,不需要STW,扫描对象图;
- 最终标记:与G1一样,需要STW,处理剩余的原始快照扫描,并选择回收价值最高的Region构成一组回收集;
- 并发清理:清理不存在存活对象的Region;
- 并发回收:不需要STW,将回收集内存活的对象复制到空的Region中,因为用户线程仍然在对对象进行读写访问,Shenandoah使用了读屏障和“Brooks Pointers”转发指针解决;
- 初始引用更新:对象复制结束后,需要将堆中所有指向旧对象的引用修正到复制后的新地址,实际上未作具体处理,只是为了确保所有的收集器线程都完成了对象移动任务,会产生一个非常短的停顿;
- 并发引用更新:不需要STW,真正的开始引用更新,不是沿着对象图操作,而是按照内存物理地址的顺序,线性的搜索引用类型并处理;
- 最终引用更新:需要STW,堆中的引用更新完成后,还需要修正GC Roots中的引用,停顿时间只与GC Roots的数量相关;
- 并发清理:并发引用更新完成后,再次执行并发清理,回收回收集中所有的Region。
并发整理算法实现
-
内存保护陷阱:在被移动对象原有的内存上设置保护陷阱,这样当用户程序访问时会产生自陷中断,然后使用预设好的异常处理器将访问转发到复制后的新对象上,缺点就是如果没有操作系统层面的直接支持,则会导致用户态频繁的切换到和心态。
-
Brooks Pointer:在原对象布局结构的最前面统一增加一个新的引用字段,即转发指针,不处于并发移动时转发指针指向对象自己,并发移动时指向对象新的副本,同时通过CAS操作保证并发时对象的访问正确性。Shenandoah收集器支持并发整理的核心,为了实现Brooks Pointer,Shenandoah在读、写屏障中都加入额外的转发处理。
ZGC
使用参数(-XX:+UseZGC)开启,支持TB级别的堆大小(ZGC已经支持到16TB),目标是在尽可能不影响吞吐量的前提下在任意堆容量下的停顿时间都不超过10毫秒。基于Region、(暂时)不设分代、使用了读屏障、染色指针和内存多重映射等实现可并发的标记-整理算法、以低延迟作为首要目标、支持“NUMA-Aware”的内存分配。
Region/Page/ZPage
动态的创建和销毁、动态的区域容量大小。
- 小型Region:容量固定为2MB,用于放置小于256KB的小对象;
- 中型Region:容量固定为32MB,用于放置大于256KB但小于4MB的对象;
- 大型Region:容量可动态变化,但必须为2MB的整数倍,用于放置4MB以上的大对象。每个大型Region只会存放一个大对象,故实际容量可能小于中型Region,同时不会被重分配。
并发整理算法实现
染色指针是一种将少量额外信息存储在指针上的技术。64位系统中实际上指针只有低48位可以用来寻址,ZGC将这其中的高4位用于存储四个标志信息(三色标记状态、是否进入重分配集、是否只能通过finalize()访问),即一个指针只有44位的地址空间,故ZGC可管理的最大内存为16TB,且不再支持指针压缩。由于染色指针重新定义了指针,需要使用虚拟内存映射技术去支持正常的寻址操作,使用多重映射的方式,将多个虚拟内存地址(一个染色指针不同的视图对应到不同的虚拟地址)映射到同一个物理内存地址上。
工作过程
- 并发标记:同G1、Shenandoah,且前后也有类似初始标记、最终标记的短暂停顿,但是ZGC是标记指针,更新染色指针的Marked 0、Marked 1标志位;
- 并发预备重分配:将需要清理的Region组成重分配集(Relocation Set),并设置引用重分配集中的存活对象的染色指针的Remapped标记位;ZGC每次都会扫描所有Region,这样可以省去记忆集的维护成本;并且类卸载及弱引用的处理也是在此阶段完成;
- 并发重分配:将重分配集中存活的对象复制到新的Region上,并为重分配集中的每个Region维护一个转发表,用于记录旧对象到新对象的转向关系;根据染色指针可以从引用上就明确得知一个对象是否处于重分配集中,当用户线程并发访问这些对象时,“读屏障”会根据Region上的转发表将访问转发到新复制的对象上,并同时修正更新引用值,这就是ZGC中指针的“自愈”能力;Region中的存活对象赋值完毕后,这个Region可以立即释放用作新对象的分配,但是转发表需要保留;
- 并发重映射:修正整个堆中指向重分配集中旧对象的所有引用,因为指针的自愈能力存在,所以重映射并不是必须迫切完成的任务,因此会在下一次垃圾收集过程的并发标记阶段去完成,以此节省了一次遍历对象图的开销,之后就可以释放掉上一次的转发表了。
内存分配与回收策略
对象优先在Eden分配
大多数情况下,对象在新生代的Eden区中分配,当Eden区没有足够空间进行分配时,虚拟机会发起一次Minor GC,当已有的存活对象无法放入Survivor空间时,会通过分配担保机制提前转移到老年代去。
大对象直接进入老年代
大对象需要大量连续内存空间,为了避免在Eden区及两个Survivor区之间来回复制,超过一定阈值的大对象直接在老年代分配。
长期存活对象将进入老年代
新生代的对象每熬过一次Minor GC,其年龄就增加一岁,当达到阈值后就会被晋升到老年代中。
动态年龄判断
并不要求新生代对象的年龄一定达到阈值才能晋升到老年代,如果Survivor空间中相同年龄的所有对象大小的总和超过了空间的一半,则年龄大于等于该年龄的对象就可以直接进入老年代。
空间分配担保
发生Minor GC之前,虚拟机必须先检查老年代最大可用的连续空间是否大于新生代所有对象的总空间或者历次晋升到老年代对象的平均大小,如果大于则允许尝试进行一次Minor GC,如果小于则需要改为进行一次Full GC。