目录
[一、JVM 简介](#一、JVM 简介)
[二、JVM 发展史](#二、JVM 发展史)
[1. Sun Classic VM(1996 年,JDK1.0)](#1. Sun Classic VM(1996 年,JDK1.0))
[2. Exact VM(JDK1.2)](#2. Exact VM(JDK1.2))
[3. HotSpot VM(当前主流)](#3. HotSpot VM(当前主流))
[4. JRockit(专注服务器端)](#4. JRockit(专注服务器端))
[5. J9 VM(IBM)](#5. J9 VM(IBM))
[6. Taobao JVM(国产深度定制)](#6. Taobao JVM(国产深度定制))
[关键补充:JVM 与《Java 虚拟机规范》](#关键补充:JVM 与《Java 虚拟机规范》)
[三、JVM 执行流程](#三、JVM 执行流程)
[1. 核心组件](#1. 核心组件)
[2. 完整执行流程](#2. 完整执行流程)
[1. 堆(线程共享)](#1. 堆(线程共享))
[2. 虚拟机栈(线程私有)](#2. 虚拟机栈(线程私有))
[3. 本地方法栈(线程私有)](#3. 本地方法栈(线程私有))
[4. 程序计数器(线程私有)](#4. 程序计数器(线程私有))
[5. 方法区(线程共享)](#5. 方法区(线程共享))
[6. 内存溢出异常(OOM)详解](#6. 内存溢出异常(OOM)详解)
[(1)Java 堆溢出(java.lang.OutOfMemoryError: Java heap space)](#(1)Java 堆溢出(java.lang.OutOfMemoryError: Java heap space))
[(2)虚拟机栈 / 本地方法栈溢出](#(2)虚拟机栈 / 本地方法栈溢出)
[(3)元空间溢出(java.lang.OutOfMemoryError: Metaspace)](#(3)元空间溢出(java.lang.OutOfMemoryError: Metaspace))
[1. 类的生命周期](#1. 类的生命周期)
[2. 类加载核心流程](#2. 类加载核心流程)
[3. 双亲委派模型](#3. 双亲委派模型)
[4. 破坏双亲委派模型的案例(SPI 机制:JDBC)](#4. 破坏双亲委派模型的案例(SPI 机制:JDBC))
[1. 对象存活判断算法](#1. 对象存活判断算法)
[2. 垃圾回收算法](#2. 垃圾回收算法)
[(1)标记 - 清除算法(基础)](#(1)标记 - 清除算法(基础))
[(3)标记 - 整理算法(老年代首选)](#(3)标记 - 整理算法(老年代首选))
[面试题:Minor GC 与 Full GC 的区别](#面试题:Minor GC 与 Full GC 的区别)
[3. 垃圾收集器(HotSpot)](#3. 垃圾收集器(HotSpot))
[① CMS 收集器(低停顿首选)](#① CMS 收集器(低停顿首选))
[② G1 收集器(全区域首选)](#② G1 收集器(全区域首选))
[4. 一个对象的一生(总结)](#4. 一个对象的一生(总结))
[七、Java 内存模型(JMM)](#七、Java 内存模型(JMM))
[1. 核心概念:主内存与工作内存](#1. 核心概念:主内存与工作内存)
[2. 内存间交互操作(8 种原子操作)](#2. 内存间交互操作(8 种原子操作))
[3. JMM 的三大核心特性](#3. JMM 的三大核心特性)
[4. volatile 关键字详解](#4. volatile 关键字详解)
[(3)volatile 的适用场景](#(3)volatile 的适用场景)
[5. 双重检查锁定单例模式(volatile 应用)](#5. 双重检查锁定单例模式(volatile 应用))
[(1)问题代码(未用 volatile)](#(1)问题代码(未用 volatile))
[(2)修复方案(volatile 修饰 instance)](#(2)修复方案(volatile 修饰 instance))
前言
作为 Java 开发者,我们每天都在编写代码、运行程序。但你是否曾想过,你的代码是如何从.java文件变成可执行程序的?为什么有时程序会突然变慢甚至崩溃?这些问题背后,都离不开 Java 虚拟机的支持。
JVM 就像一位幕后导演,它不直接参与表演(不执行你的代码),但决定了表演的规则、舞台的布置、演员的调度。掌握 JVM 的工作原理,不仅能让你写出更高效的代码,还能在遇到性能问题时快速定位并解决。
一、JVM 简介
JVM(Java Virtual Machine,Java 虚拟机)是通过软件模拟的、具备完整硬件功能的计算机系统,运行在完全隔离的环境中,是 Java 程序跨平台运行的核心基础。
与 VMware、VirtualBox 等通用虚拟机不同,JVM 是一款 "定制化的虚拟计算机",核心差异体现在指令集模拟上:
- VMware/VirtualBox:模拟物理 CPU 的完整指令集,包含大量寄存器,可运行任意操作系统。
- JVM:仅模拟 Java 字节码的指令集,仅保留 PC 寄存器(程序计数器),其他寄存器均被裁剪,专门为执行 Java 程序设计。
JVM 的核心价值是 "一次编译,到处执行"------Java 源代码编译为字节码(.class 文件)后,无需针对不同操作系统重新编译,可在任何安装了对应 JVM 的平台上运行,屏蔽了底层硬件和操作系统的差异。
二、JVM 发展史
自 1996 年 Java 1.0 发布以来,JVM 经历了多代演进,不同厂商推出了各具特色的实现,主流版本如下:
1. Sun Classic VM(1996 年,JDK1.0)
- 世界上第一款商业 JVM,伴随 Java 1.0 诞生,是 Java 早期生态的核心支撑。
- 核心特性:内部仅提供解释器,无内置编译器(JIT);若需使用 JIT 编译器,需额外外挂,且一旦启用 JIT,将完全接管执行系统,解释器不再工作(二者无法协同)。
- 命运:JDK1.4 时被完全淘汰,目前 HotSpot 虚拟机中仍内置了该版本的兼容实现。
2. Exact VM(JDK1.2)
- 为解决 Classic VM 的性能问题而生,是现代高性能 JVM 的雏形。
- 核心特性:支持热点探测(识别高频执行的 "热点代码",编译为字节码加速执行);实现了解释器与编译器的混合工作模式,兼顾启动速度和运行效率。
- 命运:仅在 Solaris 平台短暂使用,其他平台仍沿用 Classic VM,最终因兼容性和生态问题被 HotSpot 替代。
3. HotSpot VM(当前主流)
- 历史渊源:最初由 Longview Technologies 公司设计,1997 年被 Sun 收购,2009 年随 Sun 被 Oracle 收购;JDK1.3 时成为默认虚拟机,至今占据绝对市场地位。
- 核心特性:名称源于 "热点代码探测技术"------ 通过计数器识别最具编译价值的代码,触发即时编译(JIT)或栈上替换;编译器与解释器协同工作,在程序响应时间和执行性能间取得最佳平衡。
- 应用场景:Sun/Oracle JDK 和 OpenJDK 的默认虚拟机,覆盖服务器、桌面、移动端、嵌入式等全场景。
4. JRockit(专注服务器端)
- 核心定位:专为服务器端应用优化,不关注启动速度,仅保留 JIT 编译器,无解释器实现,所有代码均通过 JIT 编译后执行。
- 核心优势:运行速度快,被称为 "世界上最快的 JVM",部分场景性能提升超 70%;提供 JRockit Real Time(面向延迟敏感型应用,响应时间达毫秒 / 微秒级)和 MissionControl(低开销监控分析工具)。
- 命运:2008 年随 BEA 被 Oracle 收购,Oracle 在 JDK8 中完成其与 HotSpot 的整合,将 JRockit 的优秀特性移植到 HotSpot 中。
5. J9 VM(IBM)
- 全称:IBM Technology for Java Virtual Machine(IT4J),内部代号 J9。
- 核心定位:与 HotSpot 类似,是多用途 JVM,适用于服务器端、桌面应用、嵌入式等场景,广泛用于 IBM 的各类 Java 产品。
- 核心优势:号称 "世界上最快的 Java 虚拟机",在 IBM 自有产品上稳定性极强。
- 命运:2017 年 IBM 将其开源,命名为 OpenJ9,交由 Eclipse 基金会管理,更名为 Eclipse OpenJ9。
6. Taobao JVM(国产深度定制)
- 研发背景:由阿里 AliJVM 团队基于 OpenJDK HotSpot 开发,是国内首个开源的高性能服务器级 JVM,为阿里云计算、金融、电商等高并发场景量身定制。
- 核心特性:
- GCIH(GC invisible heap)技术:实现 "堆外存储",将长生命周期对象移至堆外,GC 无法管理,降低 GC 回收频率、提升回收效率。
- 跨进程对象共享:GCIH 中的对象可在多个 JVM 进程间共享,节省内存开销。
- 优化 JNI 调用:通过 crc32 指令实现 JVM intrinsic,降低 JNI 调用开销。
- 硬件适配:针对 Intel CPU 优化,牺牲部分兼容性换取极致性能。
- ZenGC:专为大数据场景设计的垃圾收集器。
- 应用场景:已全面应用于淘宝、天猫等阿里核心产品,替换了 Oracle 官方 JVM。
关键补充:JVM 与《Java 虚拟机规范》
上述所有 JVM 实现(HotSpot、J9、Taobao JVM 等)均需遵循《Java 虚拟机规范》------Oracle 发布的 Java 领域权威著作,详细定义了 JVM 的组成、指令集、内存模型等核心规范,确保不同厂商的 JVM 能正确执行 Java 字节码。本文后续内容均以 HotSpot(Oracle JDK 默认虚拟机)为核心展开。
三、JVM 执行流程
JVM 的核心职责是将 Java 字节码转换为底层系统指令并执行,完整流程涉及四大核心组件,执行步骤如下:
1. 核心组件
- 类加载器(ClassLoader):负责加载字节码文件(.class)到内存。
- 运行时数据区(Runtime Data Area):存储类信息、对象实例、线程状态等数据。
- 执行引擎(Execution Engine):将字节码翻译为底层系统指令,交由 CPU 执行。
- 本地库接口(Native Interface):调用 C/C++ 等本地方法库,实现 Java 无法直接完成的底层功能(如操作系统交互、硬件操作)。
2. 完整执行流程
- 编译:Java 源代码(.java)通过 javac 编译器编译为字节码文件(.class),字节码是 JVM 的指令集规范,不依赖底层操作系统。
- 加载:类加载器根据类的全限定名,读取字节码文件,将其转换为方法区的运行时数据结构,并在内存中生成代表该类的 java.lang.Class 对象(访问入口)。
- 存储:类信息、静态变量、常量等存入方法区;对象实例存入堆;线程执行方法时,生成栈帧存入虚拟机栈;本地方法执行时使用本地方法栈;程序计数器记录线程执行位置。
- 执行:执行引擎通过解释器或 JIT 编译器,将字节码翻译为 CPU 可执行的机器指令;若涉及底层操作(如文件 IO),通过本地库接口调用本地方法库完成。
- 收尾:程序执行完毕后,垃圾收集器回收堆中无用对象,线程终止后释放虚拟机栈、本地方法栈等线程私有资源。
四、运行时数据区(内存布局)
运行时数据区是 JVM 在运行过程中分配和管理内存的区域,与 "Java 内存模型(JMM)" 是完全不同的概念 ------ 前者是 JVM 的物理内存划分,后者是并发编程的内存访问规范。运行时数据区分为 5 个部分,按 "线程共享 / 私有" 可分为两类:
| 类型 | 包含区域 | 核心特点 |
|---|---|---|
| 线程共享 | 堆、方法区(元空间) | 所有线程共用,生命周期与 JVM 一致,需垃圾回收 |
| 线程私有 | 虚拟机栈、本地方法栈、程序计数器 | 每个线程独立拥有,生命周期与线程一致,无需垃圾回收 |
1. 堆(线程共享)
- 核心作用:存储 Java 程序中创建的所有对象实例(包括数组),是 JVM 内存中最大的区域,也是垃圾回收的主要目标。
- 内存参数:通过 JVM 参数配置大小,
-Xms指定最小启动内存,-Xmx指定最大运行内存(如-Xms20m -Xmx20m表示堆内存固定为 20MB)。 - 区域划分:
- 新生代(Young 区):存储新建对象,分为 1 个 Eden 区和 2 个 Survivor 区(S0/S1,也叫 From/To 区),默认比例为 Eden:S0:S1=8:1:1。
- 老年代(Old 区):存储经过多次垃圾回收后仍存活的对象、大对象(超过新生代阈值的对象)。
- 垃圾回收逻辑:新生代触发 Minor GC(复制算法),老年代触发 Full GC(标记 - 整理算法);Minor GC 时,Eden 区存活对象复制到空闲的 Survivor 区,清理 Eden 和已使用的 Survivor 区;对象在 Survivor 区复制次数达到阈值(默认 15 次,由
MaxTenuringThreshold参数控制)后,晋升到老年代。
2. 虚拟机栈(线程私有)
- 核心作用:描述 Java 方法的执行内存模型,每个方法执行时都会创建一个 "栈帧",栈帧入栈(方法调用)和出栈(方法返回)的过程即方法的执行过程。
- 生命周期:与线程一致,线程启动时创建,线程终止时销毁,不存在线程安全问题。
- 栈帧组成:
- 局部变量表:存储方法参数和局部变量,包含 8 大基本数据类型、对象引用(地址指针),内存空间在编译期间确定,执行时不可修改。
- 操作数栈:方法执行时的临时数据存储区,通过入栈、出栈操作完成运算(如
a+b需先将 a、b 入栈,执行加法后将结果入栈)。 - 动态连接:指向运行时常量池的方法引用,用于将符号引用转换为直接引用(支持方法重写等动态特性)。
- 方法返回地址:存储方法执行完毕后返回的 PC 寄存器地址,确保线程能恢复到之前的执行位置。
- 关键概念:"线程私有" 指每个线程拥有独立的虚拟机栈,线程切换时无需担心栈数据冲突,因为处理器同一时刻仅执行一条线程的指令。
3. 本地方法栈(线程私有)
- 核心作用:与虚拟机栈功能类似,区别在于虚拟机栈为 Java 方法服务,本地方法栈为 Native 方法(由 C/C++ 实现,被 Java 调用的方法)服务。
- 生命周期:与线程一致,线程终止时销毁。
- 异常:若本地方法执行时栈深度超过限制,会抛出 StackOverflowError;若扩展栈时无法申请到足够内存,会抛出 OutOfMemoryError。
4. 程序计数器(线程私有)
- 核心作用:记录当前线程执行的字节码行号指示器,相当于线程的 "执行进度条"。
- 具体逻辑:
- 若线程执行 Java 方法,计数器存储当前执行的字节码指令地址。
- 若线程执行 Native 方法,计数器值为空(Undefined)。
- 核心特点:JVM 规范中唯一没有规定 OutOfMemoryError 的区域 ------ 内存占用极小,且生命周期与线程一致,无需额外内存分配。
5. 方法区(线程共享)
- 核心作用:存储被虚拟机加载的类信息(类名、父类、接口、字段、方法)、常量、静态变量、即时编译器编译后的代码等数据。
- 实现差异:
- JDK7 及之前:称为 "永久代(PermGen)",属于 JVM 内存的一部分,大小通过
-XX:PermSize和-XX:MaxPermSize配置。 - JDK8 及之后:改为 "元空间(Metaspace)",使用本地内存(操作系统内存),大小不再受 JVM 内存限制,仅受本地内存大小约束。
- JDK7 及之前:称为 "永久代(PermGen)",属于 JVM 内存的一部分,大小通过
- 关键变化(JDK8):
- 字符串常量池从永久代移至堆中(减少永久代内存溢出风险)。
- 静态变量、类元信息仍存储在元空间。
- 运行时常量池:方法区的一部分,存储字面量(字符串、final 常量、基本数据类型值)和符号引用(类全限定名、字段 / 方法名称及描述符),是字节码文件中 "常量池表" 的运行时体现。
6. 内存溢出异常(OOM)详解
运行时数据区的各区域均可能出现内存溢出,不同区域的异常场景和排查方式不同:
(1)Java 堆溢出(java.lang.OutOfMemoryError: Java heap space)
-
触发条件:不断创建对象,且 GC Roots 到对象存在可达路径(避免被 GC 回收),当对象数量超过堆最大容量时触发。
-
测试配置:JVM 参数
-Xmx20m -Xms20m -XX:+HeapDumpOnOutOfMemoryError(堆固定 20MB,溢出时生成内存快照)。 -
测试代码:
javapublic class HeapOOMTest { static class OOMObject {} public static void main(String[] args) { List<OOMObject> list = new ArrayList<>(); while (true) { list.add(new OOMObject()); // 无限创建对象,无法回收 } } } -
排查思路:通过 MAT 等工具分析内存快照,判断是 "内存泄漏"(对象无用但无法回收)还是 "内存溢出"(对象确实需要存活,但堆内存不足);前者修复代码释放引用,后者增大
-Xmx参数。
(2)虚拟机栈 / 本地方法栈溢出
HotSpot 将虚拟机栈和本地方法栈合二为一,溢出分为两种情况:
-
StackOverflowError:线程请求的栈深度超过虚拟机允许的最大深度(单线程递归过深)。
-
测试配置:
-Xss128k(减小栈容量,易触发溢出)。 -
测试代码:
javapublic class StackOverflowTest { private int stackLength = 1; public void stackLeak() { stackLength++; stackLeak(); // 无限递归 } public static void main(String[] args) { StackOverflowTest test = new StackOverflowTest(); try { test.stackLeak(); } catch (Throwable e) { System.out.println("栈深度:" + test.stackLength); throw e; } } }
-
-
OutOfMemoryError:虚拟机扩展栈时无法申请到足够内存(多线程创建过多,耗尽系统内存)。
-
测试代码:
javapublic class StackOOMTest { public void dontStop() { while (true) {} } public void stackLeakByThread() { while (true) { new Thread(() -> dontStop()).start(); // 无限创建线程 } } public static void main(String[] args) { new StackOOMTest().stackLeakByThread(); } } -
注意:该代码会耗尽系统内存,运行前需保存工作。
-
-
排查思路:StackOverflowError 需减少递归深度或增大
-Xss;OutOfMemoryError 需减少线程数,或通过减小堆内存、栈容量换取更多线程创建空间。
(3)元空间溢出(java.lang.OutOfMemoryError: Metaspace)
- 触发条件:加载过多类(如动态生成类、依赖过多 jar 包),元空间(本地内存)不足。
- 测试配置:
-XX:MaxMetaspaceSize=10m(限制元空间最大 10MB)。 - 排查思路:增大
-XX:MaxMetaspaceSize参数,或减少不必要的类加载(如清理冗余依赖、避免动态类滥用)。
五、类加载机制
类加载是 JVM 将字节码文件转换为可执行代码的过程,核心包括 "类加载流程""双亲委派模型""破坏双亲委派模型的场景" 三部分。
1. 类的生命周期
一个类从加载到卸载的完整生命周期为:加载 → 连接(验证→准备→解析) → 初始化 → 使用 → 卸载。其中 "加载、验证、准备、解析、初始化" 是类加载的核心流程,顺序固定。
2. 类加载核心流程
(1)加载(Loading)
- 核心任务:
- 通过类的全限定名(如
java.lang.String)获取定义该类的二进制字节流(可从.class 文件、网络、内存等来源获取)。 - 将字节流的静态存储结构转换为方法区的运行时数据结构(类元信息)。
- 在内存中生成一个代表该类的 java.lang.Class 对象,作为方法区中该类数据的访问入口(存储在堆中)。
- 通过类的全限定名(如
- 关键说明:"加载" 是 "类加载(Class Loading)" 的第一步,二者不可混淆 ------"类加载" 是完整流程(加载→连接→初始化),"加载" 仅指上述三步操作。
(2)验证(Verification)
- 核心目的:确保字节码文件的信息符合《Java 虚拟机规范》,避免恶意或无效字节码危害 JVM 安全。
- 验证内容:
- 文件格式验证:验证字节码文件的魔数、版本号、常量池格式等(确保是合法的.class 文件)。
- 字节码验证:验证字节码指令的语义合法性(如避免非法跳转、类型转换错误)。
- 符号引用验证:验证常量池中的符号引用(类、字段、方法引用)是否有效(如引用的类是否存在)。
(3)准备(Preparation)
- 核心任务:为类中静态变量(被
static修饰的变量)分配内存,并设置 "默认初始值"(而非代码中指定的初始值)。 - 示例:若类中有
public static int value = 123;,准备阶段会为value分配内存,设置默认值 0(int 类型默认值),123的赋值会在初始化阶段执行。 - 特殊情况:若静态变量被
final修饰(public static final int value = 123;),准备阶段会直接设置为 123------final静态变量是 "常量",编译时已确定值,存储在常量池。
(4)解析(Resolution)
- 核心任务:将常量池中的 "符号引用" 替换为 "直接引用"。
- 概念区分:
- 符号引用:用字符串描述目标(如
Ljava/lang/String;表示 String 类),与内存地址无关。 - 直接引用:指向目标的内存地址(如对象指针、方法入口地址),可直接访问目标。
- 符号引用:用字符串描述目标(如
- 解析对象:类、接口、字段、方法、方法类型等。
(5)初始化(Initialization)
- 核心任务:执行类构造器
<clinit>()方法,完成静态变量的赋值(代码中指定的初始值)和静态代码块的执行。 - 关键细节:
<clinit>()方法由编译器自动生成,整合了静态变量赋值语句和静态代码块(按代码顺序执行)。- 若类中无静态变量和静态代码块,编译器不会生成
<clinit>()方法。 - 父类的
<clinit>()方法会先于子类执行(保证父类静态变量初始化完成)。 - 初始化阶段是类加载流程中唯一由应用程序主导的阶段,仅在类被首次主动使用时触发(如创建对象、调用静态方法、访问静态变量)。
3. 双亲委派模型
(1)类加载器层次结构
JVM 中存在多层类加载器,自 JDK1.2 以来形成固定架构:
- 启动类加载器(Bootstrap ClassLoader):最顶层,由 C++ 实现,是 JVM 的一部分;加载 JDK 核心类库(
$JAVA_HOME/lib目录下的 jar 包,如 rt.jar)。 - 扩展类加载器(Extension ClassLoader):由 Java 实现,加载
$JAVA_HOME/lib/ext目录下的扩展类库。 - 应用程序类加载器(Application ClassLoader):由 Java 实现,也称系统类加载器;加载 CLASSPATH 指定的类(应用程序代码、第三方 jar 包)。
- 自定义类加载器:继承
java.lang.ClassLoader,由开发者实现,用于加载特殊来源的类(如加密字节码、网络字节码)。
(2)双亲委派模型的工作流程
"双亲委派" 是类加载的核心规则:当一个类加载器收到类加载请求时,不会直接加载,而是先将请求委派给父类加载器;父类加载器若无法加载(搜索范围中无该类),子类加载器才会尝试自己加载。流程如下:
- 应用程序类加载器收到加载请求,先委派给扩展类加载器。
- 扩展类加载器收到请求,委派给启动类加载器。
- 启动类加载器在核心类库中搜索,若找到则加载;若未找到,返回给扩展类加载器。
- 扩展类加载器在扩展类库中搜索,若找到则加载;若未找到,返回给应用程序类加载器。
- 应用程序类加载器在 CLASSPATH 中搜索,若找到则加载;若未找到,抛出
ClassNotFoundException。
(3)双亲委派模型的核心优势
- 避免类重复加载:若父类加载器已加载某类,子类加载器无需重复加载,节省内存。
- 保证核心类库安全:核心类库(如
java.lang.Object)由启动类加载器加载,开发者无法通过自定义类加载器替换核心类(如自定义java.lang.Object类),避免恶意代码篡改核心功能。
4. 破坏双亲委派模型的案例(SPI 机制:JDBC)
双亲委派模型虽有优势,但在某些场景下需 "破坏" 以满足功能需求,最典型的案例是 Java 的 SPI(Service Provider Interface)机制,以 JDBC 为例:
(1)问题背景
- JDBC 的核心接口(
java.sql.Driver、DriverManager)定义在 JDK 的 rt.jar 包中,由启动类加载器加载。 - 数据库驱动(如 MySQL 的
com.mysql.cj.jdbc.Driver)是第三方实现,存在于 CLASSPATH 中的 mysql-connector-java.jar 包,需由应用程序类加载器加载。 - 按双亲委派模型,启动类加载器加载的
DriverManager无法访问应用程序类加载器加载的第三方驱动(父类加载器无法访问子类加载器加载的类),导致驱动无法被调用。
(2)解决方案:线程上下文类加载器
DriverManager通过 "线程上下文类加载器"(Thread.currentThread ().getContextClassLoader ())破坏双亲委派模型,核心流程如下:
- 线程启动时,线程上下文类加载器默认设置为应用程序类加载器。
DriverManager的getConnection()方法中,通过线程上下文类加载器获取应用程序类加载器。- 用该类加载器加载第三方数据库驱动(如 MySQL 的 Driver 类),实现启动类加载器加载的核心类访问子类加载器加载的第三方类。
(3)核心源码(DriverManager)
java
private static Connection getConnection(String url, Properties info, Class<?> caller) throws SQLException {
ClassLoader callerCL = caller != null ? caller.getClassLoader() : null;
synchronized (DriverManager.class) {
if (callerCL == null) {
// 获取线程上下文类加载器(默认是应用程序类加载器)
callerCL = Thread.currentThread().getContextClassLoader();
}
}
// 用callerCL加载第三方驱动
for (DriverInfo aDriver : registeredDrivers) {
if (isDriverAllowed(aDriver.driver, callerCL)) {
try {
return aDriver.driver.connect(url, info);
} catch (SQLException ex) {
// 异常处理
}
}
}
throw new SQLException("No suitable driver found");
}
(4)总结
线程上下文类加载器的本质是 "逆向委派"------ 允许父类加载器通过子类加载器加载类,打破了双亲委派模型 "父类加载器优先" 的规则,解决了 SPI 机制中核心类库与第三方实现的协作问题。
六、垃圾回收(GC)
垃圾回收是 JVM 自动回收堆和方法区中无用对象的过程,核心目标是释放内存,避免内存泄漏。GC 的核心流程是 "判断对象是否存活 → 回收无用对象",涉及 "对象存活判断算法""垃圾回收算法""垃圾收集器" 三部分。
1. 对象存活判断算法
GC 前需先判断对象是否 "死亡"(无用),主流 JVM 采用 "可达性分析算法",辅助以 "引用计数法"(已淘汰)。
(1)引用计数法(淘汰)
-
核心逻辑:给每个对象添加引用计数器,有引用时计数器 + 1,引用失效时 - 1;计数器为 0 时,对象死亡。
-
优点:实现简单,判定效率高。
-
缺点:无法解决 "循环引用" 问题 ------ 两个对象互相引用,计数器均为 1,但实际已无用,无法被回收。
-
示例(循环引用):
javapublic class ReferenceCountTest { public Object instance = null; private static final int _1MB = 1024 * 1024; private byte[] bigSize = new byte[2 * _1MB]; // 占用内存,便于观察GC public static void testGC() { ReferenceCountTest a = new ReferenceCountTest(); ReferenceCountTest b = new ReferenceCountTest(); a.instance = b; // a引用b b.instance = a; // b引用a a = null; // 取消引用 b = null; // 取消引用 System.gc(); // 触发GC } public static void main(String[] args) { testGC(); } } -
运行结果:GC 日志显示
6092K->856K,说明循环引用的对象被回收,证明 HotSpot 未使用引用计数法。
(2)可达性分析算法(主流)
- 核心逻辑:以 "GC Roots" 为起始点,向下搜索引用链;若对象到 GC Roots 无任何引用链相连(不可达),则判定为死亡对象。
- GC Roots 的可选对象(Java 语言中):
- 虚拟机栈(栈帧本地变量表)中引用的对象(如方法参数、局部变量)。
- 方法区中类静态属性引用的对象(如
static Object obj = new Object())。 - 方法区中常量引用的对象(如
final Object obj = new Object())。 - 本地方法栈中 JNI(Native 方法)引用的对象。
- 示例:对象 Object5-Object7 虽互相引用,但到 GC Roots 不可达,被判定为可回收对象。
(3)引用的扩展分类(JDK1.2)
JDK1.2 后,Java 对 "引用" 的概念进行扩展,分为四级(强度递减),影响对象的存活判定:
- 强引用:程序中普遍存在的引用(如
Object obj = new Object()),只要强引用存在,GC 永不回收对象。 - 软引用:描述 "有用但非必需" 的对象(
SoftReference类),系统内存不足时(即将 OOM),会回收软引用关联的对象。 - 弱引用:描述 "非必需" 的对象(
WeakReference类),GC 触发时,无论内存是否充足,都会回收弱引用关联的对象。 - 虚引用:最弱的引用(
PhantomReference类),无法通过虚引用获取对象,仅用于在对象被 GC 时接收系统通知(跟踪对象回收状态)。
2. 垃圾回收算法
垃圾回收算法是 GC 的核心思想,不同算法适用于不同场景,主流算法包括以下四种:
(1)标记 - 清除算法(基础)
- 核心流程:分为 "标记" 和 "清除" 两步。
- 标记:通过可达性分析,标记所有需要回收的死亡对象。
- 清除:遍历堆内存,回收所有被标记的对象,释放内存空间。
- 优点:实现简单,无需移动对象。
- 缺点:
- 效率低:标记和清除过程均需遍历所有对象,耗时较长。
- 内存碎片:回收后产生大量不连续的内存碎片,后续分配大对象时,可能因无法找到足够连续内存而提前触发 GC。
(2)复制算法(新生代首选)
- 核心流程:将可用内存划分为大小相等的两块(A 区和 B 区),每次仅使用一块。
- 标记:标记 A 区中存活的对象。
- 复制:将 A 区存活对象复制到 B 区,按顺序排列(无内存碎片)。
- 清除:清空 A 区,下次使用 B 区,重复上述流程。
- 优点:实现简单,运行高效,无内存碎片。
- 缺点:内存利用率低(仅 50%),不适用于对象存活率高的场景(需频繁复制)。
- 优化(HotSpot 新生代实现):新生代中 98% 的对象 "朝生夕死",无需 1:1 划分内存,而是分为 1 个 Eden 区(大)和 2 个 Survivor 区(小),默认比例 Eden:S0:S1=8:1:1。
- 初始分配:对象优先在 Eden 区创建。
- Minor GC 触发:Eden 区满时,标记 Eden 和 S0 区的存活对象,复制到 S1 区,清空 Eden 和 S0 区。
- Survivor 区切换:下次 Minor GC 时,标记 Eden 和 S1 区的存活对象,复制到 S0 区,清空 Eden 和 S1 区。
- 晋升老年代:对象在 S0 和 S1 区之间复制次数达到阈值(默认 15 次)后,晋升到老年代;若 Survivor 区空间不足,直接晋升老年代(分配担保)。
- 内存利用率:新生代可用内存为 90%(8+1),仅 10% 用于存储存活对象。
(3)标记 - 整理算法(老年代首选)
- 核心流程:结合 "标记 - 清除" 和 "复制" 的优点,适用于对象存活率高的老年代。
- 标记:标记所有死亡对象。
- 整理:将所有存活对象向内存一端移动,紧凑排列。
- 清除:清空存活对象另一端的内存空间。
- 优点:无内存碎片,内存利用率高。
- 缺点:效率低于复制算法(需移动对象,额外消耗 CPU 资源)。
(4)分代收集算法(实际应用)
- 核心思想:根据对象存活周期的不同,将堆分为新生代和老年代,采用不同的回收算法,兼顾效率和内存利用率。
- 新生代:对象存活时间短、存活率低,采用复制算法(高效、无碎片)。
- 老年代:对象存活时间长、存活率高,采用标记 - 整理算法(无碎片、高利用率)。
- 补充:方法区的回收(元空间):回收废弃常量和无用类(类的所有实例已回收、类加载器已回收、无引用指向类对象),频率较低。
面试题:Minor GC 与 Full GC 的区别
| 维度 | Minor GC(新生代 GC) | Full GC(老年代 GC/Major GC) |
|---|---|---|
| 触发区域 | 新生代(Eden/Survivor) | 老年代(可伴随 Minor GC) |
| 触发条件 | Eden 区满 | 老年代满、元空间满、调用 System.gc () 等 |
| 回收算法 | 复制算法 | 标记 - 整理算法(或标记 - 清除) |
| 执行效率 | 快(对象存活率低,复制量小) | 慢(对象存活率高,需整理),约为 Minor GC 的 10 倍 |
| 影响范围 | 仅新生代线程,影响小 | 全堆回收,影响大(STW 时间长) |
3. 垃圾收集器(HotSpot)
垃圾收集器是垃圾回收算法的具体实现,HotSpot 虚拟机提供了多款收集器,适用于不同场景(吞吐量、低停顿),核心收集器如下:
(1)核心概念
- 并行(Parallel):多条 GC 线程同时工作,用户线程暂停(STW)。
- 并发(Concurrent):GC 线程与用户线程同时执行(交替运行),用户线程无需长时间暂停。
- 吞吐量:CPU 用于运行用户代码的时间 / CPU 总消耗时间(吞吐量 = 用户代码时间 / (用户代码时间 + GC 时间))。
(2)收集器分类与特性
| 收集器 | 适用区域 | 核心特性 | 回收算法 | 并发 / 并行 | 优点 | 缺点 |
|---|---|---|---|---|---|---|
| Serial | 新生代 | 单线程,STW | 复制算法 | 串行 | 实现简单,单 CPU 效率高 | 多 CPU 场景效率低,STW 时间长 |
| ParNew | 新生代 | 多线程(Serial 多线程版),STW | 复制算法 | 并行 | 支持与 CMS 配合,多 CPU 效率高 | 单 CPU 场景有线程交互开销 |
| Parallel Scavenge | 新生代 | 多线程,吞吐量优先 | 复制算法 | 并行 | 自适应调节策略,可控制吞吐量和停顿时间 | 不支持与 CMS 配合 |
| Serial Old | 老年代 | 单线程,STW | 标记 - 整理 | 串行 | 实现简单,Client 模式默认 | 多 CPU 场景效率低 |
| Parallel Old | 老年代 | 多线程,吞吐量优先 | 标记 - 整理 | 并行 | 与 Parallel Scavenge 搭配,吞吐量最优 | 停顿时间较长 |
| CMS(Concurrent Mark Sweep) | 老年代 | 并发收集,低停顿 | 标记 - 清除 | 并发 | 响应速度快,STW 时间短 | 占用 CPU 资源,产生内存碎片,无法处理浮动垃圾 |
| G1(Garbage First) | 全区域 | 分区收集,兼顾吞吐量与低停顿 | 标记 - 整理 + 复制 | 并发 + 并行 | 无内存碎片,可预测停顿时间 | 实现复杂,小内存场景效率低 |
(3)重点收集器详解
① CMS 收集器(低停顿首选)
- 核心目标:获取最短回收停顿时间,适用于 B/S 系统、互联网应用等对响应速度敏感的场景。
- 运作流程(4 步):
- 初始标记(Initial Mark):标记 GC Roots 直接关联的对象,速度快,需 STW。
- 并发标记(Concurrent Mark):遍历引用链,标记所有死亡对象,与用户线程并发执行,无需 STW。
- 重新标记(Remark):修正并发标记期间因用户线程执行导致标记变动的对象,需 STW(停顿时间短于初始标记)。
- 并发清除(Concurrent Sweep):回收死亡对象,与用户线程并发执行,无需 STW。
- 核心缺点:
- CPU 敏感:并发阶段占用 CPU 资源,导致应用程序吞吐量下降(默认 GC 线程数 = (CPU 数 + 3)/4)。
- 浮动垃圾:并发清除阶段用户线程仍产生新垃圾,无法在本次 GC 中回收,需预留内存空间(避免 OOM)。
- 内存碎片:基于标记 - 清除算法,产生大量碎片,可能导致提前触发 Full GC。
② G1 收集器(全区域首选)
- 核心定位:面向服务端应用,旨在替代 CMS,兼顾吞吐量和低停顿,适用于大堆内存场景(如 10GB 以上)。
- 核心特性:
- 分区收集:将堆划分为多个大小相等的 Region(区域),每个 Region 可动态标记为 Eden、Survivor、Old、Humongous(存储大对象,超过 Region 大小 50%)。
- 优先回收:基于 "Garbage First" 策略,优先回收垃圾最多的 Region(垃圾回收率最高)。
- 无内存碎片:整体采用标记 - 整理算法,局部(Region 之间)采用复制算法,回收后自动压缩内存。
- 可预测停顿时间:通过参数
-XX:MaxGCPauseMillis设置最大停顿时间,G1 会动态调整回收 Region 数量,确保停顿时间不超标。
- 运作流程(4 步):
- 初始标记:标记 GC Roots 直接关联的对象,与 Minor GC 同步执行,需 STW。
- 并发标记:遍历引用链,标记死亡对象,与用户线程并发执行;同时计算每个 Region 的垃圾回收率。
- 最终标记(Remark):修正并发标记期间的标记变动,采用 SATB(快照原子性)算法,STW 时间短。
- 筛选回收(Clean Up/Copy):筛选垃圾回收率高的 Region,复制存活对象到空 Region,清空原 Region;与 Minor GC 同步执行,需 STW。
4. 一个对象的一生(总结)
- 诞生:对象在新生代 Eden 区创建。
- 新生代流转:Eden 区满触发 Minor GC,存活对象复制到 Survivor 区(S0/S1),反复流转。
- 晋升老年代:对象在 Survivor 区复制 15 次后(默认阈值),或为大对象,晋升到老年代。
- 老年代存活:在老年代中持续存活,经历多次 Full GC。
- 死亡:当对象到 GC Roots 不可达,被标记为死亡对象,最终被 GC 回收(新生代复制算法 / 老年代标记 - 整理算法)。
七、Java 内存模型(JMM)
JMM(Java Memory Model)是 JVM 定义的内存访问规范,用于屏蔽不同硬件和操作系统的内存访问差异,确保 Java 程序在多线程环境下的并发安全性(原子性、可见性、有序性)。
1. 核心概念:主内存与工作内存
JMM 定义了线程与内存的交互规则,将内存分为两类:
- 主内存:存储所有线程共享的变量(实例字段、静态字段、数组元素),是物理内存的抽象。
- 工作内存:每个线程私有的内存区域,存储主内存变量的副本拷贝;线程对变量的所有操作(读取、赋值)均在工作内存中执行,无法直接访问主内存。
2. 内存间交互操作(8 种原子操作)
JMM 定义了 8 种原子操作,确保主内存与工作内存之间的数据同步,操作需满足原子性、不可分割:
- lock(锁定):作用于主内存变量,标记为线程独占状态。
- unlock(解锁):作用于主内存变量,释放锁定状态,允许其他线程锁定。
- read(读取):作用于主内存变量,将变量值传输到线程工作内存。
- load(载入):作用于工作内存变量,将 read 操作获取的变量值存入工作内存副本。
- use(使用):作用于工作内存变量,将变量值传递给执行引擎(如运算)。
- assign(赋值):作用于工作内存变量,将执行引擎的结果赋给变量副本。
- store(存储):作用于工作内存变量,将变量值传输到主内存。
- write(写入):作用于主内存变量,将 store 操作获取的变量值存入主内存。
3. JMM 的三大核心特性
并发程序正确执行需保证三大特性,JMM 通过上述操作和关键字(volatile、synchronized)实现:
(1)原子性
- 定义:一个操作或多个操作要么全部执行且不被打断,要么全部不执行。
- JMM 保证:read、load、assign、use、store、write 等基本操作原子性;更大范围的原子性(如
i++)需通过synchronized或java.util.concurrent.locks锁实现。 - 示例:
i++并非原子操作(分为读取 i、i+1、赋值给 i 三步),多线程环境下可能出现线程安全问题。
(2)可见性
- 定义:当一个线程修改共享变量的值,其他线程能立即感知到该修改。
- 实现方式:
volatile:修饰的变量修改后,会立即同步到主内存,其他线程读取时直接从主内存加载(禁用工作内存缓存)。synchronized:解锁前,线程需将工作内存中变量的修改同步到主内存;加锁时,线程清空工作内存副本,从主内存重新加载变量(happen-before 原则)。final:final 修饰的变量初始化完成后,其值对所有线程可见(不可修改)。
(3)有序性
- 定义:线程内操作按代码顺序执行(线程内串行);线程间观察时,操作可能无序(指令重排序、工作内存与主内存同步延迟)。
- 指令重排序:编译器或 CPU 为优化性能,在不影响单线程执行结果的前提下,调整指令执行顺序(如
a=1; b=2可能被重排为b=2; a=1)。 - 实现方式:
volatile:禁止指令重排序(修饰的变量前后的指令不可重排)。synchronized:保证同一时刻只有一个线程执行同步块,间接保证有序性。- happen-before 原则(先天有序性):无需任何关键字,JMM 默认保证的有序性,包括:
- 程序次序规则:线程内代码按书写顺序执行。
- 锁定规则:unlock 操作先行发生于后面对同一锁的 lock 操作。
- volatile 变量规则:对 volatile 变量的写操作先行发生于读操作。
- 传递规则:A 先行发生于 B,B 先行发生于 C,则 A 先行发生于 C。
- 线程启动规则:Thread.start () 先行发生于线程内所有操作。
- 线程中断规则:interrupt () 先行发生于线程检测到中断事件。
- 线程终结规则:线程内所有操作先行发生于线程终止检测(如 join ())。
- 对象终结规则:对象初始化完成先行发生于 finalize () 方法。
4. volatile 关键字详解
volatile是 JMM 提供的最轻量级同步机制,仅保证可见性和有序性,不保证原子性。
(1)核心特性
- 可见性:volatile 变量的修改会立即同步到主内存,其他线程读取时直接从主内存加载,避免缓存不一致。
- 禁止指令重排序:volatile 变量前后的指令不可重排,确保代码执行顺序与预期一致。
(2)不保证原子性的示例
java
public class VolatileAtomicTest {
public static volatile int num = 0;
public static void increase() {
num++; // 非原子操作,volatile无法保证
}
public static void main(String[] args) {
Thread[] threads = new Thread[10];
for (int i = 0; i < 10; i++) {
threads[i] = new Thread(() -> {
for (int j = 0; j < 100; j++) {
increase();
}
});
threads[i].start();
}
while (Thread.activeCount() > 2) {
Thread.yield();
}
System.out.println(num); // 结果小于1000,存在线程安全问题
}
}
- 原因:
num++分为读取 num、num+1、赋值给 num 三步,volatile 仅保证读取时获取最新值,但运算过程中可能被其他线程修改,导致结果覆盖。
(3)volatile 的适用场景
需满足以下条件之一:
- 运算结果不依赖变量当前值(如布尔标志位)。
- 仅单一线程修改变量,其他线程读取。
-
示例(正确场景):
javavolatile boolean shutdownRequested; public void shutdown() { shutdownRequested = true; // 单一线程修改 } public void work() { while (!shutdownRequested) { // 多线程读取 // 执行任务 } }
(4)禁止指令重排序的作用
-
示例:
java// x、y为非volatile变量,flag为volatile变量 x = 2; // 语句1 y = 0; // 语句2 flag = true; // 语句3 x = 4; // 语句4 y = -1; // 语句5 -
规则:语句 3(volatile 写)不能重排到语句 1、2 之前,也不能重排到语句 4、5 之后;语句 1、2 之间,语句 4、5 之间可重排。
-
意义:确保执行到语句 3 时,语句 1、2 的修改已完成且可见,避免多线程环境下因重排导致的逻辑错误。
5. 双重检查锁定单例模式(volatile 应用)
(1)问题代码(未用 volatile)
java
public class Singleton {
private static Singleton instance = null;
private Singleton() {}
public static Singleton getInstance() {
if (instance == null) { // 第一次检查
synchronized (Singleton.class) { // 加锁
if (instance == null) { // 第二次检查
instance = new Singleton(); // 非原子操作
}
}
}
return instance;
}
}
- 问题:
instance = new Singleton()分为三步:1. 分配内存;2. 初始化成员变量;3. 赋值给 instance(instance 非 null)。JIT 编译可能重排为 1→3→2,导致线程 B 在第二次检查时看到 instance 非 null,但未初始化完成,使用时抛出异常。
(2)修复方案(volatile 修饰 instance)
java
public class Singleton {
private volatile static Singleton instance = null; // 禁止重排
private Singleton() {}
public static Singleton getInstance() {
if (instance == null) {
synchronized (Singleton.class) {
if (instance == null) {
instance = new Singleton(); // 重排被禁止,确保1→2→3执行
}
}
}
return instance;
}
}
- 原理:volatile 禁止
instance = new Singleton()的指令重排,确保对象初始化完成后,才将 instance 赋值为非 null,避免线程安全问题。
总结
-
内存布局:堆、栈、方法区各司其职
-
类加载:双亲委派确保安全,SPI 等场景需要打破规则
-
垃圾回收:分代收集是核心思想,不同场景选择不同收集器
-
内存模型:JMM 为并发编程提供保障