JVM虚拟机(三)垃圾回收算法、分代回收、垃圾回收器种类、G1垃圾回收器
JVM虚拟机(三)垃圾回收算法、分代回收、垃圾回收器种类、G1垃圾回收器
一、什么是垃圾回收?
1.1 什么是垃圾回收?
在介绍垃圾回收之前,我们需要先明确几个点:
- 为什么要进行垃圾回收呢?
- 回收哪里的垃圾呢?
是这样的,垃圾回收主要指的是回收堆中的对象,堆的位置如下图所示:
堆是一个共享区域,我们创建的对象和数组都存储在当前的堆里。但是我们也不能无限地去创建对象,也不是所有的对象都需要一直存在,如果说不进行垃圾回收的话,内存迟早都会被耗尽的,所以说及时的垃圾回收就显得非常有必要了。那什么对象才能被回收呢?
1.2 什么对象能被垃圾回收?
简单一句话就是:如果一个或多个对象没有任何的引用指向它了,那么这个对象现在就可能会被垃圾回收器回收。
定位什么样的对象是垃圾有两种方式:第一个是引用计数法,第二个是可达性算法。
1)引用计数法
引用计数法
:一个对象被引用了一次,在当前的对象头上递增一次引用次数,如果这个对象的引用次数为0,代表这个对象可回收。
比如下面这段代码:
String demo = new String("123");
因为我们在这里 new 了一个 String,所以会在堆中开辟一块空间去存储当前的对象:
这时,我们的引用计数法就会去增加一次引用的次数,ref=1。
假如目前的 demo 指向 null:
String demo = null;
这时,demo 就不会指向原来的那块内存了,当前的 ref 就会从 1 变成 0:
ref 变成 0 就表示当前的对象 new String(“123”) 是可以被垃圾回收的。
这个方法看起来非常简单,但是引用计数法也有一定的问题,我们来看下一个例子:
假如有下面这样一段代码:
我们先来分析一下这个代码:
- 左边代码中,Demo 类中有一个 Demo 的属性指向它自己,然后还有一个 String 的 name 和一个带参构造函数。
- 右边代码中,使用了 Demo 类创建了 a 和 b 两个实例,其中 a 的 instance 成员变量指向了 b,b 的 instance 成员变量指向了 a。
这段代码在内存中的表示方式如下:
首先,在栈中有一个变量 a 和一个变量 b,分别指向堆中的两块内存。由于除了 a 和 b 指向了这两块内存,它们之间还存在互相引用,只要有对象引用,引用次数就会加1,所以此时引用次数 ref=2。
下面我们继续执行代码:
代码中把 a 和 b 都指向 null,这时我们栈中的变量 a 和变量 b 就不再引用堆中那两块内存了,这样堆中两块内存的引用次数 ref 就会都变成 1 了,如下所示:
这时大家就会发现,目前堆中的这两个对象的引用次数都是1,但是目前这两个对象是没有人使用的,但是它们依然不会被回收。如果出现了这种情况,就是出现了 循环引用,就会引发内存泄漏。因为这两个对象一直不会被回收。
以上就是引用计数法,使用起来是非常简单,但是缺点就是容易导致内存泄漏。所以说一般不会采用这种方法去定位某个对象是否是垃圾。
下面我们看第二种定位垃圾的方式,可达性分析算法。
2)可达性分析算法
现在的虚拟机采用的都是通过可达性算法来确定哪些对象是垃圾。
首先,我们来看下面这张图:
最上面有一个 GC Roots
,相当于是一个树根。从根中探索查看是否有关联的对象:
- 如果说能关联到,不管是直接关联(如A)还是间接关联(如B),找到的这些对象都是存活的对象,这些对象就不会被垃圾回收。
- 如果扫描堆的过程中,不能沿着 GC Roots 找到的对象,比如X、Y,它们目前没有与任何的 GC Root 进行关联,这两个就证明是可以被回收的对象。
对比我们刚才说的引用计数法,可达性分析算法能够更精确地定位哪些是可回收的垃圾。所以现在的虚拟机都是采用可达性分析算法来去确定哪些是垃圾。
其实这里还有一个问题,就是哪些对象可以作为 GC Root 呢?
一共有4种对象可以作为 GC Root:
-
虚拟机栈(栈帧中的本地变量表)中引用的对象: 比如下面这个示例中的 demo:
-
方法区中类静态属性引用的对象: 比如下面这个示例中,变量a 作为静态变量,它所引用的 new Demo() 也可以作为 GC Root。
-
方法区中常量引用的对象: 比如下面这个示例中,变量a 作为静态常量,这种情况下,变量a 所引用的 new Demo() 也是可以作为 GC Root 的。
-
本地方法栈中 JNI(即一般说的 Native 方法)引用的对象: 通过 JNI 接口由本地代码(如C/C++)持有的对象引用。
-
系统类加载器加载的类: 由系统类加载器加载的类,这些类本身以及它们通过静态字段持有的对象通常被认为是不可回收的。
在我们平时的开发过程中,用到前三种的对象类型会多一些。以上就是可达性分析算法。
二、JVM 垃圾回收算法
上面我们已经能够区分出内存中存活的对象和死亡的对象,GC接下来的任务就是去执行垃圾回收,释放掉那些无用对象所占用的内存空间,以便有足够的空间为新对象分配可用的内存。
目前 JVM 中就有三种比较常见的垃圾回收算法:
- 标记清除算法
- 复制算法
- 标记整理算法
这三种算法每一个都有各自的特点,下面一起了解一下。
2.1 标记清除算法
标记清除
是将垃圾回收分为 2 个阶段,分别是标记和清除:
- 对这些标记为可回收的内容进行 垃圾回收。
我们先来看下面这张图:
图中蓝色部分代表 “存活的对象”,灰色部分代表 “待回收的对象”,其他的都是 “空闲的空间”。
标记清除算法的第1步就是:根据可达性分析算法得出的垃圾进行 标记。
可达性分析算法就是使用 GC Root 去标记哪些是存活的对象,如下所示,可以看到蓝色部分都是被 GC Root 标记存活的对象:
标记清除算法的第2步就是:对这些标记为可回收的内容进行垃圾回收。
根据步骤,标记的对象都留下来,然后直接清除没有标记的内容就可以了。回收后的效果如下:
现在这个图中只留下了蓝色的部分,这些是做了标记,属于目标存活的对象,这个就是 标记清除算法
。
- 优点: 标记和清除速度较快。
- 缺点: 内存碎片化较为严重,内存不连贯。
我们知道数组也是会存储到堆中,并且数组存储必须是一个连续的内存空间。如果我们使用了标记清除算法,由于内存不连续,有可能没有办法进行存储新的对象,也没有办法去存储一个比较大的数组。所以说这个算法用的相对比较少一些,用的比较多的主要是后面两种。
我们继续来看第二种:
2.2 标记整理算法(标记压缩算法)
“标记整理算法”,也称标记压缩算法,与我们刚才介绍的 “标记清除算法” 差不多,我们先看下面这张图:
首先,它也会通过 GC Root 去标记哪些对象是存活的对象,然后再去清除待回收的对象。但是它同时还多了一步,我们再来看下面这张图:
从图中可以看到,标记整理算法清除之后,它会把存活的对象进行整理,就是把所有的对象像一端移动。这样就避免了内存碎片化的问题了。但是由于标记整理算法多了一步,需要移动对象在内存中的位置,所以说它的性能也会收到一定的影响。
以上就是标记整理算法,很多老年代的垃圾回收器都是使用标记整理算法。
下面我们再来看最后一种垃圾回收算法:复制算法。
2.3 复制算法
复制算法
是将整个内存分成了大小相等的区域,标记阶段与我们前面的算法是类似的,也是通过 GC Root 进行标记哪些对象是存活的对象,然后将存活的对象进行复制,复制到另外一块内存区域。
当然,复制的过程中就自动完成了碎片的整理。如下图所示,就是回收之后的效果:
左边的 4 个对象被挪到了右边的区域,接下来把左边整个区域清空就可以了。
优点:
- 在垃圾对象多的情况下,效率较高;
- 清理后,内存无碎片。
缺点:
- 分配的 2 块内存空间,在同一个时刻,只能使用一半,内存使用率较低。
一般垃圾回收的时候,年轻代的垃圾回收器都会使用复制算法。
以上就是全部的三种垃圾回收算法了,下面我们进行一下总结。
2.4 总结
JVM 垃圾回收算法有哪些?
- 标记清除算法:垃圾回收分为 2 个阶段,分别是:标记、清除。效率高,有磁盘碎片,内存不连续。
- 标记整理算法:与标记清除算法一样,将存活的对象都向内存另一端 移动,然后清理边界以外的垃圾。无碎片,对象需要移动,效率低。
- 复制算法:将原有的内存空间一分为二,每次只用其中的一块。将正在使用的对象 复制 到另一个内存空间中,然后将该内存空间清空,交换两个内存的角色,完成垃圾的回收。无碎片,内存使用率低。
三、JVM 的分代回收
在之前介绍 JVM 组成的时候说话,在堆中分为新生代和老年代。下面我们就要详细说明一下,在垃圾回收的时候它们到底有什么作用。
3.1 堆中的区域划分
首先,我们还是要重新介绍一下堆中的组成:
- 在 Java8 中,堆被分成了两份:新生代和老年代(比例为1:2)
如下图所示:
左边的新生代占了 1/3,右边的老年占了 2/3。在新生代中又划分为了三份:
- 一个是伊甸园区Eden,新生的对象都分配到这里。
- 还有两个幸存者区Survivor,分为 from 和 to。
- Eden区、from区、to区比例为 8:1:1。
以上就是目前堆中的结构划分,下面我们来介绍一下垃圾产生之后它们是如何工作的。
3.2 分代收集算法-工作机制
- 首先,我们刚才介绍过,新生的对象都需要存储到 Eden 区,如下图所示:
- 当伊甸园区内存不足的时候(如上图所示已经占满了),这个时候 JVM 就会使用之前我们讲过的 “可达性分析算法” 来去标记 Eden 区和 from 区中存活的对象,当然目前 from 区中是没有对象的。
- 假如标记了 A 对象是存活的,接下来就会采用 “复制算法” 将 A 对象复制到 to 区中。复制完毕之后,Eden 区和 from 区都要清空掉。操作后如下所示:
- 假如经过一段时间后,Eden 区内存又出现不足,如下所示:
- 这时候依然会采用 “可达性分析算法” 去标记 Eden 区和 to 区中存活的对象,然后把这些对象复制到 from 区中。
- 假如现在 1 和 A 这两个对象依然存活,这时候就直接复制到了 from 区中,然后 Eden 区和 to 区都会清空掉。操作后如下所示:
- 又经过了一段时间,又有一些新的对象存到了 Eden 区中,如下所示:
- 假如这时候 A 对象被挪动的次数太多,比如超过了 15 次,那么这时候 A 对象就不会再在 from 区和 to 区之间挪过来挪过去了。它会直接把 A 对象存储到 老年代 中。因为这种情况下,我们一般认为 A 对象会被一直引用着,它的存活时间会更长一些。
- 当然还有一种情况,假如我们的幸存者区已经内存不足了,或者说当前某个对象太大了,它也会提前晋升到老年代。例如下图中,A 对象代表挪动次数超过 15 次之后进入了老年代;而 w 对象是一个新生的对象,它并没有到达一定的挪动次数,所以 w 对象是正常地在新生代中进行复制挪动,等 w 对象挪动到一定次数也会进入到老年代中。
以上就是新生代和老年代工作的配合方式,但是在实际面试过程中还会问一些名词,比如:MinorGC、Mixed GC、FullGC。
3.3 Minor GC、Major GC、Mixed GC、Full GC 的区别是什么?
我们前面了解到新生代和老年代的比例如下所示:
Minor GC
,也称 Young GC,发生在 新生代 的垃圾回收,暂停时间短(STW)。Major GC
,清理 Tenured 区,[ˈtenjərd],用于回收 老年代,CMS 收集器特有。Mixed GC
,新生代 + 老年代两块 部分 区域的垃圾回收,G1 收集器特有。Full GC
,新生代+老年代 完整 垃圾回收,暂停时间长(STW),应尽量避免。
FullGC=Minor GC+Major GC+Metaspace GC。
名词解释:
STW
(Stop-The-World):,暂停所有应用程序线程,等待垃圾回收的完成。Minor GC 暂停时间短就说明效率比较高,如果暂停时间比较长就说明效率降低。
3.4 总结
1)堆的区域划分
- 堆被分为了两份:新生代和老年代【1:2】。
- 对于新生代,内部又被分为了三个区域:Eden区,幸存者区survivor(分成 from 和 to)【8:1:1】。
2)对象分代回收策略
- 新创建的对象,都会先分配到 Eden 区。
- 当 Eden 区内存不足,标记 Eden 区与 from 区(现阶段没有)的存活对象。
- 将存活对象采用复制算法复制到 to 区中,复制完毕后,Eden 区和 from 内存都得到释放。
- 经过一段时间后,Eden 区的内存又出现不足,标记 Eden 区和 to 区存活的对象,将其复制到 from 区。
- 当幸存者区对象熬过几次回收(最多15次),晋升到老年代(幸存者区内存不足或大对象会提前晋升)。
四、JVM 有哪些垃圾回收器?
在 jvm 中,实现了多种垃圾收集器,包括:
- 单线程垃圾收集器(Serial 和 Serial Old)。
- 多线程垃圾收集器(Parallel Scavenge 和 Parallel Old),JDK8默认。
- 并发垃圾收集器(ParNew 和 CMS)。
- 智能并发垃圾收集器(G1),JDK9默认。
我们根据新生代和老年代可以进行如下分类:
- 新生代收集器: Serial、ParNew、Parallel Scavenge。
- 老年代收集器: Serial Old、CMS、Parallel Old。
- 通用收集器: G1。
常用组合:Serial + Serial Old,Parallel Scavenge + Parallel Old,ParNew + CMS,G1(不需要组合)
4.1 单线程垃圾收集器(Serial+Serial Old)
单线程垃圾收集器
,也称串行垃圾收集器,包含了两个收集器:Serial 和 Serial Old,是指使用单线程进行垃圾回收,堆内存较小,适合个人电脑。
- Serial: 作用于新生代,采用复制算法。
- Serial Old: 作用于老年代,采用标记-整理算法。
垃圾回收时,只有一个线程在工作,并且 Java 应用中的所有线程都要暂停(STW),等待垃圾回收的完成。
举个例子,如下图所示:
当前一个电脑里面有4核 CPU,绿色的线表示我们的程序在正常的运行,当到达安全点后就会产生一个垃圾回收。这时候Serial 垃圾回收器会暂停其它线程,保留一个专门的线程来进行垃圾回收。等垃圾回收线程执行完了垃圾回收之后,才能恢复运行程序的线程继续运行。
4.2 多线程垃圾收集器(Parallel Scavenge+Parallel Old)
多线程垃圾收集器
,也称并行垃圾收集器,也包含了两个收集器:Parallel Scavenge 和 Parallel Old。JDK8默认使用此垃圾回收器。
- Parallel Scavenge: 作用于新生代,采用复制算法。
- Parallel Old: 作用于老年代,采用标记-整理算法。
垃圾回收时,多个线程在工作,并且 Java 应用中的所有线程都要暂停(STW),等待垃圾回收的完成。
举个例子,如下图所示:
当前一个电脑里面有4核 CPU,对应的多个线程都在同时进行垃圾回收,对比我们刚才说的串行垃圾回收器,有更多的线程参与了垃圾回收。这个性能的确会更高一些,但是在进行垃圾回收的时候,依然也会进行线程的中断(STW)。JDK8 默认使用这种垃圾回收器
4.3 并发垃圾收集器(ParNew+CMS)
并发垃圾收集器
,也包含了两个垃圾收集器:ParNew 和 CMS。CMS 全程 Concurrent Mark Sweep,是一款并发的、使用标记-清除算法的垃圾回收器。该回收器是针对老年代垃圾回收的,是一款以获取最短回收停顿时间为目标的收集器,停顿时间短,用户体验就好。其最大特点是在进行垃圾回收时,应用仍然能正常运行。
举个例子,如下图所示:
当前一个电脑里面有4核 CPU:
-
在进行垃圾回收的时候,首先它会有一个初始标记,这个标记就是使用我们之前讲过的 “可达性分析算法” 来标记存活的对象的,这时候会进行 STW,即标记的时候其他线程会进行阻塞状态。
-
然后再进行并发标记,重新标记。为了方便理解,我们看下面这张图:
-
情况一:当我们的代码在执行过程中,有可能在并发标记完成之后,本来 X 被认为是一个垃圾对象,但是在并发标记阶段的同时,我们的代码是可以正常运行的,也有可能会出现新的引用,比如:A对象又去引用了 X对象。那这个时候 X对象就不能被回收了,所以说我们在这里有一个重新标记的阶段。
-
情况二:比如说,在一开始标记的时候,当前的 D对象的确是一个存活的对象,JVM 在重新标记的过程中,B对象取消了对 D对象的引用,那么此时的 D对象就要把它作为一个垃圾对象进行回收。
以上两种情况就体现了重新标记的重要作用,我们再回到并发垃圾收集器的回收过程:
- 重新标记完成之后,才真正进入到并发清理的阶段。从图中可以看到,并发清理的过程中,其他线程还是可以正常运行的。
关于并发垃圾收集器,主要关注点在于停顿时间短,记住这点即可。
4.4 智能并发垃圾收集器(G1)
由于 G1 垃圾收集器比较重要,也是面试官常问的内容,我们单独作为一个章节进行讲解。
五、G1垃圾收集器
G1垃圾收集器
是一种智能并发垃圾收集,应用于新生代和老年代,在 JDK9 之后默认使用 G1。
5.1 G1垃圾回收器的特点
- 在 G1 垃圾收集器中划分了多个区域,每个区域都可以充当 Eden区、Survivor区、Old(老年代)、Humongous,其中 humongous 专为大对象准备。这里面并没有之前的老年代和新生代的比例划分,也没有新生代中的比例划分,每个区域都一样,并且每个区域可以存储各种对象,还专门增加了一个 humongous 区用来存储大对象。
- 采用复制算法,没有内存碎片。
- 响应时间与吞吐量兼顾,即效率高的同时处理任务比较多。
- G1垃圾收集器在垃圾回收的过程中主要分为三个阶段:新生代回收、并发标记、混合收集。如下图所示:
- 如果并发失败(或者说垃圾回收失败,即回收速度赶不上创建新对象速度),会触发 FullGC。
以上就是 G1 垃圾回收器的特点了,下面我们就从 G1垃圾回收器的工作流程入手,进行了解。
5.2 垃圾回收的三个阶段
1)Young Collection(新生代垃圾回收)
首先,我们来看一张图:
这张图表示了整个堆空间,我们把整个堆划分成了大小相等的区域。其中的每个区域都可以作为我们刚才提到的 Eden区、Survivor区、Old(老年代)、Humongous。
- 初始时,所有区域都处于空闲状态。
- 当创建对象时,就会挑出一些空闲区域作为 Eden区来存储这些对象。比如下图中就表明了几个 E,这些就是我们的 Eden区:
- 随着对象越来越多,堆中的 Eden区可能快要放满了,这时就会触发一个新生代的垃圾回收。
大家可能有疑问:还有这么多的空间不去存储就开始垃圾回收了嘛?是这样的,G1垃圾收集器中新生代的占比不是固定的,它是在 5% ~ 6% 之间波动,G1会自动进行调整。但是大家可以思考一下,既然新生代的大小是被限制的,不管怎么波动都会有限制,都会限制当前的 Eden 区的大小,我们就不能随便地去创建新对象。所以说当我们的 Eden 区的数量达到一定程度后,它就会触发一次 Eden 区的垃圾回收。它也是采用复制算法,即会使用 “可达性分析算法” 去标明哪些对象是存活下来的。把所有存活的对象进行标记之后,没有标记的对象就成为垃圾了。G1就会将这些存活的对象用复制算法复制到 Survivor区中。
如下图所示:
- 图中的 s 其实就是 Survivor 区,经过标记之后,将剩下的存活对象都复制到幸存者区,然后之前的 Eden 区就可以释放掉了,如下图所示:
大家注意,在标记的过程当中或者是刚刚复制的过程中,都需要暂停,即触发一个 STW。不过因为我们幸存者的对象相对来说是比较少的,所以暂停时间也不会太长。
以上就是新生代的第一次垃圾回收。随着时间的流逝,还会产生很多的对象,如下图所示:
后续产生的对象都会分配到 Eden区,所以 Eden区的内存就逐渐耗尽了。这时垃圾回收器就会把 Eden区中的幸存者对象和 Survivor区中的幸存者对象合并到一起,复制到一个新的幸存者区中。如下图所示:
图中出现了一个新的幸存者区,然后新的幸存者区和之前的幸存者区就会进行一次复制。
当然,这里还有一种情况,如果当前的幸存者区中有一些对象已经超过了阈值,这些比较老的对象就会晋升到老年代中。也就是说,如果一个对象经过了很多次的垃圾回收,它就会晋升到老年代,另外一部分幸存者区和伊甸园区中的幸存者对象就会复制到一个新的幸存者区中。这样一来,伊甸园区和上一次的幸存者区就可以释放了。
以上就是垃圾回收的第一个阶段:年轻代的垃圾回收。下面我们进入第二个阶段:并发的标记阶段。
2)Young Collection + Concurrent Mark(年轻代垃圾回收+并发标记)
在当前的阶段中有一个触发条件:
- 当老年代占用内存超过阈值(默认是45%)后,触发并发标记,这时无需暂停用户线程。
其实,在老年代中找到那些存活的对象,并且给它们加上标记,这个过程是并发执行的,在此期间不会暂停用户的线程。当然这个阶段也有一次重新标记,需要处理那些漏标的对象,所以说还是需要一次 STW 的,即需要暂停用户线程。这个就是并发标记的阶段了。
并发标记完成以后,就可以进入到第三个阶段了,也就是混合收集阶段。
3)Mixed Collection(混合垃圾回收)
在混合收集阶段,它并不是一次性收集了所有的老年代的内存,那样暂停的时间会比较长,达不到预期的暂停时间。在 JVM 里面设置了一个预期的暂停时间,比如暂停时间不能超过XXms,这个是可以设置的。为了让老年代的垃圾回收不超过预期的时间,它并不是一次性把所有的老年代都进行回收,而是先挑选出回收价值比较高的对象。比如下图中标红的几个老年代:
它们里面存活的对象数量比较少,所以回收的价值比较高,即进行少量的回收就可以获得同样的内存空间。相反,其它的一些老年代对象因为存活对象比较多,回收的价值就比较低了。因此它会挑选回收价值比较高的老年代,连同我们的 Eden区和 Survivor区一起来做一次垃圾回收。这个就是混合收集(既包含了新生代的垃圾回收,也包含了老年代的垃圾回收)。
如下图所示:
在混合收集阶段中,Eden区和原有 Survivor区中存活的对象会复制到一个新的 Survivor区,而 Survivor区中到达一定挪动次数的存活对象和原有老年代中的存活对象会复制到一个新的老年代中。等混合收集完成之后,内存就得到释放了,如下图所示:
复制完成,内存得到释放,这样就完成了一次混合收集。
当然,混合收集可能要重复执行多次。因为第一次我们在预期的时间内可能会收到了刚才的三个老年代区,后续有可能再多执行几次混合收集,把剩下的老年代再重新标记,再将标记的内存逐渐地释放出来。这个就是我们第三阶段的混合收集了。
当进行了多次混合收集之后,又会进入下一轮的新生代回收、并发标记、混合收集。
以上就是 G1 垃圾回收器的三个阶段。JVM 里面还有可能出现并发失败的情况,也就是我们垃圾回收的速度小于我们创建新对象的速度,此时就会触发一次 FullGC,它的暂停时间就非常久了。但是经历了多次的 G1 垃圾回收器的三个阶段回收,它们都是并发执行的,用户的暂停时间是比较短的。一般是不会触发 FullGC 的。
5.2 Humongous区
其实还有这里一个问题:如果说一个对象太大了,一个区域放不下的话,会存入到一个 Humongous区中。如果说一块区域不够的话,会分配一块连续的区域进行存储大对象,如下图所示:
讲到这里,G1 垃圾回收器就讲完了,下面我们进行下总结。
六、总结
详细聊一下 G1 垃圾回收器:
- 应用于新生代和老年代,在 JDK9 之后默认使用 G1。
- 划分为多个区域,每个区域都可以充当 Eden、Survivor、Old、Humongous 区,其中 Humongous 区专为大对象准备。
- 采用复制算法。
- 响应时间与吞吐量兼顾。
- 分成三个阶段:新生代回收(STW)、并发标记(重新标记 STW)、混合收集。
- 如果并发失败(即回收速度赶不上创建新对象速度),会触发 FullGC。
整理完毕,完结撒花~🌻
参考地址:
1.新版Java面试专题视频教程,java八股文面试全套真题+深度详解(含大厂高频面试真题),https://www.bilibili.com/video/BV1yT411H7YK
更多推荐
所有评论(0)