JVM 团队设计出 G1 收集器的目的就是取代 CMS 收集器,因为 CMS 收集器在很多场景下存在诸多问题,缺陷暴露无遗,具体如下:
(1)CMS收集器对CPU资源非常敏感。在并发阶段,虽然不会导致用户线程停顿,但是会占用CPU资源而导致引用程序变慢,总吞吐量下降。CMS默认启动的回收线程数是:(CPU数量+3) / 4
(2)CMS收集器无法处理浮动垃圾,由于CMS并发清理阶段用户线程还在运行,伴随程序的运行自热会有新的垃圾不断产生,这一部分垃圾出现在标记过程之后,称为“浮动垃圾”,CMS 无法在本次收集中处理它们,只好留待下一次GC时将其清理掉。
(3)垃圾收集阶段用户线程还需要运行,因此CMS收集器不能像其他收集器那样等到老年代几乎完全被填满了再进行收集,需要预留一部分内存空间提供并发收集时的程序运作使用。在默认设置下,CMS收集器在老年代使用了68%的空间时就会被激活,也可以通过参数-XX:CMSInitiatingOccupancyFraction的值来提高触发百分比,以降低内存回收次数提高性能。要是CMS运行期间预留的内存无法满足程序其他线程需要,就会出现“Concurrent Mode Failure”失败,这时候虚拟机将启动后备预案:临时启用Serial Old收集器来重新进行老年代的垃圾收集,这样停顿时间就很长了。所以参数 -XX:CMSInitiatingOccupancyFraction 设置的过高将会很容易导致 “Concurrent Mode Failure” 失败,性能反而降低。
(4)CMS是基于“标记-清除”算法实现的收集器,会产生大量不连续的内存碎片。空间碎片太多时,如果无法找到一块足够大的连续内存存放对象时,将不得不提前触发一次Full GC。为了解决这个问题,CMS收集器提供了一个-XX:UseCMSCompactAtFullCollection开关参数,用于在Full GC之后增加一个碎片整理过程,还可通过-XX:CMSFullGCBeforeCompaction参数设置执行多少次不压缩的Full GC之后,跟着来一次碎片整理过程。
G1(Garbage First)收集器是 JDK7 提供的一个新收集器,在 JDK9 中更被指定为官方GC收集器,与CMS收集器相比,最突出的改进是:
在介绍G1的垃圾收集流程之前,我们先简单了解下G1中的内存模型以及主要的数据结构,这些数据结果对我们了解G1的垃圾回收流程十分重要
G1 收集器不采用传统的新生代和老年代物理隔离的布局方式,仅在逻辑上划分新生代和老年代,将整个堆内存划分为2048个大小相等的独立内存块Region,每个Region是逻辑连续的一段内存,具体大小根据堆的实际大小而定,整体被控制在 1M - 32M 之间,且为2的N次幂(1M、2M、4M、8M、16M和32M),并使用不同的Region来表示新生代和老年代,G1不再要求相同类型的 Region 在物理内存上相邻,而是通过Region的动态分配方式实现逻辑上的连续。
G1收集器通过跟踪Region中的垃圾堆积情况,每次根据设置的垃圾回收时间,回收优先级最高的区域,避免整个新生代或整个老年代的垃圾回收,使得stop the world的时间更短、更可控,同时在有限的时间内可以获得最高的回收效率。
通过区域划分和优先级区域回收机制,确保G1收集器可以在有限时间获得最高的垃圾收集效率。
G1 垃圾收集器将堆内存划分为若干个 Region,每个 Region 分区只能是一种角色,Eden区、S区、老年代O区的其中一个,空白区域代表的是未分配的内存,最后还有个特殊的区域H区(Humongous),专门用于存放巨型对象,如果一个对象的大小超过Region容量的50%以上,G1 就认为这是个巨型对象。在其他垃圾收集器中,这些巨型对象默认会被分配在老年代,但如果它是一个短期存活的巨型对象,放入老年代就会对垃圾收集器造成负面影响,触发老年代频繁GC。为了解决这个问题,G1划分了一个H区专门存放巨型对象,如果一个H区装不下巨型对象,那么G1会寻找连续的H分区来存储,如果寻找不到连续的H区的话,就不得不启动 Full GC 了。
在串行和并行收集器中,GC时是通过整堆扫描来确定对象是否处于可达路径中。然而G1为了避免STW式的整堆扫描,为每个分区各自分配了一个 RSet(Remembered Set),它内部类似于一个反向指针,记录了其它 Region 对当前 Region 的引用情况,这样就带来一个极大的好处:回收某个Region时,不需要执行全堆扫描,只需扫描它的 RSet 就可以找到外部引用,来确定引用本分区内的对象是否存活,进而确定本分区内的对象存活情况,而这些引用就是 initial mark 的根之一。
事实上,并非所有的引用都需要记录在RSet中,如果引用源是本分区的对象,那么就不需要记录在 RSet 中;同时 G1 每次 GC 时,所有的新生代都会被扫描,因此引用源是年轻代的对象,也不需要在RSet中记录;所以最终只需要记录老年代到新生代之间的引用即可。
如果一个线程修改了Region内部的引用,就必须要去通知RSet,更改其中的记录。需要注意的是,如果引用的对象很多,赋值器需要对每个引用做处理,赋值器开销会很大,因此 G1 回收器引入了 Card Table 解决这个问题。
一个 Card Table 将一个 Region 在逻辑上划分为若干个固定大小(介于128到512字节之间)的连续区域,每个区域称之为卡片 Card,因此 Card 是堆内存中的最小可用粒度,分配的对象会占用物理上连续的若干个卡片,当查找对分区内对象的引用时便可通过卡片 Card 来查找(见RSet),每次对内存的回收,也都是对指定分区的卡片进行处理。每个 Card 都用一个 Byte 来记录是否修改过,Card Table 就是这些 Byte 的集合,是一个字节数组,由 Card 的数组下标来标识每个分区的空间地址。默认情况下,每个 Card 都未被引用,当一个地址空间被引用时,这个地址空间对应的数组索引的值被标记为”0″,即标记为脏被引用,此外 RSet 也将这个数组下标记录下来。
一个Region可能有多个线程在并发修改,因此也可能会并发修改 RSet。为避免冲突,G1垃圾回收器进一步把 RSet 划分成了多个 HashTable,每个线程都在各自的 HashTable 里修改。最终,从逻辑上来说,RSet 就是这些 HashTable 的集合。哈希表是实现 RSet 的一种常见方式,它的好处就是能够去除重复,这意味着,RS的大小将和修改的指针数量相当,而在不去重的情况下,RS的数量和写操作的数量相当。
HashTable 的 Key 是别的 Region 的起始地址,Value是一个集合,里面的元素是Card Table的Index。
前面三个数据结构的关系如下:
图中RS的虚线表明的是,RSet 并不是一个和 Card Table独立的、不同的数据结构,而是指RS是一个概念模型。实际上,Card Table 是 RS 的一种实现方式。
G1对内存的使用以分区(Region)为单位,而对对象的分配则以卡片(Card)为单位。
写屏障是指,每次 Reference 引用类型在执行写操作时,都会产生 Write Barrier 写屏障暂时中断操作并额外执行一些动作。
对写屏障来说,过滤掉不必要的写操作是十分有必要的,因为写栅栏的指令开销是十分昂贵的,这样既能加快赋值器的速度,也能减轻回收器的负担。G1 收集器的写屏障是跟 RSet 相辅相成的,产生写屏障时会检查要写入的引用指向的对象是否和该 Reference 类型数据在不同的 Region,如果不同,才通过 CardTable 把相关引用信息记录到引用指向对象的所在 Region 对应的 RSet 中,通过过滤就能使 RSet 大大减少。
(1)写前栅栏:即将执行一段赋值语句时,等式左侧对象将修改引用到另一个对象,那么等式左侧对象原先引用的对象所在分区将因此丧失一个引用,那么JVM就需要在赋值语句生效之前,记录丧失引用的对象。但JVM并不会立即维护RSet,而是通过批量处理,在将来RSet更新
(2)写后栅栏:当执行一段赋值语句后,等式右侧对象获取了左侧对象的引用,那么等式右侧对象所在分区的RSet也应该得到更新。同样为了降低开销,写后栅栏发生后,RSet也不会立即更新,同样只是记录此次更新日志,在将来批量处理
G1垃圾回收器进行垃圾回收时,在GC根节点枚举范围加入RSet,就可以保证不进行全局扫描,也不会有遗漏。另外JVM使用的其余的分代的垃圾回收器也都有写屏障,举例来说,每次将一个老年代对象的引用修改为指向年轻代对象,都会被写屏障捕获并记录下来,因此在年轻代回收的时候,就可以避免扫描整个老年代来查找根。
G1的垃圾回收器的写屏障使用一种两级的log buffer结构:
Collect Set(CSet)是指,在 Evacuation 阶段,由G1垃圾回收器选择的待回收的Region集合,在任意一次收集器中,CSet 所有分区都会被释放,内部存活的对象都会被转移到分配的空闲分区中。G1 的软实时性就是通过CSet的选择来实现的,对应于算法的两种模式 fully-young generational mode 和 partially-young mode,CSet的选择可以分成两种:
候选老年代分区的CSet准入条件,可以通过活跃度阈值 -XX:G1MixedGCLiveThresholdPercent(默认85%) 进行设置,从而拦截那些回收开销巨大的对象;同时,每次混合收集可以包含候选老年代分区,可根据CSet对堆的总大小占比 -XX:G1OldCSetRegionThresholdPercent(默认10%) 设置数量上限。
由上述可知,G1的收集都是根据CSet进行操作的,年轻代收集与混合收集没有明显的不同,最大的区别在于两种收集的触发条件。
G1提供了两种GC模式,Young GC 和 Mixed GC,两种都是Stop The World(STW)的,不过讲垃圾回收之前,我们先介绍下 G1 的对象分配策略。
每一个分配的 Region 都可以分成两个部分,已分配的和未被分配的,它们之间的界限被称为top。总体上来说,把一个对象分配到Region内,只需要简单增加top的值。过程如下:
(1)线程本地分配缓冲区 Thread Local allocation buffer (TLab):
如果对象在一个共享的空间中分配,那么我们就需要采用同步机制来解决并发冲突问题,而为了减少并发冲突损耗的同步时间,G1 为每个应用线程和GC线程分配了一个本地分配缓冲区TLAB,分配对象内存时,就在这个 buffer 内分配,线程之间不再需要进行任何的同步,提高GC效率。但是当线程耗尽了自己的Buffer之后,需要申请新的Buffer。这个时候依然会带来并发的问题。G1回收器采用的是CAS(Compate And Swap)操作。
显然的,采用TLAB的技术,就会带来碎片。举例来说,当一个线程在自己的Buffer里面分配的时候,虽然Buffer里面还有剩余的空间,但是却因为分配的对象过大以至于这些空闲空间无法容纳,此时线程只能去申请新的Buffer,而原来的Buffer中的空闲空间就被浪费了。Buffer的大小和线程数量都会影响这些碎片的多寡。
每次垃圾收集时,每个GC线程同样可以独占一个本地缓冲区(GCLAB)用来转移对象,将存活对象复制到Suvivor空间或老年代空间;
对于从Eden/Survivor空间晋升(Promotion)到Survivor/老年代空间的对象,同样有GC独占的本地缓冲区进行操作,该部分称为晋升本地缓冲区(PLAB)。
(2)Eden区中分配:
对TLAB空间中无法分配的对象,JVM会尝试在Eden空间中进行分配。如果Eden空间无法容纳该对象,就只能在老年代中进行分配空间。
(3)Humongous区分配:
巨型对象会独占一个、或多个连续分区,其中第一个分区被标记为开始巨型(StartsHumongous),相邻连续分区被标记为连续巨型(ContinuesHumongous)。由于无法享受 TLab 带来的优化,并且确定一片连续的内存空间需要扫描整堆,因此确定巨型对象开始位置的成本非常高,如果可以,应用程序应避免生成巨型对象。
G1内部做了一个优化,一旦发现没有引用指向巨型对象,则可直接在年轻代收集周期中被回收。
当Eden区已满,JVM分配对象到Eden区失败时,便会触发一次STW式的年轻代收集young GC,将 Eden 区存活的对象将被拷贝到 to survivor 区;from survivor 区存活的对象则根据存活次数阈值分别晋升到 PLAB、to survivor 区和老年代中;如果 survivor 空间不够,Eden区的部分数据会直接晋升到年老代空间。最终Eden空间的数据为空,GC停止工作,应用线程继续执行。
young GC 还负责维护对象的年龄(存活次数),辅助判断老化(tenuring)对象晋升时的去向。young GC 首先将晋升对象尺寸总和、年龄信息维护到年龄表中,再根据年龄表、Survivor尺寸、Survivor填充容量 -XX:TargetSurvivorRatio(默认50%)、最大任期阈值 -XX:MaxTenuringThreshold(默认15),计算出一个恰当的任期阈值,凡是超过任期阈值的对象都会被晋升到老年代。
这时,我们需要考虑一个问题,如果仅仅 GC 新生代对象,我们如何找到所有的根对象呢? 老年代的所有对象都是根么?那这样扫描下来会耗费大量的时间。于是就需要使用到我们上文介绍到的 RSet 了,RSet 中记录了其他 region 对当前 region 的引用,因此,在进行Young GC 时,扫描根时,仅仅需要扫描这一块区域,而不需要扫描整个老年代。
(1)第一阶段,根扫描:
根是指static变量指向的对象、正在执行的方法调用链上的局部变量等。根引用连同 RSet 记录的外部引用作为扫描存活对象的入口。
(2)第二阶段,更新RSet:
处理 dirty card 队列中的 card,更新 RSet,此阶段完成后,RSet 可以准确的反映老年代对所在的region 分区中对象的引用
(3)第三阶段:处理RSet:
识别被老年代对象指向的 Eden 中的对象,这些被指向的Eden中的对象被认为是存活的对象
(4)第四阶段:对象拷贝:
将 Eden 区存活的对象将被拷贝到 to survivor 区;from survivor 区存活的对象则根据存活次数阈值分别晋升到 PLAB、to survivor 区和老年代中;如果 survivor 空间不够,Eden区的部分数据会直接晋升到年老代空间。
(5)第五阶段:处理引用:
处理软引用、弱引用、虚引用,最终Eden空间的数据为空,GC停止工作,而目标内存中的对象都是连续存储的、没有碎片,所以复制过程可以达到内存整理的效果,减少碎片。
年轻代不断进行垃圾回收活动后,为了避免老年代的空间被耗尽。当老年代占用空间超过整堆比 IHOP 阈值 -XX:InitiatingHeapOccupancyPercent(默认45%)时,G1就会启动一次混合垃圾回收Mixed GC,Mixed GC不仅进行正常的新生代垃圾收集,同时也回收部分后台扫描线程标记的老年代分区。Mixed GC步骤主要分为两步:
(1)全局并发标记(global concurrent marking)
(2)拷贝存活对象(evacuation)
这里需要特别注意的是 Mixed GC 并不是 Full GC,只有当 Mixed GC 来不及回收old region,也就说在需要分配老年代的对象时,但发现没有足够的空间,这个时候就会触发一次 Full GC
在进行混合回收前,会先进行 global concurrent marking,在 G1 GC 中,它并不是一次GC过程的必须环节,主要是为 Mixed GC 提供标记服务的。global concurrent marking的执行过程分为五个步骤:
(1)初始标记(initial mark,STW):
会标记出所有 GC Roots 节点以及直接可达的对象,这一阶段需stop the world,但是耗时很短。
初始标记过程与 young GC 息息相关。事实上,当达到 IHOP 阈值时,G1并不会立即发起并发标记周期,而是等待下一次年轻代收集,利用年轻代收集的STW时间段,完成初始标记,这种方式称为借道。
(2)根区域扫描(root region scan):
扫描初始标记的存活区中(即 survivor 区)可直达的老年代区域对象,并标记根对象。该阶段与应用程序并发运行,并且只有完成该阶段后,才能开始下一次 STW 的 young GC。
因为 RSet 是不记录从 young region 出发的引用,那么就可能出现一种情况,一个老年代的存活对象,只被年轻代的对象引用。在一次young GC中,这些存活的年轻代的对象会被复制到 Survivor Region,因此需要扫描这些 Survivor region 来查找这些指向老年代的对象的引用,作为并发标记阶段扫描老年代的根的一部分。
(3)并发标记(Concurrent Marking):
从 GC Roots 对堆中的对象进行可达性分析,找出存活的对象,此过程可能被 young GC 中断,并发标记阶段产生的新的引用(或引用的更新)会被 SATB 的 write barrier 记录下来,同时,并发标记线程还会定期检查和处理STAB全局缓冲区列表的记录,更新对象引用信息。在此阶段中,如果发现区域中的所有对象都是垃圾,那这个区域会立即被回收。同时,并发标记过程中,会计算每个区域中对象的存活比例。
在并发标记阶段,我们就不得不了解一下三色标记算法,该算法我们放在下文介绍
(4)重新标记(Remark,STW):
重新标记阶段是为了修正在并发标记期间,因应用程序继续运作而导致标记产生变动的那一部分标记记录,就是去处理剩下的 SATB日志缓冲区和所有更新,找出所有未被访问的存活对象。
CMS收集器中,重新标记使用的增量更新,而 G1 使用的是比 CMS 更快的初始快照算法 SATB 算法:snapshot-at-the-beginning。
SATB 在标记开始时会创建一个存活对象的快照图,从而确保并发标记阶段所有的垃圾对象都能通过快照被鉴别出来。当赋值语句发生时,应用将会改变了它的对象图,那么JVM需要记录被覆盖的对象,因此写前栅栏会在引用变更前,将值记录在SATB日志或缓冲区中(每个线程都会独占一个SATB缓冲区,初始有256条记录空间)。当空间用尽时,线程会分配新的SATB缓冲区继续使用,而原有的缓冲去则加入全局列表中。最终在并发标记阶段,并发标记线程在标记的同时,还会定期检查和处理全局缓冲区列表的记录,然后根据标记位图分片的标记位,扫描引用字段来更新RSet,修正 SATB 的误差。
SATB的 log buffer 如 RSet 的写屏障使用的 log buffer 一样,都是两级结构,作用机制也是一样的。
(5)清除(Cleanup,STW):
该阶段主要是排序各个 Region 的回收价值和成本,并根据用户所期望的GC停顿时间来制定回收计划。(这个阶段并不会实际去做垃圾的回收,也不会执行存活对象的拷贝)
清除阶段执行的详细操作有一下几点:
① RSet梳理:启发式算法会根据活跃度和RSet尺寸对分区定义不同等级,同时RSet数理也有助于发现无用的引用。
② 整理堆分区:为混合收集识别回收收益高(基于释放空间和暂停目标)的老年代分区集合;
③ 识别所有空闲分区:即发现无存活对象的分区,该分区可在清除阶段直接回收,无需等待下次收集周期。
如果不考虑维护Remembered Set的操作,可以分为上图4个步骤(与CMS较为相似),其中初始标记、并发标记、重新标记跟CMS收集器相同,只有第四阶段的筛选回收有些区别。
当G1发起全局并发标记之后,并不会马上开始混合收集,G1会先等待下一次年轻代收集,然后在该 young gc 收集阶段中,确定下次混合收集的CSet
全局标记完成后,G1 就知道哪些 old region 的可回收垃圾最多了,只需等待合适的时机就可以开始混合回收了,而混合回收除了回收这个young region,还会回收部分 old region(不需要回收全部 old region)。根据停顿目标,G1 可能没法一次回收掉所有的old region 候选分区,只能选择优先级高的若干个 region 进行回收,所以G1可能会产生连续多次的混合收集与应用线程交替执行,而这些被选中的 region 就是 CSet 了,而单次的混合回收的算法与上文的 Young GC 算法完全一样,只不过回收集CSet 中多了老年代的内存分段;而第二个步骤就是将这些 region 中存活的对象复制到空间 region 中去,同时把这些已经被回收的 region 放到空闲 region 列表中。
G1会计算每次加入到CSet中的分区数量、混合收集进行次数,并且在上次的年轻代收集、以及接下来的混合收集中,G1会确定下次加入CSet的分区集(Choose CSet),并且确定是否结束混合收集周期。
(1)并发标记结束以后,老年代中100%为垃圾的 region 就直接被回收了,仅部分为垃圾的region会被分成8次回收(可以通过 -XX:G1MixedGCCountTarget 设置,默认阈值8),所以 Mixed GC 的回收集(CSet)包括八分之一的老年代内存分段、Eden 区内存分段、Survivor 区内存分段。
(2)由于老年代的内存分段默认分8次回收,G1会优先回收垃圾多的内存分段。垃圾占内存分段比例越高的,越会被先回收。并且由一个阈值决定内存分段是否被回收 -XX:G1MixedGCLiveThresholdPercent,默认为 65%,意思是垃圾占内存分段比例要达到 65% 才会被回收。如果垃圾占比太低,意味着存活的对象占比高,在复制的时候会花费更多的时间。
(3)混合回收并不一定要进行8次,有一个阈值 -XX:G1HeapWastePercent,默认值 10%,意思是允许整个堆内存有 10% 的空间浪费,意味着如果发现可以回收的垃圾占堆内存的比例低于10%,则不再进行混合回收,因为 GC 会花费很多的时间,但是回收到的内存却很少
G1 垃圾回收流程小结:Young CG 和 Mixed GC,是G1回收空间的主要活动。当应用开始运行时,堆内存可用空间还比较大,只会在年轻代满时,触发年轻代收集;随着老年代内存增长,当到达 IHOP 阈值 -XX:InitiatingHeapOccupancyPercent(老年代占整堆比,默认45%) 时,G1开始着手准备收集老年代空间。首先经历并发标记周期,识别出高收益的老年代分区。但随后G1并不会马上开启一次混合收集,而是让应用线程先运行一段时间,等待触发一次年轻代收集,在这次STW中,G1将开始整理混合收集周期。接着再次让应用线程运行,当接下来的几次年轻代收集时,将会有老年代分区加入到CSet中,即触发混合收集,这些连续多次的混合收集称为混合收集。
当 G1 无法在堆空间中申请新的分区时,G1便会触发担保机制,执行一次STW式的、单线程的 Full GC,Full GC会对整堆做标记清除和压缩,最后将只包含纯粹的存活对象。参数-XX:G1ReservePercent(默认10%)可以保留空间,来应对晋升模式下的异常情况,最大占用整堆50%,更大也无意义。
G1在以下场景中会触发 Full GC,同时会在日志中记录to-space-exhausted以及Evacuation Failure:
由于G1的应用场合往往堆内存都比较大,所以Full GC的收集代价非常昂贵,应该避免Full GC的发生。
三色标记算法是并发收集阶段的重要算法,它是描述追踪式回收器的一种有用的方法,利用它可以推演回收器的正确性。 首先,我们将对象分成三种类型的。
下面我们就以一组演变图,加深下对三色标记算法的理解,当GC开始扫描对象时,按照如下图步骤进行对象的扫描:
(1)根对象被置为黑色,子对象被置为灰色:
(2)继续由灰色遍历,将已扫描了子对象的对象置为黑色。
(3)遍历了所有可达的对象后,所有可达的对象都变成了黑色。不可达的对象即为白色,需要被清理。
如果在标记过程中,应用程序也在运行,那么对象的指针就有可能改变。这样的话,我们就会遇到一个问题:对象丢失问题。我们看下面一种情况,当垃圾收集器扫描到下面情况时:
这时候应用程序执行了以下操作:
A.c=C
B.c=null
这样,对象的状态图变成如下情形:
这时候垃圾收集器再标记扫描的时候就会下图成这样:
很显然,此时C是白色,被认为是垃圾需要清理掉,显然这是不合理的。那么我们如何保证应用程序在运行的时候,GC标记的对象不丢失呢?有如下两种可行的方式:
刚好这对应 CMS 和 G1 的两种不同实现方式:
(1)CMS采用的是增量更新(Incremental update):只要在写屏障里发现要有一个白对象的引用被赋值到一个黑对象的字段里,那就把这个白对象变成灰色的,即插入的时候记录下来。
(2)G1 使用的是 STAB(snapshot-at-the-beginning)的方式:删除的时候记录所有的对象,它有三个步骤:
(1)如果停顿时间过短的话,可能导致每次选出的回收集只占堆内存很小一部分,收集器收集的速度逐渐跟不上分配器的分配速度,进而导致垃圾慢慢堆积,最终造成堆空间占满,引发Full GC 反而降低性能。
(2)G1 无论是在垃圾收集产生的内存占用还是程序运行时的额外执行负载都要比CMS要高
(3)CMS在小内存应用上大概率由于 G1。所以小内存的情况下使用CMS收集器,大内存的情况下可以使用G1收集器(G1收集器6GB以上)
参考文章:
https://www.cnblogs.com/lsgxeva/p/10231201.html
https://blog.csdn.net/weixin_43899069/article/details/117996701
https://blog.csdn.net/coderlius/article/details/79272773
版权说明 : 本文为转载文章, 版权归原作者所有 版权申明
原文链接 : https://blog.csdn.net/a745233700/article/details/121724998
内容来源于网络,如有侵权,请联系作者删除!