424 lines
33 KiB
Markdown
424 lines
33 KiB
Markdown
- [深入理解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缺陷)
|
||
|
||
|
||
# 深入理解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、oops(Ordinary 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%的对象生命周期都很短暂,完全不需要根据1:1来划分内存空间。`对于新生代内存空间,将其划分为了一块较大的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收集器
|
||
CMS(Concurrent 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后都进行碎片整理)
|
||
|
||
|
||
|