JVM基础
JVM
JVM(Java Virtual Machine,Java 虚拟机)是 Java 程序的运行环境,是 Java 语言的核心和关键部分之一。
JVM负责将.class字节码转化成机器码,能够根据不同的硬件平台和操作系统进行了优化和适配。
基本组成

类加载子系统:
- 功能:加载类文件(.class),生成类对象
- 组成:启动类加载器、扩展类加载器、应用程序类加载器
运行时数据区:
- 功能:用于存放Java程序运行时的数据,包括类的信息、对象实例、方法调用栈、线程状态等
执行引擎:
- 功能:负责执行Java程序中的字节码指令,将字节码翻译成硬件支持的指令集格式
- 组成:解释器、即时编译器(JIT编译器)
本地接口:
- 功能:允许Java程序调用本地方法库中的本地方法,实现更底层的功能和操作
垃圾回收器(GC)和内存管理子系统:
- 功能:管理Java程序的内存资源,包括内存分配、回收、释放
- 组成:GC、堆内存管理器
从功能上看,可以分为类加载系统、内存管理系统、执行引擎、本地接口
类加载机制
什么是类加载机制
JVM将.class文件依次进行 加载 => 连接(验证、整备、解析) => 初始化,最终变成JVM可以直接使用的Java类型并加载到内存中
类加载的过程
- 加载
- 通过类的全限定名找到对应的.class文件
- 读取.class文件的二进制数据,转化成方法区的运行时数据结构
- 在内存中生成一个代表这个类的 java.lang.Class 对象,作为方法区这个类的各种数据的访问入口。
JVM并没有规定类的字节流必从.class文件中加载,在加载阶段,程序员可以通过自定义的类加载器,自行定义读取的地方,例如通过网络、数据库等。
连接
验证:目的是保证读取的字节流符合JVM规范,且对JVM无害
- 文件格式验证:确保字节流格式上符合Java类型信息的要求。比如是否魔数开头,主次版本号、常量合理性等
- 元数据验证:确保元数据是符合Java语言规范的。比如抽象方法是否有实现,继承链是否正确(禁止父类不存在或存在环)等
- 字节码验证:验证类的方法体的数据流和控制流,确保程序语义符合逻辑。比如:跳转指令不会跳转到方法外等
- 符号引用验证:在解析阶段发生,确保符号引用能够被转化成直接引用
可以通过
-Xverify:none参数来关闭大多数类验证措施,缩短JVM类加载时间准备
- 为静态类变量分配内存并设置类变量初始值(不是程序值),这些变量所使用的内存都将在方法区中进行分配
- 方法区的类型信息、静态变量等数据就是在准备阶段添加到内存的
解析
将符号引用转化成直接引用,解析动作主要针对类或接口、字段、类方法、接口方法、方法类型等
支持运行时绑定,解析过程在某些情况下可在初始化之后再开始。
什么是符号引用?
Java代码在编译期间,是不知道最终引用的类型,具体指向内存中哪个位置的,这时候会用一个符号引用,来表示具体引用的目标是”谁”。Java虚拟机规范中明确定义了符号引用的形式,符合这个规范的前提下,符号引用可以是任意值,只要能通过这个值能定位到目标。什么是直接引用?
直接引用就是可以直接或间接指向目标内存位置的指针或句柄。引用的类型,还未加载初始化怎么办?
动态解析,初始化后再开始解析
初始化
- 初始化是是执行
<clinit>()方法的过程,主要是为静态成员变量赋值,执行静态代码块,初始化静态方法等。 - 初始化类不会执行构造器内的语句,构造器是初始化对象的,类加载完成后,如果包含创建对象的操作,将调用的
<init>()方法来初始化对象,这时会在堆区为对象实例分配内存
- 初始化是是执行
什么时候发生类加载,类加载的时机
JVM规范并没有对类加载的时机没有强制约束,通常取决于JVM的具体实现,但在初始化阶段严格规定了必须初始化的场景
- 遇到创建实例
new,读取静态字段getStatic, 设置静态字段putStatic, 调用静态方法invokeStatic这四条指令字节码时 - 使用反射机制时,如果类没有init,会先init
- 初始化一个子类时,如果父类没有初始化,必须先初始化父类
- JVM启动时,会指定一个执行的主类(如main的类),虚拟机会先加载这个类
- 使用动态语言支持时(jdk1.7),必须初始化句柄对应的类(为什么?因为动态连接的原理是先将引用指向句柄,句柄包含了符合条件但未初始化的类的信息,等到实例对象出现时,借助句柄将建立引用和实例对象的联系,这个句柄本质上也是类,在动态链接时必须被初始化)
以上5个场景称为对一个类的主动引用。除此之外,其他引用类的方式都不会触发初始化,称为被动引用
被动引用的场景:
- 通过子类引用父类的静态对象,不需要子类的初始化
- 通过数组定义来引用类不会触发类的初始化(因为类的数组本质上存的只是引用)
- 当一个常量已经在常量池中的时候,不会触发类的初始化
一个对象是如何被创建的
类加载:当 JVM 遇到一条 new 指令时,首先将去检查这个指令的参数是否能在常量池中定位到一个类的符号引用,并且检查这个符号引用代表的类是否已被加载、解析以及初始化。如果没有,则先执行类加载过程。
内存分配:类加载检查通过后,对象所需的内存空间大小在类加载完成之后便可确定,JVM 会根据垃圾回收器选取内存分配算法:
指针碰撞法
- serial,ParNew 等带有压缩功能的回收器,内存是连续的,内存指针移动基于对象大小移动。
空闲列表法
- CMS,通过维护一个空间内存列表,存放对象、分配内存还需考虑并发申请内存问题(CAS、TLAB本地线程缓冲)。
堆中的内存并不是规整的,已使用的内存和空闲的内存相互交错。虚拟机必须维护一个列表,记录那些内存块是可用的,在分配的时候从列表中找到一块足够大的内存划分给对象实例,并更新列表上的记录。
初始化:
- 内存分配完成之后,则需要初始化对象的信息,主要涉及属性默认值、对象头信息以及执行构造函数。
- JVM 就需要将分配的内存空间都初始化为零(不包括对象头),如果在 TLAB 上分配内存,此过程可提前至 TLAB 分配时进行。这一步保证了对象的实例字段可以不赋初值也可以直接使用。
- 设置对象头信息,这些信息包括该对象是那个类的实例,如何才能找到该类的元数据信息,对象的哈希码,对象的 GC 分代信息等。
执行完以上步骤之后,对于 JVM 来说新的对象已经创建完成,但对于 Java 程序来说,对象创建才刚开始,因为构造函数还没有执行,所以要执行方法进行自定义初始化。
类加载器
什么是类加载器
类加载器负责将.class文件加载到内存并且生成对应的Class对象,类加载器是类加载机制的具体实现
在Java中,通过全限定类名来标识一个类,但在JVM中,使用全限定名+类加载器来标识一个类,换言之,同一个类,用不同的类加载器加载,生成的Class对象会被JVM标识成两个不同的对象
有哪些类加载器
启动类加载器(BootstrapClassLoader)
嵌在JVM内核中的C++实现的类加载器,负责加载
JAVA_HOME/lib目录下的类库,启动类加载器无法被应用程序直接使用扩展类加载器(ExtensionClassLoader)
Java实现,继承自启动类加载器,负责加载
JAVA_HOME/lib/ext目录下的类库,可以通过-Djava.ext.dirs=参数设置加载路径系统类加载器(AppClassLoader)
继承自扩展类加载器,也称应用程序类加载器,复杂加载应用程序
classpath目录下的jar包和class文件自定义类加载器
继承自
java.lang.ClassLoader,需要重写findClass()方法
基础类无法调用类加载器加载用户提供代码,怎么解决
使用线程上下文类加载器在运行时动态加载类,通过
Thread.setContextClassLoader()方法设置- 这个类加载器默认是系统类加载器
- 代码实现简单,性能较高,但需要开发人员显式地管理线程上下文类加载器的设置。
通过反射机制在运行时动态获取类的信息,复杂度高,性能相对较低
双亲委派模式
- 是什么:任何一个类加载器在接到一个类的加载请求时,总是先让其父类进行加载,只有父类无法加载(或者没有父类)的情况下,才尝试自己加载

为什么需要双亲委派模式:
- 保证类识别唯一:因为JVM使用全限定类名和类加载器共同表示一个Class对象,双亲委派模式是为了确保一个类只有一个类加载器将其加载成Class对象
- 避免核心库被篡改:如果没有使用双亲委派模式,可以任由自定义加载器进行加载的话,Java这些核心类的API就会被随意篡改
如何破坏双亲委派模式:
双亲委派模型通过调用父类的
loadClass()方法实现类加载,只需要绕开这个方法即可破坏双亲委派模型使用自定义类加载器,重写
loadClass()方法给当前线程设定关联类加载器(线程上下文类加载器),使用SPI(Service Provider Interface)绕开
loadClass()方法。第二种方法被广泛采用,如JDBC,Dubbo等
SPI:
SPI(Service Provider Interface)是Java提供的一种服务提供者接口,用于实现框架的可插拔机制。在SPI中,服务接口定义了一组抽象方法,而具体的服务实现则通过一种约定的方式注册到系统中。SPI允许框架开发者定义一组标准的接口,而具体的实现可以由第三方开发者进行扩展和定制。
SPI的优点在于其灵活性和扩展性,可以实现框架的解耦和插件化。
实现:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25protected synchronized Class<?> loadClass(String name, boolean resolve) throws ClassNotFoundException {
// 首先,检查请求的类是否已经被加载过了
Class c = findLoadedClass(name);
if (c == null) {
try {
if (parent != null) {
c = parent.loadClass(name, false);
} else {
c = findBootstrapClassOrNull(name);
}
} catch (ClassNotFoundException e) {
// 如果父类加载器抛出ClassNotFoundException
// 说明父类加载器无法完成加载请求
}
if (c == null) {
// 在父类加载器无法加载的时候
// 再调用本身的findClass方法来进行类加载
c = findClass(name);
}
}
if (resolve) {
resolveClass(c);
}
return c;
}
运行时数据区
基本组成
方法区(Method Area): 存放已经加载完成的类的元数据、常量、静态变量等数据。
- 永久代:早期的JVM方法区使用永久代实现,永久代是堆内存中的一块区域。因为大小固定,可能会发生永久代溢出
- 元空间(jdk8):永久代的替换方案,元空间的内存的由OS管理,可以动态调整大小。元空间更加灵活。
堆(Heap): 存放已加载的对象实例和数组。
- 虚拟机中只有一个堆,程序中所有的线程都共享它。
- 在程序运行中,可以动态的分配堆的内存大小。
- 现代JVM将堆划分为新生代和老年代
- 新生代:主要包括伊甸园区(Eden Space)、幸存者0区(Survivor 0)、幸存者1区(Survivor 1)。
- 老年代:存放生命周期较长的对象。
虚拟机栈(JVM Stack):
- 作用:每个方法在执行的同时都会创建一个栈帧(Stack Frame),存放局部变量、方法参数、方法的返回地址。主要数据类型是基本数据类型和对象引用
- 特性:
- 线程私有的,生命周期同线程。
- 栈内创建的基本类型数据在超出其作用域后,会被自动释放掉,它不由JVM GC管理
- 方法参数和局部变量存储在局部变量表中,由编译器完成分配
- 临时的数据存储在操作数栈
- 动态链接:指向运行时常量池中该方法的引用
本地方法栈(Native Method Stack):
- 作用:本地方法栈为虚拟机使用的 Native 方法服务,与 Java 虚拟机栈作用类似,只不过它为 Native 方法服务。
- 特性:
- 线程私有,生命周期与线程相同。
- 存储本地方法调用信息。
- 具体实现与平台相关,不同 JVM 可能实现不同。
程序计数器(Program Counter Register):
- 作用:是一块很小的内存空间,用于记录当前线程执行的字节码的行号
- 特性:
- 每个线程都有的独立计数器
- 如果当前线程正在执行的是一个Java方法,则指向行号,如果是Native方法,则为空
运行时常量池(Runtime Constant Pool):
作用:运行时常量池是方法区的一部分,用于存放编译期生成的各种字面量和符号引用,这部分内容将在类加载后进入方法区的运行时常量池中。
特性:
线程共享。
包含了类的字面量(如字符串、整数等)和符号引用(如类和方法的引用)。
动态链接机制、延迟加载机制等都会在类加载后放入常量池中,在运行时被解析。
直接内存(Direct Memory):
作用:直接内存并不是 JVM 运行时数据区的一部分,但也被频繁使用,它主要用于 NIO(New Input/Output)中的直接缓冲区。
特性:
- 直接内存是在堆外的,由操作系统管理,使用
Unsafe类或 NIO 类库直接分配内存。 - 可以提高 IO 操作性能,减少数据从 JVM 内存到本地内存的拷贝。
- 直接内存是在堆外的,由操作系统管理,使用
堆与栈的区别
- 物理地址:堆不连续,性能慢;
- 内存分配:堆分配的内存是在运行期确认,栈在编译器,大小固定;
- 存放内容:对象、数组 vs 局部变量,操作数栈,返回结果;
- 可见性:堆对于整个程序共享可见,栈是线程私有,生命周期同线程。
创建对象如何解决并发问题?
由于 JVM 中创建对象的行为非常频繁,因此需要考虑内存分配的并发问题解决方案:
1)对分配内存空间的动作进行同步,即用CAS失败重试的方式;
2)把内存分配的动作按照线程划分在不同的空间中进行,每个线程在 Java 堆中预先分配一小块内存,即本地线程分配缓冲 TLAB(Thread Local Allocation Buffer),各线程首先在 TLAB 上分配内存,TLAB 使用完之后,分配新的 TLAB 时才需要同步锁定。JVM 是否使用 TLAB 可以通过 -XX:+/-UseTLAB 参数指定。
如何定位到内存中的对象?
Java 程序需要通过栈上的 reference 数据来操作堆上的具体对象。由于 reference 类型在 JVM 规范中只规定了一个指向对象的引用,并未定义这个引用如何定位和访问具体位置,所以对象访问方式由具体虚拟机实现而定。
目前主流的访问方式有句柄和直接指针两种
直接指针
Java堆对象的布局中就必须考虑如何放置访问方法区中类型数据的相关信息。

句柄访问
Java 堆中将会划分出一块内存来作为句柄池,reference 中存储的就是对象的句柄地址,而句柄中包含了对象实例和类型数据各自的具体地址信息。

二者各有优势,使用句柄访问这样做的好处是栈中 reference 存储的句柄地址较为稳定,因为在 Java 堆中进行了垃圾回收,对象的地址发生了改变的时候,只需要修改句柄的对象实例数据指针就行。而使用直接指针的最大好处就是速度更快。
对象在内存中是怎么存在的
对象在堆内存的内存布局主要有三部分,即对象头、实例数据以及对齐填充。
对象头
对象头包含两部分内容
- Mark Word(运行时元数据)
- HashCode:对象在堆空间中有一个首地址值,栈空间的引用根据这个地址指向堆中的对象,这个值通过HashCode获得
- GC分代年龄:本质是计数标记器,对象首先在Eden中创建,期间如果没被回收会转移到Survivor0/1中,最终达到阈值会被转移到老年代
- 锁状态标记State:0-为持有锁,1-持有锁,x-可重入锁重入次数
- 线程持有什么锁:
- 线程偏向ID:偏向锁的配套信息
- 偏向时间戳
- 类型指针(Class Metadata Address)
- 指向Class对象,确定对象所属的类型
实例数据(Instance Data)
- 它是对象真正存储的有效信息,包括程序代码中定义的各种字段类型,当然也包含从父类继承下来的字段。注意这里有一些规则:相同宽度的字段总是被分配在一起,父类中定义的变量会出现在子类之前,因为父类的加载是优先于子类加载的。
对齐填充
- 没有特殊含义,仅仅起到占位符的作用。
内存溢出
内存溢出是指程序运行过程中需要分配内存但可用内存不足,导致JVM或操作系统抛出 OutOfMemoryError 错误。内存溢出通常表示堆内存或其他内存区域已耗尽,无法满足新的内存分配请求。
原因
- 内存泄漏:未正确释放不再需要的对象,导致内存占用持续增长。
- 对象创建过多:程序中创建了大量对象,超过了JVM分配的堆内存。
- 过大数据结构:使用了过大的数据结构或缓存,没有及时清理。
- JVM参数设置不当:堆内存、方法区等JVM内存区域配置过小。
解决方法
分析堆转储(Heap Dump):使用工具如Eclipse MAT、VisualVM分析堆转储文件,找到内存占用过大的对象或数据结构。
优化代码:减少不必要的对象创建,使用轻量级的数据结构,优化算法。
调整JVM参数:根据应用需求调整堆内存大小、方法区大小等参数(如
-Xmx、-XX:MaxMetaspaceSize)。清理缓存:定期清理缓存,避免占用过多内存。
垃圾收集调优:调整垃圾收集器参数,选择合适的垃圾收集策略(如G1、CMS等)。
内存泄漏
内存泄漏是指程序中存在一些不再需要的对象,但它们仍然被引用,导致这些对象无法被垃圾收集器回收,持续占用内存。
- 原因
- 长生命周期对象引用短生命周期对象:例如,全局静态集合类持有对局部对象的引用。
- 未关闭资源:如文件流、数据库连接、网络连接等未及时关闭。
- 错误的数据结构操作:例如,未正确移除集合中的元素。
- 缓存未及时清理:使用缓存时,没有设置合理的清理策略,导致过多数据滞留。
- 解决方法
- 代码审查:检查代码中是否存在长生命周期对象引用短生命周期对象的情况,优化引用关系。
- 使用工具:使用内存分析工具(如Eclipse MAT、VisualVM)定位内存泄漏对象和引用链。
- 合理管理资源:确保文件流、数据库连接等资源使用完毕后及时关闭,使用try-with-resources语法简化资源管理。
- 清理缓存:使用合适的缓存策略,定期清理缓存数据。
- 弱引用:对于缓存等场景,使用
WeakReference或SoftReference,允许垃圾收集器在内存不足时回收这些对象。
垃圾回收
什么是垃圾回收系统
程序在运行过程中,会产生大量的内存垃圾(一些没有引用指向的内存对象都属于内存垃圾,因为这些对象已经无法访问,对程序而言它们已经死亡),为了确保程序运行时的性能,Java 虚拟机在程序运行的过程中不断地进行自动的垃圾回收(GC)。
JVM 的垃圾回收器都不需要我们手动处理无引用的对象了,这个就是最大的优点。而缺点也显而易见,Java 并未提供显式的内存管理操作,仅有的 System.gc() 方法也只是通知 JVM 需要进行 GC 操作,但是否执行仍需 JVM 决定。
哪些区域需要GC
主要是堆区和方法区,因为在 JVM 的堆和方法区中,一个接口的实现类所需的内存可能不同,一个方法的不同分支所需内存也可能不同。只有在程序运行期才能知道要创建多少对象,这部分的内存分配和回收具有动态性。
如何判断一个对象是否可以被回收
- 引用计数
- 给对象添加一个引用计数器,每当有一个对象引用时 +1,当引用失效时 -1,任何时刻计数器为 0 的对象就是可以被回收的对象。
- 实现简单且高效,但是解决不了循环引用的问题
- 可达性分析
- 基本概念
- 根集合(GC Roots):一组特殊的引用,作为GC算法的起点。如果一个对象可以通过这些引用链(Reference Chain)直接或间接到达,则该对象是可达的。
- 引用链(Reference Chain):从GC Roots开始,通过对象的引用逐步遍历,如果某个对象在某条引用链上可达,则认为该对象是存活的。
- GC Roots的来源
- 栈帧中的局部变量(虚拟机栈中的栈帧):所有活动线程的局部变量。
- 方法区中的静态变量:所有已加载类的静态变量。
- 方法区中的常量:常量池中的常量。
- 本地方法栈中的引用:JNI(Java Native Interface)引用。
- 所有被同步锁持有的对象
- 主要步骤:
- 可达性分析算法通过从GC Roots开始,递归地查找所有可达的对象,标记为活跃对象。
- 标记阶段:从GC Roots出发,遍历所有引用的对象,将所有可达的对象标记为存活。
- 清除阶段:对未被标记的对象进行清除,释放其占用的内存。
- 基本概念
引用类型总结
无论是通过引用计数法判断对象引用数量,还是通过可达性分析法判断对象的引用链是否可达,判定对象的存活都与“引用”有关。

强引用(StrongReference):绝不会被GC回收
软引用(SoftReference):如果内存空间足够,垃圾回收器就不会回收它,如果内存空间不足了,就会回收这些对象的内存。
- 软引用可用来实现内存敏感的高速缓存。
弱引用(WeakReference):
弱引用与软引用的区别在于:在垃圾回收器线程扫描它所管辖的内存区域的过程中,一旦发现了只具有弱引用的对象,不管当前内存空间足够与否,都会回收它的内存。不过,由于垃圾回收器是一个优先级很低的线程, 因此不一定会很快发现那些只具有弱引用的对象。
弱引用可以和一个引用队列(ReferenceQueue)联合使用,如果弱引用所引用的对象被垃圾回收,Java 虚拟机就会把这个弱引用加入到与之关联的引用队列中。
用队列在内存管理和资源清理中提供了非常有用的机制,允许开发者在对象被垃圾回收后执行特定的操作。
它有助于资源管理、避免内存泄漏、监控内存使用情况和实现复杂的内存管理逻辑。
通过引用队列,开发者可以更好地控制对象的生命周期和相关资源的管理。
虚引用(PhantomReference):任何时候都会被垃圾回收器回收,必须和引用队列联合使用。无法通过 PhantomReference 获取对象,作用是GC时返回一个通知
特别注意,在程序设计中一般很少使用弱引用与虚引用,使用软引用的情况较多,这是因为软引用可以加速 JVM 对垃圾内存的回收速度,可以维护系统的运行安全,防止内存溢出(OutOfMemory)等问题的产生。
内存分配和回收原则
堆区分为年轻代和老年代,新生代分为Eden、S0、S1三个区(大小比例是8:1:1)
大多数情况下,对象在新生代中 Eden 区分配。当 Eden 区没有足够空间进行分配时,虚拟机将发起一次 Minor GC(年轻代的GC)
Minor GC
标记:从GC Roots开始,标记所有Eden和Survivor的所有存活对象
复制:将存活对象移动到另一个Survivor中(存活够久的对象会被移到老年代)
清理:清理Eden和原Survivor的所有对象
如果新Survivor不能同时存放Eden和旧Survivor的所有对象,会触发分配担保机制,提前将年轻代的对象转移到老年代。执行Minor GC后,后面分配的对象如果能够存在Eden区,还是会在Eden区分配内存。
如何进入老年代:
长期存活的对象将进入老年代
第一次从Eden进入Survivor时会将age计数器设置为1,此后每次Minor GC存活一次,age计数器+1,直到15后移入老年代
15这个阈值不是绝对的,取决于具体的GC实现,可通过
-XX:MaxTenuringThreshold设置动态调整机制:
Hotspot 遍历所有对象时,按照年龄从小到大对其所占用的大小进行累积,当累积的某个年龄大小超过了 survivor 区的 50% 时(默认值是 50%,可以通过
-XX:TargetSurvivorRatio=percent来设置),取这个年龄和MaxTenuringThreshold中更小的一个值,作为新的晋升年龄阈值”。大对象直接进入老年代
- 大对象就是需要大量连续内存空间的对象(比如:字符串、数组)
- 大对象直接进入老年代的行为是由虚拟机动态决定的,它与具体使用的垃圾回收器和相关参数有关。大对象直接进入老年代是一种优化策略,旨在避免将大对象放入新生代,从而减少新生代的垃圾回收频率和成本。
- G1 垃圾回收器会根据
-XX:G1HeapRegionSize参数设置的堆区域大小和-XX:G1MixedGCLiveThresholdPercent参数设置的阈值,来决定哪些对象会直接进入老年代。
空间分配担保
空间分配担保是为了确保在 Minor GC 之前老年代本身还有容纳新生代所有对象的剩余空间。
jdk6以后,只要老年代的连续空间大于新生代对象总大小或者历次晋升的平均大小,就会进行 Minor GC,否则将进行 Full GC。
垃圾回收算法
标记-清除算法
过程:标记、清除
存在的问题:
- 标记和清楚这两个过程的效率都不太高
- 容易产生大量不连续的内存碎片呢
标记-复制算法(复制算法)
将内存区域分成大小相等的两块,每次只使用其中一块,GC后,将存活对象复制到另一块
- 优点:解决标记-清除算法的缺点,减少了内存碎片
- 缺点:内存空间减半
标记-整理算法(压缩算法)
流程基本和标记-清除算法一样,但是标记后不马上清除,而是将存活对象都向一端移动,然后清理掉端边界以外的内存。
- 优点:解决了复制算法的内存空间缺陷,同时不容易产生内存碎片
分代收集算法
当前虚拟机的垃圾收集都采用分代收集算法
这种算法没有什么新的思想,只是根据对象存活周期的不同将内存分为几块。
一般将 Java 堆分为新生代和老年代,这样我们就可以根据各个年代的特点选择合适的垃圾收集算法。
新生代中用复制算法,老年代中用标记-清除算法或者标记-整理算法
常见的垃圾回收器

Serial收集器
- 特点:
- 串行的,单线程的,jdk1.3之前的新生代回收器唯一选择
- 进行垃圾回收时必须暂停其他所有工作线程(Stop The World),直到收集结束
- 优点:
- 虽然历史久远,但它依然是HotSpot虚拟机运行在客户端模式下,或者4核4GB以下服务端的默认新生代收集器,这种核心数和内存空间较小的场景下,它单线程的优势就体现出来了,没有线程交互的开销,加上内存空间不大,单次回收耗时几十毫秒,这点停顿时间,完全是可以接受的。
- 缺点:
- 单线程工作,Stop The World太占用时间了(后续的垃圾回收器一直在优化这个时间)
- 多核心,大内存容量的场景下会成为瓶颈
- 图示:

ParNew收集器
特点:
- Serial垃圾回收器的并行版本,其他行为(功能性、配置、策略等等)和Serial垃圾回收器完全一样
在JDK9之后,Java官方取消了ParNew和除了CMS收集器之外的所有老年代收集器的搭配,而且还取消了
- XX:+UseParNewGC这个参数。所以JDK9之后,ParNew只能和CMS搭配使用了。
图示:

Parallel Scavenge 收集器
特点:
Parallel Scavenge 收集器关注点是吞吐量(高效率的利用 CPU)。CMS 等垃圾收集器的关注点更多的是用户线程的停顿时间(提高用户体验)
jdk1.8 的默认手机器(jdk1.9是G1)
吞吐量的计算公式:运行用户代码时间 / (运行用户代码时间 + 运行垃圾收集时间)
Parallel Scavenge 收集器提供了很多参数供用户找到最合适的停顿时间或最大吞吐量,既可以手动优化,也可以主动优化
手动优化:
-XX:ParallelGCThreads=4并行垃圾收集的线程数XX:MaxGCPauseMillis控制最大的垃圾收集停顿时间(ms)XX:GCTimeRatio设置垃圾收集时间占比的计算因子(相当于直接设置吞吐量的大小),参数范围是0 - 100的整数。它的公式是 1 / (1 + GCTimeRatio)
自动优化:
-XX:+UseAdptiveSizePolicy当打开时不需要手动指定新生代的大小(-Xmn)、Eden 与 Survivor区的比例(-XX:SurvivorRation)、晋升老年代的对象年龄(-XX:PretenureSizeThreshold)等信息,虚拟机会根据系统的运行状况收集性能监控信息,动态设置这些参数以提供最优的停顿时间和最高的吞吐量。图解:

Serial Old收集器
- 特点:
- Serial收集器的老年代版本
- 目前主要应用在客户端模式(Client VM)下的HotSpot虚拟机使用。
- 如果在服务端模式(Server VM)下,它也有两种用途:
- 在JDK5以及之前,和Parallel Scavenge收集器搭配使用
- 作为CMS收集器在出现并发模式故障(Concurrent Mode Failure) 时作为后备收集器。
- 图解:

Parallel Old收集器
- 特点:
- Parallel Scavenge收集器的老年代版本
- 在多CPU核心和大内存的场景下,在注重吞吐量以及 CPU 资源的场合,都可以优先考虑 Parallel Scavenge 收集器和 Parallel Old 收集器。
- 图解:

CMS收集器
特点:
- CMS(Concurrent Mark Sweep)收集器是一种以获取最短回收停顿时间为目标的收集器
- CMS(Concurrent Mark Sweep)收集器是 HotSpot 虚拟机第一款真正意义上的并发收集器,它第一次实现了让垃圾收集线程与用户线程(基本上)同时工作。
收集过程:
初始标记: STW,标记从GC roots直接可达的对象,速度很快(ms级)
并发标记: 同时开启 GC 和用户线程,GC时使用闭包结构去记录可达对象。由于并发标记阶段与应用程序线程同时运行,可能会出现应用程序线程修改对象引用的情况。为了处理这些并发修改,CMS使用了”写屏障”(write barriers)机制。这种机制会拦截对引用的修改,并记录下被修改的引用,以便在重新标记阶段处理。
写屏障是一种在对象引用被修改时执行的额外操作。CMS通过写屏障机制来跟踪并发修改,确保在重新标记阶段能够正确标记这些新创建或新引用的对象。写屏障机制的实现通常依赖于硬件支持或虚拟机指令重写。
重新标记: STW,标记在并发标记阶段后新创建或改变引用的对象(保证可达的不会被回收),时间较短,但比初始标记稍长
并发清除: 开启用户线程,同时 GC 线程开始清除未标记的区域
图解:

注意到,最后还有一步重置线程。在并发清除阶段,用户线程和垃圾回收线程可能会交替执行,部分用户线程会被挂起,而另一部分用户线程则会继续执行。这样设计的目的是为了尽量减少对应用程序的影响,以保证系统的响应性。用户线程的挂起通常发生在一些需要保证数据一致性或者安全性的操作时,例如在并发标记阶段或者更新堆结构时。
哪些用户线程可能会被挂起:
- 访问被垃圾回收线程标记的对象:如果用户线程正在访问堆中被垃圾回收线程标记为垃圾的对象,为了确保访问的一致性,这些用户线程可能会被挂起。
- 执行可能影响垃圾回收的操作:如果用户线程正在执行可能影响垃圾回收过程的操作,例如更新堆结构或执行需要一致性的数据操作,为了避免与垃圾回收线程的冲突,这些用户线程可能会被挂起。
举一个具体的例子:
假设有一个在线购物系统,其中有一个用户正在进行支付操作,这是一个涉及到数据库交互的事务操作。同时,系统后台正在进行垃圾回收,以清理不再使用的内存空间。在这种情况下,为了确保数据的一致性和完整性,可能会挂起支付操作的用户线程。
垃圾回收线程可能会标记一些已经不再使用的对象为垃圾,并且需要释放它们占用的内存空间。如果支付操作的用户线程正在访问或者修改某些被标记为垃圾的对象,那么这些操作可能会导致数据的不一致性或者异常情况。因此,在这种情况下,为了确保垃圾回收的正确执行,支付操作的用户线程可能会被挂起,直到垃圾回收过程完成。
通过挂起支付操作的用户线程,可以保证垃圾回收过程能够正确地进行,并且不会影响到支付操作的数据一致性。这样做可以确保系统在垃圾回收过程中依然能够保持正常的运行状态,同时尽量减少对用户的影响。
- 缺点:
- 对 CPU 资源敏感:因为GC和用户线程同时运行,会竞争CPU资源,负载较高时会导致垃圾回收器性能下降,STW时间增加
- 无法处理浮动垃圾:浮动垃圾是指在 CMS 的并发标记阶段开始后产生的新的垃圾对象。
- 空间碎片问题:它使用的回收算法-“标记-清除”算法会导致收集结束时会有大量空间碎片产生。
CMS 垃圾回收器在 Java 9 中已经被标记为过时(deprecated),并在 Java 14 中被移除。
为什么?
- 停顿时间长:CMS垃圾回收器虽然致力于减少停顿时间,但在并发标记和清理过程中仍然存在较长的停顿时间。这对于需要高响应性和低延迟的应用程序来说可能是不可接受的。随着业务需求的不断增加,对停顿时间的要求也越来越高。
- 内存碎片化:CMS垃圾回收器使用的标记-清除算法会导致堆空间的内存碎片化问题。这可能会影响到堆空间的有效利用率,进而导致频繁的Full GC操作。在大型应用程序中,内存碎片化可能会成为性能瓶颈。
- 无法处理大型堆:CMS垃圾回收器在处理大型堆时性能下降明显。随着应用程序的规模不断增大,对于处理大型堆的需求也越来越迫切。而CMS在处理大型堆时可能会出现内存碎片化和停顿时间过长的问题。
- 并发标记效率低:CMS垃圾回收器在并发标记阶段的效率相对较低,这会影响到垃圾回收的整体性能。在多核处理器上,并发标记的效率可能无法达到理想状态。
G1收集器
- 是什么:
- G1 (Garbage-First) 是一款面向服务器的垃圾收集器,主要针对配备多颗处理器及大容量内存的机器
- 以极高概率满足 GC 停顿时间要求的同时,还具备高吞吐量性能特征
被视为 JDK1.7 中 HotSpot 虚拟机的一个重要进化特征。
特点:
并行与并发:G1 能充分利用 CPU、多核环境下的硬件优势,使用多个 CPU(CPU 或者 CPU 核心)来缩短 Stop-The-World 停顿时间。部分其他收集器原本需要停顿 Java 线程执行的 GC 动作,G1 收集器仍然可以通过并发的方式让 java 程序继续执行。
分代收集:虽然 G1 可以不需要其他收集器配合就能独立管理整个 GC 堆,但是还是保留了分代的概念。
空间整合:与 CMS 的“标记-清除”算法不同,G1 从整体来看是基于“标记-整理”算法实现的收集器;从局部上来看是基于“标记-复制”算法实现的。
- 可预测的停顿:这是 G1 相对于 CMS 的另一个大优势,降低停顿时间是 G1 和 CMS 共同的关注点,但 G1 除了追求低停顿外,还能建立可预测的停顿时间模型,能让使用者明确指定在一个长度为 M 毫秒的时间片段内,消耗在垃圾收集上的时间不得超过 N 毫秒。
收集过程:
- 初始标记:同CMS
- 并发标记:同CMS
- 最终标记:同CMS的重新标记
- 筛选回收:局部复制算法
图解:

G1相比于CMS的创新
- 分区式垃圾收集:
- G1采用了分区式垃圾收集的方式,将堆空间划分为一系列固定大小的区域(Region)(解决碎片化问题),每个区域都有分配回收的责任周期(处理浮动垃圾),而不是像CMS那样将堆分为老年代和新生代。这种分区方式使得G1能够更精细地管理堆空间,减少了全局垃圾收集的时间。
- 在G1中,堆空间被划分为一系列固定大小的区域(Region),每个区域都有自己的角色,包括Eden区、幸存者区、老年代区等,并且使用的是标记-整理算法
- G1中的每个区域都是相互独立的,它们之间不存在明确的年轻代和老年代的划分。这意味着在G1中,所有的区域都可以用于存储新生代和老年代对象,从而消除了年轻代和老年代之间的严格划分,提高了堆空间的利用率。
- G1引入了混合收集(Mixed Collection)的概念,在混合收集过程中,G1可以同时回收新生代和老年代的内存
- G1采用了分区式垃圾收集的方式,将堆空间划分为一系列固定大小的区域(Region)(解决碎片化问题),每个区域都有分配回收的责任周期(处理浮动垃圾),而不是像CMS那样将堆分为老年代和新生代。这种分区方式使得G1能够更精细地管理堆空间,减少了全局垃圾收集的时间。
- 可定制的动态的垃圾回收:
- G1会根据堆空间的使用情况和收集目标动态选择需要回收的区域,从而实现更加灵活的垃圾回收
- 堆空间使用情况监控:
- G1会监控堆空间中各个区域的使用情况,包括每个区域中存活对象的数量、垃圾对象的数量以及碎片化程度等。这些信息可以帮助G1更好地了解堆空间的实时状态。
- 收集目标可定制:
- G1在每次垃圾回收之前会根据预设的收集目标来确定回收的策略。这些收集目标可以是最大停顿时间、期望的垃圾回收效果、当前堆空间的使用率等。从而更好地满足实时性要求较高的应用场景。
- 动态选择回收区域:
- 根据堆空间的使用情况和收集目标,G1会动态选择需要回收的区域。通常情况下,G1会优先选择包含垃圾对象最多的区域进行回收,以最大程度地减少垃圾回收的停顿时间。
- 灵活调整策略:
- 在垃圾回收过程中,G1会不断地监控堆空间的使用情况,并根据实时的情况动态调整回收策略。如果某个区域的垃圾对象数量超过了预设的阈值,G1可能会优先选择回收该区域,以尽快释放内存空间。
- 堆空间使用情况监控:
- G1会根据堆空间的使用情况和收集目标动态选择需要回收的区域,从而实现更加灵活的垃圾回收
- 分区式垃圾收集:
G1解决了CMS的三大痛点:
- 浮动垃圾:分区(分配回收的责任周期)(多核处理)
- 内存碎片:分区(堆内存分区)
- 和用户线程的CPU竞争问题:
- 自适应停顿时间控制:G1具有自适应的停顿时间控制机制,可以根据应用程序的性能需求动态调整垃圾回收的停顿时间。通过监控应用程序的响应时间和吞吐量等指标,G1可以动态地调整停顿时间的目标值,以保证垃圾回收的效率和用户线程的响应速度。(CMS有基于参数的手动停顿时间控制)
- 增量式处理:在执行垃圾回收过程中,G1采用增量式处理的方式,将长时间的停顿分解为多个短暂的停顿。这样可以使垃圾回收的停顿时间更加可控,减少了用户线程与垃圾回收线程之间的竞争。(CMS也有,但不如G1细致)
- 优先级控制:G1可以根据应用程序的工作负载和性能需求动态调整垃圾回收的优先级。例如,可以根据应用程序的响应时间和吞吐量等指标来调整垃圾回收线程的优先级,以保证应用程序的性能和响应速度。(CMS也有,但更多地依赖参数配置)
总的来说,G1相对于CMS在这些方面的设计更加灵活和先进,能够更好地满足现代应用程序对垃圾回收的需求。
ZGC收集器
美团技术沙龙《新一代垃圾回收器ZGC的探索与实践》
