一、JVM的基础概念
1、概述
JVM是 Java 程序的运行基础环境,是 Java 语言实现 “一次编写,到处运行” ("write once , run anywhere. ")特性的关键组件,具体从以下几个方面来理解:
概念层面
JVM 是一种抽象的计算机规范, 它定义了字节码文件的执行环境,提供了一套虚拟的硬件架构,包括指令集、寄存器组、栈、堆、方法区等。在这个虚拟环境中,Java 字节码可以被解释或编译成机器码并执行, 就如同真实的计算机硬件执行机器指令一样。
作用表现
- 字节码执行:Java 源文件(.java)经过编译器(javac)编译成字节码文件(.class),JVM 负责加载这些字节码文件,并通过执行引擎将字节码解释或编译成对应操作系统和硬件平台能够识别的机器码 ,从而让 Java 程序在不同的系统上都能运行。比如,同样一份 Java 程序的字节码,无论是在 Windows、Linux 还是 macOS 系统上,只要安装了对应的 JVM,都可以正常执行。
- 内存管理:JVM 管理着程序运行时的内存,划分出程序计数器、Java 虚拟机栈、本地方法栈、方法区、堆等运行时数据区 。它自动进行对象的内存分配(主要在堆上)和垃圾回收(释放不再使用的对象占用的内存),比如 Java 程序员不需要像 C/C++ 程序员那样手动释放内存,减少了内存泄漏和野指针等问题。
- 类加载:JVM 的类加载子系统负责从文件系统、网络或其他来源加载 Java 类,并且对类进行验证、准备、解析和初始化等操作,保证类在使用前已经被正确加载和初始化,比如加载第三方库中的类,以支持程序的功能实现。
与 Java 生态的关系
JVM 不只是运行 Java 语言编写的程序,很多其他语言,如 Kotlin、Groovy、Scala 等,也可以编译成字节码在 JVM 上运行, 这使得 JVM 成为一个多语言的运行平台,构建起庞大的 Java 生态系统,为企业级开发、大数据处理(如基于 JVM 的 Apache Spark)、安卓应用开发(安卓虚拟机基于 JVM 原理定制)等领域提供了强大的支持。
2、JVM的组成
JVM的组成主要包括:类加载器、运行时数据区(堆、虚拟机栈等)、执行引擎和本地方法接口四大部分组成。
执行引擎
负责执行字节码文件中的指令,它包含以下几个重要组件:
- 解释器:可以将字节码文件逐行解释执行,但是执行效率相对较低。
- 即时编译器(JIT):可以将热点代码(经常被执行的代码)编译成机器码,下次可直接调用,提高代码的执行效率。在程序运行过程中,JIT 会监测到热点代码,然后对其进行编译优化。
- 垃圾回收器:负责回收堆中不再使用的对象所占用的内存空间,保证堆内存的有效利用。常见的垃圾回收算法包括标记 - 清除算法、复制算法、标记 - 整理算法、分代收集算法等。
本地方法接口
它是 JVM 调用本地方法(用其他语言,如 C、C++ 编写的代码)的接口。通过本地方法接口,Java 程序可以调用底层操作系统的功能,实现与硬件交互等操作。
3、Java程序执行流程:
二、Java类加载机制
1、类加载器:
负责加载 .class 文件到内存中,即将编译生成的字节码文件加载进 JVM,供后续使用。JVM不会一次性加载所有类。如果一次性加载,那么会占用很多的内存。
类加载过程分为加载、验证、准备、解析和初始化五个阶段:
- 加载:通过类的全限定名获取定义此类的二进制字节流,将字节流所代表的静态存储结构转化为方法区的运行时数据结构,并在内存中生成一个代表这个类的 java.lang.Class 对象。
- 验证:确保 .class 文件的字节流符合 JVM 规范,从文件格式、元数据、字节码逻辑到符号引用层层校验,过滤非法内容(如格式错误、类型不匹配、恶意指令等 ),既保证字节码能被 JVM 正确解析执行,也避免恶意代码攻击、破坏虚拟机运行安全。
- 准备:为类变量分配内存并设置初始值,这些变量所使用的内存都将在方法区中进行分配。
- 解析:将常量池中的符号引用转换为直接引用的过程。
- 初始化:执行类构造器 <clinit>() 方法,对类变量进行赋值操作和执行静态代码块中的语句。
2、类加载的时机
2.1 主动引用
虚拟机规范未强制约束“何时加载类”,但严格规定以下六种场景必须触发类的完整加载流程(加载→验证→准备→解析→初始化前置步骤):
1、字节码指令触发(4 种核心场景)
当 JVM 执行以下字节码指令时,会先加载目标类(若未加载):
- new:创建类实例(如 new User() ,需先加载 User 类 )
- getstatic:访问非 final 修饰的静态字段(如 Config.appName ,appName 是静态变量;若为 final static String APP_NAME = "App" ,常量直接进入运行时常量池,不触发类加载 )
- putstatic:修改类的静态字段(如 Counter.count = 1 ,需先加载 Counter 类 )
- invokestatic:调用类的静态方法(如 StringUtils.isEmpty("") ,需先加载 StringUtils 类 )
2、反射操作触发
使用 java.lang.reflect 包反射调用类时(如 Class.forName("com.example.User")、User.class.newInstance() ),若类未初始化,则强制触发加载+初始化流程(Class.forName 默认会初始化类,区别于 ClassLoader.loadClass 仅加载不初始化 )。
3、父类/父接口依赖触发
加载一个类时,若其直接父类、间接父类或父接口(JDK8+ 含默认方法的接口)未加载,则先触发父类/父接口的加载。
示例:加载 Student 类(继承 Person 类,Person 实现 Serializable 接口 )时,若 Person 或 Serializable 未加载,会先加载它们。
4、JVM 启动主类触发
虚拟机启动时,必须加载包含 main() 方法的主类(程序入口类 )。若主类未找到或加载失败,会抛出 NoSuchMethodError: main 等错误。
5、接口默认方法依赖触发(JDK8+)
当一个接口定义了 default 修饰的默认方法(如 interface A { default void hello() {} } ),若其实现类(如 class B implements A )被加载,则该接口需在实现类之前加载,保证默认方法的字节码可被解析。
补充说明: JVM 判断“类是否加载”的依据是全限定名 + 类加载器,双亲委派模型会影响加载优先级,但上述场景属于“强制触发加载”的规则,与委派逻辑协同保证类加载的正确性。
2.2 被动引用
- 通过子类引用父类的静态字段,不会导致子类加载。
public class demo {public static void main(String[] args) {// 子类通过引用父类的静态字段,不会导致子类加载(父类会加载)System.out.println(Sonclass.value);}
}class Dadclass{static int value = 123;static {System.out.println("Dadclass类被加载。。。。。");}
}class Sonclass extends Dadclass{static {System.out.println("Sonclass类被加载。。。。。");}
}
运行结果:
- 通过数组定义类引用类,不会触发此类的加载。该过程会对数组类进行加载,数组类是一个虚拟机自动生成的、直接继承Object类的子类,其中包含了数组的属性和方法。
public class demo2 {public static void main(String[] args) {// 通过数组定义来引用类,不会触发此类的加载Data[] data = new Data[10];}
}class Data{static final int max = 100;static {System.out.println("Data类被加载了。。。。。");}
}
运行结果:
- 常量在编译阶段会存入调用类的常量池中,本质上并没有直接引用到定义常量的类,因此不会触发定义常量的类的加载
public class demo2 {public static void main(String[] args) {// 引用静态常量不会触发类加载System.out.println(Data.max);}
}class Data{static final int max = 100;static {System.out.println("Data类被加载了。。。。。");}
}
运行结果:
3、类与类加载器
类加载器的分类
- 启动类加载器:由C/C++实现的JVM原生类加载器,不属于Java类(无法通过Java代码直接引用)。加载核心类库,诸如 java.lang、java.util 等位于jre/lib目录下的核心jar包(rt.jar)。
- 扩展类加载器 :Java语言实现,继承自 java.lang.ClassLoader。加载Java扩展类库,位于 jre/lib/ext 目录下或系统属性 java.ext.dirs 指定的路径下的类。
- 应用程序类加载器 :Java语言实现,继承自 java.lang.ClassLoader。加载用户类路径(classpath)上所指定的类库,例如:我们自己编写的类或第三方的 jar 包。
- 自定义类加载器: 在 Java 中,开发者可以继承 java.lang.ClassLoader 类,重写 findClass 等方法来自定义类加载器。
4、双亲委派模型
类加载器之间的层次关系,称为双亲委派模型。当一个类加载器收到类加载请求时,会先将请求委派给父加载器(遵循「启动类加载器→扩展类加载器→应用程序类加载器」的层级,自定义加载器需手动指定父加载器,默认是应用程序类加载器 ),只有父加载器无法加载时,才由当前类加载器尝试加载。
这一机制确保了:
- Java核心类的安全性(假设自定义一个 `java.lang.Object` 类,当类加载器收到加载请求时,会先委派给父加载器。由于核心类 `java.lang.Object` 属于启动类加载器的加载范围,启动类加载器会直接加载自己负责的 `rt.jar` 里的 `java.lang.Object` ,不会让自定义的同名类被加载,从而防止核心类被恶意或错误代码覆盖)
- 类的唯一性(双亲委派确保核心类只会被启动类加载器加载,避免不同加载器重复加载、造成类型混乱)
4.1 JVM判断两个类是否为“同一个类”的依据:
- 完全限定名相同
- 由同一个类加载器加载
4.2 通过自定义类加载器打破双亲委派模型
自定义类加载器是打破双亲委派模型的常用方式,核心思路是重写类加载器的 loadClass() 方法,改变 “先委派父加载器” 的默认逻辑:
原理:双亲委派的核心逻辑在 ClassLoader 类的 loadClass() 方法中(先检查类是否已加载,未加载则委派父加载器,父加载器失败才自己加载)。通过继承 ClassLoader 并重写 loadClass(),可跳过 “委派父加载器” 的步骤,直接由当前类加载器加载指定类,从而打破委派链条。
注意:重写时需谨慎处理核心类(如 java.lang.*),JVM 对核心包有安全校验(SecurityManager),强制加载自定义核心类可能触发 SecurityException,确保打破委派的同时不破坏 JVM 基础安全机制。
5、对象的创建过程
Step1:类加载检查
当执行 new User() 这样的代码时,JVM首先会检查这个类(User)是否已被加载、链接和初始化。
- 如果类未加载,JVM会通过类加载器执行类加载流程(加载→验证→准备→解析→初始化)。
- 只有类成功加载到元空间后,才能创建其对象。
Step2:分配内存
类加载完成后,JVM会为新对象在堆内存中分配一块内存空间,大小在类加载时已确定。(类的字段、方法等元数据决定对象大小)
内存分配的两种方式:
- 指针碰撞:当堆内存是连续规整的,JVM 会通过移动指针的方式,直接划分出对应大小的内存给新对象。
- 空闲列表:若堆内存碎片化,JVM 会维护一张 “空闲内存块列表”,从中挑选合适大小的内存块,分配给新对象。
至于采用哪种分配方式,取决于 Java 堆内存是否规整。而堆内存是否规整,又由 GC 收集器的算法决定 —— 若用 “标记 - 清除” 算法,会产生内存碎片,堆内存不规整;若用 “标记 - 整理” 算法,会整理内存碎片,让堆内存恢复规整 。
Step3:初始化零值
内存分配完成后,JVM会将分配到的内存空间(对象的实例字段)初始化零值。(如 int 为0,Object引用为 null 等)
这一步保证了:即使对象未显式初始化字段,也能访问到零值(符合Java语言规范)。
Step4:设置对象头
初始化零值完成后,JVM会在对象内存的对象头进行必要的设置。
例如:这个对象是哪个类的实例、如何才能找到累的元数据信息、对象的哈希码和对象的GC分代年龄等信息。
Step5:执行init构造方法
最后,JVM会执行对象的初始化方法:实例字段的显式初始化→实例代码块→构造函数,当构造方法执行完毕,new 指令会返回堆内存中对象的引用(可理解为对象在堆中的地址标识 ),此时一个完整对象创建完成,可通过引用操作对象。
三、Java内存模型(JMM)
JMM,全称 Java Memory Model(Java内存模型)
1、概述
Java 内存模型是 Java 虚拟机规范中定义的一套抽象规则,它围绕多线程并发场景下的数据可见性、原子性、有序性等问题,规范了 Java 程序中各种变量(实例字段、静态字段、数组元素等,不包含局部变量和方法参数,因后者线程私有 )的访问方式。
核心目标
解决多线程环境里,由于 CPU 缓存、编译器优化等因素,导致不同线程对共享变量操作出现的可见性紊乱、执行顺序混乱问题,为 Java 并发编程提供内存访问的底层保证,让开发者基于 JMM 规则编写代码,能在不同硬件和操作系统的 JVM 实现上,获得一致的并发表现。
2、运行时数据区域划分
JDK1.8以后分为:线程共享(Heap 堆区、MetaSpace 元空间)、线程私有(虚拟机栈、本地方法栈、程序计数器)
3、程序计数器
程序执行过程中会不断切换当前执行线程,切换后为了能让当前线程恢复到正确的执行位置,每一条线程都需要一个独立的程序计数器。(是当前线程所执行的字节码的行号指示器)
作用:
- 字节码解释器通过改变其值来一次读取指令,从而实现代码的流程控制(如顺序直接、选择、循环等)
- 程序计数器是唯一一个不会出现OutOfMemoryError 的内存区域,它随着线程的创建而创建,也随线程的结束而死亡。
4、Java虚拟机栈(VM Stack)
Java 虚拟机栈是由一个个栈帧组成,而每个栈帧中都拥有:局部变量表、操作数栈、动态链接、方法出口信息。每一次方法调用都会有一个对应的栈帧被压入VM Stack 虚拟机栈,每一个方法调用结束后,代表该方法的栈帧会从 VM Stack 虚拟机栈中弹出。
在活动线程中, 只有位于栈顶的帧才是有效的,称为当前活动栈帧,代表正在执行的当前方法。在 JVM 执行引擎运行时,所有指令都只能针对当前栈帧进行操作。虚拟机栈通过 pop 和 push 的方式,对每个方法对应的活动栈帧进行运算处理,方法正常执行结束,肯定会跳转到另一个栈帧上。
VM Stack(虚拟机栈)的栈帧弹出主要发生在以下几种情况:
- 方法正常执行完毕:当方法执行到末尾的 } 或 return 语句时,当前方法的栈帧会从 VM Stack 中弹出,程序回到上层调用方法继续执行。
- 方法抛出未捕获的异常:若方法执行中抛出异常且未被自身的 try-catch 捕获,该方法的栈帧会弹出,异常向上层调用链传播,直至被捕获或导致程序终止。
- 线程执行结束:当线程的所有任务执行完毕,该线程对应的 VM Stack 中所有栈帧会被依次弹出,释放资源。
- 递归调用终止:递归方法达到终止条件后,从最内层递归开始,栈帧会逐层弹出,直到回到最初的调用点。
这些情况本质上都是方法生命周期结束的体现,栈帧的弹出确保了 VM Stack 的内存资源能被正确回收和复用。
5、本地方法栈(Native Method Stack)
native 关键字修饰的本地方法被执行的时候,在本地方法栈中也会创建一个栈帧,用于存放该 native 本地方法的局部变量表、操作数栈、动态链接、方法出口信息。方法执行完毕后,相应的栈帧也会出栈并释放内存空间。也会出现 StackOverFlowError 和 OutOfMemoryError 两种错误。
6、堆(Heap)
Heap 堆是JVM所管理的内存中最大的一块区域,被所有线程共享的一块内存区域。堆区中存放对象实例,“几乎”所有的对象实例以及数组都在这里分配内存。
6.1 新生代和老年代
Heap 堆是垃圾收集器GC(Garbage Collectde)管理的主要区域,因此堆区也被称为GC堆。从垃圾回收的角度,由于现在收集器基本都采用分代垃圾收集算法,所以JVM中的堆区往往进行分代划分。例如:新生代和老年代。目的是为了更好地回收内存,或者更快地分配内存。
6.2 创建对象的内存分配
当创建新对象时,内存分配起始于堆空间,核心流程围绕新生代(Young Generation)的 Eden 区、Survivor 区,及对象晋升老年代的机制展开:
- Eden 区:对象初始诞生地,大部分对象优先在 Eden 区 分配内存。当 Eden 区被对象 “填满”(达到内存阈值),会触发 Young Garbage Collection(YGC,新生代垃圾回收 )。回收时,Eden 区执行 “清除策略”:无引用关联的对象,直接被标记回收,释放内存。
- Survivor 区:对象存活的 “过渡站”,YGC 后仍存活的对象,会被转移至 Survivor 区(由 s0、s1 两块等大内存组成,同一时间仅一块被使用 )。每次 YGC 执行时,存活对象会被复制到未被使用的 Survivor 空间(s0 或 s1 ),随后清空当前正在使用的 Survivor 区域,同时交换两块空间的使用状态(下次 YGC 切换目标 Survivor 区 )。伴随每次 Survivor 区的 “复制 - 交换”,对象的年龄计数器 +1(记录对象在新生代经历的 YGC 次数 )。
- 老年代:长期存活对象的归宿
- 直接晋升条件:若 YGC 中待转移的对象大小,超过 Survivor 区剩余容量上限,会跳过 Survivor 区,直接 “移交” 到老年代(避免因多次复制浪费性能 )。
- 阈值晋升条件:对象不会永久停留在新生代。JVM 默认 “新生代晋升老年代的年龄阈值为 15”,即对象在 Survivor 区经历 14 次 “复制 - 交换” 后,第 15 次 YGC 时会晋升到老年代,进入长期内存管理阶段。
这样的流程设计,通过 “分代回收” 策略,让短期存活对象(大部分对象在 Eden 区一次 YGC 就被回收 )和长期存活对象(逐步晋升老年代 )的内存管理更高效,是 JVM 垃圾回收机制适配实际应用场景(对象 “朝生夕死” 特性 )的核心体现。
7、元空间(Meta Space)
元空间是 JDK 8+ 中 HotSpot 虚拟机对方法区的实现,替代原永久代。它基于本地内存分配,主要存储类元数据(类结构信息)、运行时常量池(类相关常量与符号引用 )、静态变量(static 修饰),以及 JIT 即时编译器生成的热点方法机器码。其内存可通过 MetaspaceSize MaxMetaspaceSize 等参数调控,支持类卸载时的垃圾回收,解决永久代内存限制僵化问题,适配 Java 动态类加载场景,是 JVM 内存管理架构演进的关键部分 。
四、Java垃圾收集器
1、判断对象是否存活
1.1 引用计数算法
核心原理
- 为每个对象设置一个引用计数器,记录该对象被其他对象引用的次数。
- 当对象被新的引用指向时,计数器 + 1;当引用失效(如引用指向其他对象或超出作用域)时,计数器 - 1。
- 当计数器的值为0时,认为该对象不再被使用,可被回收。
引用计数算法存在的问题:对象循环引用
假设有两个对象 A 和 B,A 持有 B 的引用,B 同时持有 A 的引用,且两者都没有被其他外部对象引用。此时:在引用计数算法中,A 和 B 的引用计数器始终为 1(互相引用),永远不会变为 0,因此无法被回收,造成内存泄漏。
1.2 可达性分析算法
核心原理:
通过定义一系列称为“ GC Roots ”的根对象作为起始节点集,从 GC Roots 开始,根据引用关系往下进行搜索,查找的路径我们把它称为“引用链”。每当一个对象到 GC Roots 之间没有任何引用链相连时(对象与 GC Roots 之间不可达),那么该对象就是可被GC回收的垃圾对象。
2、Java中的四种引用类型
2.1 强引用(Strong Reference)
强引用时使用最普遍的引用。如果一个对象具有强引用,垃圾回收器就绝对不会回收它。当内存不足时,GC 宁愿抛出 OutOfMemoryError 错误,是程序异常终止,也不会靠随意回收具有强引用的对象来解决内存不足的问题。
Object strongRef = new Object();
// strongRef 是强引用,指向 new Object() 创建的对象
如果强引用对象不使用时,需要弱化从而使 GC 能够回收。
方式1:显式置空强引用
主动把强引用变量赋值为 null,切断引用和堆中对象的关联。这样 GC 就能识别出对象无有效引用,待 GC 执行时(具体时机由 GC 算法决定),回收对象释放内存。
关键场景:
当强引用是全局变量、静态变量(生命周期与类 / 程序一致)时,若不手动置空,对象会一直占内存。比如工具类的静态缓存对象,不用时及时 staticRef = null ,避免内存泄漏。
// 强引用变量
Object strongRef = new Object();
// 不再使用对象时,显式置空
strongRef = null;
// GC 运行时,可回收原对象(无其他强引用时)
方式2:让强引用随作用域结束失效
利用 Java 虚拟机栈(VM Stack)的栈帧机制:方法执行时,会在虚拟机栈创建栈帧,存储局部变量(含强引用);方法执行完毕,栈帧弹出销毁,局部强引用也失效。若对象无其他强引用,就会被 GC 回收 。
应用场景
方法内的局部强引用(如临时变量),无需手动置空,方法结束后自动解除引用。适合处理短期使用的对象,减少手动管理成本。
public void test() {// 方法内局部强引用,存于 test 方法的栈帧Object strongReference = new Object(); // 执行方法逻辑...
}
// 方法执行完,栈帧弹出,strongReference 失效
// 若对象无其他强引用,GC 可回收
2.2 软引用(Soft Reference)
软引用是一种 “相对灵活” 的引用,关联的对象在 内存足够时,不会被 GC 回收;当内存不足,GC 会回收软引用关联的对象 。它常用来实现内存敏感的缓存,比如图片缓存,内存充足时缓存图片,内存紧张时释放缓存,避免 OOM。
import java.lang.ref.SoftReference;public class SoftRefDemo {public static void main(String[] args) {Object obj = new Object();SoftReference<Object> softRef = new SoftReference<>(obj);obj = null; // 清除强引用try {int size = 2048 * 4096;List<int[]> list = new ArrayList<>();while (true) {int[] array = new int[size];list.add(array);System.out.println("已分配 " + list.size() + " 个数组,软引用状态:" +(softRef.get() != null ? "存在" : "已回收"));System.gc(); // 建议GC}} catch (OutOfMemoryError e) {System.out.println("发生内存溢出!最终软引用状态:" +(softRef.get() != null ? "未回收" : "已回收"));}}
}
调用System.gc() 方法只是起通知作用,最终何时回收,由JVM决定。(当内存不足时,JVM首先将软引用中的对象置为 null,然后通知垃圾回收器进行回收。)
运行结果:
2.3 弱引用(Weak Reference)
弱引用的 “生存门槛” 更低,只要发生 GC,不管内存是否充足,弱引用关联的对象都会被回收 。它常用于实现那些 “存在时可用,回收也不影响核心逻辑” 的场景,比如 ThreadLocal、弱引用哈希表(WeakHashMap )。
结合WeakHashMap的示例:
public class WeakHashMapDemo{public static void main(String[] args) {WeakHashMap<Key, Value> map = new WeakHashMap<>();Key key = new Key("key1");Value value = new Value("value1");map.put(key, value);key = null;System.gc();// 大概率已被回收,size 可能为 0System.out.println("WeakHashMap size: " + map.size());}
}class Key {private String key;public Key(String key) {this.key = key;}
}class Value {private String value;public Value(String value) {this.value = value;}
}
运行结果:
2.4 虚引用(Phantom Reference)
虚引用是最 “弱” 的引用,它的存在不影响对象的生命周期,主要用于跟踪对象被 GC 回收的状态 。虚引用必须和 引用队列(ReferenceQueue) 配合使用,当 GC 准备回收对象时,会把虚引用加入关联的引用队列,程序可通过队列感知对象回收时机,做一些资源释放的收尾工作(比如释放直接内存)。
public static void main(String[] args) throws InterruptedException {ReferenceQueue<Object> refQueue = new ReferenceQueue<>();Object obj = new Object();PhantomReference<Object> phantomRef = new PhantomReference<>(obj, refQueue);obj = null;System.gc();Thread.sleep(100);// 检查引用队列,若有元素,说明对象将被回收if (refQueue.poll() != null) {System.out.println("虚引用对象即将被 GC 回收");}}
3、垃圾收集算法
3.1 分代收集理论
目前主流 JVM 虚拟机中的垃圾收集器,都遵循分代收集理论:
- 弱分代:绝大多数对象都是朝生夕灭。
- 强分代:经历越多次垃圾收集过程的对象,越难以回收,难以消亡。
按照分代收集理论设计的“分代垃圾收集器,所采用的设计原则:收集器应该将 Java 堆划分成不同的区域,然后将回收对象依据其年龄(年龄即对象经历过垃圾收集过程的次数)分配到不同的区域存储。
3.1.1 分代存储
如果一个区域中大多数对象都是朝生夕灭(新生代),难以熬过垃圾收集过程的话,把它们集中存储在一起,每次回收时,只关注如何保留少量存活对象,而不是去标记大量将要回收的对象,就能以较低代价回收到大量的空间。
如果一个区域中大多数对象都是难以回收(老年代),那么把它们集中放在一起,VM 虚拟机就可以使用较低的频率,来对这个区域进行回收。
这样设计的好处是,兼顾垃圾收集的时间开销和内存空间的有效利用。
3.1.2 分代收集
堆区按照分代存储的好处:
在 Java 堆区划分成不同区域后,垃圾收集器才可以每次只回收其中某一个或者某些区域,所以才有 MinorGC、MajorGC、FullGC 等垃圾收集类型划分。
在 Java 堆区划分成不同区域后,垃圾收集器才可以针对不同的区域,安排与该区域存储对象存亡特征相匹配的垃圾收集算法:复制算法、标记-清除算法、标记-整理算法等。
垃圾收集类型划分:
1. 部分收集(Partial GC):没有完整收集整个 Java 堆的垃圾收集,其中又分为:
- 新生代收集(Minor GC / Young GC)
- 老年代收集(Major GC / Old GC)
- 混合收集(Mixed GC):收集整个新生代和部分老年代的垃圾收集。
2. 整堆收集(Full GC):收集整个 Java 堆的垃圾收集。
3.2 垃圾收集算法的种类
标记-清除算法(Mark-Sweep)
“标记-清除” 算法实现思路:
该算法分为 “标记” 和 “清除” 阶段:首先标记出所有不需要回收的对象,在标记完成后统一回收掉所有没有被标记的对象。它是最基础的收集算法,后续的算法都是对其不足进行改进得到。
“标记-清除” 算法会带来两个明显的问题:
- 执行效率不稳定问题:如果执行垃圾收集的区域,大部分对象是需要被回收的,则需要大量的标记和清除动作,导致效率变低。
- 内存空间碎片化问题:标记清除后会产生大量不连续的碎片,空间碎片太多,会导致分配较大对象时,无法找到足够的连续空间,从而会触发新的垃圾收集动作。
复制算法(Copying)
核心原理:将内存划分为两块大小相等的区域,每次只使用其中一块。当这一块内存用完时,垃圾收集器将存活的对象复制到另一块区域,然后将使用过的区域一次性清理掉,这样就实现了垃圾回收。下次使用时,再切换到刚刚清理过的区域。
“复制”算法的问题 :
- 对象存活率较高,需要进行较多的内存间复制,效率降低。
- 浪费过多的内存,但使现有的可用空间变为原先的一半。
“复制”算法特点:
如果内存中多数对象都是存活的,这种算法将会产生大量的内存间复制的开销。所以,复制算法适合仅需要复制少数存活对象的场景,而且每次都是针对整个半区进行内存回收,分配内存时也就不用考虑有空间碎片的复杂情况,只要移动堆顶指针,按顺序分配即可。这样实现简单运行高效。
标记整理算法(Mark-Compact)
核心原理:该算法在标记 - 清除算法的基础上进行了改进,同样先进行标记阶段,标记出所有存活的对象。然后在整理阶段,将存活的对象向内存的一端移动,最后直接清理掉边界以外的内存,即垃圾对象所占用的内存。
优点:
- 解决内存碎片问题:通过将存活对象移动到连续的内存区域,避免了标记 - 清除算法产生的内存碎片问题,提高了内存的利用率。
- 相对较高的效率:虽然比复制算法多了移动对象的操作,但在对象存活率较高的情况下,比标记 - 清除算法的效率要高,因为不需要反复扫描内存来处理碎片。
缺点:移动对象的过程需要额外的开销,需要修改对象的引用地址,并且在移动对象时,可能需要暂停程序的执行,对应用程序的响应性有一定影响。
应用场景:适用于对象存活率较高的场景,比如 Java 中的老年代,老年代中的对象生命周期较长,存活的对象较多,使用标记 - 整理算法可以较好地管理内存。
3.3 综上所述
当前虚拟机的垃圾收集都基于分代收集思想,根据对象存活周期的不同,将内存分为几个不同的区域,在不同的区域使用不同的垃圾收集算法。
例如: Heap 堆分为新生代和老年代,这样我们就可以根据各个年代的特点选择合适的垃圾收集算法。
在新生代中,每次收集都会有大量垃圾对象被回收,所以可以选择“标记-复制”算法,只需要付出少量对象的复制成本就可以完成每次垃圾收集。
在老年代中,对象存活几率是比较高的,而且没有额外的空间对它进行分配担保,所以选择“标记-清除”或“标记-整理”算法进行垃圾收集。
4、垃圾收集器
4.1 Serial 收集器(新生代)
Serial(串行)收集器是最基本、历史最悠久的垃圾收集器,采用 “标记-复制” 算法负责新生代的垃圾收集。它是 Hotspot 虚拟机运行在客户端模式下的默认新生代收集器。
它是一个单线程收集器。它会使用一条垃圾收集线程去完成垃圾收集工作,并且它在进行垃圾收集工作的时候,必须暂停其他所有的工作线程(“Stop The World”),直到收集结束。
这样的设计,带来的好处就是:简单高效。对于内存资源受限制的环境,它是所有收集器中额外内存消耗最小的收集器。适合单核处理器或处理器核心数较少的环境,每次收集几十 MB 甚至一两百 MB 的新生代内存,垃圾收集的停顿时间完全可以控制在十几毫秒或几十毫秒,最多一百多毫秒。
4.2 Serial Old 收集器(老年代)
Serial Old 收集器同样是一个单线程收集器,采用“标记-整理”算法负责老年代的垃圾收集,主要用于客户端模式下的Hotspot虚拟机使用。
如果在服务器端使用,"它主要有两种用途:
- 在 JDK5 及以前版本,与 Parallel Scavenge 收集器搭配使用。
- 作为 CMS 收集器发生失败时的后备预案;
4.3 ParNew 收集器(新生代)
ParNew 收集器是一个多线程的垃圾收集器。它是运行在 Server 模式下的虚拟机的首要选择,可以与Serial Old,CMS 垃圾收集器一起搭配工作,采用“复制”算法。
4.4 Parallel Scavenge 收集器(新生代)
Parallel Scavenge:收集器是也是一款新生代收集器,使用“标记-复制”算法实现的多线程收集器。
Parallel Scavenge 收集器与其它收集器的目标不同,CMS 等其它收集器目标是尽可能缩短垃圾收集时用户线程的停顿时间。但是 Parallel Scavenge 收集器的目标则是达到一个可控制的吞吐量。所谓吞吐量就是处理器用于运行用户代码的时间与处理器总消耗时间的比值。
若虚拟机执行某任务时,用户代码执行与垃圾收集的总耗时为 100 分钟,其中垃圾收集耗时 1 分钟,那么吞吐量即为 99% 。当停顿时间较短时,程序对用户交互场景或服务响应质量要求高的情况适配性更好,良好的响应速度有助于提升用户体验;而高吞吐量可让处理器资源得到最高效利用,能快速完成程序运算任务,更适合无需频繁交互、以后台运算为主的分析类任务 。
4.5 Parallel Old 收集器(老年代)
Parallel Old 收集器是一款多线程垃圾收集器,采用 “标记 - 整理” 算法,可视为 Parallel Scavenge 收集器的老年代版本。
在对吞吐量要求较高,或者处理器资源相对稀缺的应用场景中,Parallel Scavenge 收集器搭配 Parallel Old 收集器的组合,往往是优先之选。不过,这个组合直到 JDK 6 时才得以提供。在此之前,新生代的 Parallel Scavenge 收集器处境颇为尴尬。因为当新生代选用 Parallel Scavenge 收集器时,老年代除了 Serial Old 收集器外,便没有其他可选。而像 CMS 这类表现出色的老年代收集器,无法与之协同工作。由于老年代 Serial Old 收集器在服务端应用性能上存在局限性,即便使用 Parallel Scavenge 收集器,也难以从整体上实现吞吐量的最大化。
此外,在老年代内存空间充裕且硬件规格较高的运行环境中,Serial Old 收集器单线程的特性,使其无法充分发挥服务器多处理器的并行处理能力。在这种情况下,Parallel Scavenge 与 Serial Old 的组合,其总吞吐量甚至可能比不上 ParNew 搭配 CMS 的组合。
4.6 CMS 收集器(老年代)
4.6.1 简介
CMS(Concurrent Mark Sweep)收集器以 缩短垃圾回收停顿时间 为核心目标,基于 “标记 - 清除” 算法实现,是 HotSpot 虚拟机中首款真正意义的 并发垃圾收集器 。它开创性地让垃圾收集线程与用户线程(基本)并行工作,打破传统垃圾回收 “全程停顿业务” 的局限。
在当前 Java 生态里,大量基于浏览器(或移动端)的 B/S 架构服务端应用,对 服务响应速度 极为关注 —— 需尽可能压缩系统停顿时间,保障用户交互体验流畅。而 CMS 收集器的设计,恰好 适配这类应用场景,成为关注低延迟场景的优选方案。
4.6.2 工作流程
CMS 垃圾回收过程拆解为 4 个核心步骤 ,各环节分工与协同逻辑如下:
- 初始标记(CMS initial mark):快速标记与 GC Roots 直接关联的对象,需短暂暂停用户线程(STW)。由于仅处理 “根对象直接关联” 范围,此阶段耗时极短,为后续分析奠定基础。
- 并发标记(CMS concurrent mark):从 GC Roots 直接关联对象出发,遍历整个堆对象图 ,精准标记所有存活对象。该过程耗时较长,但可与用户线程 并行执行 ,不阻塞业务逻辑,让垃圾回收 “后台化” 运行。
- 重新标记(CMS remark):修正并发标记阶段因用户线程操作(如对象引用变更),产生的标记偏差。需短暂 STW,停顿时间通常 长于初始标记、远短于并发标记 ,保障标记结果准确,为最终清理做准备。
- 并发清除(CMS concurrent sweep):清理并删除标记阶段判定的 “死亡对象” 。因无需移动存活对象,可与用户线程 并行执行 ,利用空闲 CPU 资源完成垃圾回收,降低对业务的影响。
4.6.3 优点和缺点
核心优势
- 并发回收,低停顿:通过 “并发标记、并发清除” 与用户线程并行,大幅压缩 STW 时长,适配对延迟敏感的应用场景(如 Web 服务、交互系统 )。
主要不足
- CPU 资源竞争:并发标记、并发清除阶段,垃圾回收线程与用户线程共享 CPU。默认回收线程数为 (CPU 数量 +3)/4 ,当 CPU≥4 时,回收线程至少占用 25% CPU 资源,会 间接降低用户线程执行效率 。
- 浮动垃圾问题:并发清除阶段,用户线程持续产生新垃圾(“浮动垃圾” ),无法被当前回收周期处理,需等待 下次垃圾回收 才能清理,可能加剧内存压力。
- 内存碎片风险:基于 “标记 - 清除” 算法,回收后会产生大量 不连续内存空间 。若长期运行,可能导致大对象分配失败,迫使虚拟机提前触发 Full GC ,反而增加停顿时间。
4.6.4特殊机制与风险
由于垃圾回收与用户线程并行,需 预留足够内存 供用户线程运行,因此 CMS 无法像其他收集器(如 Serial Old )“等老年代满了再回收” 。
- 触发阈值:JDK6 默认老年代使用率达 92% 时启动 CMS ,提前回收避免内存耗尽。
- 并发失败风险:若 CMS 运行中,无法满足程序 “分配新对象” 的内存需求,会触发 “并发失败” —— 临时启用 Serial Old 收集器(单线程、全程 STW )回收老年代,可能导致业务出现明显卡顿。
4.6.5总结
CMS 收集器以 “并发回收、低停顿” 为核心优势,适配对响应速度敏感的服务端场景,但因 CPU 资源竞争、浮动垃圾、内存碎片等问题,实际使用需结合业务特性(如 CPU 资源、内存分配模式 )权衡。在追求低延迟的 Web 服务、交互系统中,它是经典方案;但面对高吞吐量、内存碎片敏感场景,需谨慎评估或结合其他优化策略(如内存整理机制 )。
4.7 G1 收集器(老年代)
4.7.1 简述
大吞吐量垃圾收集的痛点在于,若整个垃圾收集过程耗时过长,会触发 “Stop The World(简称 STW )”。并且,STW 的时间很难精准预估,甚至可能在某次垃圾收集时,因标记阶段耗时超预期,导致问题难以通过常规手段规避(比如让 JVM 程序线程执行主动退让)。这是因为随着垃圾对象不断增多,标记工作本身不可避免会耗时,所以 G1 垃圾收集器的发展,很大程度上围绕优化 STW 时间展开。
4.7.2 什么是 G1 收集器
G1(Garbage - First)是面向服务端的垃圾收集器,专注于多处理器、大内存场景,无需严格按分代思想划分内存处理对象。
它把堆内存划分为多个大小相同的独立区域(Region),Region 数量不超 2048 个。每个 Region 有 “角色” 区分,像 E(Eden,新生代区域 )、S(Survivor,新生代 Survivor 区 )、H(Humongous,大对象区域 ,用于存放大对象,若大对象放不下单个 Region,会跨 Region 连续分配 ),未被使用的 Region 为空闲状态。这种灵活的内存布局,让 G1 能依据各内存分区垃圾分布情况,动态调整收集策略,优先处理垃圾多的分区(即 “Garbage - First” 理念 “垃圾优先”),以此降低垃圾回收对整体应用的影响。当回收后存活对象少,回收收益就高,这也对应了 G1 收集器的 Mixed GC(混合回收模式 ),也就是部分区域的 GC 模式。
4.7.3 G1 垃圾收集器工作流程
- 初始标记(Initial Marking):标记与 GC Roots 直接关联的对象,需暂停用户线程(STW 短暂停顿 )。此阶段为后续并发标记做基础,确定 “根对象” 关联的初始范围。
- 并发标记(Concurrent Marking):从 GC Roots 出发,对堆中对象进行可达性分析,遍历整个堆查找存活对象。耗时较长,但可与用户线程并行,不阻塞业务。
- 最终标记(Final Marking):处理并发标记阶段因用户线程操作,产生的 “写屏障” 记录,需短暂 STW,保证标记结果准确(用于处理并发阶段结束后遗留的记录)。
- 筛选回收(Live Data Counting and Evacuation):统计每个 Region 存活对象数量、回收价值,按策略选择回收的 Region 集合(即 “回收集” )。把选中 Region 里的存活对象,移动到空 Region,最后清空原 Region,实现内存整理与回收,该阶段会 STW 。
4.7.4 G1 垃圾收集器的特点
- 并行与并发:利用多 CPU 多核优势,多线程并行执行部分 GC 操作(如初始标记、最终标记 );并发标记阶段与用户线程并行,减少 STW 总时长,提升应用吞吐量。
- 分代收集兼容:虽弱化严格分代,但仍保留分代概念。能区分新生代、老年代对象,针对不同 “年龄” 对象(经历 GC 次数不同 ),用不同策略处理,适配应用对象生命周期变化。
- 空间整合:G1 从整体来看是基于“标记-整理”算法实现的收集器,从局部(两个 Region 之间)上来看是基于“标记-复制”算法实现的。这意味着 G1 运行期间不会产生内存空间碎片,收集后能提供规整的可用内存。此特性有利于程序长时间运行,分配大对象时不会因为无法找到连续内存空间而提前触发下一次GC。
- 可预测的停顿:对比 CMS 等收集器,G1 除控制 STW 时间,还能通过 “Region 回收优先级”,让用户设置期望 STW 时长。它会根据 Region 垃圾占比,动态选择回收集合,尽量满足停顿时间目标,适合对延迟敏感的应用。
五、三色标记算法
1、概念及原理:
三色标记算法是垃圾回收领域中一种重要的追踪式垃圾回收算法,主要用于并发或增量垃圾回收场景,能有效减少垃圾回收时的停顿时间。
其核心思想是通过三种颜色(白色、灰色、黑色)标记对象的状态,来追踪哪些对象是可达的(存活的),哪些是不可达的(需要回收的):
【1】白色对象
- 定义:尚未被垃圾回收器访问到的对象
- 初始状态:在标记开始节点,所有对象均为白色,表示“未被发现”或“待处理”状态。
- 最终状态:标记结束后,仍然是白色的对象被视为垃圾对象,会在后续的清楚阶段被回收。
【2】灰色对象
- 定义:已经被垃圾回收器访问到,但该对象引用的其他对象还没有被完全扫描,表示“已发现但未处理完”状态。
- 中间状态:表示该状态正在被处理中,其部分引用已经被扫描,但还有一些引用未被扫描。
【3】黑色对象
- 定义:已经被垃圾回收器访问过,并且该对象引用的所有其他对象也都已经被扫描过,表示“已处理完成”状态。
- 终结状态:黑色对象不会再被扫描,垃圾回收器认为其引用的所有对象都已经被标记。
2、基本工作流程:
【1】初始阶段:所有对象都被标记为白色
【2】标记阶段:
- 根对象标记:垃圾回收器从GCRoots根对象(如静态变量引用、栈引用等)开始扫描,将根对象标记为灰色。
- 灰色对象处理:依次处理每个灰色对象,将其引用的所有白色对象标记为灰色,并将该灰色对象自身标记为黑色。
- 循环处理:重复上述步骤,直到所有灰色对象都变为黑色对象为止。
【3】完成阶段:所有对象的颜色均为黑色或白色。白色对象即为垃圾对象,会在后续的清除阶段被回收。
3、漏标问题
3.1 漏标的产生条件
漏标问题的发生需要同时满足以下两个条件:
- 黑色对象引用白色对象:一个已经被标记为黑色的对象(认为其引用的子对象都已处理完毕)新增了对一个白色对象的引用。
- 灰色对象丢失对白色对象的引用:原本引用该白色对象的灰色对象(正在处理中的对象),在标记过程中失去了对它的引用。
当这两个条件同时满足时,白色对象会因为没有任何灰色对象指向它,且黑色对象不会再被重新处理,导致该对象最终被判定为垃圾(白色)而被错误回收。
3.2 解决漏标的核心思路
1. 写屏障(Write Barrier)
当对象引用发生变化时(如 A 对象引用了 B 对象),通过写屏障拦截这一操作,并根据策略对相关对象进行处理:
- 增量更新(Incremental Update):当黑色对象新增对白色对象的引用时,将该黑色对象重新标记为灰色,确保其新引用的对象能被扫描到(打破第一个条件)。
- 原始快照(Snapshot At The Beginning, SATB):在并发标记开始时记录对象引用的快照,当灰色对象要删除对白色对象的引用时,保留该引用的记录,确保白色对象能被正确标记(打破第二个条件)。
2. 读屏障(Read Barrier)
较少使用,主要在读取对象引用时触发检查,确保引用的对象处于正确的标记状态。