概述
Java中,垃圾收集 Garbage Collection 通常被称为“GC”,它诞生于1960年 MIT 的 Lisp 语言,经过半个多世纪,目前已经十分成熟了。
jvm 中,程序计数器、虚拟机栈、本地方法栈都是都是线程私有的,随线程而生随线程而灭,栈帧(栈中的对象)随着方法的进入和退出做入栈和出栈操作,实现了自动的内存清理,因此,我们的内存垃圾回收主要集中于 Java 堆和方法区中,在程序运行期间,这部分内存的分配和使用都是动态的。
垃圾回收算法意义与价值
Java应用程序不用程序员手动管理内存中的垃圾回收,是因为JVM有专门的垃圾回收线程做这件事。当内存不够用时,会自动触发回收。为了在效率和内存碎片之间均衡,衍生出了一系列的垃圾回收算法。
对象存活判断
jvm在进行回收资源时需要判断当前对象是否存活,对于不存活对象进行回收处理,那么是如何判断对象存活呢?判断对象存活有如下两种方式:
-
引用计数
每个对象都有一个引用计数属性,当新增一个引用时计数加1,引用释放时计数减1,当计数为0时就可以进行回收,此法简单,无法解决对象相互循环引用的问题。
对象循环引用问题,即对象A引用对象B的,而在对象B中又引用了对象A,那么对于对象A和对象B来说,其引用计数器都为1,难以判断其是否存活。
-
可达性分析(Reachability Analysis)
从GC的Roots开始向下搜索,搜索所走过的路径称为引用链。当一个对象到GC Roots没有任何引用链相连时,则证明此对象是不可用的,即为不可达对象。
Java语言中GC Roots包含(虚拟机栈中引用的对象、方法区中类静态属性实体引用的对象、方法区中常量引用的对象、本地方法栈中JNI引用的对象),简言之GC Roots包括栈中引用对象和方法区中引用对象。
垃圾收集算法
标记-清除算法(Mark-Sweep)
执行步骤
标记:遍历整个内存区域,对需要回收的对象打上标记;
清除:再次遍历内存,对标记过的内存进行回收;
图示
适用场合
- 存活对象较多的情况下比较高效;
- 适用于年老代(即旧生代);
算法优缺点分析
优点如下:
- 执行过程比较简单
缺点如下:
-
效率问题: 遍历了两次内存空间(第一次标记,第二次清除)。
-
空间问题: 容易产生大量内存碎片,当再需要一块比较大的内存时,虽然总的可用内存是够的,但是由于太过分散,无法找到一块连续的且满足分配要求的,因而不得不再次触发一次GC。
复制算法(Copying)
将内存按容量划分为大小相等的两块,每次只使用其中的一块。当一块用完了,触发GC时,将该块中存活的对象复制到另一块区域,然后一次性清理掉这块没有用的内存。下次触发GC时将那块中存活的的又复制到这块,然后抹掉那块,循环往复。
简言之:从根集合节点进行扫描,标记出所有的存活对象,并将这些存活的对象复制到一块儿新的等量大小内存上去,之后将原来的那一块儿内存全部回收掉。
复制算法的高效性是建立在存活对象少、垃圾对象多的前提下的。
现在的商业虚拟机都采用这种收集算法来回收新生代
图解
适用场合
- 存活对象较少的情况下比较高效;
- 扫描了整个空间一次(标记存活对象并复制移动);
- 适用于年轻代(即新生代):基本上98%的对象是”朝生夕死”的,存活下来的会很少
算法优缺点分析
优点如下:
- 相对于标记–清理算法解决了内存的碎片化问题,因为复制的时候,会把存活的对象,聚拢在一起。
- 效率更高(清理内存时,记住首尾地址,一次性抹掉)
缺点如下:
- 需要一块儿空置的内存空间;
- 需要复制移动对象;
标记-整理算法(Mark-Compact)
当对象的存活率比较高时,或者对象比较大时,用前面的复制算法这样复制过来,复制过去,没啥意义且浪费时间。所以针对老年代提出了“标记整理”算法。
执行步骤
- 标记:对需要回收的进行标记;
- 整理:让存活的对象,向内存的一端移动,然后直接清理掉没有用的内存;
图解
适用场合
- 存活对象较多的情况下比较高效;
- 适用于年老代(即旧生代);
算法优缺点分析
优点如下:
- 克服了标记清除算法的内存碎片化的问题;
- 克服了复制算法的低效问题;
缺点如下:
- 效率问题: 遍历了两次内存空间;
分代收集算法
GC分代的基本假设:绝大部分对象的生命周期都非常短暂,存活时间短。
分代收集算法其实没有什么新东西,就是上面新生代和老年代根据对象不同的特点,采用不同的算法进行回收,取名为分代收集,是一种划分策略。
分代收集算法就是目前虚拟机使用的回收算法,它解决了标记整理不适用于老年代的问题,将内存分为各个年代。一般情况下将堆区划分为老年代(Tenured Generation)和新生代(Young Generation),在堆区之外还有一个代就是永久代(Permanet Generation),在jdk1.8后永久代合并到元空间(MetaSpace)
在不同年代使用不同的算法,从而使用最合适的算法,新生代存活率低,可以使用复制算法。而老年代对象存活率搞,没有额外空间对它进行分配担保,所以只能使用标记清除或者标记整理算法。
新生代与老年代关系
对象通过如下方式由年轻代晋升至老年代
-
提升
对象在多次垃圾回收后,依然存活,也就是多次从from->to 又从to->from 这样多次,jvm认为无需让这样的对象继续这样复制,因此将其晋升到老年代。
-
分配担保
默认的Survivor只占整个年轻代的10%,当从eden区复制到from / to的时候,存不下了,这个时候对象会被移动到老年代
-
大对象直接分配老年代
-
动态对象年龄判断
当eden区中,某一年龄的对象已经占用整个eden的一半了,那么大于或者等于这一年龄的对象都会进入老年代。
垃圾回收算法总结
年轻代
复制算法适合年轻代。
-
所有新生成的对象首先都是放在年轻代的,年轻代的目标就是尽可能快速的收集掉那些生命周期短的对象。
-
新生代内存按照8:1:1的比例分为一个eden区和两个survivor(survivor0,survivor1)区。
一个Eden区,两个Survivor区(一般而言)。大部分对象在Eden区中生成。回收时先将eden区存活对象复制到一个survivor0区,然后清空eden区,当这个survivor0区也存放满了时,则将eden区和survivor0区存活对象复制到另一个survivor1区,然后清空eden和这个survivor0区,此时survivor0区是空的,然后将survivor0区和survivor1区交换,即保持survivor1区为空, 如此往复。
-
当survivor1区不足以存放 eden和survivor0的存活对象时,就将存活对象直接存放到老年代。若是老年代也满了就会触发一次Full GC(Major GC),也就是新生代、老年代都进行回收。
-
新生代发生的GC也叫做Minor GC,MinorGC发生频率比较高(不一定等Eden区满了才触发)。
老年代
标记-清除或标记-整理适合老年代。
- 在年轻代中经历了N次垃圾回收后仍然存活的对象,就会被放到年老代中。因此,可以认为年老代中存放的都是一些生命周期较长的对象。
- 内存比新生代也大很多(大概比例是1:2),当老年代内存满时触发Major GC即Full GC,Full GC发生频率比较低,老年代对象存活时间比较长,存活率标记高。
分代收集
以上这种年轻代与年老代分别采用不同回收算法的方式称为”分代收集算法”,这也是当下企业使用的一种方式。
每一种算法都会有很多不同的垃圾回收器去实现,在实际使用中,根据自己的业务特点做出选择就好
垃圾收集器
收集算法是jvm内存回收过程中具体的、通用的方法,垃圾收集器是jvm内存回收过程中具体的执行者,即各种GC算法的具体实现。
Serial收集器
串行收集器是最古老,最稳定以及效率高的收集器,可能会产生较长的停顿,只使用一个线程去回收。新生代、老年代使用串行回收;新生代复制算法、老年代标记-整理;垃圾收集的过程中会Stop The World(服务暂停)参数控制:-XX:+UseSerialGC 串行收集器。
图解
ParNew收集器
ParNew收集器其实就是Serial收集器的多线程版本。**新生代并行,老年代串行;**新生代复制算法、老年代标记-整理。
参数控制:-XX:+UseParNewGC ParNew收集器,-XX:ParallelGCThreads 限制线程数量参数。
图解
Parallel收集器
Parallel Scavenge收集器类似ParNew收集器,Parallel收集器更关注系统的吞吐量。可以通过参数来打开自适应调节策略,虚拟机会根据当前系统的运行情况收集性能监控信息,动态调整这些参数以提供最合适的停顿时间或最大的吞吐量;也可以通过参数控制GC的时间不大于多少毫秒或者比例;新生代复制算法、老年代标记-整理。
参数控制:-XX:+UseParallelGC 使用Parallel收集器+ 老年代串行。
Parallel Old 收集器
Parallel Old是Parallel Scavenge收集器的老年代版本,使用多线程和“标记-整理”算法。这个收集器是在JDK 1.6中才开始提供
参数控制:-XX:+UseParallelOldGC使用Parallel收集器+ 老年代并行
CMS 收集器
CMS(Concurrent Mark Sweep)收集器是一种以获取最短回收停顿时间为目标的收集器。目前很大一部分的Java应用都集中在互联网站或B/S系统的服务端上,这类应用尤其重视服务的响应速度,希望系统停顿时间最短,以给用户带来较好的体验。
从名字(包含“Mark Sweep”)上就可以看出CMS收集器是基于“标记-清除”算法实现的,它的运作过程相对于前面几种收集器来说要更复杂一些,整个过程分为4个步骤,包括:
- 初始标记(CMS initial mark)
- 并发标记(CMS concurrent mark)
- 重新标记(CMS remark)
- 并发清除(CMS concurrent sweep)
其中初始标记、重新标记这两个步骤仍然需要“Stop The World”。初始标记仅仅只是标记一下GC Roots能直接关联到的对象,速度很快,并发标记阶段就是进行GC Roots Tracing的过程,而重新标记阶段则是为了修正并发标记期间,因用户程序继续运作而导致标记产生变动的那一部分对象的标记记录,这个阶段的停顿时间一般会比初始标记阶段稍长一些,但远比并发标记的时间短.由于整个过程中耗时最长的并发标记和并发清除过程中,收集器线程都可以与用户线程一起工作,所以总体上来说,CMS收集器的内存回收过程是与用户线程一起并发地执行。老年代收集器(新生代使用ParNew)
优缺点分析
优点如下:
- 并发收集
- 低停顿
缺点如下:
- 产生大量内存碎片
- 并发阶段会降低吞吐量
参数控制:
-XX:+UseConcMarkSweepGC 使用CMS收集器
-XX:+ UseCMSCompactAtFullCollection Full GC后,进行一次碎片整理;整理过程是独占的,会引起停顿时间变长
-XX:+CMSFullGCsBeforeCompaction 设置进行几次Full GC后,进行一次碎片整理
-XX:ParallelCMSThreads 设定CMS的线程数量(一般情况约等于可用CPU数量)
图解
G1 收集器
G1是目前技术发展的最前沿成果之一,HotSpot开发团队赋予它的使命是未来可以替换掉JDK1.5中发布的CMS收集器。与CMS收集器相比G1收集器有以下特点:
- 空间整合,G1收集器采用标记整理算法,不会产生内存空间碎片。分配大对象时不会因为无法找到连续空间而提前触发下一次GC。
- 可预测停顿,这是G1的另一大优势,降低停顿时间是G1和CMS的共同关注点,但G1除了追求低停顿外,还能建立可预测的停顿时间模型,能让使用者明确指定在一个长度为N毫秒的时间片段内,消耗在垃圾收集上的时间不得超过N毫秒,这几乎已经是实时Java(RTSJ)的垃圾收集器的特征了。
上面提到的垃圾收集器,收集的范围都是整个年轻代或者老年代,而G1不再是这样。使用G1收集器时,Java堆的内存布局与其他收集器有很大差别,它将整个Java堆划分为多个大小相等的独立区域(Region),虽然还保留有新生代和老年代的概念,但新生代和老年代不再是物理隔阂了,它们都是一部分(可以不连续)Region的集合。
图解
G1的新生代收集跟ParNew类似,当新生代占用达到一定比例的时候,开始出发收集。和CMS类似,G1收集器收集老年代对象会有短暂停顿。
执行步骤
-
标记阶段,首先初始标记(Initial-Mark),这个阶段是停顿的(Stop the World Event),并且会触发一次普通Mintor GC。对应GC log:GC pause (young) (inital-mark)
-
Root Region Scanning,程序运行过程中会回收survivor区(存活到老年代),这一过程必须在young GC之前完成。
-
Concurrent Marking,在整个堆中进行并发标记(和应用程序并发执行),此过程可能被young GC中断。在并发标记阶段,若发现区域对象中的所有对象都是垃圾,那个这个区域会被立即回收(图中打X)。同时,并发标记过程中,会计算每个区域的对象活性(区域中存活对象的比例)。
-
Remark, 再标记,会有短暂停顿(Stop the World Event)。再标记阶段是用来收集 并发标记阶段 产生新的垃圾(并发阶段和应用程序一同运行);G1中采用了比CMS更快的初始快照算法:snapshot-at-the-beginning (SATB)。
-
Copy/Clean up,多线程清除失活对象,会有STW。G1将回收区域的存活对象拷贝到新区域,清除Remember Sets,并发清空回收区域并把它返回到空闲区域链表中。
-
复制/清除过程后。回收区域的活性对象已经被集中回收到深蓝色和深绿色区域.
ZGC收集器
在JDK 11中即将迎来ZGC(The Z Garbage Collector),这是一个处于实验阶段的,可扩展的低延迟垃圾回收器.
ZGC特点
- 并发
- 基于Region的
- 标记整理
- NUMA感知
- 使用colored oops
- 使用load barrier
- 仅root扫描时STW,因此GC暂停时间不会随堆的大小而增加。
描述
ZGC的核心原则是将load barrier与colored oops结合使用。这使得ZGC能够在Java应用程序线程运行时执行并发操作,例如对象迁移。
从Java线程的角度来看,在Java对象中加载引用字段的行为受到load barrier的影响。除了对象地址之外,colored oops还包含load barrier使用的信息,以确定在允许Java线程使用指针之前是否需要采取某些操作。 例如,对象可能已迁移,在这种情况下,load barrier将检测情况并采取适当的操作。
与其他替代技术相比,colored oops提供了如下非常有吸引力的特性:
- 它允许ZGC在对象迁移和整理阶段回收和重用内存。这有助于降低一般堆开销。这也意味着不需要为Full GC实现一个单独的标记整理算法。
- 目前在colored oops中仅存储标记和对象迁移相关信息。然而,这种方案的通用性使我们能够存储任何类型的信息(只要我们可以将它放入指针中)并让load barrier根据该信息采取它想要的任何动作。比如,在异构内存环境中,这可以用于跟踪堆访问模式,以指导GC对象迁移策略,将很少使用的对象移动到冷存储。
ZGC并发执行如下任务:
- 标记
- 引用处置
- relocation集选择
- 迁移和整理
局限性
-
当前版本不支持类卸载
-
当前版本不支持JVMCI
JVMCI是JDK 9 引入的JVM编译器接口。这个接口允许用Java编写的编译器被JVM用作动态编译器。JVMCI的API提供了访问VM结构、安装编译代码和插入JVM编译系统的机制。现有支持Java编译器的项目主要是 Graal 和 Metropolis 。
工作机制与原理
指针标记
在64位系统上,引用是64位的,引用结构如下:
+-------------------+-+----+-----------------------------------------------+
|00000000 00000000 0|0|1111|11 11111111 11111111 11111111 11111111 11111111|
+-------------------+-+----+-----------------------------------------------+
| | | |
| | | * 41-0 Object Offset (42-bits, 4TB address space)
| | |
| | * 45-42 Metadata Bits (4-bits) 0001 = Marked0 (Address view 4-8TB)
| | 0010 = Marked1 (Address view 8-12TB)
| | 0100 = Remapped (Address view 16-20TB)
| | 1000 = Finalizable (Address view N/A)
| |
| * 46-46 Unused (1-bit, always zero)
|
* 63-47 Fixed (17-bits, always zero)
如上表所示, ZGC使用41-0存储对象实际地址的前42位, 42位地址为应用程序提供了理论4TB的堆空间; 45-42位为metadata比特位, 对应于如下状态: finalizable,remapped,marked1和marked0; 46位为保留位,固定为0; 63-47位固定为0。
在引用中添加元数据, 使得解除引用的代价更加高昂, 因为需要操作掩码以获取真实的地址, ZGC采用了一种有意思的技巧, 读操作时是精确知道metadata值的, 而分配空间时, ZGC映射同一页到3个不同的地址,而在任一时间点,这3个地址中只有一个正在使用中。
具体代码实现如下:
void ZPhysicalMemoryBacking::map(ZPhysicalMemory pmem, uintptr_t offset) const {
if (ZUnmapBadViews) {
// Only map the good view, for debugging only
map_view(pmem, ZAddress::good(offset), AlwaysPreTouch);
} else {
// Map all views
map_view(pmem, ZAddress::marked0(offset), AlwaysPreTouch);
map_view(pmem, ZAddress::marked1(offset), AlwaysPreTouch);
map_view(pmem, ZAddress::remapped(offset), AlwaysPreTouch);
}
}
void ZPhysicalMemoryBacking::unmap(ZPhysicalMemory pmem, uintptr_t offset) const {
if (ZUnmapBadViews) {
// Only map the good view, for debugging only
unmap_view(pmem, ZAddress::good(offset));
} else {
// Unmap all views
unmap_view(pmem, ZAddress::marked0(offset));
unmap_view(pmem, ZAddress::marked1(offset));
unmap_view(pmem, ZAddress::remapped(offset));
}
}
采用此法后,ZGC堆空间如下结构
// Address Space & Pointer Layout
// ------------------------------
//
// +--------------------------------+ 0x00007FFFFFFFFFFF (127TB)
// . .
// . .
// . .
// +--------------------------------+ 0x0000140000000000 (20TB)
// | Remapped View |
// +--------------------------------+ 0x0000100000000000 (16TB)
// | (Reserved, but unused) |
// +--------------------------------+ 0x00000c0000000000 (12TB)
// | Marked1 View |
// +--------------------------------+ 0x0000080000000000 (8TB)
// | Marked0 View |
// +--------------------------------+ 0x0000040000000000 (4TB)
// . .
// +--------------------------------+ 0x0000000000000000
由此产生副作用,ZGC无法兼容指针压缩。
分页
在G1中,堆内存通常被分为几千个大小相同region。同样的,在ZGC中堆内存也被分成大量的区域,它们被称为page,不同的是,ZGC中page的大小是不同的。 ZGC有3种不同的页面类型:小型(2MB大小),中型(32MB大小)和大型(2MB的倍数)。 在小页面中分配小对象(最大256KB大小),在中间页面中分配中型对象(最多4MB)。大页面中分配大于4MB的对象。大页面只能存储一个对象,与小页面或中间页面相对应。有些令人困惑的大页面实际上可能小于中等页面(例如,对于大小为6MB的大对象)。 这种分配方式有点类似操作系统的内存分配方式。
标记整理
主要分为10个步骤,具体参考如下代码实现过程
void ZDriver::run_gc_cycle(GCCause::Cause cause) {
ZDriverCycleScope scope(cause);
// Phase 1: Pause Mark Start
{
ZMarkStartClosure cl;
vm_operation(&cl);
}
// Phase 2: Concurrent Mark
{
ZStatTimer timer(ZPhaseConcurrentMark);
ZHeap::heap()->mark();
}
// Phase 3: Pause Mark End
{
ZMarkEndClosure cl;
while (!vm_operation(&cl)) {
// Phase 3.5: Concurrent Mark Continue
ZStatTimer timer(ZPhaseConcurrentMarkContinue);
ZHeap::heap()->mark();
}
}
// Phase 4: Concurrent Reference Processing
{
ZStatTimer timer(ZPhaseConcurrentReferencesProcessing);
ZHeap::heap()->process_and_enqueue_references();
}
// Phase 5: Concurrent Reset Relocation Set
{
ZStatTimer timer(ZPhaseConcurrentResetRelocationSet);
ZHeap::heap()->reset_relocation_set();
}
// Phase 6: Concurrent Destroy Detached Pages
{
ZStatTimer timer(ZPhaseConcurrentDestroyDetachedPages);
ZHeap::heap()->destroy_detached_pages();
}
// Phase 7: Concurrent Select Relocation Set
{
ZStatTimer timer(ZPhaseConcurrentSelectRelocationSet);
ZHeap::heap()->select_relocation_set();
}
// Phase 8: Prepare Relocation Set
{
ZStatTimer timer(ZPhaseConcurrentPrepareRelocationSet);
ZHeap::heap()->prepare_relocation_set();
}
// Phase 9: Pause Relocate Start
{
ZRelocateStartClosure cl;
vm_operation(&cl);
}
// Phase 10: Concurrent Relocate
{
ZStatTimer timer(ZPhaseConcurrentRelocated);
ZHeap::heap()->relocate();
}
}
ZGC包含10个阶段,但是主要是两个阶段标记和relocating。 GC循环从标记阶段开始,递归标记所有可达对象,标记阶段结束时,ZGC可以知道哪些对象仍然存在,哪些是垃圾。ZGC将结果存储在每一页的位图(称为live map)中。
在标记阶段,应用线程中的load barrier将未标记的引用压入线程本地的标记缓冲区。一旦缓冲区满,GC线程会拿到缓冲区的所有权,并且递归遍历此缓冲区所有可达对象。注意:应用线程负责压入缓冲区,GC线程负责递归遍历。
标记阶段后,ZGC需要迁移relocate集中的所有对象。relocate集是一组页面集合,包含了根据某些标准(例如那些包含最多垃圾对象的页面)确定的需要迁移的页面。对象由GC线程或者应用线程迁移(通过load barrier)。ZGC为每个relocate集中的页面分配了转发表。转发表是一个哈希映射,它存储一个对象已被迁移到的地址(如果该对象已经被迁移)。
GC线程遍历relocate集的活动对象,并迁移尚未迁移的所有对象。有时候会发生应用线程和GC线程同时试图迁移同一个对象,在这种情况下,ZGC使用CAS操作来确定胜利者。
一旦GC线程完成了relocate集的处理,迁移阶段就完成了。虽然这时所有对象都已迁移,但是旧地引用址仍然有可能被使用,仍然需要通过转发表重新映射(remapping)。然后通过load barrier或者等到下一个标记循环修复这些引用。
这也解释了为什么对象引用中有两个标记位(marked0和marked1)。标记阶段交替使用在marked0和marked1位。
load barrier
它的比较容易和CPU的内存屏障(memory barrier)弄混淆,但是它们是完全不同的东西。
从堆中读取引用时,ZGC需要一个所谓的load barrier(也称为read-barrier)。每次Java程序访问对象字段时,ZGC都会执行load barrier的代码逻辑,例如obj.field。访问原始类型的字段不需要屏障,例如obj.anInt或obj.anDouble。ZGC不使用存储/写入障碍obj.field = someValue
。
常用收集器组合搭配
年轻代GC策略 | 老年代GC策略 | 说明 |
---|---|---|
组合1 | Serial | Serial Old |
组合2 | Serial | CMS+Serial Old |
组合3 | ParNew | CMS |
组合4 | ParNew | Serial Old |
组合5 | Parallel Scavenge | Serial Old |
组合6 | Parallel Scavenge | Parallel Old |
组合7 | G1GC | G1GC |
垃圾收集器问题延伸
STW为什么周期短?
仅root扫描时STW,其他标记、清理、迁移阶段,均通过colored oops和load-barrier配合使用,并发执行。
JVM全局停顿
Java中Stop-The-World机制简称STW,是在执行垃圾收集算法时,Java应用程序的其他所有线程都被挂起(除了垃圾收集帮助器之外)。Java中一种全局暂停现象,全局停顿,所有Java代码停止,native代码可以执行,但不能与JVM交互;这些现象多半是由于gc引起.
JVM里有一条特殊的线程VM Threads,专门用来执行一些特殊的VM Operation,比如分派GC,thread dump等,这些任务,都需要整个Heap,以及所有线程的状态是静止的,一致的才能进行。所以JVM引入了安全点(Safe Point)的概念,想办法在需要进行VM Operation时,通知所有的线程进入一个静止的安全点。
JVM触发安全点操作包含哪些?
- GC
- JIT相关,比如Code deoptimization, Flushing code cache ;
- Class redefinition (javaagent,AOP代码植入的产生的instrumentation等) ;
- Biased lock revocation 取消偏向锁
- Various debug operation(hread dump or deadlock check等)
参数控制:-XX:+PrintGCApplicationStoppedTime
查看何种原因导致停顿 参数控制:-XX:+PrintSafepointStatistics -XX: PrintSafepointStatisticsCount=1
{{m.name}}
原创文章,作者:carmelaweatherly,如若转载,请注明出处:https://blog.ytso.com/tech/pnotes/203120.html