如今Java应用越来越大,方法区的大小动辄上百G,里面的类和常量更是恒河沙滩。因此,Java虚拟机在实现这些算法时,需要严格考虑算法的执行效率,以保证虚拟机的高效运行。今天我们就来讨论一下HotSpot虚拟机是如何发起内存回收的,如何加速内存回收,如何保证回收的正确性。如何启动内存恢复?目前主流的JVM采用可达性分析算法,通过根节点枚举来寻找死对象。可以作为GCRoots的固定节点主要在全局引用(如常量或类静态属性)和执行上下文(如栈帧中的局部变量表)。虽然目标很明确,但是查找的过程并不是一件可以高效的事情,里面的类,常量等等,要将起源于这里的引用一个一个的去查,肯定会花不少时间。目前所有收集器都必须在根节点枚举步骤中暂停用户线程,因此毫无疑问根节点枚举将面临与前述内存碎片整理相同的“StopTheWorld”问题。虽然可达性分析算法中耗时最长的查找引用链的过程可以与用户线程并发完成,但根节点枚举必须始终在保证一致性的快照中进行。这也是垃圾回收过程中必须暂停所有用户线程的重要原因之一。即使是CMS、G1、ZGC等号称停顿时间可控或者(几乎)没有停顿的收集器,也必须枚举根节点。停止。一致性说明在整个枚举过程中,执行子系统仿佛冻结在某个时间点,不会出现分析过程中根节点集合的对象引用关系还在变化的情况。如果不能做到这一点,则无法保证分析结果的准确性。如何加快内存回收?主要解决方案是优化GCRoots的搜索和并行可达性分析。优化对GCRoots的搜索由于目前主流的Java虚拟机都采用了精准的垃圾回收,当用户线程停止时,没有必要对所有的执行上下文和全局引用位置进行无遗漏的检查。虚拟机应该有一种方法可以直接获取对象引用的存储位置。解决方法:在程序执行时使用安全点。在HotSpot的解决方案中,使用了一组称为OopMap的数据结构来实现这一目标。一旦类加载动作完成,HotSpot就会计算出什么类型的数据在对象中的什么偏移处。在即时编译过程中,它还会记录堆栈和寄存器中的哪些位置是特定位置的引用。.这样收集器在扫描的时候就可以直接知道信息,不需要从方法区等GCRoots开始查找,没有遗漏。在OopMap的协助下,HotSpot可以快速准确的完成GCRoots的枚举,但是一个很现实的问题出现了:有很多指令可能会引起引用关系的变化,或者引起OopMap内容的变化。如果每条指令都生成对应的OopMap,这将需要大量额外的存储空间。实际上,HotSpot并不会为每条指令生成一个OopMap,而只是将这些信息记录在一个“特定的位置”,这个位置被称为安全点(Safepoint)。通过安全点的设置,确定用户程序的执行不会停在代码指令流的任何位置开始垃圾回收,但强制执行必须到达安全点后才能被回收暂停。因此,安全点的选择既不能太少而使收集器等待时间过长,也不能太频繁而过度增加运行时的内存负载。安全点的选择安全点位置的选择基本上以“是否具有允许程序长时间执行的特性”为标准,因为每条指令的执行时间很短,程序是由于指令流的长度,不太可能被执行。“长执行”最明显的特点就是指令序列的复用,比如方法调用、循环跳转、异常跳转等,都属于指令序列的复用,所以只有具备这些功能的指令才会生成安全的观点。关于安全点,还有一个需要考虑的问题就是当垃圾回收发生时,如何让所有线程(不包括执行JNI调用的线程)运行到最近的安全点,然后停止。有两个选项可供选择:先发制人暂停和自愿暂停。抢占式中断不需要线程的执行代码主动配合。当垃圾收集发生时,系统首先中断所有用户线程。一段时间后,它会再次中断,直到到达安全点。几乎没有虚拟机实现使用抢占式中断来挂起线程以响应GC事件。主动中断的思想是当垃圾回收需要中断线程时,并不直接对线程进行操作,而是简单地设置一个标志,每个线程在执行过程中都会主动轮询这个标志。一旦发现中断标志为真时,就主动在最近的安全点中断挂起。轮询标志与安全点重合的地方,加上所有对象创建等需要在Java堆上分配内存的地方,这是为了检查是否即将发生垃圾回收,避免没有足够的内存分配新的对象。由于轮询操作会在代码中频繁出现,因此必须足够高效。HotSpot使用内存保护陷阱将轮询操作简化为只有一条汇编指令。解决方案:程序不执行时,使用安全区使用安全点的设计,看似完美解决了如何停止用户线程,让虚拟机进入垃圾回收状态的问题,但实际情况不一定如此。安全点机制保证了程序在执行时,会在不太长的时间内遇到一个可以进入垃圾回收过程的安全点。但是当程序“不执行”时呢?所谓程序不执行,就是没有分配处理器时间。典型的场景是用户线程处于Sleep状态或者Blocked状态。这时候线程无法响应虚拟机的中断请求,也无法去安全的地方中断并挂起自己。虚拟机也显然不可能继续等待线程重新激活来分配处理器时间。对于这种情况,需要引入安全区域(SafeRegion)来解决。安全区是指能够保证在某段代码中,引用关系不会发生变化。因此,在这个区域的任何地方开始垃圾收集都是安全的。我们也可以把安全区看成是被拉长了的安全点。当用户线程执行到安全区的代码时,会先标记自己已经进入安全区,这样虚拟机在这期间要发起垃圾回收时,就不需要关心这些已经进入安全区的线程宣布自己在安全区。.当线程即将离开enclave时,它??会检查虚拟机是否已完成根节点枚举(或需要挂起用户线程的垃圾收集过程的任何其他阶段)。如果完成了,线程会若无其事地继续执行;否则,它必须等到它收到可以离开安全区域的信号。如何保证内存回收的正确性?解决对象跨代引用问题:内存集和卡表为了解决对象跨代引用带来的问题,垃圾回收器在新生代中建立了一个名为记忆集(RememberedSet)的数据结构来避免添加整个老年代到GCRoots扫描范围。其实,不仅仅是新生代和老一代之间的代际参照问题。所有涉及局部区域收集(PartialGC)行为的垃圾收集器,比如G1、ZGC和Shenandoah收集器,都会面临同样的问题。问题。记忆集是一种抽象数据结构,用于记录从非收集区到收集区的指针集合。如果不考虑效率和成本,最简单的实现可以使用非集合区中所有跨代引用的对象数组来实现这个数据结构。这种记录包含所有跨代参考对象,无论是空间占用还是维护成本都相当昂贵。伪代码如下:classRememberedSet{Object[]set[OBJECT_INTERGENERATIONAL_REFERENCE_SIZE];}复制代码在垃圾回收场景下,回收器只需要使用内存集判断是否有非回收区指向给收藏区指针就好了,你不需要知道这些跨代指针的所有细节。设计者在实现内存集时,可以选择更粗略的记录粒度,以节省内存集的存储和维护成本。下面列出了一些可选的(当然你也可以选择这个范围之外的)记录精度:字长精度:每条记录精确到一个机器字长(即处理器的寻址位数,如常见的32位还是64位,这个精度决定了机器访问物理内存地址的指针长度),word中包含Substitute指针。对象精度:每条记录都精确到一个对象,对象中有包含代际指针的字段。卡片精度:每条记录都精确到一个内存区域,这个区域中有包含跨代指针的对象。其中,第三种“卡片精度”是指使用一种叫做“卡片表”的方法来实现内存集,这是目前最常用的内存集实现形式。内存集和卡片列表的区别:内存集实际上是一个“抽象”的数据结构,也就是说它只定义了内存集的行为意图,并没有定义其行为的具体实现。卡表是内存集的具体实现,定义了内存集的记录精度,与堆内存的映射关系等。卡表的最简单形式可以只是一个字节数组,HotSpot虚拟机也是如此。下面这行代码是HotSpot默认的卡表标记逻辑。CARD_TABLE[这个地址>>9]=0;拷贝码字节数组CARD_TABLE的每个元素对应于其标识的内存区中一个特定大小的内存块。这个内存块被称为“卡片页”(CardPage)。一般来说,卡片页大小是字节数,即2的N次方。从上面的代码可以看出,HotSpot中使用的卡片页是2的9次方,即512字节(地址右移9位,相当于地址除以512)。若卡表标识内存区起始地址为0x0000,则数组CARD_TABLE的0、1、2分别对应地址范围为0x0000~0x01FF、0x0200~0x03FF、0x0400~0x05FF的卡页内存块。如下所示。一张卡片页的内存通常包含多个对象。只要卡片页中有一个(或多个)对象字段带有跨代指针,卡片表对应的数组元素的值就被标记为1,称这个元素是脏的(Dirty),如果不是,则标记为0。当发生垃圾回收时,只要过滤掉卡表中的脏元素,就很容易找出哪些卡页内存块包含跨代指针,并加入到GC中根一起扫描。卡表元素如何维护:writebarriers我们解决了如何使用内存集来缩小GCRoots的扫描范围的问题,但是我们还没有解决卡表元素如何维护的问题,比如什么时候变成脏,谁会弄脏等等。卡片列表元素什么时候变脏的答案很明确——当其他分代区域的对象引用本区域的对象时,对应的卡片列表元素应该变脏。原则上,dirty时间点应该发生在type字段被赋值的时刻。但问题是如何弄脏,即如何在对象赋值的时刻更新维护卡表?如果是解释执行的字节码,还是比较好办的。虚拟机负责每条字节码指令的执行,有足够的干预空间;但是编译和执行场景呢?即时编译后的代码已经是纯机器指令流了,所以需要在机器码层面找到一种手段,将维护卡表的动作放到每次赋值操作中。在HotSpot虚拟机中,通过WriteBarrier技术维护卡表的状态。写屏障可以看作是虚拟机级别的“引用类型字段赋值”动作的AOP方面。当引用对象被赋值时,会产生一个循环(Around)通知,让程序执行额外的动作。也就是说,赋值前后都在writebarrier的覆盖范围内。赋值前的写屏障部分称为写前屏障(Pre-WriteBarrier),赋值后的部分称为写后屏障(Post-WriteBarrier)。Writebarrier在HotSpot虚拟机的很多收集器中都有使用,但是直到G1收集器的出现,其他收集器才使用post-writebarriers。post-writebarrier更新卡表代码如下:voidoop_field_store(oop*field,oopnew_value){//引用字段赋值操作*field=new_value;//Post-writebarrier,完成卡表状态更新在这里post_write_barrier(field,new_value);}copycodewritebarrier存在的一些问题应用writebarrier后,虚拟机会对所有的赋值操作生成相应的指令。一旦收集器在writebarrier中加入更新卡表操作,无论更新是否为旧时代中新生代对象的引用,每次更新引用都会产生额外的开销,但这个开销比在MinorGC期间扫描整个老年代的成本。除了写barrier的开销,卡表在高并发场景下还面临着“伪共享”的问题。伪共享是处理并发底层细节时经常需要考虑的问题。现代CPU的缓存系统是以缓存行(CacheLine)为单位存储的。当多个线程修改自变量时,如果这些变量恰好共享同一个缓存行,就会相互影响(回写、失效或同步),导致性能下降。这就是虚假共享问题。假设处理器的cacheline大小为64字节,由于一个cardlist元素占用1个字节,所以64个cardlist元素将共享同一个cacheline。这64个卡表元素对应的卡页的总内存为32KB(64×512字节),也就是说,如果不同线程更新的对象正好在这32KB内存区域,就会导致卡表更新卡表时要写的恰到好处。进入同一缓存行并影响性能。为了避免虚假共享问题,一个简单的解决方案是不使用无条件写屏障,而是先检查卡片标记,只有没有标记为过时的卡片元素才标记为脏。将更新卡表的逻辑改为如下代码:if(CARD_TABLE[本地址>>9]!=0)CARD_TABLE[本地址>>9]=0复制代码在JDK7之后,HotSpot虚拟机增加了一个新增参数-XX:+UseCondCardMark用于判断是否开启卡表更新条件判断。启用它会增加额外判断的开销,但可以避免虚假共享的问题。两者都有性能损失。是否启用需要根据应用的实际运行情况进行测试和权衡。并发可达性分析目前主流编程语言的垃圾收集器基本上都是依靠可达性分析算法来判断对象是否存活。可达性分析算法理论上要求整个过程基于一个能保证一致性的快照。分析只能在中间进行,也就是说用户线程的运行必须全程冻结。在根节点枚举这一步,由于GCRoots相对于整个Java堆中的所有对象来说还是非常小的,在各种优化技术(比如OopMap)的支持下,它带来的停顿已经很短暂了相对固定(不随堆容量增长)。可以从GCRoots继续遍历对象图。这一步的停顿时间肯定和Java堆的容量成正比:堆越大,存放的对象越多,对象图结构也就越复杂。多个对象造成的停顿时间自然要长一些。知道包含“标记”阶段是所有跟踪垃圾收集算法的共同特征。如果这个阶段随着堆的大小按比例增加暂停时间,它的影响将影响到几乎所有的垃圾收集器。同样,如果这部分停顿时间能够减少,好处也会是系统性的。三色标记工具如果要解决或减少用户线程的停顿,首先要搞清楚为什么要在一个能保证一致性的快照上遍历对象图?为了清楚的解释这个问题,我们引入了Tri-colorMarking作为辅助推导的工具,将对象图遍历过程中遇到的对象按照“是否访问过”的情况标记为以下三种颜色":白色:表示该对象还没有被垃圾收集器访问过。显然,在可达性分析的开始阶段,所有的对象都是白色的,如果在分析阶段结束时对象仍然是白色的,就说明它们是不可达的。黑色:表示该对象已经被垃圾收集器访问过,所有对该对象的引用都已扫描。黑色物体代表已经扫描完毕,可以安全存活。如果有其他对象引用指向黑色对象,则无需重新扫描。黑色物体不可能直接(不经过灰色物体)指向白色物体。灰色:表示对象已经被垃圾收集器访问过,但是这个对象上至少有一个引用没有被扫描过。对于可达性分析的扫描过程,可以看作是在对象图上以灰色为顶点,由黑向白推进的过程。如果此时用户线程被冻结,只有收集器线程在工作,那么就不会出现问题。但是如果用户线程和收集器并发工作呢?用户线程和收集器是并发工作中的问题。收集器在对象图上标记颜色,而用户线程正在修改引用关系——即修改对象图的结构。可能有两种后果。一种是错误地将死对象标记为活的。这不是什么好事,但其实是可以忍受的。只是产生了一点逃过这次收集的漂浮垃圾,下次清理一下就好了。另一种是错误地将原本存活的对象标记为死亡,这是非常致命的后果,程序肯定会出错。下面演示一下这种致命错误到底是如何发生的:如果此时用户线程被冻结,只有收集器线程在工作,就不会出现问题。但是如果用户线程和收集器并发工作,下面两种情况都会导致对象消失。威尔逊在1994年从理论上证明,当且仅当同时满足以下两个条件时,才会出现“物体消失”的问题,即本应为黑色的物体被错误地标记为白色:评价者插入一个或从黑色对象到白色对象的多个新引用;设置器删除从灰色对象到白色对象的所有直接或间接引用。如何保证内存回收的正确性?要解决并发扫描时对象消失的问题,我们只需要破坏上述两个条件中的任何一个即可。于是产生了两种方案:增量更新(IncrementalUpdate)和原始快照(SnapshotAtTheBeginning,SATB)。增量更新需要破坏第一个条件。当黑色对象插入指向白色对象的新引用关系时,记录新插入的引用。并发扫描结束后,记录的引用关系中的黑色对象为根,重新扫描一遍。这个可以简单理解为,一旦新插入了黑色对象并引用了白色对象,它就变成了灰色对象。原始快照将破坏的是第二个条件。当灰色对象要删除指向白色对象的引用关系时,它会记录要删除的引用。灰色物体是根,重新扫描一遍。这也可以简单理解为,无论是否删除引用关系,都会根据刚开始扫描的那一刻对象图的快照进行查找。无论引用关系记录的插入还是删除,虚拟机的记录操作都是通过写屏障实现的。在HotSpot虚拟机中,增量更新和原始快照方案都有实际应用。比如CMS基于增量更新进行并发标记,而G1和Shenandoah则使用原始快照来实现。总结本文对HotSpot虚拟机的内存回收进行了简要概述,比较详细。首先提到,目前主流的JVM都是采用可达性分析算法,通过根节点枚举找到死对象,并发起内存回收。同时,还存在以下问题:现在Java应用越来越大,光是方法区的大小就动辄上百GB。所有收集器在从GCRoots继续向下遍历对象图的根节点枚举步骤中必须暂停用户线程,并且该步骤的暂停时间必须与Java堆容量成正比:堆越大,对象越多存储的对象图结构越复杂,需要标记的对象就越多。由此产生的停顿时间自然要长一些。因此,采用优化GCRootssearch和parallelreachabilityanalysis两种方法来减少停顿时间,加快内存回收。首先,通过使用安全点和安全区域优化GCRoots的搜索。通过记忆套和卡表解决代际参照问题。同时,提到了通过写屏障维护卡表的要素。同时也提到写屏障的一些问题:写屏障会带来额外的开销和虚假共享问题。其次,用户线程和收集器并发工作,实现并行可达性分析。了解使用三色标记工具遍历对象图的过程。同时提到了用户线程和收集器并发工作会导致对象消失,还提到了增量更新和原始快照两种解决方案来解决这个问题。最后,如果您觉得这篇文章对您有点帮助,请点个赞。或者可以加入我的开发交流群:1025263163互相学习,我们会有专业的技术解答。如果您觉得这篇文章对您有用,请给我们的开源项目一个小星星:http://github。crmeb.net/u/defu非常感谢!完整源码下载地址:https://market.cloud.tencent....PHP学习手册:https://doc.crmeb.com技术交流论坛:https://q.crmeb.com
