这篇文章将为大家详细讲解有关怎样实现JVM垃圾回收,文章内容质量较高,因此小编分享给大家做个参考,希望大家阅读完这篇文章后对相关知识有一定的了解。
垃圾回收需要解决的第一个问题就是:如何判断一个对象是否应该被回收,通常有这两种算法:引用计数算法和可达性分析算法。
引用计数算法(Reference Counting Algorithm)
在对象中添加一个引用计数器,每增加一个对它的引用,计数就加1,每取消一个对它的引用,计数就减1,当计数器为0时,该对象就是可以回收的。引用计数算法原理简单,判定高效,但需要额外占用内存空间,且无法解决循环引用。
可达性分析算法(Reachability Analysis Algorithm)
通过一系列称为“ GC Roots”的根对象作为起始节点集,从这些节点开始向下搜索,搜索所经过的路径称为“引用链”(Reference Chain),如果对象到GC Roots没有任何引用链相连,则意味着该对象不可访问,即该对象无法再使用,则可以回收。JVM使用的就是这个算法。
Java中固定可作为GC Roots的对象:
虚拟机栈栈帧中本地变量表中引用的对象,如各个线程被调用方法使用的参数、变量等
方法区中类静态属性引用的对象,如Java类变量
方法去中常量引用的对象,如字符串常量池的引用
本地方法栈JNI引用的对象
JVM内部的引用,如基本数据类型对应的Class对象、常驻的异常对象、系统加载器等
所有被同步锁(syschronized)持有的对象
反应JVM内部情况的JMXBean、JVMTI中注册的回调、本地代码缓存等
finalize方法 对象在可达性分析中标记为不可达后,并不一定就会被回收,可以在finalize方法中拯救一下自己。如果对象覆盖了finalize方法,并且finalize方法从未被执行过,那么该对象会被放置到F-Queue队列中,稍后会有单独的JVM线程执行队列中对象的finalize方法,但并不承诺一定会等待它运行结束。收集器会对F-Queue中的对象进行二次的标记,如果在finalize方法中重新与引用链上的对象建立了关联,那么二次标记将会把它移除回收的集合。特别注意的是,任何对象的finalize方法只会被执行一次,下一次回收时将不会被执行。由于并不能保证finalize方法被完整执行,我们最好不要使用它。
由于方法区的垃圾回收性价比比较低,所以对方法区的回收并不是必须的,有些收集器也没有实现方法区的回收。方法区的回收主要回收两部分:废弃的常量和不再使用的类型(类型卸载)。
废弃的常量,如字符串常量池中的一个字符串,已经没有任何字符串对象引用它,那么它就会被回收。
类型卸载,必须满足三个条件:
类型卸载并不和对象回收一样,满足条件并不一定就肯定被回收,HotSopt可以通过-Xnoclassgc
参数控制。
该类的所以实例都已经被回收
加载该类的类加载器已经被回收
该类对应的Class对象没有在任何地方被引用
当前大多数JVM垃圾收集器都遵循分代收集(Generational Collection)理论,它基于下面三个假说:
弱分代假说:绝大多数对象都是朝生夕灭的
强分代假说:熬过越多次GC过程的对象越难以被回收
跨代引用假说:跨代引用相对同代引用仅占极少数
基于前两个假说,收集器将Java堆分为了新生代和老年代。新生代中绝大多数对象都是朝生夕灭的,每次GC都可以回收都可以清理大量的内存空间,并且可以更加频繁的执行回收。老年代中的对象难已被回收,GC结果远低于新时代,回收频率更低。
跨代引用较少的原因:存在引用关系的对象是应该倾向于同时生存或消亡的。若某个新生代对象存在跨代引用,由于老年代对象难以消亡,随着多次GC,新生代对象就会晋升到老年代,从而消除了这种跨代引用。因为跨代引用上应该很少,所以在Minor GC时,不能为了解决少数的跨代引用就把整个老年代对象都加入到GC Roots中,那样太影响效率了。
在收集区中建立一个全局的数据集——记忆集,记录从非收集区指向收集区的的所有引用。记忆集的实现有多种方法,大多数收集器都采用卡表的方式。举例来说,在新生代中创建一个卡表,然后把老年代划分成若干块,在卡表中记录老年代那一块内存存在跨代引用,Minor GC时,只需要把存在跨代引用的那块内存对象加入到GC Roots中。
HotSopt中卡表的实现是一个byte数组,数组元素的值为0和1两种,不用bit数组的原因是现代计算机硬件都是最小按字节寻址的,用bit反而需要多条指令。byte数组中每一个元素对应一个大小为512 byte的内存块,每个对象的内存地址除以512就是其在数组中的位置,这个内存块叫卡页,一个卡页通常不止包含一个对象,当卡页中有一个对象的字段存在跨代引用时,那么卡表中相应的元素的值就是1,GC时只要把卡表中值为1的元素对应的卡页中的对象加入到GC Roots中就可以了。
至于为什么要用卡表这样粗旷的粒度,是因为可以节约存储空间和维护成本。
什么时候改变卡表中的值?有其他分代区域的对象引用了本区域的对象时,就需要改变卡表中的值。
如何改变卡表中的值——写屏障。写屏障和AOP的思想很像,使用写屏障,JVM会为所有的赋值操作生成相应的指令,来维护卡表中的值。
收集器首先标记出需要被回收的对象,标记完成后统一回收所有被标记过的对象,当然也可以反过来标记不需要被回收的对象,然后清理未被标记的对象。它有两个明显的缺陷:
执行效率不稳定,如果堆中包含大量对象且大部分需要被回收,则必须进行大量的标记和清理动作,执行效率是随着对象增多而降低的。
产生大量不连续的内存碎片。
将内存划分为两个大小相等的区域,每次只使用其中一块,这块区域用完时,将存活的对象复制到另一块中,然后将已满这块内存空间一次清理掉。如果大多数对象都是可回收的,那么复制少量的对象开销就很小,由于只需要一次清理,因此执行效率相比于标记清除是很高且稳定的,而且也不会存在内存碎片。代价就是每次可用的内存空间只有一半。
实际中,大多数收集器都采用这种算法回收新生代,将新生代分为一个Eden区和两个Survivor区,默认比例为8:1:1,每次只使用Eden区和一个Survivor区,即新生代的可用区域只有实际区域的90%,Minor GC时将存活对象复制到另一Survivor区,然后清理掉Eden和已经用过的Survivor区。如果GC时存活下来的对象需要的空间超过了Survivor的大小,则会直接放入老年代中,这就是用老年代内存进行分配担保。
很明显标记复制算法是不太适合老年代的,老年代GC通常使用标记整理,即先标记出可被回收的对象,然后让存活的对象向内存空间的一侧移动,然后直接清理掉边界以外的内存。
和标记-清除相比最大的不同就是需要移动活着的对象,移动对象可以规避内存碎片,且需要更新所有引用这些对象的地方,但分配内存时更加简单;不移动对象,回收内存简单,而分配内存时会因为内存碎片变得复杂。所以不同的收集器会根据自己的特性来选择适合自己的算法,如Parallel Old关注吞吐量则使用了标记复制,CMS关注延迟则使用了标记清除。
迄今为止,所有的收集器在枚举GC Roots时都是需要冻结所有用户线程的,即STW(Stop The World),因此如何高效的找到所有的根节点对象是必须解决的。HotSpot采用了准确式GC以提升GC roots的枚举速度。所谓准确式GC,就是让JVM知道内存中某位置数据的类型什么。比如当前内存位置中的数据究竟是一个整型变量还是一个引用类型。这样JVM可以很快确定所有引用类型的位置,从而更有针对性的进行GC roots枚举。
HotSpot是利用OopMap来实现准确式GC的。当类加载完成后,HotSpot 就将对象内存布局之中什么偏移量上数值是一个什么样的类型的数据这些信息存放到 OopMap 中;在 HotSpot 的 JIT 编译过程中,同样会插入相关指令来标明哪些位置存放的是对象引用等,这样在 GC 发生时,HotSpot 就可以直接扫描 OopMap 来获取对象引用的存储位置,从而进行 GC Roots 枚举。(?还是不明白为什么需要OopMap)
由于导致引用关系变化即改变OopMap内容的指令非常多,如果每条指令都生成对应的OopMap,那将耗费大量额外空间,所以HotSpot只在特定位置记录这些信息,这个位置就是安全点。安全点的存在就要求GC时,用户线程必须执行到安全点后才能暂停,而不能在指令流的任意位置暂停。当收集器需要暂停用户线程时,并不直接对线程操作,而是设置一个标志位,各个线程执行过程中会不停的主动轮询这个标志,如果标志为真则在最近的安全点上主动中断挂起。
安全点有一个问题不能解决,那就是用户线程如果处于sleep或blocked状态时,肯定是不能响应中断自己走到安全点的,于是就需要安全区域来解决。安全区域就是指能保证在某一代码片段中,引用关系不会发生变化,因此在这个区域中任何地方开始GC都是安全的。当用户线程执行到安全区域的代码时,就会标识自己进入到安全区域,那这段时间JVM要发起GC就不必管这些已在安全区域的线程了。当线程要离开安全区域时,它就必须检查JVM是否已经完成了根节点的枚举,如果没有则必须一直等待。
根节点枚举完成后,需要从GC Roots开始遍历整个对象图,这个过程是非常耗时的,因此一些收集器为了减少GC的STW时间,让遍历对象图的过程和用户线程并发执行,这样就极大减少了STW时间,但是如果用户线程在此期间修改引用关系,就会造成标记错误,如下面这个图:
上图中对象D就被错误的回收掉。导致一个白色对象被错误回收掉需要同时满足两个条件:
赋值器插入了一条或多条从黑色对象到白色对象的新引用
赋值器删除了全部灰色对象到该白色对象的直接或间接引用
解决的两种办法:
增量更新:破坏条件1,当黑色对象插入了新的指向白色对象的引用时,将新插入的引用记录下来,等并发扫描结束后,再将这些记录中的黑色对象当作Roots重新扫描一次。
原始快照:破环条件2,当灰色对象删除指向白色对象的引用时,将要删除的引用记录下来,当并发扫描结束后,以灰色对象为Roots,重新扫描一次。注意了,第二次扫描是扫描的原始数据(所以叫原始快照),第二次扫描发现了C到D的引用,因此D会被判为存活。这样有一个问题,那就是如果没有新增A到D的引用,此时D会被误判为存活,但这是没关系的,因为把应该回收的对象被判存活远比把应该存活的对象判为回收问题小的的,不过是等待下一次回收而已。
在HotSpot中,CMS使用增量更新做并发标记,G1和Shenandoah则使用了原始快照。
Serial收集器是一个出现非常早,实现简单的新生代收集器,它是一个“单线程”(描述为串行可能更合适)收集器,额外消耗的资源非常小,在硬件资源受限的条件下,它是任然是首选的收集器,所以HotSpot VM的客户端模式下默认的收集器就是Serial。Serial的缺点也非常的明显,就是整个GC期间都会STW。
Serial old收集器是Serial的老年代版本,不同点是:Serial采用标记-复制算法,Serial old采用标记-整理算法。
相关参数:-XX:+UseSerialGC
PerNew收集器实质上是Serial收集器的多线程并行版本,也是一个新生代收集器,通常和CMS或Serial old(JDK9后不再支持)搭配使用。PerNew在单核甚至双核处理器环境下,由于线程交互的开销,效果没有Serial好,但是随着核心的增多效率提升还是非常明显的。PerNew默认使用的线程数与处理器逻辑核心数量相同。
启用参数:-XX:+UseParNewGC
,JDK 9 后此参数被取消
指定垃圾收集线程数:-XX:ParallelGCThreads
Parallel Scavenge也是一个面向新生代、采用标记-复制、并行收集的收集器。Scaveng与PerNew非常的相似,GC过程也是一样,但它的主要关注点是系统的吞吐量。
可以控制系统的吞吐量
# 每次GC的最大停顿时间,参数值为大于0的毫秒数 -XX:MaxGCPauseMillis # 吞吐量,参数值为大于0小于100的整数,默认值为99,即GC的时间最多占系统允许总时间的1/(1+99)=1% -XX:GCTimeRatio
Scavenge能够根据系统运行情况,动态调整Eden区和Survivor区的比例、对象晋升老年代年龄等参数以提供合适的停顿时间或最大的吞吐量。开启这个功能的参数为-XX:+UseAdaptiveSizePolicy
Parallel Scavenge是JDK 8默认的年轻代垃圾收集器,启动参数为-XX:+UseParallelGC
。
Parallel old是一款基于标记-整理算法的多线程并发收集器,它的出现就是为了和Scavenge搭配使用,它俩也是JDK 8的默认的收集器组合。启动参数:-XX:+UseParallelOldGC
CMS(Concurrent Mark Sweep)是一款基于标记-清除算法的老年代收集器,它是为了减少GC停顿时间而出现的。前面的几款收集器都有一个致命弱点:GC的全过程都需要STW,CMS虽然没能彻底解决这个问题,但却大大缩短了STW的时间。它把GC过程分成了下面几个步骤:
初始标记 (initial mark)
需要STW,仅标记GC Roots能直接关联的对象,这个过程是非常快的。
并发标记(concurrent mark)
不需要STW,从上一步得到的对象开始遍历对象图,这个过程耗时较长但与用户线程并发运行的。
重新标记(remark)
需要STW,修正并发标记期间因用户线程运行而导致标记变动的那一部分对象的标记记录,耗时较短。
并发清除(concurrent sweep)
不需要STW,并发清除被标记死亡的对象,由于是直接清除不需要整理,所以也是与用户线程并发运行的。
启用参数:-XX:+UseConcMarkSweepGC
CMS的缺点:
由于采用标记-清除算法,因此内存碎片是不可避免的,当碎片过多无法给大对象分配空间时就会触发Full GC。
无法处理浮动垃圾。在并发标记和并发整理期间新产生的垃圾,在本次GC无法清理掉,只能留到下一次GC。同样由于GC时,用户线程任然在运行,所以不能等到老年代空间全部被占满了才开始GC,需要留一些空间给GC期间的用户线程使用,因此还剩多少空间的时候开始GC(-XX:CMSInitialingOccupancyFracion
),这个很关键,如果GC期间预留的内存无法满足对象的分配使用,出现并发失败,这时JVM就会启用Serial old来重新进行老年代的垃圾收集。
并行处理期间虽然不会冻结用户线程,但收集器自身会占用一部分系统资源,导致用户线程的吞吐量下降。CMS默认使用的线程数是$(处理器核心数+3)/4$。
在G1(Garbage First)之前,垃圾收集器的目标范围很明确:新生代(Minor GC),老年代(Major GC)以及整堆(Full GC)。G1则不再这样,它可以面向堆内存任何部分来组成回收集进行回收,衡量标准不再是它属于哪个分代,而是那块内存中垃圾数量最多,回收收益最高,这就是G1的Mixed GC模式。从G1开始,大部分新的收集器都不再追求一次性把垃圾清理干净,而是追求能够应付应用的内存分配速率,只要回收速度能够跟上新对象内存分配的速度,那就是完美的。
G1不再坚持固定大小或数量的分代区域划分,而是把连续的堆划分为多个大小相等的独立区域(Region),每个Region都可以根据需要扮演Eden、Survivor或Old空间(G1依然是按照分代收集的)。每个Region的大小可以通过参数-XX:G1HeapRegionSize
设定,取值范围为1MB-32MB,且应为2的N次幂。Region中有一类专门用来存放大对象的区域——Humongous Region,G1认为超过Region容量一半的对象就是大对象,而那些超过Region容量的大对象,将会被存放在N个连续的Humongous Region中。
G1把Region作为单次回收的最小单元,跟踪每个Region回收所能获得的空间大小及需要的时间,然后在后台维护一个优先级列表,每次根据用户设定的允许停顿时间(-XX:MaxGCPauseMillis
,默认200ms),优先回收那些价值最大的Region,这样就保证了在有限的时间及可能高的回收效率。这样的内存布局使得G1从整体上看是标记-整理,从两个Region看又是标记-复制,避免了CMS的采用标记-清除所带来的内存碎片问题。
为了解决“跨代”引用问题,G1让每个Region都有自己的记忆集,其记录了哪一个Region引用了本Region的对象。记忆集的实现是一个hash表,key是别的Region的起始地址,value是一个集合,里面存储的元素是卡表的索引号。因此G1的记忆集比CMS的卡表维护起来更加复杂,且占用空间更大,记忆集耗费的空间大致上占堆空间的10%到20%。
G1在并发标记阶段采用原始快照算法,并且为每个Region加入了两个TAMS(Top at Mark Start)指针,把Region中分出一部分空间用于并发过程中新对象下的分配,这部分空间的对象默认存活的,不会纳入当前GC的回收范围。
G1收集器的运作的大致分为四个步骤:
初始标记
需要STW,标记GC Roots能直接关联的对象,修改TAMS指针的值。
并发标记
不需要STW,从上一步得到的对象开始遍历对象图。
最终标记
需要STW,处理并发标记期间有引用变动的对象
筛选回收
需要STW,更新Region的统计数据,对各个Region的回收价值和成本排序,根据用户期望的停顿时间制定回收计划,自由选择多个Region构成回收集,把决定回收的Region中存活的对象复制到空的Region中,然后清理掉整个Region空间。
G1是JDK11默认的收集器。启用参数:-XX:+UseG1GC
G1的缺点:
由于每个Region都有记忆集且结构更复杂,导致内存占用比CMS要高。
除了用写后屏障维护更复制的记忆集外,还需使用写前屏障来跟踪并发标记阶段引用的变化,会为用户程序带来额外的负担。
Shenandoah使用了和G1一样的Region内存布局,默认也是优先处理回收价值大的Region,其与G1的最大不同是:
默认不使用分代收集
回收阶段可以与用户线程并行
使用连接矩阵(Connection Matrix)的全局数据结构来记录跨Region的引用关系。
如上图,Region5的对象引用Region3的对象,就在3行5列打个标记;Region3引用了Region1的对象,就在1行3列上打个标记(图上位置是错误的)。
Shenandoah收集器的工作过程大致分为九个过程:
初始标记
需要STW,标记GC Roots能直接关联的对象。
并发标记
不需要STW,从上一步得到的对象开始遍历对象图。
最终标记
需要STW,处理并发标记期间有引用变动的对象,统计出回收价值最高的Region构成回收集。
并发清理
不需要STW,清理那些没有一个存活对象的的Region。
并发回收
不需要STW,将存活对象复制到未使用的Region中。
Shenandoah是如何解决复制对象时,用户线程访问被复制的对象问题,因为这个时候其他对象持有的引用并未更新,如果这个时候读写该对象,会造成数据不一致。办法就是在原有的对象布局结构的最前面统一增加一个新的引用字段——即转发指针,在正常不处于并发移动的情况下,转发指针指向对象自己,移动时,修改转发指针的值为新地址,这样便可以将对该对象的所有引用转发到新副本上。为了并发安全,对转发指针的修改采用了CAS。
初始引用更新
需要STW,建立一个线程集合点,确保所有并发回收线程都已经完成对象的移动。
并发引用更新
不需要STW,把堆中指向旧对象的引用修正到复制后的新地址。
最终引用更新
需要STW,修正GC Roots的引用。
并发清理
不需要STW,并发清理Region的内存空间。
从上面的九个步骤就可以看出,Shenandoah的停顿时间是非常短的,所有的耗时步骤都可以与用户线程并行,因此Shenandoah是一款低延迟的收集器,实际运行中GC的平均停顿时间不会超过50ms。
启用参数:-XX:UseShenandoahGC
,openjdk12加入,目前任处于实验阶段,预计JDK15可用于生产。
ZGC收集器是一款基于Region内存布局的,不设分代,使用了读屏障、染色指针和内存多重映射等技术来实现可并发的标记-整理算法的,以低延迟为首要目标的一款垃圾收集器。参考
ZGC也采用了基于Region的内存布局,与G1不同的是,ZGC的Region具有动态性——动态创建和销毁及动态的区域容量大小,大致分为三类:
小型Regoin(Small Region):容量固定2MB,用于放置小于256KB的对象。
中性Region(Medium Region):容量固定为32MB,放置大于等于256KB小于4MB的对象。
大型Region(Large Region):容量不固定,可动态变化,但必须为2MB的整数倍,用于放置4MB及以上的对象。每个Large Region中只会存放一个大对象,因此Large Region的容量最小可能只有4MB。
一个对象只有它的引用关系能决定它存活于否,对象自身的所以属性都不能影响它存活的判定结果,因此把标记信息直接记录在对象指针上是最最直接的,Serial收集器记录在对象头中,G1用了一个单独的位图存储。染色指针(Colored Pointer)就是一种直接将少量额外信息存储在指针上的技术。在64位系统中,理论可以访问的内存高达16EB(64位地址),但实际在AMD64架构中只支持到52位(4PB)的地址总线和48位(256TB)的虚拟地址空间,在操作系统层面,64位Linux则只支持47位(128TB)的进程虚拟地址空间和46位(64TB)的物理地址空间,Windows系统则更少。因此,ZGC的就将Liunx中46位指针中的高4位提取出来存储四个标志信息,通过这些标志位,JVM可以直接从指针中看到其引用对象的三色标记状态(Marked1、Marked0)、是否进入了从分配集(即被移动过,Remapped),是否只能通过finalize方法才能被访问到(Finalizable)。这也导致了ZGC的能够管理的内存不可以超过4TB(42位,JDK13增加到了16TB),不能支持32位平台,并且不能使用压缩指针。
染色指针带来的优势也是非常明显的,如下:
染色指针使得Region中存活的对象被移走后,该Region就可以立即释放或重用,而不必像Shenandoah那样需要等待堆中指向该Region的引用都修正后才能清理。因此理论上来说,只要还有一个空闲的Region,ZGC都能完成收集。
染色指针可以大幅减少在垃圾收集过程中内存屏障的使用数量,因为引用变动信息已经维护在了指针中,就可以省去以前那些通过写屏障来记录的操作了。
染色指针可以作为一种可扩展的存储结构用来记录更多与对象标记、重定位过程相关的数据,以便日后进一步提高性能。比如想办法用未使用的高18位来做一些事情
要了解多重映射的工作原理,我们需要简要解释虚拟内存和物理内存之间的区别。 物理内存是系统可用的实际内存,通常是安装的DRAM芯片的容量。 虚拟内存是抽象的,这意味着应用程序对(通常是隔离的)物理内存有自己的视图。 操作系统负责维护虚拟内存和物理内存范围之间的映射,它通过使用页表和处理器的内存管理单元(MMU)和转换查找缓冲器(TLB)来实现这一点,后者转换应用程序请求的地址。
Linux/X84-64平台上ZGC使用了内存多重映射(Multi-Mapping)将多个不同虚拟机内存地址映射到同一物理地址上,这是一种多对一映射,意味着ZGC在虚拟内存中看到的地址空间要比实际堆内存空间更大。把染色指针中的标志位看作是地址的分段符,那只要将这些不同的地址段都映射到同一物理内存空间,经过多重映射转换后,就可以使用染色指针正常进行寻址了,如下图:
ZGC的运作过程大致可划分以下阶段:
初始标记
需要STW,标记GC Roots能直接关联的对象。ZGC每次回收都会扫描所有的Region,用范围更大的扫描成本来省去G1中记忆集的维护成本。
并发标记
不需要STW,从上一步得到的对象开始遍历对象图,更新染色指针的Marked0、Marked1标志位。
最终标记
需要STW,处理并发标记期间有引用变动的对象。
并发预备重分配(Concurrent Prepare for Relocate)
不需要STW,根据特定查询条件统计出本次收集过程中要清理那些Region,将这些Region组成重分配集。由于是扫描了所有的Region,ZGC的重分配集只决定了里面存活的对象会被复制到其他Region,分配集中Region被释放,所以标记是针对全堆的,回收却不是。
初始重分配(Relocate Start)
需要STW,将重分配集中的GC Roots直接关联的对象复制到新的Region中。
并发重分配(Concurrent Relocate)
不需要STW,将重分配集中的存活对象复制到新的Region中,并为分配集中的每个Region维护一个转发表(不是在Region中,在单独的区域),记录从旧对象到新对象的转发关系。在此期间用户线程访问了位于重分配集中的对象,这次访问将会被预置的内存屏障截获,然后根据Region的转发表将访问转发到新对象上,并同时修正更新该引用的值,使其指向新对象——ZGC把这种行为称为指针的自愈能力。这样做的好处是只有第一次访问旧对象会陷入转发,而不像Shenandoah的转发指针那样每次都存在转发开销。
并发重映射(Concurrent Remap)
不需要STW,修正整个堆中指向重分配集中就对象的引用,完成后释放掉转发表。由于自愈能力的存在,这个步骤并不需要马上完成,因此ZGC把该步骤合并到了下一次回收的并发阶段里去完成,省了一次遍历对象图的开销。
开启方法:-XX:+UnlockExperimentalVMOptions -XX:+UseZGC
,ZGC在JDK11的Linux版正式以实验性质加入,mac和windows则需要JDK14,预计在JDK15中正式Release,用于生产。
关于怎样实现JVM垃圾回收就分享到这里了,希望以上内容可以对大家有一定的帮助,可以学到更多知识。如果觉得文章不错,可以把它分享出去让更多的人看到。