Files
rikako-note/jvm/深入理解java虚拟机.md
2025-12-06 23:38:53 +08:00

467 lines
38 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

- [深入理解java虚拟机](#深入理解java虚拟机)
- [Java内存区域和内存溢出异常](#java内存区域和内存溢出异常)
- [运行时数据区](#运行时数据区)
- [程序计数器](#程序计数器)
- [Java虚拟机栈Java Virutal Machine Stack](#java虚拟机栈java-virutal-machine-stack)
- [局部变量](#局部变量)
- [本地方法栈](#本地方法栈)
- [Java堆](#java堆)
- [方法区](#方法区)
- [运行时常量池](#运行时常量池)
- [直接内存](#直接内存)
- [Hotspot虚拟机对象探秘](#hotspot虚拟机对象探秘)
- [对象创建](#对象创建)
- [类加载](#类加载)
- [内存分配方式](#内存分配方式)
- [TLAB](#tlab)
- [初始化](#初始化)
- [对象布局](#对象布局)
- [对象头](#对象头)
- [实例数据](#实例数据)
- [对齐填充](#对齐填充)
- [对象的访问定位](#对象的访问定位)
- [垃圾收集器和内存分配策略](#垃圾收集器和内存分配策略)
- [对象是否应当被回收](#对象是否应当被回收)
- [引用计数算法](#引用计数算法)
- [可达性分析算法](#可达性分析算法)
- [引用类型](#引用类型)
- [垃圾回收细节](#垃圾回收细节)
- [回收方法区](#回收方法区)
- [判断类是否可回收](#判断类是否可回收)
- [垃圾收集算法](#垃圾收集算法)
- [标记-清除算法](#标记-清除算法)
- [复制算法](#复制算法)
- [基于复制算法的虚拟机新生代内存划分](#基于复制算法的虚拟机新生代内存划分)
- [标记-整理算法](#标记-整理算法)
- [分代收集算法](#分代收集算法)
- [Hotspot算法实现](#hotspot算法实现)
- [枚举根节点](#枚举根节点)
- [OopMap](#oopmap)
- [安全点](#安全点)
- [安全区域](#安全区域)
- [垃圾收集器](#垃圾收集器)
- [Serial收集器](#serial收集器)
- [ParNew](#parnew)
- [Parallel Scavenge](#parallel-scavenge)
- [MaxGCPauseMillis](#maxgcpausemillis)
- [GCTimeRatio](#gctimeratio)
- [Serial Old](#serial-old)
- [Parallel Old](#parallel-old)
- [CMS收集器](#cms收集器)
- [CMS缺陷](#cms缺陷)
- [G1垃圾收集器](#g1垃圾收集器)
- [G1垃圾收集器内存布局](#g1垃圾收集器内存布局)
- [Remembered Set](#remembered-set)
# 深入理解java虚拟机
## Java内存区域和内存溢出异常
### 运行时数据区
java程序在虚拟机运行时会将其所管理的内存区域划分为若干个不同的数据区。java运行时数据区的结构如下
<img src="https://i-blog.csdnimg.cn/blog_migrate/858b0aecc9d90cdb0dea25ea077c11c4.jpeg#pic_center" alt="在这里插入图片描述">
#### 程序计数器
程序计数器是一块较小的内存空间,其可以被看做是当前线程所执行字节码的行号指示器。`在虚拟机的概念模型中,字节码解释器在工作时会修改程序计数器的值,从而选取下一条需要执行的字节码指令`。分支、循环、跳转、异常处理、线程恢复等基础功能都依赖计数器来完成。
在java虚拟机中多线程是通过`线程轮流切换,并分配处理器的执行时间片`来实现的。故而,在任一给定时刻,一个处理器只会执行一个线程中的指令(即给定时刻一个处理器只和一个线程进行绑定)。为了线程切换后,仍然能恢复到线程上次执行的位置,`每个线程都会有一个独立的程序计数器`。各个线程之间的程序计数器互不干扰,独立存储,该类内存为`线程私有`的内存。
> 由于JVM支持通过`本地方法`来调用c++/c等其他语言的native方法故而
> - 如果线程正在执行的是java方法计数器记录的是正在执行的虚拟字节码指令地址
> - 如果线程正在执行的是native方法计数器的值为空
#### Java虚拟机栈Java Virutal Machine Stack
和程序计数器一样java虚拟机栈也是线程私有的其声明周期和线程相同。
虚拟机栈描述了java方法执行时的内存模型
- 每个方法在执行时都会创建一个栈帧,用于存储局部变量、操作数栈、动态链接、方法出口等信息
- 每个方法从调用直至执行完成的过程,对应一个栈帧在虚拟机栈中入栈、出栈的过程
##### 局部变量
局部变量中存放了编译器可知的各种数据类型,如下:
- 基本数据类型primitives
- 对象引用
- returnAddress类型指向一条字节码指令的地址
> 其中64位长度的long和double数据类型会占用2个局部变量空间其余数据类型只占用一个。
>
> 局部变量表所需的空间大小在编译时就已经确定,在运行时进入一个方法时,该方法需要的局部变量表大小就已经确定,并且`方法执行过程中不会改变局部变量表的大小`
在java虚拟机规范中针对该区域定义了两种异常状况
- `StackOverflowError`线程所请求的栈深度大于虚拟机所允许的栈深度将抛出StackOverflowError
- `OutOfMemoryError`:如果虚拟机栈可以动态拓展,且拓展时无法申请到足够的内存,将会抛出`OutOfMemoryError`
#### 本地方法栈
本地方法栈和虚拟机栈的作用类似,区别如下:
- 虚拟机栈为虚拟机执行Java方法服务
- 本地方法栈为虚拟机用到的Native方法服务
和虚拟机栈相同本地方法栈也会抛出OutOfMemoryError和StackOverflowError
#### Java堆
Java堆是java虚拟机所管理内存中最大的部分`java堆为所有线程所共享在虚拟机创建时启动`。java堆内存用于存放对象实例几乎所有的对象实例堆在该处分配内存。
Java堆是垃圾收集器管理的主要区域在很多时候也被称为`GC堆`。从垃圾收集的角度看现代收集器基本都采用分代收集的算法故而java堆内存还可以进一步细分为`新生代和老年代`
虽然Java堆内存为线程所共享的区域但从内存分配的角度看Java堆中可能划分出多个线程私有的分配缓冲区Thead Local Allocation Buffer,TLAB
但是不论Java堆内存如何划分其存放的内容都为对象实例。Java堆内存可以处于物理上不连续的内存空间中只需在逻辑上连续即可。通常java堆内存都是可拓展的通过-Xms和-Xmx控制如果在为实例分配内存时没有足够的内存空间进行分配并且堆也无法再进行拓展时将会抛出`OutOfMemoryError`
#### 方法区
方法区和java堆一样是各个线程共享的区域其用于存储已被虚拟机加载的类信息、常量、静态变量、JIT编译后的代码等数据。
jvm规范对方法区的限制比较宽松方法区的内存并不需要在物理上连续并可选择固定大小或可拓展。除此之外方法区还可以选择不实现垃圾回收。
通常,发生在方法区的垃圾回收比较罕见,对方法区的垃圾回收主要是针对常量池的回收和对类型的卸载。类型卸载触发的条件比较苛刻。
当方法区无法满足内存分配的需求时将抛出OutOfMemoryError。
##### 运行时常量池
运行时常量池是方法区的一部分。在class文件中除了有类的版本、字段、方法、接口等描述信息外还有一项信息是常量池用于存放在编译期生成的各种字面量和符号引用。
`该部分内存将会在类加载后,进入方法区的运行时常量池进行存放`
> 在java程序运行时也能将新的常量放入池中例如`String.intern()`方法。
#### 直接内存
直接内存不是虚拟机运行时数据区的一部分也不是java虚拟机规范中定义的内存区域但该部分内存也会被频繁使用并且可能导致OutOfMemoryError。
在JDK 1.4中引入了NIO其可以使用native函数库直接分配堆外内存然后通过一个存储在Java堆内的DirectByteBuffer对象操作堆外内存。这样能够在部分场景中显著提高性能避免在java堆和native堆中来回复制数据。
并且直接内存并不会受java堆大小的限制但仍然会受物理机物理内存大小的限制。
### Hotspot虚拟机对象探秘
#### 对象创建
##### 类加载
虚拟机在遇到new指令时首先会去方法区常量池中进行匹配检查是否有对应的符号引用并检查该符号引用对应的类是否已经被加载、解析、初始化。如果尚未被加载则先执行该类的加载过程。
在加载该类后,对象所需的内存大小便可完全确定,会为该对象的创建分配内存空间。
##### 内存分配方式
在java堆内存中的内存分配存在如下几种方式
- 指针碰撞: 如果Java堆内存中内存是绝对规整的所有用过的指针都放在一边空闲内存放在另一边那么可以通过一个中间的指针来划分使用的空间和空闲空间
- 当分配内存空间时,仅需将指针向空闲空间的那个方向移动即可
- FreeList如果Java中的堆内存并不规整已使用的内存空间和空闲内存空间相互交错此时并无法使用指针碰撞的方式来分配内存。虚拟机会维护一个列表列表中维护空闲的内存块。
- 在内存分配时,会从列表中找到一块足够大的空间划分给对象,并更新列表上的空闲内存记录
选择采用哪种内存分配方式取决于java堆内存是否规整而java堆是否规整则取决于采用的垃圾收集器是否带有`压缩整理`功能。
> 故而在使用Serial、ParNew等带Compact过程的收集器时系统采用的分配算法是指针碰撞而使用CMS和Mark-Sweep算法的收集器时通常采用FreeList
##### TLAB
对象创建是非常频繁的行为,多线程场景下容易出现并发安全的问题。即使使用指针碰撞的方式来分配内存空间,也会出现争用问题。解决内存分配下的并发问题存在两种方案:
- 对分配内存空间的操作进行同步可以采用CAS等方案保证分配内存动作的原子性
- 将内存分配的动作按照线程的不同划分在不同区域中即每个线程先在java堆内存中预先分配一小块内存称为TLAB每个线程都在自身的TLAB中分配内存空间
- 只有当线程的TLAB使用完并需要分配新的TLAB时才需要同步锁定操作这样能减少同步锁定这一耗时操作的次数
虚拟机是否采用TLAB可以通过`-XX:+/-UseTLAB`参数来设定。
内存分配完成后虚拟机需要将分配到的内存空间都初始化为0若使用TLAB则该操作可提前到分配TLAB时。该操作可以保证对象的实例字段在不赋值初始值时就能使用程序能够访问这些字段数据类型所对应的零值。
##### 初始化
在为对象分配完空间后,会执行`<init>`方法进行初始化。
#### 对象布局
在Hotspot虚拟机中对象在内存中的布局可以分为3块区域
- 对象头Header
- 实例数据Instance Data
- 对齐填充Padding
##### 对象头
对象头中包含两部分信息:
- 对象的运行时数据如hash code、gc分代年龄、锁状态标志、线程持有的锁、偏向线程ID、偏向时间戳等
- 该部分在32bit和64bit的虚拟机中长度分别为32bit和64bit被官方称为`MarkWord`
- 类型指针:该部分信息指向对象对应`类元数据`的指针,虚拟机会通过该指针来确定对象是哪个类的实例。
- 数组长度如果对象是一个java数组那么对象头中还需要记录数组的长度
##### 实例数据
实例数据为对象真正存储的有效信息,即程序代码中定义的各种类型字段内容,无论是从父类继承的还是子类中定义的。
实例数据中的存储顺序会收到虚拟机分配策略参数和字段在java源码中定义顺序的影响。Hotspot虚拟机默认的分配策略为longs/doubles, ints, shorts/chars, bytes/booleans、oopsOrdinary Object Pointers相同宽度的字段会被分配到一起。
##### 对齐填充
对齐填充并不是自然存在的仅起占位符的作用。hotsopt虚拟机自动内存管理系统要求对象起始地址必须是8字节的整数倍即对象大小必须为8字节的整数倍。
对象头刚好是8字节整数倍故而当对象的实例数据没有对对齐需要通过对象填充来对齐。
#### 对象的访问定位
java程序需要通过虚拟机栈上的reference来操作java堆中具体的对象。目前访问堆上对象有两种主流的方式
- 句柄访问java堆中会划分一块区域来作为句柄池references存储的地址即为对象句柄的地址句柄中则包含了对象实例数据地址和对象类型数据地址
- 使用句柄访问时reference中存储的是句柄地址即使对象被移动在垃圾回收时非常普遍也只需要修改句柄中的实例数据指针不需要修改虚拟机栈中的reference
<img width="825" height="477" src="https://img2024.cnblogs.com/blog/2123988/202406/2123988-20240609154659695-36774084.png" style="display: block; margin-left: auto; margin-right: auto">
- 直接指针访问采用直接指针访问时堆内存中无需维护句柄池而java的堆对象中则包含类型数据的地址。reference中直接会存储java堆对象的地址而可以通过堆对象中存储的类型数据地址再次访问类型数据
- 使用直接指针访问时,访问速度更改,在访问实例数据时能够节省一次内存查找的开销。
<a data-fancybox="gallery" href="https://img2024.cnblogs.com/blog/2123988/202406/2123988-20240609154731546-1183511045.png"><img width="808" height="500" src="https://img2024.cnblogs.com/blog/2123988/202406/2123988-20240609154731546-1183511045.png" style="display: block; margin-left: auto; margin-right: auto"></a>
在Hotspot实现中采用的是直接指针访问方案。
## 垃圾收集器和内存分配策略
在上一章节中提到的java运行时数据区域中java虚拟机栈、本地方法栈、程序计数器三个区域是线程私有的生命周期和线程一致无需考虑垃圾回收问题在方法结束/线程终止时内存会随之回收。
但是,`方法区``java堆内存`这两个区域则不同,内存的分配和回收都是动态的,垃圾收集器主要关注这部分内存。
### 对象是否应当被回收
堆中几乎存放着所有的java对象垃圾收集器在对对内存进行回收前需要确定哪些对象可以被回收哪些对象不能被回收。
#### 引用计数算法
该算法会为对象添加一个引用计数器每当有一个地方引用该对象时计数器加一当引用失效时计数器则减一技术器为0代表该对象不再被引用。
但是java虚拟机并没有采用引用计数算法来管理内存主要原因是其难以解决对象之间循环引用的问题。
#### 可达性分析算法
在主流的商用程序语言JAVA/C#等)的主流实现中,都通过可达性分析来判定对象是否可以被回收的。该算法核心是`根据一系列被称为GC Roots的对象来作为起点从这些节点开始向下搜索搜索时所走过的路径被称为引用链当一个对象GC Roots没有任何的引用链相连接时即从GC Roots到该对象不可达时证明该对象是不可用的可以被回收`
在Java语言中GC Roots包含如下内容
- 虚拟机栈中所引用的对象
- 方法区中静态属性应用的对象
- 方法区中常量所引用的对象
- 本地方法栈中JNI所引用的对象
### 引用类型
在JDK 1.2之后Java对引用的概念进行了扩充引入了如下的引用类型概念
- 强引用Strong Reference
- 软引用Soft Reference
- 弱引用Weak Reference
- 虚引用Phantom Reference
上述四种引用类型,引用强度逐级递减:
- `强引用`:对于强引用类型,只要强引用还存在,垃圾收集器永远不会回收掉被引用对象
- `软引用`:软引用用于描述一些有用但非必须的对象。对于软引用关联着的对象,在系统即将要发生内存溢出的异常之前,会将这些引用列进回收范围之内进行二次回收。如果此次回收之后还没有足够的内存,才会抛出内存溢出异常。
- 即在抛出OOM之前SoftReference会被清空如果清空后内存仍然不够就会实际抛出OOM
- `弱引用`:弱引用用于描述非必须对象,但其强度比软引用更弱一些。被弱引用关联的对象只能生存到下一次垃圾收集发生之前。
- 在垃圾收集器工作时,无论当前内存是否足够,都会回收掉只被弱引用关联的对象
- `虚引用`:虚引用是最弱的引用关系,一个对象是有虚引用存在,完全不会对其生存时间造成影响,也无法通过虚引用获得一个对象实例。
- 为对象设置虚引用关联的唯一目的是能在该对象被收集器回收时收到一个系统通知
### 垃圾回收细节
即使在可达性分析算法中被判定为不可达,对象也并非一定会被回收。要真正宣告对象的死亡,必须经历两次标记过程:
- 如果在可达性分析后发现GC Roots到该对象不可达其将会被第一次标记并进行一次筛选筛选条件是对该对象是否有必要执行finalize方法
- 如果当前对象没有覆盖finalize方法或是finalize方法已经被虚拟机调用过则虚拟机将其视为没有必要执行
- 如果当前对象被判定为需要执行finalize方法那么该对象将会被放置到F-QUEUE的队列中并且稍后由一个`由虚拟机自动建立的、优先级较低的Finalizer线程`去执行。该执行只是触发该方法,并不承诺会等待其执行结束。
- finalize方法是对象逃脱死亡命运的最后一次机会稍后GC将会对F-QUEUE中的对象进行第二次小规模的标记如果对象需要在finalize中避免自己被回收只需将自身和引用链上任一对象建立关联即可例如将自身赋值给引用链上任一对象的成员变量或类变量那么在第二次标记时其将被移除即使回收的集合如果此时对象仍未逃脱其会被真正回收
即对象被GC第一次标记为不可达后只能通过finalize方法来避免自己被回收并且`finalize方法只会被调用一次`。需要覆盖finalize逻辑在finalize方法中重新令自身变得可达此时才能避免被垃圾回收。
> 如果一个对象已经调用过一次finalize并且成功变为可达。那么其再次变为不可达后将不会有再次调用finalize的机会这次对象将会被回收。
通常不推荐使用finalize方法。
### 回收方法区
Java虚拟机规范并不强制要求对方法区进行垃圾回收并且在方法区进行垃圾回收的性价比较低
- 在堆中尤其在新生代中常规应用进行一次垃圾收集一般可以回收70%~95%的空间,但方法区垃圾回收的效率远低于此
方法区的垃圾回收主要回收两部分内容:
- 废弃常量
- 无用类
例如在回收字面量时如果一个字符串“abc"已进入常量池但是当前系统没有任何一个String对象引用常量池中“abc”常量也没有其他地方引用该字面量如果此时发生垃圾回收该常量将会被系统清理出常量池。常量池中其他方法、接口、字段、类的符号引用也与此类似。
##### 判断类是否可回收
判断类是否可回收的条件比较苛刻,必须同时满足如下条件:
- 该类所有实例已经被回收java堆中不存在任何该类的实例
- 加载该类的classloader已经被回收
- 该类对应的java.lang.Class对象没有在其他任何地方被引用无法在其他任何地方通过反射访问该类的方法
在大量使用反射、动态代理、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时用户线程也都处于暂停状态。
#### CMS收集器
CMSConcurrent Mark Sweep是一种`以获取最短回收停顿时间`的收集器。在重视服务响应时间的应用中适合使用CMS收集器进行老年代的垃圾回收。
从名字上可以看出CMS收集器基于`标记-清除`算法实现,其运作过程相对于前面集中收集器来说更加复杂,其分为如下步骤:
- 初始标记STW
- 初始标记仅仅会标记GC Roots能够直接关联到的对象速度很快
- 并发标记:
- 并发标记阶段会执行GC Roots Tracing
- 重新标记STW
- 重新标记期间会修正并发标记期间因用户线程继续运作而导致标记产生变动的那一部分对象的标记记录
- 该阶段的耗时比初始标记长,但是远比并发标记短
- 并发清除
在CMS的整个收集过程中初始标记和重新标记阶段是存在STW的但是并发标记和并发清除时收集器线程可以和用户线程同时运行。
<img src="https://cdn.tobebetterjavaer.com/stutymore/gc-collector-20231228211056.png" alt="" tabindex="0" loading="lazy" photo-swipe="" style="cursor: zoom-in;">
##### CMS缺陷
CMS虽然在垃圾收集时能够做到停顿时间较短但是其仍然存在如下缺陷
- CMS对CPU资源比较敏感。`CMS默认启动的回收线程数是CPU数量 + 3/4个当cpu物理核心数较少时垃圾收集线程将会抢占大量cpu资源`
- CMS无法处理浮动垃圾由于CMS在并发清理阶段并不会引入STW故而此时用户进程可以运行并伴随新的垃圾产生。该部分垃圾cms无法进行处理只能等待下一次GC来清掉。
- 由于在并发清除阶段同时还有用户线程运行,必须为用户线程预留内存,`故而CMS无法在老年代空间几乎被填满后才进行垃圾收集``需预留一部分空间以供并发收集时用户线程使用`
- 在JDK 1.6中CMS收集器的启动阈值提升到了92%,要是预留的内存没有办法满足用户线程的运行需要,`会出现Concurrent Mode Failure`,此时虚拟机将执行后备方案`临时启用Serial Old收集器重新进行老年代的垃圾收集`
- 故而`-XX:CMSInitiatingOccupancyFraction`参数设置过大时,容易产生大量`Concurrent Mode Failure`,性能反而降低
- CMS是一款基于标记-清除算法的老年代垃圾收集器,故而在收集结束时会有大量的内存碎片产生
- 为了解决内存碎片问题CMS提供了`-XX:+UseCMSCompactAtFullCollection`开关参数默认是开启的用于在CMS进行full gc时开启内存碎片的合并过程
- 但是每次full gc都进行内存碎片合并会导致等待时间过长故而引入了另一个参数`-XX:CMSFullGCsBeforeCompaction`该参数用于限制执行多少次不压缩的full gc后再执行一次带压缩的默认为0代表每次进入fullgc后都进行碎片整理
#### G1垃圾收集器
G1是一项面对服务端应用的垃圾收集器和其他垃圾收集器相比G1具备如下特点
- `并行和并发`
- G1能够充分利用多cpu、多核环境下的硬件优势利用多个cpu/cpu核心来缩短STW停顿时间
- 在部分GC动作中其他垃圾收集器都需要暂停用户线程的执行而G1垃圾收集器则是在这些GC动作中仍然允许用户线程的同时执行
- `分代垃圾收集`和其他垃圾收集器一样G1中也保留了分代的概念。但是G1并不需要其他垃圾收集器的配合就能够独立管理整个GC堆
- `空间整合`和CMS的`标记-清理`算法不同G1在整体上采用了`标记-整理`算法实现垃圾收集而从局部两个Region上看G1采用了复制算法。不管从整体还是局部上看G1都不会产生内存碎片
- `可预测的停顿`G1相比于CMS的一大优势是G1造成的停顿可预测。G1和CMS都关注在垃圾回收中降低停顿但G1除了追求低停顿外还能建立可预测的停顿时间模型能让使用者指明`在一个时间为M的时间片段内消耗在垃圾收集上的时间不得超过N毫秒`
##### G1垃圾收集器内存布局
在G1之前其他的垃圾收集器在进行收集时都是只关注新生代或老年代对象但G1则并非这样。在使用G1垃圾收集器时java堆的内存布局和使用其他垃圾收集器有较大区别。
在使用G1垃圾收集器时其将整个java堆划分为多个大小相等的独立区域Region虽然仍然保留有新生代和老年代的概念但新生代和老年代并非在物理上是隔离的它们都是一部分并不需要连续Region的集合。
G1之所以能够建立可预测的停顿模型是因为其可以避免在整个java堆中进行全区域的垃圾收集。`G1会跟踪各个Region中垃圾堆积的价值大小回收所获取的空间大小以及回收所需时间的经验值在后台维护一个优先列表每次根据允许的垃圾回收时间优先回收价值最大的Region`这即是G1垃圾收集器的名称由来即Garbage-First
`上述将内存空间划分为Region以及有优先级的区域回收方式能够保证G1垃圾收集器在有限的时间内以尽可能高的效率进行垃圾回收`
##### Remembered Set
即使将内存区域划分为多个Region但是Region中的对象仍然会跨Region相互引用。故而在进行可达性分析时如果不做特殊处理在分析某个Region中的对象存活与否时仍然需要扫描整个堆内存空间。
> 在使用其他垃圾收集器时即使不对内存进行Region划分在新生代和老年代之间也会存在相互引用。如果不做特殊处理在分析新生代对象时也必须扫描老年代。这将会极大的影响GC效率
G1收集器中Region之间的对象引用以及其他收集器中新生代和老年代之间的对象引用虚拟机都是通过Remembered Set来避免整个堆的扫描的。`G1中每个Region都有一个与之对应的Remembered Set虚拟机发现程序在对reference类型的数据进行写操作时会产生一个Write Barrier暂时中断写操作检查Reference引用的对象是否处于不同的Region中如果是则将相关引用信息记录到被引用对象所属Region的Remembered Set中。`在进行内存回收时在GC根节点枚举范围中加入Remembered Set即可保证不对全堆扫描也不会有遗漏。
若不计算维护Remembered Set的操作G1垃圾收集器的运作可以分为如下步骤
- 初始标记:
- 初始标记会记录GC Roots能直接关联到的对象
- 其会修改TAMS令下一阶段用户线程并发运行时能在Region中创建新对象
- 初始标记阶段需要STW暂停用户线程
- 并发标记
- 对堆中对象进行可达性分析,找出存活对象
- 该阶段耗时较长,但是可以和用户线程并发执行
- 最终标记
- 修正在并发标记期间因用户线程运行而导致的标记变动
- 该阶段需要STW暂停用户线程但是可以并行执行
- 筛选回收
- 在筛选回收期间会对各个Region的回收价值和成本进行排序根据用户期望的GC停顿时间来制定回收计划
- 在筛选回收阶段,也是会暂停用户线程的
<img src="https://ask.qcloudimg.com/http-save/5427637/ouqa71pbc7.jpeg" style="width: 100%;">