JVM 垃圾回收 - litter-fish/ReadSource GitHub Wiki

垃圾回收 ded362a2139884d899881995951d9cd9.jpeg

对象的创建

  • 虚拟机遇到new关键字,首先检查常量池中是否存在该类的符号引用,并且检查这个符号引用代表的类是否已经被加载、解析和初始化过。如果没有被加载则进行类的加载过程
  • 虚拟机为新生对象分配内存

指针碰撞和空闲列表

  • 虚拟机对对象进行一些必要设置,如对象属于哪个类的实例

  • 执行方法,进行真正的初始化操作

对象的内存布局 98cfaab3ebcaae37f7ad25cc4d7b16bb.jpeg

对象的定位

  • 通过句柄访问 b3773b8cd9792e587a72d5bb14c16ee3.jpeg

  • 通过直接指针访问 b384bde84577ebeb260b838c40c9d1a4.jpeg

判断一个对象是否可以回收的方法

  • 引用计数器算法 基本思路:给对象添加一个引用计数器,每当有一个地方引用它时,计数器值就加1;当引用失效时,计数器值就减1 存在问题:如果对象直接存在相互引用则对象不会被回收

  • 可达性分析算法 基本思路:通过一系列称为“GC Roots”的对象作为起始点,从这些节点开始向下搜索,搜索所经过的路径称为引用链,当一个对象到这些GC Roots没有任何引用链相连时,说明对象不可达,即可判断为可回收对象

可作为GC Roots 的对象

  • 虚拟机栈(帧栈中的本地变量表)中引用的对象
  • 方法区中类静态属性引用的对象
  • 方法区中常量引用的对象
  • 本地方法栈中JNI引用的对象

引用类型 df9e5a8850c9d386721866e9859a83b4.jpeg

一个对象被真正回收的步骤

  • 如果一个对象在进行可达性分析后发现没有与GC Roots相连接的引用链,那么它将会被第一次标记, 同时依据对象是否有必要执行finalize()方法进行帅选, 但对象没有覆盖finalize()方法或finalize已经被虚拟机执行过,则判断为没必要执行

  • 如果上面判断有必要执行finalize方法,则会将这个对象放到F-Queue的队列中,并在稍后由虚拟机自动建立的、低优先级的Finalizer线程执行它。GC 会将对F-Queue中对象进行二次标记。

垃圾收集算法

  • 标记-清除算法 原理:首先经过可达性分析找到所有需要回收的对象,标记结束后直接将可回收的对象清理即可 不足:效率不高、产生大量的不连续的内存碎片 83039ba1092250fdbc2b1aecc316ba4e.jpeg

  • 复制算法 原理:将内存划分为两部分,每次只使用一个部分,当内存用完之后,会将还存活的对象复制到另一边,并清理掉待回收的对象 不足:内存只有一半的使用率 e1db7e4ddcaaebe71cd2b2ec4daae59b.jpeg

  • 标记-整理算法 原理:首先经过可达性分析找到所有需要回收的对象,标记结束后然存活的对象都向一端移动,然后直接清理掉端边界以外的内存 c468fefff97bd1a332615c3299eb0e33.jpeg

  • 分代收集算法 将堆分为年轻代和年老代

垃圾收集器 连线代表可以组合使用的垃圾收集器 c20eda1c187bb2bbf603ec1872442fb8.png

年轻代

Serial 收集器 采用复制算法、串行回收和“stop-the-world”机制的方式执行内存回收 419889d280fd17699c26d4ca9ea7bc50.png

ParNew收集器 Serial多线程版本,同样采用复制和“stop-the-world”机制的方式执行内存回收 ab84ec094f5ec673415df3a24ed3857f.png

Parallel Scavenge 收集器 采用复制算法、并行的多线程收集器 该收集器目标是达到一个可控制的吞吐量,吞吐量即CPU用于运行运行用户代码的时间与CPU总消耗时间的比例,即吞吐量=运行用户代码时间 / (运行用户代码时间 + 垃圾收集时间)。 适合于后台运算而不需要太多交互的任务

参数: XX:MaxGCPauseMillis:最大垃圾收集器停顿时间 XX:GCTimeRatio:直接设置吞吐量 XX:UseAdaptiveSizePolicy:设置GC自适应调节策略,当这个开关打开时,不需要手工指定新生代的大小(-Xmn)、Eden与Survivor区的比例(-XX:SurvivorRation)、晋升老年代的对象年龄(-XX:PretenureSizeThreshold)等

年老代

Serial Old 收集器 Serial 年老代的收集器,采用标记-整理算法 e42efc7aadf2da7b43e603cd509e34a3.png

Parallel Old 收集器 Parallel Scavenge 年老代的版本,多线程、采用标记-整理算法

CMS收集器 目标:获取最短回收停顿时间 基于标记-清除算法实现。并发收集、低停顿。 运行过程:

  • 初始标记 (Initial Mark) 标记GC Roots能直接到的对象。速度很快但是仍存在Stop The World问题。

  • 并发标记 (Concurrent Marking Phase) 使用多条线程并发进行GC Roots Tracing 的过程,找出存活对象且用户线程可并发执行。

  • 重新标记 ( Remark) 修正并发标记期间因用户程序继续运行而导致标记产生变动的那一部分对象的标记记录。仍然存在 Stop The World问题。

  • 并发清除 (Concurrent Sweeping) 对标记的对象进行清除回收

过程图: 545ac52ba5f597ac6103f0f6870ee952.png

存在缺点:

  • 无法处理浮动垃圾,可能出现Concurrent Model Failure失败而导致另一次Full GC的产生
  • 采用标记-清除算法所以会存在空间碎片的问题,导致大对象无法分配空间,不得不提前触发一次Full GC。
  • 对CPU资源非常敏感

对于碎片化提供参数进行配置 -XX:+UseCMSCompactAtFullCollection 强制JVM在FGC完成后対老年代进行圧縮,执行一次空间碎片整理,但是空间碎片整理阶段也会引发STW。 -XX:+CMSFullGCsBeforeCompaction=n 在执行了n次FGC后, JVM再在老年代执行空间碎片整理

G1收集器(Garbage-First)

使用参数:-XX:+UseG1GC启用

G1的内存模型 将Java堆划分为一块块独立的大小相等的Region,每个Region大小是1M到32M的2次方值 当要进行垃圾收集时,首先估计每个Region中的垃圾数量,每次都从垃圾回收价值最大的Region开始回收,因此可以获得最大的回收效率

563d2fe19ecd433a5f6dfca09a04cbc9.png

G1将Java堆空间分割成了若干相同大小的区域,即region Eden、Survivor、Old、Humongous(是特殊的Old类型,专门放置大型对象)

CSet:(Collection set) 一次垃圾回收的区间集合,在一次GC过程中,CSet中存活的数据都会被移动到另一个可用分区。 CSet中的分区可以来自Eden空间、survivor空间或年老代。CSet会占用整个堆空间大小的1%。

RSet:(Remembered Set) 记录了其他Region中的对象引用本Region中对象的关系。 使得GC不需要扫描整个堆即可找到谁引用了当前分区中的的对象。 举个例子:Region1和Region3的中的对象引用了Region2中的对象,因此在Region2中的RSet中记录了这两个引用 07a54c3f3926cc6f906b39ebd7cc0210.jpeg

SATB:(SnapShot-At-The-Beginning) SATB是维持并发GC的正确性的一个手段,G1GC的并发理论基础就是SATB,SATB是由Taiichi Yuasa为增量式标记清除垃圾收集器设计的一个标记算法。

SATB算法的过程

在并发周期开始之前,NTAMS字段被设置到每个分区当前的顶部,并发周期启动后分配的对象会被放在TAMS之前(图里下边的部分),同时被明确定义为隐式存活对象,而TAMS之后(图里上边的部分)的对象则需要被明确地标记。

初始标记过程中的一个堆分区 21b7f7f06548548efa3094fd1f174a87.jpeg

并发标记过程中的堆分区 27dfe6e0ada4e4e3b018209eb4f7ee61.jpeg

位于堆分区的Bottom和PTAMS之间的对象都会被标记并记录在previous位图中; 0d5d9a40e8c9dbb066067cd5f7be9f38.jpeg

位于堆分区的Top和PATMS之间的对象均为隐式存活对象,同时也记录在previous位图中; c6a2a10582b3b510154f0cf9e2e270ac.jpeg

在重新标记阶段的最后,所有NTAMS之前的对象都会被标记 e2ff5d8b29bc882a92fd4c2c427f2d8e.jpeg

在并发标记阶段分配的对象会被分配到NTAMS之后的空间,它们会作为隐式存活对象被记录在next位图中。一次并发标记周期完成后,这个next位图会覆盖previous位图,然后将next位图清空。 df1f66f52696d5d59db72df8f3cbc322.jpeg

运行过程:

  • 初始标记 (Initial Mark) 标记GC Roots能直接到的对象。速度很快但是仍存在Stop The World问题。

  • 并发标记 (Concurrent Marking Phase) 使用多条线程并发进行GC Roots Tracing 的过程,找出存活对象且用户线程可并发执行。

  • 最终标记 标记出并发标记过程中用户线程新产生的垃圾.停止所有用户线程,并使用多条最终标记线程并行执行。

  • 筛选回收 回收废弃的对象.此时也需要停止一切用户线程,并使用多条筛选回收线程并行执行。

过程图: e2dfa3a801e3c5ee5d824a3bb3048568.png

G1采用Remembered Set来避免整堆扫描,G1中每个Region都有一个与之对应的Remembered Set,虚拟机发现程序在对Reference类型进行写操作时,会产生一个Write Barrier暂时中断写操作,检查Reference引用对象是否处于多个Region中(即检查老年代中是否引用了新生代中的对象),如果是,便通过CardTable把相关引用信息记录到被引用对象所属的Region的Remembered Set中。当进行内存回收时,在GC根节点的枚举范围中加入Remembered Set即可保证不对全堆进行扫描也不会有遗漏。

G1收集器的新生代垃圾收集活动 86c677a15b8d7b5357c31fb962df519b.jpeg

  • Eden区内存耗尽时会触发新生代收集,新生代收集会触发对整个新生代进行收集
  • 新生代垃圾收集期间,整个应用会STW
  • 新生代垃圾收集是由多线程并发执行的
  • 新生代收集结束后依然存活的对象,会被拷贝到一个新的Survivor分区,或者老年代

G1设计了一个参数,用于控制是否触发一次并发收集周期,即-XX:InitiatingHeapOccupancyPercent(IHOP),表示总体堆大小的比例,默认45%

并发收集周期的图例如下: 563c85af5ce823390eb8320499a12a95.jpeg

1、Young区发生了变化、这意味着在G1并发阶段内至少发生了一次YGC(这点和CMS就有区别),Eden在标记之前已经被完全清空,因为在并发阶段应用线程同时在工作、所以可以看到Eden又有新的占用 2、一些区域被X标记,这些区域属于O区,此时仍然有数据存放、不同之处在G1已标记出这些区域包含的垃圾最多、也就是回收收益最高的区域 3、在并发阶段完成之后实际上O区的容量变得更大了(O+X的方块)。这时因为这个过程中发生了YGC有新的对象进入所致。此外,这个阶段在O区没有回收任何对象:它的作用主要是标记出垃圾最多的区块出来。对象实际上是在后面的阶段真正开始被回收

G1的并发标记周期包括阶段 并发标记周期采用的算法是SATB标记算法,产出是找出一些垃圾对象最多的老年代分区。

  • 初始标记(initial-mark),在这个阶段,应用会STW,通常初始标记阶段会跟一次新生代收集一起进行,换句话说——既然这两个阶段都需要暂停应用,G1 GC就重用了新生代收集来完成初始标记的工作。在新生代垃圾收集中进行初始标记的工作,会让停顿时间稍微长一点,并且会增加CPU的开销。初始标记做的工作是设置两个TAMS变量(NTAMS和PTAMS)的值,所有在TAMS之上的对象在这个并发周期内会被识别为隐式存活对象; 日志如下所示:
50.541: [GC pause (young) (initial-mark), 0.27767100 secs][Eden: 1220M(1220M)->0B(1220M)Survivors: 144M->144M Heap: 3242M(4096M)->2093M(4096M)][Times: user=1.02 sys=0.04, real=0.28 secs]
  • 根分区扫描(root-region-scan),这个过程不需要暂停应用,在初始标记或新生代收集中被拷贝到survivor分区的对象,都需要被看做是根,这个阶段G1开始扫描survivor分区,所有被survivor分区所引用的对象都会被扫描到并将被标记。survivor分区就是根分区,正因为这个,该阶段不能发生新生代收集,如果扫描根分区时,新生代的空间恰好用尽,新生代垃圾收集必须等待根分区扫描结束才能完成。如果在日志中发现根分区扫描和新生代收集的日志交替出现,就说明当前应用需要调优

G1开始扫描根区域、日志示例

50.819: [GC concurrent-root-region-scan-start]
51.408: [GC concurrent-root-region-scan-end, 0.5890230]

如果Young区空间恰好在Root扫描的时候满了、YGC必须等待root扫描之后才能进行。带来的影响是YGC暂停时间会相应的增加。 这时的GC日志如下:

350.994: [GC pause (young)
351.093: [GC concurrent-root-region-scan-end, 0.6100090]
351.093: [GC concurrent-mark-start],0.37559600 secs]
  • 并发标记阶段(concurrent-mark),并发标记阶段是多线程的,我们可以通过-XX:ConcGCThreads来设置并发线程数; 并发标记会利用trace算法找到所有活着的对象,并记录在一个bitmap中,因为在TAMS之上的对象都被视为隐式存活,因此我们只需要遍历那些在TAMS之下的;记录在标记的时候发生的引用改变,SATB的思路是在开始的时候设置一个快照,然后假定这个快照不改变,根据这个快照去进行trace,这时候如果某个对象的引用发生变化,就需要通过pre-write barrier logs将该对象的旧的值记录在一个SATB缓冲区中,如果这个缓冲区满了,就把它加到一个全局的列表中——G1会有并发标记的线程定期去处理这个全局列表。

GC日志里面下面的信息代表这个阶段的开始和结束:

111.382: [GC concurrent-mark-start]
....
120.905: [GC concurrent-mark-end, 9.5225160 sec]
  • 重新标记阶段(remarking),重新标记阶段是最后一个标记阶段,需要暂停整个应用,G1垃圾收集器会处理掉剩下的SATB日志缓冲区和所有更新的引用,同时G1垃圾收集器还会找出所有未被标记的存活对象。这个阶段还会负责引用处理等工作。

GC日志如下:

120.910: [GC remark 120.959:
[GC ref-PRC, 0.0000890 secs], 0.0718990 secs]
[Times: user=0.23 sys=0.01, real=0.08 secs]
120.985: [GC cleanup 3510M->3434M(4096M), 0.0111040 secs]
[Times: user=0.04 sys=0.00, real=0.01 secs]
  • 清理阶段(cleanup),清理阶段真正回收的内存很小,截止到这个阶段,G1垃圾收集器主要是标记处哪些老年代分区可以回收,将老年代按照它们的存活度(liveness)从小到大排列。

GC日志如下:

120.996: [GC concurrent-cleanup-start]
120.996: [GC concurrent-cleanup-end, 0.0004520]

这个过程还会做几个事情:识别出所有空闲的分区、RSet梳理、将不用的类从metaspace中卸载、回收巨型对象等等。识别出每个分区里存活的对象有个好处是在遇到一个完全空闲的分区时,它的RSet可以立即被清理,同时这个分区可以立刻被回收并释放到空闲队列中,而不需要再放入CSet等待混合收集阶段回收;梳理RSet有助于发现无用的引用。

混合收集 同时进行YGC和清理上面已标记为X的区域 混合收集只会回收一部分老年代分区,下图是第一次混合收集前后的堆情况对比。 458dd6da57e948cc23c3d2d6e8253116.jpeg

混合收集会执行多次,一直运行到(几乎)所有标记点老年代分区都被回收,在这之后就会恢复到常规的新生代垃圾收集周期。当整个堆的使用率超过指定的百分比时,G1 GC会启动新一轮的并发标记周期。在混合收集周期中,对于要回收的分区,会将该分区中存活的数据拷贝到另一个分区,这也是为什么G1收集器最终出现碎片化的频率比CMS收集器小得多的原因——以这种方式回收对象,实际上伴随着针对当前分区的压缩。

混合GC的日志:

79.826: [GC pause (mixed), 0.26161600 secs]
....
[Eden: 1222M(1222M)->0B(1220M)Survivors: 142M->144M Heap: 3200M(4096M)->1964M(4096M)][Times: user=1.01 sys=0.00, real=0.26 secs]

G1收集器的模式主要有两种

  • Young GC(新生代垃圾收集)
  • Mixed GC(混合垃圾收集)

G1垃圾收集运行过程,如下图所示 9febd837c35411422b5752ba49c24bfb.jpeg

巨型对象的管理 如果一个对象的大小超过分区大小的一半,该对象就被定义为巨型对象(Humongous Object)。巨型对象时直接分配到老年代分区,如果一个对象的大小超过一个分区的大小,那么会直接在老年代分配两个连续的分区来存放该巨型对象。巨型分区一定是连续的,分配之后也不会被移动。

G1的堆中的分区就分成了三种类型:新生代分区、老年代分区和巨型分区,如下图所示: 2978f58ecdb0830a74303430fe87e497.jpeg

如果一个巨型对象跨越两个分区,开始的那个分区被称为“开始巨型”,后面的分区被称为“连续巨型”,这样最后一个分区的一部分空间是被浪费掉的,如果有很多巨型对象都刚好比分区大小多一点,就会造成很多空间的浪费,从而导致堆的碎片化。如果你发现有很多由于巨型对象分配引起的连续的并发周期,并且堆已经碎片化(明明空间够,但是触发了FULL GC),可以考虑调整-XX:G1HeapRegionSize参数,减少或消除巨型对象的分配。 关于巨型对象的回收:在JDK8u40之前,巨型对象的回收只能在并发收集周期的清除阶段或FULL GC过程中过程中被回收,在JDK8u40(包括这个版本)之后,一旦没有任何其他对象引用巨型对象,那么巨型对象也可以在年轻代收集中被回收。

G1执行过程中的异常情况

  • 并发标记周期开始后的FULL GC: G1启动了标记周期,但是在并发标记完成之前,就发生了Full GC,日志常常如下所示:
51.408: [GC concurrent-mark-start]65.473: [Full GC 4095M->1395M(4096M), 6.1963770 secs] [Times: user=7.87 sys=0.00, real=6.20 secs]71.669: [GC concurrent-mark-abort]

GC concurrent-mark-start开始之后就发生了FULL GC,这说明针对老年代分区的回收速度比较慢,或者说对象过快得从新生代晋升到老年代,或者说是有很多大对象直接在老年代分配。针对上述原因,我们可能需要做的调整有:调大整个堆的大小、更快得触发并发回收周期、让更多的回收线程参与到垃圾收集的动作中

  • 混合收集模式中的FULL GC 在一次混合收集之后跟着一条FULL GC,这意味着混合收集的速度太慢,在老年代释放出足够多的分区之前,应用程序就来请求比当前剩余可分配空间大的内存。针对这种情况我们可以做的调整:增加每次混合收集收集掉的老年代分区个数;增加并发标记的线程数;提高混合收集发生的频率

  • 疏散失败(转移失败) 在新生代垃圾收集快结束时,找不到可用的分区接收存活下来的对象,常见如下的日志

60.238: [GC pause (young) (to-space overflow), 0.41546900 secs]

这意味着整个堆的碎片化已经非常严重了,我们可以从以下几个方面调整: (1)增加整个堆的大小——通过增加-XX:G1ReservePercent选项的值(并相应增加总的堆大小),为“目标空间”增加预留内存量; (2)通过减少 -XX:InitiatingHeapOccupancyPercent提前启动标记周期; (3)通过增加-XX:ConcGCThreads选项的值来增加并发标记线程的数目;

  • 巨型对象分配失败 如果在GC日志中看到莫名其妙的FULL GC日志,又对应不到上述讲过的几种情况,那么就可以怀疑是巨型对象分配导致的,这里我们可以考虑使用jmap命令进行堆dump,然后通过MAT对堆转储文件进行分析。

G1的调优

G1的调优目标主要是在避免FULL GC和疏散失败的前提下,尽量实现较短的停顿时间和较高的吞吐量。关于G1 GC的调优,需要记住以下几点:

  1. 不要自己显式设置新生代的大小(用Xmn或-XX:NewRatio参数),如果显式设置新生代的大小,会导致目标时间这个参数失效。
  2. 由于G1收集器自身已经有一套预测和调整机制了,因此我们首先的选择是相信它,即调整-XX:MaxGCPauseMillis=N参数,这也符合G1的目的——让GC调优尽量简单,这里有个取舍:如果减小这个参数的值,就意味着会调小新生代的大小,也会导致新生代GC发生得更频繁,同时,还会导致混合收集周期中回收的老年代分区减少,从而增加FULL GC的风险。这个时间设置得越短,应用的吞吐量也会受到影响。

G1 GC 日志格式

Minor GC 日志 3ec1a1e7f1641aec5a650b51a7395d5b.jpeg

  1.  2015-09-14T12:32:24.398-0700: 0.356 — 在这里 2015-09-14T12:32:24.398-0700: 0.356 表示 GC 发生的时间,其中 0.356 表示 Java 进程启动 356 毫秒之后发生了 GC
  2.  GC pause (G1 Evacuation Pause) — 疏散停顿(Evacuation Pause)是将活着的对象从一个区域(young or young + old)拷贝到另一个区域的阶段。
  3.  (young) — 表示这是一个 Young GC 事件。
  4.  GC Workers: 8 – 表示 GC 的工作线程是 8 个。
  5.  [Eden: 12.0M(12.0M)->0.0B(14.0M) Survivors: 0.0B->2048.0K Heap:12.6M(252.0M)->7848.3K(252.0M)] — 这里显示了堆的大小变化:
  • Eden: 12.0M(12.0M)->0.0B(14.0M) — 表示伊甸园(Eden)空间是 12mb,并且 12mb 空间全部被占用。在 GC 发生之后,年轻代(young generation)空间下降到0,伊甸园的空间增长到 14mb,但是没有提交。因为要求,额外的空间被添加给伊甸园。
  • Survivors: 0.0B->2048.0K - 表示在 GC 发生之前,幸存者空间(Survivor space)是 0 个字节,但是在 GC 发生之后,幸存者空间增长到 2048 kb,它表明对象从年轻代(Young Generation)提升到幸存者空间(Survivor space)。
  • Heap: 12.6M(252.0M)->7848.3K(252.0M) – 表示堆的大小是 252mb,被占用 12.6mb,GC 发生之后,堆占用率将至 7848.3kb(即5mb (12.6mb – 7848.3kb)的对象被垃圾回收了),堆的大小仍然是 252mb。
  1. Times: user=0.08, sys=0.00, real=0.02 secs – 注意这里的 real 时间,它表示 GC 总共花了 0.02 秒

Full GC 日志 5daffb7d03ac8b3a72680c2586bb5310.jpeg

  1. 2015-09-14T12:35:27.263-0700: 183.216 – 在这里 2015-09-14T12:35:27.263-0700 表示 GC 发生的时间,其中 183.216 表示 Java 进程启动 183 秒后发生了 GC.
  2. Full GC (Allocation Failure) – 表示这是一个 Full GC 事件,触发的原因是因为空间分配失败(allocation failure),当堆中有很多碎片时,在老年代进行直接内存分配也许会失败,即使有许多空闲空间,这通常会导致分配失败。
  3. [Eden: 3072.0K(194.0M)->0.0B(201.0M) Survivors: 0.0B->0.0B Heap: 3727.1M(4022.0M)->3612.0M(4022.0M)], [Metaspace: 2776K->2776K(1056768K)] – 这里显示了堆的大小变化,由于这是 Full GC 事件:
  • Eden: 3072.0K(194.0M)->0.0B(201.0M) - 表示伊甸园空间(Eden space)是194mb,被占用3072kb。在 GC 发生之后,年轻代(young generation)下降到0。伊甸园空间增长到201mb,但是没有提交。因为要求,额外的空间被添加给伊甸园。
  • Survivors: 0.0B->0.0B – 表示 GC 发生前后,幸存者空间是 0kb
  • Heap: 3727.1M(4022.0M)->3612.0M(4022.0M) - 表示堆的大小是 4022mb,其中 3727.1mb 空间未被占用。在 GC 发生之后,堆占用率降至 3612mb,115.1mb (即3727.1 – 3612) 的对象被垃圾回收了,堆的大小仍然是 4022mb。
  • Metaspace: 2776K->2776K(1056768K) – 表示在 GC 发生前后,它被占用的空间大小是 2776k。基本上,意味着在这个阶段 metaspace 空间占用率是保持一致的,metaspace 的总大小是 1056768k。
  1. Times: user=19.08, sys=0.01, real=9.74 secs – real 表示 GC 总共花了 9.74 秒,这个停顿时间很长。

https://smile.blog.csdn.net/article/details/85271167

⚠️ **GitHub.com Fallback** ⚠️