全面深入-JVM虚拟机
一、JVM基础概念
众所周知,Java是一款可以跨平台的语言,那么Java是如何实现跨平台的呢?
Java之所以能够跨平台,主要原因是因为JVM,因为不同的平台使用不同的JVM。
通常,我们编写的 Java 源代码在编译后会生成一个 class 文件,称为字节码文件。Java 虚拟机负责将字节码文件翻译成特定平台下的机器代码,然后运行。简单来讲,Java 的跨平台就是因为不同版本的 JVM 。只要在不同的平台上安装相应的 JVM,就可以运行字节码文件(.class )并运行我们编写的 Java 程序。
在这个过程中,我们编写的 Java 程序没有做任何改动,只是通过 JVM 的 “中间层”,就可以在不同的平台上运行,真正实现了 “一次编译,到处运行(write once, run anywhere)” 的目的。
综上所述, JVM 是跨平台的桥梁和中间件,是实现跨平台的关键。首先将 Java 代码编译成字节码文件,然后通过 JVM 将其翻译成机器语言,从而达到运行 Java 程序的目的。因此,运行 Java 程序必须有 JVM 的支持,因为编译的结果不是机器代码,必须在执行前由 JVM 再次翻译。
注意:编译的结果不是生成机器代码,而是生成字节码。字节码不能直接运行,必须由 JVM 转换成机器码。编译生成的字节码在不同的平台上是相同的,但是 JVM 翻译的机器码是不同的。
1、Java Virtual Machine
JVM(Java Virtual Machine)是Java平台的基础,它有自己的指令集,并在运行时操作不同的内存区域( JVM 内存模型)。 JVM 虚拟机运行于操作系统之上,将字节码加载到 JVM 内存模型中,通过解释器将字节码翻译成当前平台 CPU 能识别的机器码。每一条 Java 指令, Java 虚拟机规范中都有详细定义,如怎么取操作数,怎么处理操作数,处理结果放在哪里。
JVM 是运行在操作系统之上的,所以,它与硬件没有直接交互。
2、JVM的组成
JVM的组成主要包括:类加载器、运行时数据区(堆、虚拟机栈等)、执行引擎(解释器、即时编译器、GC垃圾收集器等)、本地接口库等
3、Java代码的执行流程
Java程序的执行过程简单来说包括:
1. Java源代码编译成字节码
2. 字节码校验并把Java程序通过类加载器加载到JVM内存中**
3. 在加载到内存后针对每个类创建一个Class对象
4. 字节码指令和数据初始化到内存中
5. 找到main方法并创建栈帧
6. 初始化程序计数器内部的值为main方法的内存地址
7. 程序计数器不断递增,逐条执行 JAVA 字节码指令,把指令执行过程的数据存放到操作数栈中(入栈),执行完成后从操作数栈取出后放到局部变量表中,遇到创建对象,则在堆内存中分配一段连续的空间存储对象,栈内存中的局部变量表存放指向堆内存的引用;遇到方法调用则再创建一个栈帧,压到当前栈帧的上面
二、Java的类加载机制
类是在运行期间第一次使用时,被类加载器动态的加载至JVM。JVM不会一次性加载所有类,若一次性加载完所有类,会占用很大内存。
1、类的生命周期
类的生命周期包括以下7个阶段:
1. 加载
2. 验证
3. 准备
4. 解析
5. 初始化
6. 使用
7. 卸载
2、类加载过程
类的加载过程包括5个阶段:加载,验证,准备,解析,初始化
2.1、加载
加载是类加载的第一个阶段
加载主要完成以下几件事:
- 通过类的完全限定名获取二进制字节流
- 将该字节流的静态存储结构转换为元空间的运行时存储结构
- 在内存中生成一个该类的Class对象,作为该类在元空间中的各种数据的访问入口
2.2、验证
验证是验证Class文件的字节流中包含的信息是否符合虚拟机的要求
2.3、准备
- 为类变量分配内存:为类中的静态变量(static修饰的变量)分配内存并设置初始值。例如,对于public static int value = 10;,在准备阶段,value会被初始化为 0(而不是 10,10 是在初始化阶段才会被赋值)。
- 不包含实例变量:实例变量是在对象实例化时随着对象一起分配在内存中
2.4、解析
解析是将符号引用转换为直接引用的过程。
符号引用是以一组符号来描述所引用的目标,比如在.class 文件中,类的方法调用会以符号引用的形式记录被调用方法的全限定名等信息;直接引用是可以直接指向目标的指针、相对偏移量或者是一个能间接定位到目标的句柄
2.5、初始化
初始化阶段才真正开始执行类中定义的 Java 程序代码。初始化阶段是虚拟机执行类构造器<clinit>()方法的过程。在准备阶段,类变量已经赋过一次系统要求的初始值,而在初始化阶段,根据程序员通过程序制定的主观计划去初始化类变量和其它资源。
<clinit>() 是由编译器自动收集类中所有类变量的赋值动作和静态语句块中的语句合并产生的,编译器收集的顺序由语句在源文件中出现的顺序决定。所以,静态语句块只能访问到定义在它之前的类变量,定义在它之后的类变量只能赋值,不能访问。
3、类加载的时机
3.1、主动引用
在 Java 类加载过程中,主动引用 是触发类初始化(执行 <clinit>()
方法)的关键场景。当 JVM 遇到以下 6 种主动引用行为 时,会强制触发类的初始化(前提是类尚未初始化)。这些场景是 JVM 规范明确规定的 “必须初始化” 的情况,区别于 “被动引用”(不会触发初始化的场景 )。
1、创建类的实例(new、反射、克隆、反序列化等)
当通过以下方式创建类的实例时,会触发类的主动引用:
new
关键字:直接显式创建对象,如new User()
。反射创建对象:通过
Class.forName("com.example.User").newInstance()
或Constructor.newInstance()
反射创建实例。克隆(
Cloneable
):如果类实现Cloneable
接口,调用object.clone()
创建实例(需类已加载,且克隆会触发初始化 )。反序列化:通过
ObjectInputStream.readObject()
恢复对象时,若类未初始化则触发初始化。2、访问类的静态变量(非final常量)
当程序 读取或设置类的静态变量(
static
修饰,且变量不是编译期常量 )时,会触发类初始化。注意:
final
常量例外:如果静态变量是final
修饰的编译期常量(如static final int MAX = 10
),则不会触发初始化(因为编译期已直接内联常量值 )。静态变量归属类:静态变量属于类本身,访问时需确保类已初始化。
3、调用类的静态方法(invokestatic)
调用类的 静态方法(
static
修饰的方法 )时,会触发类的初始化。静态方法属于类,调用前需确保类已完成初始化4、初始化子类时,父类会被主动引用
如果一个类 继承自父类,当子类发生主动引用(如创建子类实例、访问子类静态变量等 )时,JVM 会 先初始化父类,再初始化子类。这是类加载的 “父类优先” 原则。
5、反射强制触发(Class.forName显式加载)
通过 反射 API 显式触发类加载 时,会强制触发类的初始化。常见场景是
Class.forName("com.example.User")
,它不仅加载类,还会执行初始化。对比:
ClassLoader.loadClass("com.example.User")
仅加载类,不会触发初始化(属于被动加载 )。6、虚拟机启动时,执行main()方法的主类
当 Java 程序启动时(如
java MainApp
),JVM 会 主动初始化main()
方法所在的类(称为 “主类” )。这是程序入口的强制要求。
3.2、被动引用
被动引用:仅触发类的加载或连接,但不执行初始化。例如:
- 访问父类的静态变量(子类未初始化 )。
- 通过数组创建类实例(如 User[] users = new User[5],仅创建数组,不初始化 User 类 )。
- 访问 final编译期常量(如 User.MAX,常量已内联 )。
除主动引用之外,所有引用类的方式都不会触发加载,称为被动引用
4、类与类加载器
4.1、概述
两个类相等,需要类本身相等,包括类的 Class 对象的 equals () 方法、isAssignableFrom () 方法、isInstance () 方法的返回结果为 true,也包括使用 instanceof 关键字做对象所属关系判定结果为 true 。
除此之外,还要求两个类使用同一个类加载器进行加载,因为每一个类加载器都拥有一个独立的类名称空间。
4.2、类加载器分类
从Java开发人员的角度来看,类加载器可以划分为以下几种:
启动类加载器(Bootstrap Class Loader)
核心功能:加载Java的核心类库,如java.lang.*、java.util.*
实现方式:由JVM的的本地代码(C++)实现,是Java类加载器的顶层
加载路径:通常加载JRE/lib/rt.jar、JRE/lib/ext/*.jar等目录下的类。
特点:无法被 Java 代码直接引用,getClassLoader()返回null。
扩展类加载器(Extension Class Loader)
核心功能:加载 Java 的扩展类库,如
javax.*
包。实现方式:由
sun.misc.Launcher$ExtClassLoader
实现(Java 类)。加载路径:加载
JRE/lib/ext
目录下的 JAR 文件。特点:继承自
URLClassLoader
,可通过java.ext.dirs
系统属性自定义扩展目录。应用程序类加载器(Application Class Loader)
核心功能:加载用户类路径(
classpath
)下的类,即应用程序自己编写的类。实现方式:由
sun.misc.Launcher$AppClassLoader
实现(Java 类)。加载路径:加载
java.class.path
指定的路径,如./bin
目录或-cp
参数指定的路径。特点:是
ClassLoader.getSystemClassLoader()
的返回值,因此也称为系统类加载器。自定义类加载器(Custom Class Loader)
核心功能:用户自定义的类加载器,用于实现特殊的类加载逻辑(如加密类加载、从网络加载类)。
实现方式:继承自
java.lang.ClassLoader
或其子类(如URLClassLoader
),重写findClass()
或loadClass()
方法。
5、双亲委派模型
应用程序是由三种类加载器互相配合,从而实现类加载(还可以加入自定义类加载器)
类加载器之间的关系,称为双亲委派模型。该模型要求除了顶层的启动类加载器外,其他的类加载器都要有自己的父类加载器(准确的说是上级类加载器,并不是继承关系)
5.1、双亲委派工作机制
一个类加载器首先将类加载请求转发到其父类加载器,只有当父类加载器无法完成时才会尝试自我加载
5.2、双亲委派的作用
双亲委派模型通过委派 - 验证 - 加载的层级机制,确保 Java 类加载的安全性、一致性和高效性
6、对象的创建过程
6.1、类加载检查
当虚拟机遇到一条new指令时:
检查类是否已加载:
通过类的全限定名(如java.lang.Object)查找对应的Class对象。
若类未加载,则触发类加载机制(加载→验证→准备→解析→初始化)。
类加载的触发条件:
首次使用new创建对象。 调用静态方法或访问静态字段。
使用反射(如Class.forName("com.example.MyClass"))。
6.2、分配内存
在类加载检查通过后,接下来虚拟机将为新生对象分配内存。对象所需的内存大小在类加载完后便可确定,为对象分配内存空间的任务相当于把一块确定大小的内存从Java堆中划分出来。内存分配的查找方式有“指针碰撞”,“空闲列表”两种。
指针碰撞
适用场景:堆内存规整(没有内存碎片)的情况下
原理:用过的内存全部整合到一边,没有用过的内存放在另一边,中间有一个分界值指针,只需要向着没有使用过的内存方向将该指针移动对象内存大小位置即可
GC收集器:Serial,ParNew
空闲列表
适用场景:堆内存不规整的情况下
原理:虚拟机会维护一个列表,该列表中会记录哪些内存块是可用的,在分配内存时,找一块足够大的内存块,来划分给对象实例,最后更新列表记录
GC收集器: CMS
选择以上两种方式的哪一种,取决于,Java堆内存空间是否规整。而Java堆内存是否规整,取决于GC收集器的算法是“标记-清除”(堆内存不规整)还是“标记-整理”(堆内存规整)
6.3、初始化零值
内存分配完成后,JVM虚拟机会将分配到的内存空间都初始化为零值(如:int为0,boolean为false,引用类型为null)(不包括对象头),这一步操作保证了实例对象不赋初始值就可以直接使用
6.4、设置对象头
将对象的哈希值,对象的GC分代年龄等信息保存到对象头中,还会设置锁状态等信息
6.5、执行init构造方法
执行对象的构造方法
三、Java内存模型(JMM)
1、运行时数据区划分
JVM虚拟机在执行Java程序过程中会把它管理的内存划分为若干个不同的数据区域
JDK 1.8 之前分为:线程共享(Heap 堆区、Method Area 方法区)、线程私有(虚拟机栈、本地方法栈、程序计数器)
JDK 1.8 以后分为:线程共享(Heap 堆区、MetaSpace 元空间)、线程私有(虚拟机栈、本地方法栈、程序计数器)
2、程序计数器
字节码解释器在解释执行字节码文件工作时,每当需要执行一条字节码指令时,就通过改变程序计数器的值来完成。程序中的分支、循环、跳转、异常处理、线程恢复等功能都需要依赖这个计数器来完成。
程序执行过程中,会不断的切换当前执行线程,切换后,为了能让当前线程恢复到正确的执行位置,每条线程都需要有一个独立的程序计数器,并且各线程之间计数器互不影响,独立存储。
程序计数器的主要作用:
字节码解释器通过改变程序计数器来依次读取指令,从而实现代码的流程控制
在多线程的情况下,程序计数器用于记录当前线程执行的位置,从而当线程被切换回来时,能够直到当前线程执行的位置
程序计数器是唯一一个不会出现OutOfMemoryError的内存区域,它随着线程的创建而创建,随着线程的结束而死亡
3、Java虚拟机栈
Java的内存区域可以粗略的分为堆区和栈区。其中栈就是虚拟机栈,或者说是虚拟机中的局部变量表部分,因为局部变量表主要存放了各种基本数据类型(boolean、byte、short、int、long、float、double)、对象引用 。
Java虚拟机栈是由一个个的栈帧组成的,每个栈帧中都拥有:局部变量表、操作数栈、动态链接、方法出口信息(方法返回值和附加信息)。每一次方法的调用都有一个对应的栈帧被压入虚拟机栈。每一个方法调用结束后,对应的栈帧将被弹出
在活动线程中,只有位于栈顶的帧是有效的,称为当前活动帧,代表正在执行当前的方法。在JVM执行引擎运行时,所有指令都只能针对当前栈帧进行操作。虚拟机栈通过pop(入栈)和push(出栈),对每个方法对应的活动栈帧进行运算处理
Java方法有两种返回方式:return语句或抛出异常,不管哪种方式都会导致栈帧被弹出
Java 虚拟机栈会出现两种错误: StackOverFlowError 和 OutOfMemoryError 。
- StackOverFlowError : 当线程请求栈的深度超过 JVM 虚拟机栈的最大深度的时候,就抛出 StackOverFlowError 错误。
- OutOfMemoryError: JVM 的内存大小可以动态扩展,如果虚拟机在动态扩展栈时无法申请到足够的内存空间,则抛出 OutOfMemoryError 异常。
4、本地方法栈
nativa关键字修饰的方法在本地执行时,在本地方法栈中也会创建一个栈帧,用于存放该native方法的局部变量表、操作数栈、动态链接、方法出口信息等。方法执行结束后,对应栈帧也会出栈并释放内存空间
5、堆
Heap堆是JVM所管理的内存中最大的一块区域,被所有内存共享的一块内存区域。堆区域中存放着对象实例,几乎”所有“的对象实例以及数组,都在这里分配内存空间
5.1、新生代、老年代
Heap堆是垃圾收集器GC管理的主要区域,因此堆也被称为GC堆。从垃圾回收的角度来说,由于当前垃圾收集器都采用的是分代收集算法,所以将JVM堆区划分为新生代与老年代。目的是更好的回收内存,或者更快的分配内存
5.2、创建对象的内存分配
创建一个新对象,会在堆中分配内存
一般情况下, 对象会在Eden区(伊甸区)生成
在多线程场景下,为了避免并发分配内存时的竞争,JVM 会启用 TLAB(Thread Local Allocation Buffer) 机制:
原理:每个线程在 Eden 区中预先申请一块私有内存区域(TLAB),线程内创建的对象会优先在自己的 TLAB 中分配,无需加锁,大幅提升分配效率。
默认行为:JVM 默认开启 TLAB(可通过
-XX:+UseTLAB
参数控制),当 TLAB 空间不足时,才会直接在 Eden 区的公共区域分配。
当Eden区装满时,会触发垃圾回收(Young Garbage Collection),即YGC垃圾回收时,在Eden区进行清除策略,没有被引用的对象直接回收
依然存活的对象会被放入Survivor区。Survivor区分为S0和S1两块区域。每次YGC的时候,都会将存活的对象复制到未被使用的Survivor空间中(S0或S1),然后将当前正在使用的空间完全清除,交换两块空间的使用状态。每次交换时,对象的年龄+1。
如果当YGC要移送的对象大于Survivor区容量的上限,则直接移交给老年代。一个对象从新生代晋升老年代的阈值为15,
6、元空间
用于存放类信息、常量、静态变量、JIT即时编译器编译后的机器代码等数据结构
JDK1.8之后,正式移除永久代,采用元空间代替
元空间本质和永久代类似,都是对JVM规范中方法区的一种具体实现。两者之间最大区别是:元空间并不在虚拟机内,而是使用本地内存。因此,默认情况下,元空间大小仅受本地内存限制,但可以通过运行参数来设置元空间大小
7、字符串常量池
String有两种创建方式
在字符串常量池中获取字符串对象,例如:String s1="曲折途穷天地窄";
直接在堆内存空间中创建一个新的字符串对象,例如:String s2=new String("重重灾劫生死微");
String的intern()方法
String s1=new String("身如柳絮随风扬");
String s2=s1.intern();//查看常量池中是否存在“身如柳絮随风扬”,如果有则返回地址,如果没有则会在常量池中创建一个
String s3="身如柳絮随风扬";//使用常量池中
String的拼接
Java基本类型的包装类大部分都实现了常量池技术,即Byte、Short、Integer、Long、Character、Boolean
前四种包装类默认创建了[-128,127]相应类型的缓存数据
Character创建了[0,127]
Boolean直接返回True或者False
如果超出相应范围还是会创建新的对
四、JVM垃圾收集器
1、判断对象是否存活
1.1、引用计数算法
1.2、可达性分析算法
通过定义了一系列称为“GC Roots”的根对象作为起始节点集,从GC Roots开始,根据引用关系向下搜索,查找的路径称为“引用链”。
当一个对象到GC Roots之间没有任何引用链相连是(对象与GC Roots之间不可达),那么该对象就是可被GC Roots对象回收的垃圾对象
可达性分析算法是JVM默认的寻找垃圾算
1.3、Java中四种引用类型
1.3.1、强引用(Strong Reference)
定义:这是最常见的引用类型,就是我们平时使用的普通对象引用。例如
Object obj = new Object();
,obj
就是一个强引用。垃圾回收机制:只要强引用存在,垃圾回收器永远不会回收被引用的对象。即使系统内存不足,JVM 宁愿抛出
OutOfMemoryError
异常,使程序异常终止,也不会回收具有强引用的对象。使用场景:几乎所有的普通对象创建和使用场景都用强引用,比如业务逻辑中创建的实体对象、服务类对象等。
如果强引用对象不使用时,需要弱化从而使GC能够回收
- 例如
Object obj = new Object();
需要显式的设置obj对象为null(obj=null;),则GC认为该对象不存在引用,具体什么时候回收,取决于GC算法。
- 让对象超出作用域范围
1.3.2、软引用(Soft Reference)
如果一个对象只具有软引用,则内存空间充足时,垃圾回收器就不会回收它;如果内存空间不足的话,就会回收这些对象的内存。
只要未被回收,该对象仍可以被使用,所以软引用可用来实现内存敏感的高速缓存
通过 SoftReference
类来实现,用于描述一些还有用但并非必须的对象。当系统将要发生内存溢出(OutOfMemoryError
)之前,会先回收软引用指向的对象。
软引用对象只有在内存不足时才会被回收,调用System.gc()方法只是起通知作用,最终何时回收,由JVM决定
1.3.3、弱引用(Weak Reference)
只具有弱引用的对象具有更短暂的生命周期。在GC扫描过程中,一旦发现只具有弱引用的对象,无论内存是否充足,其内存都会被回收
通过 WeakReference
类来实现
1.3.4、虚引用(Phantom Reference)
虚引用,主要用来追踪对象被垃圾回收的活动,可以在垃圾收集时收到一个系统通知
2、垃圾收集算法
2.1、分代收集理论
目前主流JVM虚拟机的垃圾回收机制,都遵循分代收集理论
弱分代:绝大多数对象都是朝生夕灭
强分代:经历多次垃圾收集过程的对象,越难以回收,越难以被消灭
按照分代收集理论设计的“分代垃圾收集器”,采用的原则:收集器应该将Java堆划分为不同的区域,然后将对象依据其年龄分配到不同的区域存储
2.1.1、分代存储
2.1.2、分代收集
堆区按照分代存储的好处
- 在Java堆区划分成不同的区域后,垃圾收集器才能每次收集其中一个或某些区域,所以才有MinorGC(新生代收集)、MajorGC(老年代收集)、FullGC(整堆收集)等垃圾收集类型
- 垃圾收集器会按照区域不同,安排与之对应的垃圾收集算法:复制算法、标记-清除算法、标记-整理算
垃圾收集类型划分
2.2、垃圾收集算法
2.2.1、标记-清除算法
实现思路:
标记阶段:从 GC Roots(如静态变量、栈帧中的本地变量等)开始,遍历所有可达对象,标记为存活。
清除阶段:扫描整个堆,回收未被标记的对象(即不可达对象)。
存在两个问题:
执行效率不稳定问题:如果执行垃圾回收的区域,大部分对象是需要回收的,则需要大量的标记和清除动作,导致效率变低
内存空间碎片化问题:标记后会产生大量不连续的碎片,空间碎片太多,会导致分配较大对象时,无法找到足够的连续空间,从而触发新的垃圾回收动作
适用于老年代
2.2.2、复制算法
实现思路:
该算法将内存分为大小相同的两块,每次使用其中的一块。当这一块的内存使用完了之后,就将存活的对象复制到另一块,然后将已使用的空间一次清理掉
算法特点:
如果内存中大多数对象都是存活的,这种算法将会产生大量内存间复制的开销
存在的问题:
对象存活率较高,需要进行较多的内存间复制,效率低
浪费过多的内存,使现有的可用空间变为原来的一半
2.2.3、标记-整理算法
标记阶段:从 GC Roots(如静态变量、栈帧中的本地变量等)开始,遍历所有可达对象,标记存活对象。
整理阶段:将存活对象向一端移动,然后直接清理边界外的内存。
优点:消除内存碎片,适合大对象分配。 缺点:需要移动对象,效率较低,可能导致 STW(Stop The World)时间较长。 适用场景:老年代(如 Serial Old、Parallel Old 收集器)。
2.2.4、总结
在新生代中,会有大量对象被垃圾回收,所以采用”复制“算法,只需要付出少量的对象复制成本,就可完成每次垃圾收集
在老年代中,对象存活率是比较高的,而且没有额外的空间进行内存担保,所以使用”标记-清除“算法和”标记-整理“算法
3、垃圾收集器
3.1、Serial收集器(新生代)
Serial(串行)收集器是最基本、历史最悠久的垃圾收集器,采用 “复制” 算法负责新生代的垃圾收集。它是 Hotspot 虚拟机运行在客户端模式下的默认新生代收集器。
它是一个单线程收集器。它会使用一条垃圾收集线程去完成垃圾收集工作,并且它在进行垃圾收集工作的时候,必须暂停其他所有的工作线程("Stop The World"),直到收集结束。
这样的设计,带来的好处就是:简单高效。对于内存资源受限制的环境,它是所有收集器中额外内存消耗最小的收集器。适合单核处理器或处理器核心数较少的环境,每次收集几十 MB 甚至一两百 MB 的新生代内存,垃圾收集的停顿时间完全可以控制在十几毫秒或几十毫秒,最多一百多毫秒。
3.2、Serial Old收集器(老年代)
Serial Old 收集器同样是一个单线程收集器,采用 “标记 - 整理” 算法负责老年代的垃圾收集,主要用于客户端模式下的 HotSpot 虚拟机使用。
如果在服务器端使用,它主要有两种用途:
- 在 JDK5 及以前版本,与 Parallel Scavenge 收集器搭配使用;
- 作为 CMS 收集器发生失败时的后备预案;
3.3、ParNew收集器(新生代)
ParNew 收集器是一个多线程的垃圾收集器。它是运行在 Server 模式下的虚拟机的首要选择,可以与 Serial Old , CMS 垃圾收集器一起搭配工作,采用 “ 复制” 算法
3.4、Parallel Scavenge收集器(新生代)
Parallel Scavenge 收集器是也是一款新生代收集器,使用 “复制” 算法实现的多线程收集器
Parallel Scavenge 收集器预其它收集器的目标不同, CMS 等其它收集器目标是尽可能缩短垃圾收集时用户线程的停顿时间。但是 Parallel Scavenge 收集器的目标则是达到一个可控制的吞吐量。所谓吞吐量就是处理器用于运行用户代码的时间与处理器总消耗时间的比值。
吞吐量 = 运行用户代码时间 /(用户代码时间 + 运行垃圾收集时间)
如果虚拟机完成某个任务,用户代码加上垃圾收集总共耗费了 100 分钟,其中垃圾收集花掉 1 分钟,那吞吐量就是 99% 。停顿时间越短,就越适合需要与用户交互或需要保证服务响应质量的程序,良好的响应速度能提升用户体验;而高吞吐量则可以最高效率地利用处理器资源,尽快完成程序的运算任务,主要适合在后台运算而不需要太多交互的分析任务。
3.5、Parallel Old收集器(老年代)
Parallel Old 收集器是一个多线程的垃圾收集器,使用 “标记 - 整理” 算法,是 Parallel Scavenge 收集器的老年代版本。
在注重吞吐量或者处理器资源较为稀缺的应用场景,都可以优先考虑 Parallel Scavenge 收集器 + Parallel Old 收集器这个收集器组合。
3.6、CMS收集器(老年代)
CMS收集器是一种以获取最短停顿时间为目标的收集器,基于“标记 - 清除”算法实现,是HotSpot虚拟机第一款真正意义上的并发垃圾收集器,它第一次实现了让垃圾收集线程与用户线程(基本上)同时工作
工作流程:
- 初始标记:标记一下GC Roots能直接关联到的对象,速度很快
- 并发标记:从GC Roots的直接关联对象开始遍历整个对象图的过程,此过程耗时较长,但不需要停顿用户线程,可以与垃圾收集器一起并发运行
- 重新标记:重新标记阶段,是为了修正并发标记期间因为用户程序继续运行而导致标记产生变化的那一部分对象的标记记录,此阶段停顿时间一般会比初始标记阶段的时间长,远远比并发标记的时间短
- 并发清除:清理删除掉标记阶段判断已经死亡的对象。因为不需要移动存活对象,所以此阶段是可以和用户线程同时并发的
优点和缺点
主要优点:并发收集、低停顿。
主要缺点:
- 影响用户线程的执行效率:并发标记和并发清除时,是和用户线程一起运行的,收集过程中肯定占用了用户程序的 CPU 资源。 CMS 默认启动的回收线程数是 (CPU 数量 + 3)/4 ,当 CPU 数量在 4 个以上时,垃圾回收线程占用不少于 25% 的 CPU 资源,势必影响用户线程的执行效率。
- 无法处理浮动垃圾:在并发清除阶段,用户线程并没有停止,所以还会继续产生新的垃圾,只能等待下一次收集时才能进行回收,这部分垃圾被称为 “浮动垃圾” 。
- 产生大量空间碎片:因为 CMS 收集器是基于 “标记 - 清除” 算法实现的,所以在进行大量的垃圾回收时,会产生很多不连续的内存空间。这是使用 “标记 - 清除” 算法都会有的缺点
由于垃圾收集阶段用户线程还需要持续运行,所以需要预留足够的内存空间提供给用户线程使用,因此 CMS 收集器不能像其它收集器那样等到老年代几乎完全被填满了再进行收集。
- 在 JDK6 的默认设置中, CMS 收集器的启动阈值为 92% ,代表老年代使用了 92% 的空间后,就会启动 CMS 收集器
- 如果 CMS 运行期间,无法满足程序分配新对象的需要,就会出现一次 “并发失败”,这时候虚拟机将临时启动 Serial Old 收集器进行老年代的垃圾收集。
3.7、G1收集器(老年代)
大多数垃圾收集器的共性是在整个垃圾收集过程中,一定会发生 Stop The World (简称: STW ),并且 STW 的时间是根据垃圾标记所需要的时间来确定,可能依然会存在某次垃圾收集时, STW 的时间过长的问题,导致这个问题的原因在大多数垃圾收集器都是对整个新生代或老年代进行垃圾回收,要扫描的对象太多了。但 STW 又是每个垃圾收集器都不可避免的,所以,现代垃圾收集器的发展就是为了能够尽量缩短 STW 的时间。
3.7.1、什么是G1收集器
G1(Garbage - First)是一款面向服务器的垃圾收集器,主要针对配备多颗处理器、大容量内存的机器。它不再严格按照分代思想进行垃圾回收
G1 采用局部性收集的思想,对于堆空间的划分,采用 Region 为单位的内存划分方式:
G1 垃圾回收器把堆划分成若干个大小相同的独立区域(Region)(按照 JVM 的实现,Region 的数量不会超过 2048 个):每个 Region 都会代表某一种角色,H、S、E、O。E 代表 Eden 区,S 代表 Survivor 区,H 代表的是 Humongous(G1 用来分配大对象的区域,对于 Humongous 也分配不下的超大对象,会分配在连续的 N 个 Humongous 中),剩余的深蓝色代表的是 Old 区,灰色的代表的是空闲的 region。
这种思想上的转变和设计,使得 G1 可以面向堆内存任何部分来组成回收集来进行回收,衡量标准不再是它属于哪个分代,而是哪块内存存放的垃圾最多,回收收益最大,这就是 G1 收集器的 Mixed GC 模式,即混合 GC 模式
3.7.2、G1垃圾收集器工作流程
- 初始标记 (Initial Marking):这个阶段仅仅只是标记 GC Roots 能直接关联到的对象,让下一阶段用户程序并发运行时,能在正确的可用的 Region 中创建新对象,这阶段需要停顿线程,但是耗时很短。
- 并发标记 (Concurrent Marking):从 GC Roots 开始对堆的对象进行可达性分析,递归扫描整个堆里的对象图,找出存活的对象,这阶段耗时较长,但是可以与用户程序并发执行。
- 最终标记 (Final Marking):对用户线程做另一个短暂的暂停,用于处理并发阶段结束后遗留记录。
- 筛选回收 (Live Data Counting and Evacuation):负责更新 Region 的统计数据,对各个 Region 的回收价值和成本进行排序,根据用户所期望的停顿时间来制定回收计划。可以自由选择多个 Region 来构成【回收集】,然后把回收的那一部分 Region 中的存活对象 ==> 复制 ==> 到空的 Region 中,最后对那些 Region 进行清空。
3.7.3、G1垃圾收集器的特点
- 并行与并发:G1 能充分利用 CPU、多核环境下的硬件优势,使用多个 CPU(CPU 或者 CPU 核心)来缩短 Stop - The - World 停顿时间。部分其他收集器原本需要停顿 Java 线程执行的 GC 动作,G1 收集器仍然可以通过并发的方式让 java 程序继续执行。
- 分代收集:虽然 G1 可以不需要其他收集器配合就能独立管理整个 GC 堆,但是还是保留了分代的概念。但它能够采用不同方式去处理新创建的对象和已存活一段时间、熬过多次 GC 的旧对象来获取更好的收集效果。
- 空间整合:G1 从整体来看是基于 “标记 - 整理” 算法实现的收集器,从局部(两个 Region 之间)上来看是基于 “复制” 算法实现的。这意味着 G1 运行期间不会产生内存空间碎片,收集后能提供规整的可用内存。此特性有利于程序长时间运行,分配大对象时不会因为无法找到连续内存空间而提前触发下一次 GC。
- 可预测的停顿:这是 G1 相对于 CMS 的另一个大优势,降低停顿时间是 G1 和 CMS 共同的关注点,但 G1 除了追求低停顿外,还能建立可预测的停顿时间模型,能让使用者明确指定在一个长度为 M 毫秒的时间片段内。消耗在 GC 上的时间不得超过 N 毫秒,这几乎已经是实时 Java(RTSJ)的垃圾收集器的特征了。
五、三色标记算法
在 JVM 的垃圾回收机制中,准确识别哪些对象是垃圾(即不再被引用的对象)是核心任务之一,通常采用根可达性分析算法。
传统的垃圾回收算法如标记 - 清除(Mark-Sweep )、标记 - 整理(Mark-Compact )等在标记阶段通常会产生 STW ( Stop The World )方式,这会导致应用程序出现明显的停顿。为了减少这种停顿时间, JVM 引入了三色标记算法,它是现代垃圾回收器(如 CMS 、 G1 、 ZGC 等)的核心算法之一。
5.1、三色标记的基本概念
三色标记算法将对象分为三种颜色,每种颜色代表不同的状态:
【1】. 白色对象
- 定义:尚未被垃圾回收器访问到的对象。
- 初始状态:在标记开始阶段,所有对象均为白色,表示 "未被发现" 或 "待处理" 状态。
- 最终状态:标记结束后,仍然是白色的对象被视为垃圾对象,会在后续的清除阶段被回收。
【2】. 灰色对象
- 定义:已经被垃圾回收器访问到,但该对象引用的其他对象还没有被完全扫描,表示 "已发现但未处理完" 状态
- 中间状态:表示该对象正在被处理中,其部分引用已经被扫描,但还有一些引用未被扫描。
【3】. 黑色对象
- 定义:已经被垃圾回收器访问到,并且该对象引用的所有其他对象也都已经被扫描过,表示 "已处理完成" 状态
- 终结状态:黑色对象不会再被重新扫描,垃圾回收器认为其引用的所有对象都已经被标记。
5.2、三色标记的工作流程
【1】. 初始阶段
所有对象均被标记为白色。【2】. 标记阶段
根对象标记:垃圾回收器从 GC Roots 根对象(如栈中的引用、静态变量引用等)开始扫描,将根对象标记为灰色。
灰色对象处理:依次处理每个灰色对象,将其引用的所有白色对象标记为灰色,并将该灰色对象自身标记为黑色。
循环处理:重复上述步骤,直到所有灰色对象都变为黑色对象为止。【3】. 完成阶段
所有对象的颜色均为黑色或白色。白色对象即为垃圾对象,会在后续的清除阶段被回收
5.3、漏标问题
5.3.1、漏标问题的原因
在并发标记过程中,由于用户线程和垃圾回收线程同时运行,可能会出现以下两种情况,导致对象被错误地标记为垃圾:
- 条件一:一个白色对象(未扫描的对象)被黑色对象(已扫描的对象)引用。
- 条件二:从灰色对象到该白色对象(未扫描的对象)的直接或间接引用被破坏。
当这两个条件同时满足时,由于黑色对象不会被再次扫描,而且灰色对象也无法引用这个白色对象,所以会导致白色对象被错误地标记为垃圾,这种现象称为 "对象消失问题",也叫漏标。
5.3.2、漏标问题的解决方案
为了解决并发标记中的对象消失问题,JVM 采用了两种主要的屏障机制:
(1) 增量更新 (Incremental Update)
- 原理:当黑色对象插入新的指向白色对象的引用时,将该插入操作记录下来 (通过写屏障)。在重新标记阶段,重新处理这些记录,确保白色对象被正确标记。
- 应用场景: CMS 垃圾回收器采用增量更新。
(2) 原始快照 (Snapshot At The Beginning, SATB)
- 原理:当灰色对象要删除指向白色对象的引用时,将这个要删除的引用记录下来 (通过写屏障)。在重新标记阶段,将这些记录中的白色对象标记为灰色,确保它们不会被错误回收。
- 应用场景: G1 (Garbage-First) 和 ZGC (Z Garbage Collector) 采用原始快照
六、JVM 核心知识体系总结与实践启示
1、 知识脉络梳理
JVM 知识体系围绕 “程序运行与内存管理” 核心,从基础概念到实战应用层层递进:
- 基础架构:JVM 组成、类加载机制(加载流程、双亲委派)是程序运行的 “入口”,决定代码如何加载、执行;
- 内存模型:JMM 定义线程与内存交互规则,堆、栈、元空间等区域划分,是对象存储、垃圾管理的 “舞台”;
- 垃圾回收:从对象存活判定(引用计数、可达性分析),到分代回收理论(新生代、老年代),再到具体收集器(Serial、G1 等),是 JVM 性能优化的 “核心战场”;
- 并发标记:三色标记算法解决并发回收的 “漏标问题”,支撑低延迟垃圾回收(如 G1、ZGC),是高并发场景的 “关键武器”。
2、 核心机制关联
2.1、类加载与内存管理的联动
类加载通过 “加载 → 初始化” 流程,将字节码转为内存中的 Class 对象,直接影响堆(对象实例)、元空间(类元数据)的内存占用。例如:
- 频繁动态加载类(如热部署场景),会增加元空间压力,需关注类卸载机制;
- 类加载的 “双亲委派” 保障类的唯一性,避免内存中出现多份相同类实例,减少内存浪费。
2.2、垃圾回收与内存区域的适配
不同内存区域(新生代、老年代)适配不同回收策略:
- 新生代(Eden、Survivor)用 “复制算法”(如 Serial、ParNew),利用对象 “朝生夕死” 特点快速回收;
- 老年代(Tenured)用 “标记 - 整理” 或 “并发标记清除”(如 CMS、G1),应对长存活对象的内存碎片与回收效率问题。
2.3、并发标记与回收器的协同
三色标记算法是 G1、ZGC 等现代回收器实现 “低停顿” 的基础:
- G1 结合 “SATB 写屏障 + 三色标记”,实现并发标记,将停顿分散到应用线程;
- ZGC 基于 “染色指针” 优化三色标记,突破内存大小限制,支撑 TB 级堆内存回收。
3、实践场景指导
3.1、性能优化方向
类加载优化:
- 减少不必要的类加载(如框架过度依赖反射、动态代理),降低元空间内存占用;
- 自定义类加载器时,需处理 “双亲委派” 破坏场景(如热修复),避免类冲突。
内存布局调优:
- 新生代大小(-Xmn)影响 Minor GC 频率,高并发场景可适当增大 Eden 区,减少对象晋升老年代;
- 元空间(-XX:MetaspaceSize)需根据动态类加载情况调整,避免因元空间溢出(
OutOfMemoryError
)崩溃。
回收器选型:
- 单线程、小堆内存(如桌面应用):选 Serial/Serial Old,简单高效;
- 多核心、低延迟需求(如互联网服务):选 G1,平衡吞吐量与停顿;
- 超大堆(如大数据平台):选 ZGC,支持 TB 级堆内存,停顿毫秒级。
3.2、故障排查思路
类加载异常:
遇到ClassNotFoundException
,优先检查类路径(-classpath
)、类加载器双亲委派流程(是否存在类隔离、重复加载)。内存泄漏:
通过堆 Dump(jmap -dump:live
)分析对象引用链,结合 “可达性分析” 定位泄漏点(如静态集合持有对象、线程局部变量未清理)。GC 频繁停顿:
用jstat
监控 GC 次数与时间,结合回收器日志(-XX:+PrintGCDetails
)判断问题:
- 新生代 GC 频繁 → 检查对象创建速率(如循环创建大对象);
- 老年代 GC 停顿长 → 调整分代阈值(
-XX:MaxTenuringThreshold
),或切换回收器(如 CMS 换 G1)。
4、技术演进趋势
- 模块化与轻量化:JVM 类加载机制向 “模块化” 演进(如 Jigsaw 项目),支持更细粒度的类隔离,适配微服务架构;
- 低延迟回收:ZGC、Shenandoah 等回收器持续优化,突破 “Stop The World” 瓶颈,支撑金融、实时计算等对延迟敏感的场景;
- 智能化调优:JVM 逐步引入 AI 调优(如自适应 GC 策略),自动感知应用负载,动态调整内存参数、回收策略。
一句话总结:JVM 知识体系是 “运行机制 + 内存管理 + 垃圾回收” 的协同体系,理解各模块关联、结合实践调优,才能让 Java 程序在性能、稳定性上 “更上一层楼”,适配从传统单体到云原生的复杂场景。