doc: 阅读jvm文档

This commit is contained in:
asahi
2025-11-28 22:31:14 +08:00
parent 640560d264
commit 9f51f13801

View File

@@ -28,6 +28,25 @@
- [垃圾回收细节](#垃圾回收细节)
- [回收方法区](#回收方法区)
- [判断类是否可回收](#判断类是否可回收)
- [垃圾收集算法](#垃圾收集算法)
- [标记-清除算法](#标记-清除算法)
- [复制算法](#复制算法)
- [基于复制算法的虚拟机新生代内存划分](#基于复制算法的虚拟机新生代内存划分)
- [标记-整理算法](#标记-整理算法)
- [分代收集算法](#分代收集算法)
- [Hotspot算法实现](#hotspot算法实现)
- [枚举根节点](#枚举根节点)
- [OopMap](#oopmap)
- [安全点](#安全点)
- [安全区域](#安全区域)
- [垃圾收集器](#垃圾收集器)
- [Serial收集器](#serial收集器)
- [ParNew](#parnew)
- [Parallel Scavenge](#parallel-scavenge)
- [MaxGCPauseMillis](#maxgcpausemillis)
- [GCTimeRatio](#gctimeratio)
- [Serial Old](#serial-old)
- [Parallel Old](#parallel-old)
# 深入理解java虚拟机
@@ -245,7 +264,129 @@ Java虚拟机规范并不强制要求对方法区进行垃圾回收并且在
在大量使用反射、动态代理、Cglib、动态生成jsp、动态生成osgi等频繁自定义ClassLoader的场景都需要虚拟机具备类卸载功能已保证永久代不会溢出。
### 垃圾收集算法
#### 标记-清除算法
最基础的垃圾收集算法是`标记-清除`算法Mark-Sweep算法分为`标记``清除`两个阶段:
- 首先,算法会标记出需要回收的对象
- 对象在被认定为需要回收前,需要经历两次标记,具体细节在前文有描述
- 在标记完成后,会对所有被标记对象进行统一回收
标记清除算法存在如下不足之处:
- `效率问题`:标记和清除这两个过程的效率都不高
- `空间问题`: 标记和清除之后会产生大量不连续的内存碎片,大量碎片可能导致后续需要分配大对象时无法找到足够的连续内存而不得不触发另一次垃圾收集动作
#### 复制算法
为了解决效率问题,提出了一种`复制`算法。该算法将可用内存划分为大小相等的两块,每次只使用其中的一块,当本块内存用尽后,其会将存活对象复制到另一块上,并将已使用的内存空间一次性清理掉。这样,每次回收都是针对半区的,并且在分配内存时也不用考虑内存碎片的问题。
复制算法存在如下弊端:
- 复制算法将可用内存划分为相同的两部分,每次只使用一半,该代价比较高昂
##### 基于复制算法的虚拟机新生代内存划分
当前商用虚拟机都采用该种方法来回收新生代。在新生代中98%的对象生命周期都很短暂完全不需要根据11来划分内存空间。`对于新生代内存空间将其划分为了一块较大的Eden空间和两块较小的Survivor空间每次只会使用Eden和其中一块Survivor。`
当发生垃圾回收时会将Eden区和Survivor区中还存活的对象一次性复制到另一块Survivor。Hotspot虚拟机默认Eden区和两块Survivor区的大小比例为`8:1:1`,即每次新生代内存区域中可用内存为`90%`。当然,`并不能确保每次发生垃圾回收时另一块Survivor区能够容纳Eden+Survivor中所有的存活对象`,故而,需要老年代来作为担保。
#### 标记-整理算法
在采用复制算法时如果存活对象较多那么将会需要大量复制操作这样会带来较高的性能开销并且如果不想带来50%的内存空间浪费,就需要额外的内存空间来进行担保,故而,在老年代采用复制算法来进行垃圾回收是不适当的。
基于老年代的特点,提出了`标记-整理`算法Mark-Compact
- 标记过程仍然和`标记-清除`一样
- 标记过程完成后,并不是直接对可回收对象进行清理,而是将存活对象都向一端移动,然后直接清理掉边界以外的内存
`标记-整理`算法相对于`标记-清除`算法,其能够避免产生内存碎片。
#### 分代收集算法
目前,商业虚拟机的垃圾收集都采用`分代收集`的算法,这种算法将对象根据存活周期的不同,将内存划分为好几块。
一般会将java堆内存划分为新生代和老年代针对新生代对象的特点和老年代对象的特点采用不同且适当的垃圾回收算法
- 在新生代中,对象生命周期短暂,每次垃圾收集都有大量对象需要被回收,只有少部分对象才能存活,应采用复制算法
- 在新生代,存活对象较少,复制的目标区的内存占比也可以划分的更少,并且较少存活对象带来的复制成本也较低
- 在老年代,对象存活率更高,并且没有额外的内存空间来对齐进行担保,故而不应使用复制算法,而是应当采用`标记-清除`算法或`标记-整理`算法来进行回收
### Hotspot算法实现
#### 枚举根节点
在可达性分析中需要通过GC Roots来查找引用链。可以作为GC Roots的节点有
- 全局性的引用
- 常量
- 类静态属性
- 执行上下文
- 栈帧中的本地变量表
目前,很多应用仅方法区就包含数百兆,如果需要逐个检查这方面引用,需要消耗较多时间。
可达性分析对执行时间的敏感还体现在GC停顿上在执行可达性分析时需要停顿所有的Java执行线程STW避免在进行可达性分析时对象之间的引用关系还在不断的变化。
在Hotspot实现中采用OopMap的数据结构来实现该目的在类加载完成时Hotspot会计算出对象内什么位置是什么类型的数据在JIT编译过程中也会在特定位置记录栈和寄存器中哪些位置是引用。
##### OopMap
OopMap用于存储java stack中object referrence的位置。其主用于在java stack中查找GC roots并且在堆中对象发生移动时更新references。
OopMaps的种类示例如下
- `OopMaps for interpreted methods` 这类OopMap是惰性计算的仅当GC发生时才通过分析字节码流生成
- `OopMaps for JIT-compiled methods`这类OopMap在JIT编译时产生并且和编译后的代码一起保存
#### 安全点
在OopMap的帮助下Hotspot可以快速完成GC Roots的枚举。但是可能会导致OopMap内容变化的指令非常多如果为每条指令都生成OopMap将耗费大量额外空间。
故而Hotspot引入了安全点的概念只有到达安全点时才能暂停并进行GC。在选定Safepoint时选定既不能太少以让GC等待时间太长也不能过于频繁。在选定安全点时一般会选中`令程序长时间执行`的的指令,例如`方法调用、循环跳转、异常跳转`等。
对于Safepoint需要考虑如何在GC发生时让所有线程都跑到最近的安全点上再停顿下来。这里有两种方案
- `抢先式中断`在GC发生时首先把所有线程全部中断如果有线程中断的地方不在安全点上就恢复线程让其跑到安全点上
- `主动式中断`需要中断线程时不直接对线程进行操作只是会设置一个全局标志线程在执行时主动轮询该标志发现中断标志为true时自己中断挂起。
- 轮询的时机和安全点是重合的
VM Thread会控制JVM的STW过程其会设置全局safepoint标志所有线程都会轮询该标志并中断执行。当所有线程都中断后才会实际发起GC操作。
#### 安全区域
safepoint解决了线程执行时如何进入GC的问题但是如果此时线程处于Sleep或Blocked状态此时线程无法响应JVM的中断请求jvm也不可能等待线程重新被分配cpu时间。
在上述描述的场景下,需要使用安全区域(Safe Region)。安全区域是指在一段代码片段中引用关系不会发生变化。在这个区域中的任意位置开始GC都是安全的。可以把Safe Region看作是Safe Point的拓展版本。
当线程执行到SafeRegion时首先标识自身已经进入Safe Region。此时当这段时间里JVM要发起GC时对于状态已经被标识为Safe Region的线程不用进行处理。当线程要离开安全区域时需要检查系统是否已经完成了根节点枚举或整个GC过程如果完成了线程继续执行否则其必须等待直到收到可以安全离开SafeRegion的信号。
Safe Region可以用于解决线程在GC时陷入sleep或blocked状态的问题。
### 垃圾收集器
Java中常见垃圾回收器搭配如下
<img alt="" class="has" height="433" src="https://i-blog.csdnimg.cn/blog_migrate/e13d5c0dce925d52ee6109d9345a1256.png" width="630">
#### Serial收集器
Serial是最基本、历史最悠久的收集器负责新生代对象的回收。该收集器是单线程的并且`在serial收集器进行回收时必须暂停其他所有的工作线程`直到serial收集器回收结束。
Serial搭配Serial Old的收集方式如下所示
<img src="https://pica.zhimg.com/v2-64275c9eccce584fb1154a2b681129fa_1440w.jpg" data-caption="" data-size="normal" data-rawwidth="1295" data-rawheight="645" data-original-token="v2-8519c4f1d766880d0804bd58b618f11c" class="origin_image zh-lightbox-thumb" width="1295" data-original="https://pica.zhimg.com/v2-64275c9eccce584fb1154a2b681129fa_r.jpg">
#### ParNew
ParNew即是Serial的多线程版本除了使用多线程进行垃圾收集外其他行为都和Serial完全一样。
ParNew运行示意图如下所示
<img src="https://pica.zhimg.com/v2-8bed62efa3ec77b248d87219db794a44_1440w.jpg" data-caption="" data-size="normal" data-rawwidth="838" data-rawheight="643" data-original-token="v2-d0e912b9f73670a16b0a1d9bf36d0d55" class="origin_image zh-lightbox-thumb" width="838" data-original="https://pica.zhimg.com/v2-8bed62efa3ec77b248d87219db794a44_r.jpg">
#### Parallel Scavenge
Parallel Scavenge是一个新生代的垃圾收集器也采用了多线程并行的垃圾收集方式。
Parallel Scavenge收集器的特点是其关注点和其他收集器不同CMS等收集器会尽可能缩短收集时STW的时间而Parallel Scavenge的目标则是达到一个可控制的吞吐量。
> 此处,吞吐量指的是 `运行用户代码时间/(运行用户代码时间 + 垃圾收集时间)`例如虚拟机总共运行100分钟垃圾收集花掉1分钟那么吞吐量就为99%
- 对于吞吐量较高的应用则可以更高效的利用cpu时间更快完成运算任务主要适合在后台运行且不需要太多交互的任务
- 而对于和用户交互的应用STW时间越短则相应时间越短用户交互体验越好
##### MaxGCPauseMillis
`-XX:MaxGCPauseMillis`用于控制最大的GC停顿时间该值是一个大于0的毫秒数垃圾回收器尽量确保内存回收花费的时间不超过该设定值。
##### GCTimeRatio
该值应该是一个大于0且小于100的整数如果该值为n代表允许的GC时间占总时间的最大比例为`1/(1+n)`
#### Serial Old
Serial Old是Serial的老年代版本同样是一个单线程的收集器采用`标记-整理`算法对老年代对象进行垃圾收集。
#### Parallel Old
Parallel Old是Parallel Scavenge的老年代版本使用多线程和`标记-整理`算法。
Parallel Old主要是和Parallel Scavenge搭配使用否则Parallel Scavenge只能搭配Serial Old使用。
> 在Parallel Scavenge/Parallel Old搭配使用时发生GC时用户线程也都处于暂停状态。