前段时间翻看自己多年以来攒下的满满家当 , 突然有一种满满的满足感 .
但是想想多年来找资料的艰辛 , 决定将这些文档整理出来, 分享给大家 .
笔记华而不实 , 其中可能也有不正确的地方 , 欢迎指正.
在此也感谢道友们的奉献 , 文档暂分为几个:
复制代码
另外还有其他的笔记会陆陆续续的分享处理 , 谢谢大家的支持 .
> 堆溢出
> 元空间溢出 , 元数据区的内存溢出
> 直接内存溢出
> 虚拟机栈和本地方法栈溢出
> 运行时常量池溢出
> 方法区的内存溢出
复制代码
复制代码
> 串行收集器
: 用单线程处理所有垃圾回收工作 , 效率高
: 数据量比较小(100M左右);单处理器下并且对响应时间无要求的应用
> 并行收集器
: “对吞吐量有高要求”,多CPU、对应用响应时间无要求的中、大型应用
> 并发处理器:
: 对响应时间有高要求”,多CPU、对应用响应时间有较高要求的中、大型应用
复制代码
• 强引用
• 软引用(SoftReference)
• 弱引用(WeakReference)
• 虚引用(PhantomReference)
1)强引用
我们使用的大部分引用实际上都是强引用,这是使用最普遍的引用
软引用(SoftReference)
复制代码
> 指针碰撞
- 对象通过引用指向实际的内存空间 , 而指向的即为对应的指针 , 在堆内存中 , 一片内存被一个指针一份为2 , 左边为已经分配内存的空间,右侧为空 , 每一次有新的对象创建,指针就会向右移动一个对象size的距离。这就被称为指针碰撞 , 但是当多线程高并发情况下 , 会出现指针来不及修改的情况
TLAB的全称是Thread Local Allocation Buffer,即线程本地分配缓存区,这是一个线程专用的内存分配区域。 如果设置了虚拟机参数 -XX:UseTLAB,在线程初始化时,同时也会申请一块指定大小的内存,只给当前线程使用,这样每个线程都单独拥有一个空间,如果需要分配内存,就在自己的空间上分配,这样就不存在竞争的情况,可以大大提升分配效率。
TLAB空间的内存非常小,缺省情况下仅占有整个Eden空间的1%,也可以通过选项-XX:TLABWasteTargetPercent设置TLAB空间所占用Eden空间的百分比大小。
TLAB的本质其实是三个指针管理的区域:start,top 和 end,每个线程都会从Eden分配一块空间,例如说100KB,作为自己的TLAB,其中 start 和 end 是占位用的,标识出 eden 里被这个 TLAB 所管理的区域,卡住eden里的一块空间不让其它线程来这里分配。
TLAB只是让每个线程有私有的分配指针,但底下存对象的内存空间还是给所有线程访问的,只是其它线程无法在这个区域分配而已。
// TLAB 的缺陷
1,TLAB空间大小是固定的,但是这时候一个大对象,我TLAB剩余的空间已经容不下它了。(比如100kb的TLAB,来了个110KB的对象)
2,TLAB空间还剩一点点没有用到,有点舍不得。
3,Eden空间够的时候,你再次申请TLAB没问题,我不够了,Heap的Eden区要开始GC,
4,TLAB允许浪费空间,导致Eden区空间不连续,积少成多。以后还要人帮忙打理。
复制代码
Java 虚拟机,是一个可以执行 Java 字节码的虚拟机进程 , 它允许Java 查询在多个任意平台使用 , 但是跨平台的是 Java 程序(包括字节码文件) , 而不是 JVM
> 类加载器,在 JVM 启动时或者类运行时将需要的 class 加载到 JVM 中。
> 内存区,将内存划分成若干个区以模拟实际机器上的存储、记录和调度功能模块,如实际机器上的各种功能的寄存器或者 PC 指针的记录器等。
> 执行引擎,执行引擎的任务是负责执行 class 文件中包含的字节码指令,相当于实际机器上的 CPU 。
> 本地方法调用,调用 C 或 C++ 实现的本地方法的代码返回结果。
// 运行时数据区
> 程序计数器: Java 线程私有,类似于操作系统里的 PC 计数器,它可以看做是当前线程所执行的字节码的行号指示器。
- 如果线程正在执行的是一个 Java 方法,这个计数器记录的是正在执行的虚拟机字节码指令的地址;如果正在执行的是 Native 方法,这个计数器值则为空(Undefined)
> 虚拟机栈(栈内存):Java线程私有,虚拟机栈描述的是 Java 方法执行的内存模型
> 本地方法栈 :和 Java 虚拟机栈的作用类似,区别是该区域为 JVM 提供使用 Native 方法的服务
> 堆内存(线程共享):所有线程共享的一块区域,垃圾收集器管理的主要区域
- 每个方法在执行的时候,都会创建一个栈帧用于存储局部变量、操作数、动态链接、方法出口等信息。
- 每个方法调用都意味着一个栈帧在虚拟机栈中入栈到出栈的过程。
> 方法区(线程共享):各个线程共享的一个区域,用于存储虚拟机加载的类信息、常量、静态变量、即时编译器编译后的代码等数据
- 线程共享区域,因此这是线程不安全的区域。
- 方法区也是一个可能会发生OutOfMemoryError的区域。
- 方法区存储的是从Class文件加载进来的静态变量、类信息、常量池以及编译器编译后的代码。
复制代码
// 内存堆特点
- 存储的是我们new来的对象,不存放基本类型和对象引用。
- 由于创建了大量的对象,垃圾回收器主要工作在这块区域。
- 线程共享区域,因此是线程不安全的。
- 能够发生内存溢出,主要有OutOfMemoryError和StackOverflowError。
// 分代
Java堆区还可以划分为新生代和老年代,新生代又可以进一步划分为Eden区、Survivor 1区、Survivor 2区
// 注意比例 :
8:1:1 + 2:3
复制代码
- 线程私有区域,每一个线程都有独享一个虚拟机栈,因此这是线程安全的区域。
- 存放基本数据类型以及对象的引用。
- 每一个方法执行的时候会在虚拟机栈中创建一个相应栈帧,方法执行完毕后该栈帧就会被销毁。
- 方法栈帧是以先进后出的方式虚拟机栈的。每一个栈帧又可以划分为局部变量表、操作数栈、动态链接、方法出口以及额外的附加信息。
- 这个区域可能有两种异常:如果线程请求的栈深度大于虚拟机所允许的深度,将抛出StackOverflowError异常(通常是递归导致的);JVM动态扩展时无法申请到足够内存则抛出OutOfMemoryError异常。
复制代码
栈内存用来存储基本类型的变量和对象的引用变量;堆内存用来存储Java中的对象,无论是成员变量,局部变量,还是类变量,它们指向的对象都存储在堆内存中。
栈内存归属于单个线程,每个线程都会有一个栈内存,其存储的变量只能在其所属线程中可见,即栈内存可以理解成线程的私有内存;堆内存中的对象对所有线程可见。堆内存中的对象可以被所有线程访问。
如果栈内存没有可用的空间存储方法调用和局部变量,JVM 会抛出 java.lang.StackOverFlowError 错误;如果是堆内存没有可用的空间存储生成的对象,JVM 会抛出 java.lang.OutOfMemoryError 错误。
栈的内存要远远小于堆内存,如果你使用递归的话,那么你的栈很快就会充满。-Xss 选项设置栈内存的大小,-Xms 选项可以设置堆的开始时的大小。
复制代码
HotSpot 虚拟机将其物理上分为了2个部分 :
> 新生代(young generation)
: 绝大多数最新被创建的对象会被分配到这里
: 对象从这个区域消失的过程我们称之为”minor GC“
-> 新生代三空间
:一个伊甸园空间(Eden )
:两个幸存者空间(Survivor )
创建后待伊甸园 -- 第一次GC --> 其中一个幸存者空间 -- 不断堆积--> 饱和后移动到第二个幸存者空间 --> 清空饱和空间 --> 几轮后剩下的放入老年代
> 老年代(old generation)
: 对象没有变得不可达,并且从新生代中存活下来,会被拷贝到这
: 对象从老年代中消失的过程,我们称之为”major GC“(或者”full GC“)
> card table
: 存在于老年代 ,512 byte,记录老年代对新生代的应用
: 由一个 write barrier
> 持久代( permanent generation ) 又名 方法区(method area)
: 保存类常量以及字符串常量
> 加快缓存分配
: bump-the-pointer
- 跟踪在伊甸园空间创建的最后一个对象 ,放在顶部,下次创建查找该对象
: TLABs(Thread-Local Allocation Buffers)
- 该方案为每一个线程在伊甸园空间分配一块独享的空间,这样每个线程只访问他们自己的TLAB空间,再与bump-the-pointer技术结合可以在不加锁的情况下分配内存
复制代码
> 程序员无法自动完成系统的GC ,GC 一般在以下环境被创建
大多数对象会很快变得不可达
只有很少的由老对象(创建时间较长的对象)指向新生对象的引用
复制代码
// stop-the-world
: Stop-the-world会在任何一种GC算法中发生
: Stop-the-world意味着 JVM 因为要执行GC而停止了应用程序的执行
: 当Stop-the-world发生时,除了GC所需的线程以外,所有线程都处于等待状态,直到GC任务完成
: GC优化很多时候就是指减少Stop-the-world发生的时间
// 分代回收
> 为什么垃圾回收要分代:
: 不同的对象生命周期是不一样的 ,采用不同的收集方式,可以提高回收率
> 分代的方式 :
: 年轻代
: 老年代
: 持久代
// 新生代 GC 和老年代 GC
新生代 : 一个 Eden 区 + 两个 Survivor 区
老年代 : 默认新生代(Young)与老年代(Old)的比例的值为 1:2 , 默认的 Eden:from:to=8:1:1
新生代GC(MinorGC/YoungGC):指发生在新生代的垃圾收集动作,因为 Java 对象大多都具备朝生夕灭的特性,所以 MinorGC 非常频繁,一般回收速度也比较快。
老年代GC(MajorGC/FullGC):指发生在老年代的 GC,出现了 MajorGC,经常会伴随至少一次的 MinorGC(但非绝对的,在 Parallel Scavenge 收集器的收集策略里就有直接进行 MajorGC 的策略选择过程)。MajorGC 的速度一般会比 MinorGC 慢 10 倍以上。
// 触发分代回收的方式
Scavenge GC和Full GC。
Scavenge GC : 新对象生成 , 并且在 Eden 申请空间失败 ,即触发
// 垃圾收集器
新生代收集器
-Serial 收集器
- ParNew 收集器
?- ParNew 收集器,是 Serial 收集器的多线程版。
- Parallel Scavenge 收集器
老年代收集器
- Serial Old 收集器
?- Serial Old 收集器,是 Serial 收集器的老年代版本。
- Parallel Old 收集器
?- Parallel Old 收集器,是 Parallel Scavenge 收集器的老年代版本。
- CMS 收集器
新生代 + 老年代收集器
- G1 收集器
- ZGC 收集器
// G1 和 CMS 的区别
• CMS :并发标记清除。他的主要步骤有:初始收集,并发标记,重新标记,并发清除(删除)、重置。
• G1:主要步骤:初始标记,并发标记,重新标记,复制清除(整理)
• CMS 的缺点是对 CPU 的要求比较高。G1是将内存化成了多块,所有对内段的大小有很大的要求。
• CMS是清除,所以会存在很多的内存碎片。G1是整理,所以碎片空间较小。
• G1 和 CMS 都是响应优先把,他们的目的都是尽量控制 STW 时间。
• G1 和 CMS 的 Full GC 都是单线程 mark sweep compact 算法,直到 JDK10 才优化为并行的。
复制代码
收集器 | 串行、并行or并发 | 新生代/老年代 | 算法 | 目标 | 适用场景 |
Serial | 串行 | 新生代 | 复制算法 | 响应速度优先 | 单CPU环境下的Client模式 |
Serial Old | 串行 | 老年代 | 标记-整理 | 响应速度优先 | 单CPU环境下的Client模式、CMS的后备预案 |
ParNew | 并行 | 新生代 | 复制算法 | 响应速度优先 | 多CPU环境时在Server模式下与CMS配合 |
Parallel Scavenge | 并行 | 新生代 | 复制算法 | 吞吐量优先 | 在后台运算而不需要太多交互的任务 |
Parallel Old | 并行 | 老年代 | 标记-整理 | 吞吐量优先 | 在后台运算而不需要太多交互的任务 |
CMS | 并发 | 老年代 | 标记-清除 | 响应速度优先 | 集中在互联网站或B/S系统服务端上的Java应用 |
G1 | 并发 | both | 标记-整理+复制算法 | 响应速度优先 | 面向服务端应用,将来替换CMS |
复制代码
// 方式一 : 调用 system gc 方法 , 开发者手动调用该命令 , 触发 gc
System.gc()
// 方式二 : 调用 Runtime.getRuntime().gc() 方式 , 该方法实际上会 invoke system.gc()
Runtime.getRuntime().gc()
// 方式三 : Use jmap to force GC , 通过 jmap 命令执行 gc
// 该命令不能保证万无一失 , 如果 JVM 被占用导致 GC 无法执行会出现异常
jmap -histo:live 7544
// 方式四 : 使用 Jcmd 命令执行 GC
// 通过 Java diagnostic command (JCMD) JVM 诊断命令触发 GC
jcmd 7544 GC.run
// 方式五 : Use JConsole or Java Mission Control
复制代码
> 应用计数
: 对一个对象有引用/移除 。 即添加/删除数量 , 垃圾回收会回收数量为 0 的对象
> 标记清除
: 第一阶段从引用根节点开始标记所有被引用的对象
: 第二阶段遍历整个堆,把未标记的对象清除
> 复制(Copying)
: 将算法的内存空间分为相等的两个部分,回收时,遍历当前区域,将使用的对象复制到另外的区域
> 标记-整理(Mark-Compact):
: 第一阶段从根节点开始标记所有被引用对象
: 第二阶段遍历整个堆,把清除未标记对象并且把存活对象“压缩”到堆的其中一块,按顺序排放
复制代码
3 .6 .1 Serial 收集器
Serial 收集器是最基础、历史最悠久的收集器,它在进行垃圾收集的时候会暂停所有的工作线程,直到完成垃圾收集过程。下面是Serial垃圾收集器的运行示意图:
复制代码
3 .6 .2 ParNew 收集器
ParNew 垃圾收集器实则是Serial 垃圾收集器的多线程版本,这个多线程在于ParNew垃圾收集器可以使用多条线程进行垃圾回收。
复制代码
3 .6 .3 Parallel Scavenge 收集器
复制代码
3. 6 .4 Serial Old 收集器
Serial Old 收集器是Serial 收集器的老年代版本。其垃圾收集器的运行原理和Serial 收集器是一样的。
复制代码
3 .6 .5 Parallel Old 收集器
Parallel Old 收集器同样是Parallel Scavenge 收集器的老年代版本,支持多线程并发收集。
复制代码
3 .6 .6 CMS 收集器
CMS 垃圾收集器的运作过程相对前面几个垃圾收集器来说比较复杂,整个过程可以分为四个部分:
初始标记: 需要Stop The World,这里仅仅标记GC Roots能够直接关联的对象,所以速度很快。
并发标记: 从关联对象遍历整个GC Roots的引用链,这个过程耗时最长,但是却可以和用户线程并发运行。
重新标记: 修正并发时间,因为用户线程可能会导致标记产生变动,同样需要Stop The World。
并发清除: 清除已经死亡的对象。
复制代码
3 .6 .7 Garbage First 收集器
Garbage First(简称G 1)收集器是垃圾收集器发展史上里程碑式的成果,主要面向服务端应用程序。另外G 1收集器虽然还保留新生代和老年代的概念,但是新生代和老年代不在固定,它们都是一系列区域的动态集合。
• 并行与并发:G1能充分利用多CPU、多核环境下的硬件优势,使用多个CPU来缩短Stop-The-World停顿的时间,部分其他收集器原本需要停顿Java线程执行的GC动作,G1收集器仍然可以通过并发的方式让Java程序继续执行。
• 分代收集:虽然G1可以不需要其他收集器配合就能独立管理整个GC堆,但它能够采用不同的方式去处理新创建的对象和已经存活了一段时间、熬过多次GC的就对象以获取更好的收集效果。
• 空间整合:G1从整体上来看是基于“标记-整理”算法实现的收集器,从局部(两个Region之间)上来看是基于“复制”算法实现的,这意味着G1运作期间不会产生内存空间碎片,收集后能提供规整的可用内存。
• 可预测的停顿:这是G1相对于CMS的另一大优势。
复制代码
1)检测类是否被加载
当虚拟机遇到 new 指令时,首先先去检查这个指令的参数是否能在常量池中定位到一个类的符号引用,并且检查这个符号引用代表的类是否已被加载、解析和初始化过。如果没有,就执行类加载过程。
2)为对象分配内存
类加载完成以后,虚拟机就开始为对象分配内存,此时所需内存的大小就已经确定了。只需要在堆上分配所需要的内存即可。
具体的分配内存有两种情况:
第一种情况是内存空间绝对规整
第二种情况是内存空间是不连续的。
- 对于内存绝对规整的情况相对简单一些,虚拟机只需要在被占用的内存和可用空间之间移动指针即可,这种方式被称为“指针碰撞”。
- 对于内存不规整的情况稍微复杂一点,这时候虚拟机需要维护一个列表,来记录哪些内存是可用的。分配内存的时候需要找到一个可用的内存空间,然后在列表上记录下已被分配,这种方式成为“空闲列表”。
多线程并发时会出现正在给对象 A 分配内存,还没来得及修改指针,对象 B 又用这个指针分配内存,这样就出现问题了。解决这种问题有两种方案:
• 第一种,是采用同步的办法,使用 CAS 来保证操作的原子性。
• 另一种,是每个线程分配内存都在自己的空间内进行,即是每个线程都在堆中预先分配一小块内存,称为本地线程分配缓冲(Thread Local Allocation Buffer, TLAB),分配内存的时候再TLAB上分配,互不干扰。可以通过 -XX:+/-UseTLAB 参数决定。
3)为分配的内存空间初始化零值
对象的内存分配完成后,还需要将对象的内存空间都初始化为零值,这样能保证对象即使没有赋初值,也可以直接使用。
4)对对象进行其他设置
分配完内存空间,初始化零值之后,虚拟机还需要对对象进行其他必要的设置,设置的地方都在对象头中,包括这个对象所属的类,类的元数据信息,对象的 hashcode ,GC 分代年龄等信息。
5)执行 init 方法
执行完上面的步骤之后,在虚拟机里这个对象就算创建成功了,但是对于 Java 程序来说还需要执行 init 方法才算真正的创建完成,因为这个时候对象只是被初始化零值了,还没有真正的去根据程序中的代码分配初始值,调用了 init 方法之后,这个对象才真正能使用。
复制代码
对象的内存布局包括三个部分:
- 对象头:对象头包括两部分信息。
• 第一部分,是存储对象自身的运行时数据,如哈希码,GC 分代年龄,锁状态标志,线程持有的锁等等。
• 第二部分,是类型指针,即对象指向类元数据的指针。
- 实例数据:就是数据。
- 对齐填充:不是必然的存在,就是为了对齐。
复制代码
句柄定位:Java 堆会画出一块内存来作为句柄池,reference 中存储的就是对象的句柄地址,而句柄中包含了对象实例数据与类型数据各自的具体地址信息。
直接指针访问:Java 堆对象的不居中就必须考虑如何放置访问类型数据的相关信息,而 reference 中存储的直接就是对象地址。
复制代码
引用计数算法: 为对象添加一个引用计数器,每当对象在一个地方被引用,则该计数器加1;每当对象引用失效时,计数器减1。但计数器为0的时候,就表白该对象没有被引用。
可达性分析算法: 通过一系列被称之为“GC Roots”的根节点开始,沿着引用链进行搜索,凡是在引用链上的对象都不会被回收。
// GC Root 对象 : 可达性的根对象
Java虚拟机栈中被引用的对象,各个线程调用的参数、局部变量、临时变量等。
方法区中类静态属性引用的对象,比如引用类型的静态变量。
方法区中常量引用的对象。本地方法栈中所引用的对象。
Java虚拟机内部的引用,基本数据类型对应的Class对象,一些常驻的异常对象。
被同步锁(synchronized)持有的对象。
复制代码
// 什么是类加载器
类加载器(ClassLoader),用来加载 Java 类到 Java 虚拟机中 , 一般来说,Java 虚拟机使用 Java 类的方式如下:Java 源程序(.java 文件)在经过 Java 编译器编译之后就被转换成 Java 字节代码(.class 文件) , 类加载器,负责读取 Java 字节代码,并转换成 java.lang.Class 类的一个实例
// 发生的时期
• 1、遇到 new、getstatic、putstatic、invokestatic 这四条字节码指令时,如果类还没进行初始化,则需要先触发其初始化。
• 2、使用 java.lang.reflect 包的方法对类进行反射调用的时候,如果类还没进行初始化,则需要先触发其初始化。
• 3、当初始化了一个类的时候,如果发现其父类还没进行初始化,则需要先触发其父类的初始化。
• 4、当虚拟机启动时,用户需要指定一个执行的主类,即调用其 #main(String[] args) 方法,虚拟机则会先初始化该主类。
• 5、当使用 JDK7 的动态语言支持时,如果一个 java.lang.invoke.MethodHandle 实例最后的解析结果为 REF_getStatic、REF_putStatic、REF_invokeStatic 的方法句柄,并且这个方法句柄所对应的类没有进行过初始化,则需要先触发其初始化。
// 加载Class 的方式
• 第一个阶段,加载(Loading),是找到 .class 文件并把这个文件包含的字节码加载到内存中。
• 第二阶段,连接(Linking),又可以分为三个步骤,分别是字节码验证、Class 类数据结构分析及相应的内存分配、最后的符号表的解析。
• 第三阶段,Initialization(类中静态属性和初始化赋值),以及Using(静态块的执行)等。
复制代码
// Java 中有三个类加载器
1. Bootstrap CLassloder
最顶层的加载类,主要加载核心类库,%JRE_HOME%\lib下的rt.jar、resources.jar、charsets.jar和class等。另外需要注意的是可以通过启动jvm时指定-Xbootclasspath和路径来改变Bootstrap ClassLoader的加载目录。比如java -Xbootclasspath/a:path被指定的文件追加到默认的bootstrap路径中。我们可以打开我的电脑,在上面的目录下查看,看看这些jar包是不是存在于这个目录。
2. Extention ClassLoader
扩展的类加载器,加载目录%JRE_HOME%\lib\ext目录下的jar包和class文件。还可以加载-D java.ext.dirs选项指定的目录。
3. AppClassLoader
加载当前应用的classpath的所有类
// classLoad 加载流程
Java 基于 Launcher 入口应用
- Launcher初始化了ExtClassLoader和AppClassLoader
-
// 知识点
1 父加载器不是父类
2 Bootstrap ClassLoader是由C/C++编写的
// 常用方法
- 获取父加载器 : cl.getParent() , cl.getParent().getParent()
- 通过指定的全限定类名加载class : loadClass()
复制代码
// 双亲委派
1 首先判断这个class是不是已经加载成功
2 当 class 未加载 , 先异常往根节点查找 , 是否上层加载器已经加载 (其中如果某个层已经加载 , 则直接返回)
3 当到 Bootstrap classloader 仍然未加载 , 则由 Bootstrap classloader 到指定的路径查找 , 如果没有查找到 ,则由子加载器继续到其对应路径查找
4 到此时仍然没有查找到 ,则返回异常
// 流程 TODO :
// 自定义 ClassLoader
复制代码
// Class 加载流程
1. .java 文件编译后 , 生成一个class文件
2. classloader通过相关的规则初次找到这个class
3. 然后会读取class的头文件,包括以下几种数据
a. 0xCAFEBABE:判断是否为Java编译
b. 50 , 0:判断版本号
4. String, ArrayList分别有不同层次的loader加载,最顶层的叫Bootstrap Classloader , 下一次级叫Extension Classloader,最底层App Classloade
5. 接着class会被加载到方法区 , 在堆中new 出的该class类的对象来确认class是否被加载
6. 每个class会有局部变量区,还有一个操作数栈 , 线程就会按照流程执行,例如取出局部变量区的数据,放入栈中,最后运行后变成一个数后重新放入
7. 接中从栈中取出结果,重新放入变量区
8. 而线程也不一定只有一个工作台,也可能有多个,但是只在最上面工作(多线程情况),这每个工作台叫栈帧,而多个工作台就是方法调用方法的结果
// Java 对象头
GC分代信息,锁信息,哈希码,指向Class类元信息的指针
Hotspot 虚拟机的对象头主要包括两部分数据:Mark Word(标记字段)、Klass Pointer(类型指针)
- Klass Point 是是对象指向它的类元数据的指针,虚拟机通过这个指针来确定这个对象是哪个类的实例
- Mark Word 用于存储对象自身的运行时数据,如哈希码(HashCode)、GC 分代年龄、锁状态标志、线程持有的锁、偏向线程 ID、偏向时间戳等等。Java 对象头一般占有两个机器码(在 32 位虚拟机中,1 个机器码等于 4 字节,也就是 32 bits)。但是如果对象是数组类型,则需要三个机器码,因为 JVM 虚拟机可以通过 Java 对象的元数据信息确定 Java 对象的大小,无法从数组的元数据来确认数组的大小,所以用一块来记录数组长度。
// Java 对象实例数据
实例数据部分是对象真正存储的有效信息
// Java 对象对齐填充
虚拟机规范要求对象大小必须是8字节的整数倍
复制代码
Java 对象头的存储结构 32 位 TODO : 待完善
25Bit | 4Bit | 1Bit | 2Bit | |
对象的HashCode | 对象的分代年龄 | 是否是偏向锁 | 锁标志位 | |
HotSpot 通过 OOP-Klass 模型来在虚拟机中表示一个对象 , 这里的 OOP 指的是 Ordinary Object Pointer (普通对象指针),它用来表示对象的实例信息,看起来像个指针实际上是藏在指针里的对象。而 Klass 则包含元数据和方法信息,用来描述Java类。
作用 :
避免让每个对象中都含有一个vtable(虚函数表),所以就把对象模型拆成klass和oop,其中oop中不含有任何虚函数,而Klass就含有虚函数表,可以进行method dispatch。
Klass : Java类在HotSpot中的c++对等体,用来描述Java类 , 在加载过程中创建
- 实现语言层面的Java类
- 实现Java对象的分发功能
OOP : 在Java程序运行过程中new对象时创建的 , 包含以下部分
- instanceOopDesc,也叫对象头
- Mark Word,主要存储对象运行时记录信息,如hashcode, GC分代年龄,锁状态标志,线程ID,时间戳等
- 元数据指针,即指向方法区的instanceKlass实例
- 实例数据
复制代码
GC 监控是指监控 JVM 执行 GC 的过程
例如 :
> 何时一个新生代被移动到老年代,以及其中被花费的时间
> stop the world 何时发生,执行了多长时间
> GC 访问的接口 : GUI / CUI 两大类
: cUI GC 监控方法使用的独立的 jstat 的 CUI 应用
: cUI 或者在启动的时候选择JVM 参数 verbosegc
: GUI GC 由一个单独的图形化界面完成 : jconsole ,jvisualvm , Visual GC
jstat :
参数名称见附录
-verbosegc : 启动 Java 应用时可指定
复制代码
• jps :虚拟机进程状况工具
JVM Process Status Tool ,显示指定系统内所有的HotSpot虚拟机进程。
-q:忽略输出的类名、Jar名以及传递给main方法的参数,只输出pid。
-m:输出传递给main方法的参数,如果是内嵌的JVM则输出为null。
-l:输出完全的包名,应用主类名,jar的完全路径名
-v:输出传给jvm的参数
-V:输出通过标记的文件传递给JVM的参数(.hotspotrc文件,或者是通过参数-XX:Flags=指定的文件)。
-J 用于传递jvm选项到由javac调用的java加载器中,
• jstat :虚拟机统计信息监控工具
JVM statistics Monitoring ,是用于监视虚拟机运行时状态信息的命令,它可以显示出虚拟机进程中的类装载、内存、垃圾收集、JIT编译等运行数据。 常见的用法包括类的加载及卸载情况 , 查看新生代、老生代及持久代的容量及使用情况 , 查看新生代、老生代及持久代的垃圾收集情况,包括垃圾回收的次数及垃圾回收所占用的时间 , 查看新生代中Eden区及Survior区中容量及分配情况
• jinfo :Java 配置信息工具
JVM Configuration info ,这个命令作用是实时查看和调整虚拟机运行参数。
• jmap :Java 内存映射工具
JVM Memory Map ,命令用于生成 heap dump 文件。
• jhat :虚拟机堆转储快照分析工具
JVM Heap Analysis Tool ,命令是与 jmap 搭配使用,用来分析 jmap 生成的 dump 文件。jhat 内置了一个微型 的HTTP/HTML 服务器,生成 dump 的分析结果后,可以在浏览器中查看。
• jstack :Java 堆栈跟踪工具
Java Stack Trace ,用于生成 Java 虚拟机当前时刻的线程快照。
• HSDIS :JIT 生成代码反编译
// Java 自带
• JConsole :Java 监视与管理控制台
Java Monitoring and Management Console 是从 Java5 开始,在 JDK 中自带的 Java 监控和管理控制台,用于对 JVM 中内存,线程和类等的监控。
• VisualVM :多合一故障处理工具
JDK 自带全能工具,可以分析内存快照、线程快照、监控内存变化、GC变化等。
特别是 BTrace 插件,动态跟踪分析工具。
// 其他
• MAT :内存分析工具
• [GChisto](GC 日志分析工具 —— GChisto) :一款专业分析 GC 日志的工具。
// JMC : Java Mission Control
-> 完整的图形化界面
-> 提供对象查看
复制代码
// 获取 Java 程序使用的内存
Runtime#freeMemory() 方法,返回剩余空间的字节数。
Runtime#totalMemory() 方法,总内存的字节数。
Runtime#maxMemory() 方法,返回最大内存的字节数。
复制代码
// --------------- jconsole 使用
- 控制台直接输入 : jconsole
- 1 选择需要调试的本地连接 , 点击连接
- 2 选择远程连接 , 输入用户名 , 口令连接
// -------------- jvisualvm 使用
- 找到 JDK 的安装目录 , 点击运行 jvisualvm.exe
- 右侧直接选择运行中的应用
// --------------- jstat 使用 (命令行)
jstat
// 常用的压测工具
1 LoadRunner : 预测系统行为和性能的负载测试工具
2 Apache JMeter : 开源压测产品
3 NeoLoad : 负载和性能测试工具
4 WebLOAD : 来自Radview公司的负载测试工具,它可被用以测试系统性能和弹性,也可被用于正确性验证
5 阿里云PTS : 一个SaaS性能测试平台,具有强大的分布式压测能力
6 Loadstorm : 一款针对Web应用的云端负载测试工具,通过模拟海量点击来测试Web应用在大负载下的性能表现
7 CloudTest : 一个集性能和功能测试于一体的综合压力测试云平台
8 Load impact : 一款服务于DevOps的性能测试工具,支持各种平台的网站、Web应用、移动应用和API测试
// JMeter 使用
复制代码
> Step 1 : 拿到 pid
ps -ef | grep java
> Step 2 : 查看资源进程
top -Hp 30275
printf "%x\n" 3440
> 简单使用
jstack 30275
> 查看指定进程
printf "%x\n" 17880
jstack 17880|grep 45d8 -A 30
// 查看 TimeWait
// Windows 版本
netstat -ano |findstr "80" windows
netstat -an | find "TIME_WAIT" /C
// jstack 统计线程数
jstack -l 28367 | grep 'java.lang.Thread.State' | wc -l
// jstack
Usage:
jstack [-l]
(to connect to running process) 连接活动线程
jstack -F [-m] [-l]
(to connect to a hung process) 连接阻塞线程
jstack [-m] [-l]
(to connect to a core file) 连接dump的文件
jstack [-m] [-l] [server_id@]
(to connect to a remote debug server) 连接远程服务器
Options:
-F to force a thread dump. Use when jstack does not respond (process is hung)
-m to print both java and native frames (mixed mode)
-l long listing. Prints additional information about locks
-h or -help to print this help message
复制代码
JOL:查看Java 对象布局、大小工具
org.openjdk.jol
jol-core
put-the-version-here
static Object generate() {
Map map = new HashMap<>();
map.put("a", new Integer(1));
map.put("b", "b");
map.put("c", new Date());
for (int i = 0; i < 10; i++) {
map.put(String.valueOf(i), String.valueOf(i));
}
return map;
}
查看对象内部信息: ClassLayout.parseInstance(obj).toPrintable()
查看对象外部信息:包括引用的对象:GraphLayout.parseInstance(obj).toPrintable()
查看对象占用空间总大小:GraphLayout.parseInstance(obj).totalSize()
复制代码
Thread Dump是非常有用的诊断Java应用问题的工具。每一个Java虚拟机都有及时生成所有线程在某一点状态的thread-dump的能力,虽然各个 Java虚拟机打印的thread dump略有不同,但是大多都提供了当前活动线程的快照,及JVM中所有Java线程的堆栈跟踪信息,堆栈信息一般包含完整的类名及所执行的方法,如果可能的话还有源代码的行数。
1. 查找内存泄露,常见的是程序里load大量的数据到缓存;
2. 发现死锁线程;
// Linux 抓取 Dump 的方式 (20810 是 jstack 在Java 目录下 )
jstack -l 20810 | tee -a /opt/jstack.log
// 简单学习 :
// 虚拟机信息
Full thread dump Java HotSpot(TM) 64-Bit Server VM (25.251-b08 mixed mode):
// 线程info信息块:
// 线程名称 - #36 - 线程类型 (daemon) - 优先级 (prio)
// tid : JVM 线程ID
// nid : 对应系统线程id
// 线程状态:in Object.wait().
// 起始栈地址:[0xae77d000]
"Attach Listener" #36 daemon prio=9 os_prio=0 tid=0x00007f5fec001000 nid=0x6658 waiting on condition [0x0000000000000000]
java.lang.Thread.State: RUNNABLE
Locked ownable synchronizers:
- None
// 堆栈信息
线程状态 - java.lang.Thread.State: WAITING (parking)
线程抛出节点 - at sun.misc.Unsafe.park(Native Method)
- parking to wait for <0x00000000e4e7bdf8> (a java.util.concurrent.locks.AbstractQueuedSynchronizer$ConditionObject)
at java.util.concurrent.locks.LockSupport.park(LockSupport.java:175)
at java.util.concurrent.locks.AbstractQueuedSynchronizer$ConditionObject.await(AbstractQueuedSynchronizer.java:2039)
at java.util.concurrent.LinkedBlockingQueue.take(LinkedBlockingQueue.java:442)
at org.apache.tomcat.util.threads.TaskQueue.take(TaskQueue.java:107)
at org.apache.tomcat.util.threads.TaskQueue.take(TaskQueue.java:33)
at java.util.concurrent.ThreadPoolExecutor.getTask(ThreadPoolExecutor.java:1074)
at java.util.concurrent.ThreadPoolExecutor.runWorker(ThreadPoolExecutor.java:1134)
at java.util.concurrent.ThreadPoolExecutor$Worker.run(ThreadPoolExecutor.java:624)
at org.apache.tomcat.util.threads.TaskThread$WrappingRunnable.run(TaskThread.java:61)
at java.lang.Thread.run(Thread.java:748)
// 方案
cpu飙高,load高,响应很慢 --> 单请求 dump 多次
查找占用cpu最多的线程信息 --> 对对应的线程进行 dump , 先 top 查询对应 id
cpu使用率不高但是响应很慢 --> 进行dump,查看是否有很多thread struck在了i/o、数据库等地方,定位瓶颈原因
请求无法响应 --> 多次dump,对比是否所有的runnable线程都一直在执行相同的方法
// 常见分析
死锁 , 热锁
复制代码
> GC 优化永远是最后一项任务
> 原则 :
> 将转移到老年代的对象数量降到最少
:调整新生代空间的大小。
> 减少 Full GC 的执行时间
: 你需要将老年代空间设定为一个“合适”的值
复制代码
> 使用 StringBuilder 或者StringBuffer 来替代String
> 尽量少的输出日志
GC 优化考虑的参数
复制代码
定义 | 参数 | 描述 |
堆内存空间 | -Xms | Heap area size when starting JVM启动JVM时的堆内存空间。 |
-Xmx | Maximum heap area size堆内存最大限制 | |
新生代空间 | -XX:NewRatio | Ratio of New area and Old area新生代和老年代的占比 |
-XX:NewSize | New area size新生代空间 | |
-XX:SurvivorRatio | Ratio of Eden area and Survivor area伊甸园空间和幸存者空间的占比 |
分类 | 参数 | 备注 |
Serial GC | -XX:+UseSerialGC | |
Parallel GC | -XX:+UseParallelGC -XX:ParallelGCThreads=value | |
Parallel Compacting GC | -XX:+UseParallelOldGC | |
CMS GC | -XX:+UseConcMarkSweepGC -XX:+UseParNewGC -XX:+CMSParallelRemarkEnabled -XX:CMSInitiatingOccupancyFraction=value -XX:+UseCMSInitiatingOccupancyOnly | |
G1 | -XX:+UnlockExperimentalVMOptions -XX:+UseG1GC | 在JDK6中这两个参数必须同时使用 |
1 > 监控 GC 状态
2 > 分析监控结果 , 考虑是否需要GC
3 > 调整 GC 类型 , 分配存储空间
4 > 分析结果
复制代码
> 1 堆栈溢出
- java.lang.OutOfMemoryError: ......java heap space.....
- 看到heap相关的时候就肯定是堆栈溢出 , 适当调整 -Xmx和-Xms
- 访问量太多并且每个访问的时间太长或者数据太多,导致数据释放不掉
- java.lang.OutOfMemoryError:GC over head limit exceeded -- 系统处于高频的GC状态,而且回收的效果依然不佳
> 2 PermGen的溢出
- java.lang.OutOfMemoryError: PermGen space
- 系统的代码非常多或引用的第三方包非常多、或代码中使用了大量的常量、或通过intern注入常量、或者通过动态代码加载等方法,导致常量池的膨胀
-XX:PermSize和-XX:MaxPermSize的大小
> 3 ByteBuffer中的allocateDirect() 溢出
- java.lang.OutOfMemoryError: Direct buffer memory
-直接或间接使用了ByteBuffer中的allocateDirect方法的时候,而不做clear的时候就会出现类似的问题
-XX:MaxDirectMemorySize
> 4 java.lang.StackOverflowError
- java.lang.StackOverflowError
- -Xss太小了,我们申请很多局部调用的栈针等内容是存放在用户当前所持有的线程中的
> 5 java.lang.OutOfMemoryError: unable to create new native thread
- 说明除了heap以外的区域,无法为线程分配一块内存区域了,这个要么是内存本身就不够,要么heap的空间设置得太大了
> 6 java.lang.OutOfMemoryError: request {} byte for {}out of swap
- 一般是由于地址空间不够而导致
复制代码
// 原因 :
1 . 应用程序创建了太多无法快速回收的对象。
2 . 当堆被分割时,即使有很多空闲空间,在老代中直接分配也可能失败
https://blog.gceasy.io/2020/06/02/simple-effective-g1-gc-tuning-tips/
https://www.oracle.com/technetwork/tutorials/tutorials-1876574.html
复制代码
TODO
复制代码
TODO
复制代码
理论上说上 32 位的 JVM 堆内存可以到达 2^32,即 4GB
4 位 JVM 允许指定最大的堆内存,理论上可以达到 2^64
复制代码
> 直接内存(Direct Memory),并不是虚拟机运行时数据区的一部分,也不是 Java 虚拟机规范中农定义的内存区域
> NIO(New Input/Output) 类,引入了一种基于通道(Channel)与缓冲区(Buffer)的 I/O 方式,它可以使用 native 函数库直接分配堆外内存,然后通脱一个存储在 Java 堆中的 DirectByteBuffer 对象作为这块内存的引用进行操作
// 对比
直接内存申请空间耗费更高的性能,当频繁申请到一定量时尤为明显
直接内存 IO 读写的性能要优于普通的堆内存,在多次读写操作的情况下差异明显
复制代码
> GCEasy :
复制代码
// Step 1 : 开启指定应用的飞行记录
// Step 2 : 分析模块 , 飞行记录提供了以下几个模块
> 一般信息 : | 概述 | JVM 信息 | 系统属性 | 记录
- CPU 占用率 : 可以判断是否CPU占满导致的缓慢
- 堆使用率 : 内存使用情况会导致垃圾收集的频率 , Redis 的使用(AOF/RDB持久化异常) ,
- JVM 信息 : 可以了解到当前使用的虚拟机类型(不同类型虚拟机会使用不同的回收策略 , 以及使用的JDK , 配置的 JVM 参数等)
> 内存 : | 概述 | 垃圾收集 | GC 时间 | GC 配置 | 分配 | 对象统计信息
- 概述 :
- GC 配置 : 包含 GC 的配置信息 , 以及对应的收集器类型
- GC 统计时间 :
- 垃圾收集 : 包含垃圾收集的次数和消耗的时间
-> 垃圾收集的频率是否正常 , 是否过于频繁
-> 每次消耗的时候会不会太长 ?stop-world 后会影响其他的运行
- GC 时间 : 该时间为不同年龄代的时间
- GC 配置 : 配置的 GC 线程数 , 堆内存等配置详情
- 分配 : 主要是 TLAB 的分配情况 ,
> 代码 : | 概述 | 热点方法 | 调用树 | 异常错误 | 编译 | 类加载
- 热点方法 : 判断代码中对相关方法的调用是否合理(对应类的堆会不会过大 , 对象会不会过多)
- 热点方法 : 判断常用的对象会不会有多线程风险及死锁风险 , 是否效率过低
- 调用树 : 通过调用树追溯问题的根源
- 异常错误 : 判断是否存在异常
> 线程 : | 概述 | 热点线程 | 争用 | 等待时间 | 线程转储 | 锁定实例
- 判断死锁
- 判断线程的销毁情况
- 判断是否有切换线程带来的损失 (频繁切换) , 热锁的情况
- 判断线程是否合理使用了线程池等工具
> IO : | 概述 | 文件读写 | 套接字读写 |
- 这个模块可以有效的分析是否为文件读写时间导致的延迟或者套接字访问导致的系统缓慢
> 系统信息及环境变量 , 略
> 事件 : 发生的事件比例 , 包括 Java Thread Park , Java Thread start , Java Thread end 等
复制代码
skywalking 是链路分析工具 , 是很好的辅助工具 , 能快速的分析瓶颈点
// 使用方式(不过多简述 , 很多) , 注意需要点击一下刷新才会出数据
-javaagent:D:\java\plugins\shywalking\agent82\skywalking-agent.jar
// 常用用法:
- 仪表盘 : 用于查看各服务器状态
- 拓扑图 : 用于分析服务器的结构是否合理 (redis 服务器 , mysql 服务器 , 等等其他的)
- 追踪 : 可以快速判断慢SQL , 慢 接口
- 性能分析 : (创建的端点是追踪里面的端点,点击分析后可以直接追踪到对应的代码行)
复制代码
> 对连接数进行优化 , 包括
- Mysql 连接数
- redis 连接数
- Mysql 连接池数量 (连接池数量不是越大月好)
- Redis 连接池数量
- tomcat 连接数
- TCP 连接数 (包括指定端口 , 随机端口 , 端口总数限制)
- LDAP 连接数
- OpenFile 连接数
> 对 GC 进行分析
- 1 查看 CPU 使用情况 : top
- 2 查看指定进程的使用 : top -Hp [进程ID]
- 3 分析当前进程栈 : jstack [进程ID] > jstack_01
- 4 查看 GC 情况 : jstat -gcutil [进程id] 1000
- 5 查看堆内存 : jmap -histo [进程id] > jmap.txt
- 6 打印堆内存快照 : jmap -dump:format=b,file=aa.bin 1232134
> 对 GC 进行优化
// 80 时进行 GC
- -XX:CMSInitiatingOccupancyFraction=80 -XX:+UseCMSInitiatingOccupancyOnly
// 保留 GC log
- -XX:+PrintGCDetails -XX:+PrintGCDateStamps -XX:+PrintHeapAtGC -Xloggc:gc.log
> 负载均衡
- 判断负载的方式是轮询还是压力
// 优化 新生代容积
-Xmn350M -> -Xmn800M
-XX:SurvivorRatio=4 -> -XX:SurvivorRatio=8
-Xms1000m ->-Xms1800m
// 优化 metaspace
-Xmn350M -> -Xmn800M
-Xms1000M ->1800M
-XX:MetaspaceSize=200M
-XX:CMSInitiatingOccupancyFraction=75
复制代码
参数名称 | 描述 |
gc | 输出每个堆区域的当前可用空间以及已用空间(伊甸园,幸存者等等),GC执行的总次数,GC操作累计所花费的时间。 |
gccapactiy | 输出每个堆区域的最小空间限制(ms)/最大空间限制(mx),当前大小,每个区域之上执行GC的次数。(不输出当前已用空间以及GC执行时间)。 |
gccause | 输出-gcutil提供的信息以及最后一次执行GC的发生原因和当前所执行的GC的发生原因 |
gcnew | 输出新生代空间的GC性能数据 |
gcnewcapacity | 输出新生代空间的大小的统计数据。 |
gcold | 输出老年代空间的GC性能数据。 |
gcoldcapacity | 输出老年代空间的大小的统计数据。 |
gcpermcapacity | 输出持久带空间的大小的统计数据。 |
gcutil | 输出每个堆区域使用占比,以及GC执行的总次数和GC操作所花费的事件。 |
列 | 说明 | Jstat参数 |
S0C | 输出Survivor0空间的大小。单位KB。 | -gc -gccapacity -gcnew -gcnewcapacity |
S1C | 输出Survivor1空间的大小。单位KB。 | -gc -gccapacity -gcnew -gcnewcapacity |
S0U | 输出Survivor0已用空间的大小。单位KB。 | -gc -gcnew |
S1U | 输出Survivor1已用空间的大小。单位KB。 | -gc -gcnew |
EC | 输出Eden空间的大小。单位KB。 | -gc -gccapacity -gcnew -gcnewcapacity |
EU | 输出Eden已用空间的大小。单位KB。 | -gc -gcnew |
OC | 输出老年代空间的大小。单位KB。 | -gc -gccapacity -gcold -gcoldcapacity |
OU | 输出老年代已用空间的大小。单位KB。 | -gc -gcold |
PC | 输出持久代空间的大小。单位KB。 | -gc -gccapacity -gcold -gcoldcapacity -gcpermcapacity |
PU | 输出持久代已用空间的大小。单位KB。 | -gc -gcold |
YGC | 新生代空间GC时间发生的次数。 | -gc -gccapacity -gcnew -gcnewcapacity -gcold -gcoldcapacity -gcpermcapacity -gcutil -gccause |
YGCT | 新生代GC处理花费的时间。 | -gc -gcnew -gcutil -gccause |
FGC | full GC发生的次数。 | -gc -gccapacity -gcnew -gcnewcapacity -gcold -gcoldcapacity -gcpermcapacity -gcutil -gccause |
FGCT | full GC操作花费的时间 | -gc -gcold -gcoldcapacity -gcpermcapacity -gcutil -gccause |
GCT | GC操作花费的总时间。 | -gc -gcold -gcoldcapacity -gcpermcapacity -gcutil -gccause |
NGCMN | 新生代最小空间容量,单位KB。 | -gccapacity -gcnewcapacity |
NGCMX | 新生代最大空间容量,单位KB。 | -gccapacity -gcnewcapacity |
NGC | 新生代当前空间容量,单位KB。 | -gccapacity -gcnewcapacity |
OGCMN | 老年代最小空间容量,单位KB。 | -gccapacity -gcoldcapacity |
OGCMX | 老年代最大空间容量,单位KB。 | -gccapacity -gcoldcapacity |
OGC | 老年代当前空间容量制,单位KB。 | -gccapacity -gcoldcapacity |
PGCMN | 持久代最小空间容量,单位KB。 | -gccapacity -gcpermcapacity |
PGCMX | 持久代最大空间容量,单位KB。 | -gccapacity -gcpermcapacity |
PGC | 持久代当前空间容量,单位KB。 | -gccapacity -gcpermcapacity |
PC | 持久代当前空间大小,单位KB | -gccapacity -gcpermcapacity |
PU | 持久代当前已用空间大小,单位KB | -gc -gcold |
LGCC | 最后一次GC发生的原因 | -gccause |
GCC | 当前GC发生的原因 | -gccause |
TT | 老年化阈值。被移动到老年代之前,在新生代空存活的次数。 | -gcnew |
MTT | 最大老年化阈值。被移动到老年代之前,在新生代空存活的次数。 | -gcnew |
DSS | 幸存者区所需空间大小,单位KB。 | -gcnew |
说明 | 命令 |
开启 GC Log (java8) | -XX:+PrintGCDetails -XX:+PrintGCDateStamps -Xloggc:{file-path} |
开启 GC Log (java9) | -Xlog:gc*:file={file-path} |
// 此篇笔记是一个总结分析的笔记 , 时间周期较长 , 很多知识点已经难以追溯出处 , 如果此处遗漏了某位道友 ,敬请谅解
Java 技术驿站 , 一系列死磕看的相当爽
http://cmsblogs.com/?p=5140
CSDN
http://blog.csdn.net/linxdcn/article/details/72896616
芋道源码 , 很不错的源码博客
http://www.iocoder.cn/
掘金老哥
https://juejin.im/post/5c31dca7e51d45524975d046
CSDN
https://blog.csdn.net/briblue
以及所有对该文章有所帮助的表示感谢
原文链接:
https://juejin.cn/post/69306051