一、类加载机制
核心目标:理解 JVM 类加载全流程、类加载器体系及双亲委派机制。
- 类加载全过程
- 加载:通过类加载器将.class 文件字节流加载为内存中的 Class 对象(如从 jar 包、网络或自定义源加载)。
- 验证:确保字节流符合 JVM 规范(如文件格式验证、字节码验证)。
- 准备:为类变量分配内存并设置初始值(如static int value = 123,此时 value 初始值为 0)。
- 解析:将符号引用转为直接引用(如将类名转为内存地址)。
- 初始化:执行类构造器<clinit>(),初始化类变量和静态代码块。
- 类加载器体系
- 启动类加载器(Bootstrap ClassLoader):加载 JRE 核心类(如rt.jar中的java.lang.*),由 C++ 实现,不可被 Java 代码访问。
- 扩展类加载器(Extension ClassLoader):加载jre/lib/ext目录或-Djava.ext.dirs指定路径的类库。
- 应用类加载器(Application ClassLoader):加载应用程序类路径(ClassPath)下的类,是自定义类加载器的默认父加载器。
- 自定义类加载器:继承ClassLoader,用于加载特定来源的类(如加密的.class 文件、动态生成的类)。
- 双亲委派机制
- 工作原理:类加载时,先委托父加载器加载,直至启动类加载器;若父加载器无法加载,才由当前加载器加载。
- 作用:确保核心类(如java.lang.Object)由启动类加载器加载,避免用户自定义类覆盖核心类,保障类型安全。
- 打破场景:如 Tomcat 为支持多应用隔离,采用 “逆双亲委派” 机制,优先使用自定义类加载器加载应用类。
案例:手写自定义类加载器加载加密的.class 文件,通过重写findClass()方法解密字节流后生成 Class 对象。
二、JVM 内存结构
核心目标:掌握 JVM 内存分区、对象创建流程及垃圾回收机制。
- 运行时数据区域
- 程序计数器:记录当前线程执行的字节码行号,是线程私有的 “指针”。
- Java 虚拟机栈:存储栈帧(局部变量表、操作数栈、动态链接等),线程私有,生命周期与线程一致。
- 本地方法栈:为 Native 方法服务,结构与虚拟机栈类似。
- 堆:存储对象实例,线程共享,是垃圾回收的主要区域(分新生代、老年代,新生代含 Eden、Survivor 区)。
- 方法区(元空间):存储类元数据、常量、静态变量等,JDK 8 后由元空间(MetaSpace)替代永久代,使用本地内存。
- 对象创建与内存分配
- 流程:
- 类加载检查(检查类是否已加载)→ 2. 分配内存(指针碰撞或空闲列表算法)→ 3. 初始化零值 → 4. 设置对象头(存储哈希码、GC 分代年龄等)→ 5. 执行<init>()方法。
- 并发安全:通过 CAS(Compare-And-Swap)保证原子性,或使用 TLAB(Thread Local Allocation Buffer)为每个线程分配专属内存块。
- 流程:
- 垃圾回收(GC)
- 对象存活判断:
- 引用计数法:记录对象被引用次数,存在循环引用缺陷(如 A→B,B→A,两者均无法回收)。
- 可达性分析:从 GC Roots(如栈变量、类静态变量)出发,标记所有可达对象,不可达对象判定为垃圾。
- 垃圾收集算法:
- 标记 - 清除:标记垃圾对象后统一清除,产生内存碎片。
- 复制算法:将存活对象复制到另一块区域,适合新生代(如 Eden→Survivor 区)。
- 标记 - 整理:标记后压缩内存,避免碎片,适合老年代。
- 垃圾收集器:
- Serial:单线程收集器,STW(Stop The World)时间长,适合客户端应用。
- Parallel:多线程收集器,吞吐量优先(如-XX:+UseParallelGC)。
- CMS:并发收集器,低停顿,适合 Web 应用(如-XX:+UseConcMarkSweepGC)。
- G1:分代收集器,可预测停顿时间,适合大内存场景(如-XX:+UseG1GC)。
- 对象存活判断:
三、性能调优工具与实战
核心目标:掌握 JVM 调优工具使用及线上问题排查方法。
- 命令行工具
- jps:查看 JVM 进程 ID。
- jstat:监控 GC 状态(如jstat -gc 12345查看进程 12345 的 GC 数据)。
- jmap:生成堆转储文件(如jmap -dump:format=b,file=heap.hprof 12345)。
- jstack:查看线程栈信息,定位死锁或阻塞(如jstack 12345 | grep "WAITING")。
- 可视化工具
- JVisualVM:图形化监控工具,支持堆分析、线程分析、插件扩展(如安装 Visual GC 插件实时查看 GC 情况)。
- Arthas:阿里开源诊断工具,支持实时查看变量、热更新代码、监控方法调用(如arthas --pid 12345启动后执行trace com.example.Service method追踪方法调用耗时)。
- 线上问题排查
- CPU 飙高:通过top找到高 CPU 进程,top -Hp <pid>定位线程,jstack打印线程栈,分析是否存在死循环或锁竞争。
- 内存溢出(OOM):配置-XX:+HeapDumpOnOutOfMemoryError生成 dump 文件,用 MAT(Memory Analyzer Tool)分析大对象或内存泄漏。
- 频繁 GC:通过jstat -gcutil观察 GC 频率和耗时,调整堆大小(如-Xms2g -Xmx2g)或切换 GC 收集器。
四、高级主题
核心目标:深入理解 JVM 底层机制及优化策略。
- 字节码与类文件结构
- Class 文件组成:魔数(0xCAFEBABE)、版本号、常量池、字段表、方法表、属性表等。
- 常量池:存储字面量(如字符串、数字)和符号引用(如类名、方法名),分为 Class 常量池和运行时常量池。
- 性能调优参数
- 堆设置:
- -Xms:初始堆大小,建议与-Xmx一致,避免堆自动扩展带来的性能波动。
- -Xmn:新生代大小,通常占堆的 1/3(如-Xmn1g)。
- 元空间设置:-XX:MetaspaceSize=256m(JDK 8+),控制类元数据内存。
- GC 参数:
- -XX:+UseG1GC:启用 G1 收集器。
- -XX:MaxGCPauseMillis=200:设置 GC 最大停顿时间(G1 可用)。
- 堆设置:
- 特殊场景优化
- 大对象处理:通过-XX:PretenureSizeThreshold设置大对象直接进入老年代(如-XX:PretenureSizeThreshold=1048576表示 1MB 以上对象直接进入老年代)。
- 字符串常量池优化:使用String.intern()将字符串入池,避免重复创建(如new String("abc").intern())。
1.1 类加载运行全过程梳理
类加载是 JVM 将.class 文件转换为可执行字节码的核心流程,分为加载、验证、准备、解析、初始化五个阶段:
- 加载
- 目标:通过类加载器获取类的二进制字节流(如从本地文件系统、网络、jar 包加载)。
- 关键动作:
- 通过类的全限定名(如com.example.User)查找对应的.class 文件。
- 将字节流转换为内存中的java.lang.Class对象。
- 示例:当执行new User()时,若User类未加载,JVM 会触发类加载器加载User.class。
- 验证
- 目标:确保字节流符合 JVM 规范,防止恶意代码破坏 JVM。
- 验证阶段:
- 文件格式验证:检查魔数(0xCAFEBABE)、版本号是否合法。
- 字节码验证:确保字节码指令合法(如操作数栈深度正确)。
- 符号引用验证:确保类之间的引用有效(如引用的类存在)。
- 准备
- 目标:为类变量(static修饰的变量)分配内存并设置初始值。
- 注意:
- 实例变量(非static)在对象创建时分配内存,此处不处理。
- 初始值为数据类型的默认值(如static int value = 123,准备阶段value为 0,初始化阶段才赋值 123)。
- 解析
- 目标:将符号引用转为直接引用(如将类名java.lang.Object转为内存地址)。
- 符号引用 vs 直接引用:
- 符号引用:一组符号(如字符串)描述引用目标,与虚拟机实现无关。
- 直接引用:指向目标的指针、句柄或偏移量,直接指向内存地址。
- 初始化
- 目标:执行类构造器<clinit>(),初始化类变量和静态代码块。
- 触发时机:
- 首次使用类(如创建实例、调用静态方法)。
- 子类初始化前,先初始化父类(除非父类已初始化)。
- 示例:
public class ClassLoadingDemo { static { System.out.println("Class is initializing..."); } public static void main(String[] args) { System.out.println("Main method executed."); } } |
执行main方法时,触发ClassLoadingDemo类初始化,输出 “Class is initializing...”。
1.2 Java.exe 运行一个类时 JVM HotSpot 底层做了什么
当我们在命令行输入java.exe运行一个 Java 类(如java com.example.MyApp)时,JVM HotSpot 虚拟机的底层会执行一系列复杂操作,其核心流程如下:
1. 启动 JVM 进程java.exe作为 Java 虚拟机的启动程序,会首先创建一个 JVM 进程。在此过程中,HotSpot 虚拟机初始化底层环境,包括分配内存空间、加载必要的动态链接库(如libjvm.so或jvm.dll)。这些动态链接库包含了 JVM 运行的核心功能,如内存管理、字节码执行引擎等。同时,JVM 会根据启动参数(如-Xms、-Xmx)初始化堆内存大小,并设置其他关键组件(如方法区、虚拟机栈)。
2. 加载引导类加载器JVM 启动后,首先加载启动类加载器(Bootstrap ClassLoader),该加载器由 C++ 编写,负责加载 JRE 核心类库,如rt.jar中的java.lang.Object、java.util.List等。这些核心类是 Java 程序运行的基础,Bootstrap ClassLoader 将其加载到内存中,为后续类加载奠定基础。由于其由 C++ 实现,在 Java 代码层面无法直接访问和操作。
3. 触发目标类加载当 JVM 执行java com.example.MyApp时,会触发com.example.MyApp类的加载。此时,应用类加载器(Application ClassLoader)开始工作,它会按照双亲委派机制,先将加载请求委托给父加载器(扩展类加载器 Extension ClassLoader),扩展类加载器再委托给启动类加载器。由于com.example.MyApp属于应用程序自定义类,不在 JRE 核心类库中,启动类加载器和扩展类加载器均无法加载,最终由应用类加载器从 ClassPath 路径下找到对应的.class文件,并将其字节流加载为内存中的Class对象。
4. 执行类加载流程在加载com.example.MyApp类时,JVM 严格遵循加载→验证→准备→解析→初始化的流程:
- 加载:应用类加载器将.class文件字节流转换为Class对象;
- 验证:检查字节流是否符合 JVM 规范,防止恶意代码入侵;
- 准备:为类的静态变量分配内存并设置初始值(如static int count = 10,此时count为 0);
- 解析:将符号引用(如类名、方法名)转换为直接引用(内存地址);
- 初始化:执行类构造器<clinit>(),初始化静态变量和静态代码块。若MyApp类有父类,会先初始化父类。
5. 创建主线程并执行类初始化完成后,JVM 创建主线程(main线程),并执行com.example.MyApp类中的main方法。在此过程中,虚拟机栈为main方法创建栈帧,用于存储局部变量、操作数栈和方法调用信息。随着main方法中代码的执行,JVM 通过字节码执行引擎解析和执行字节码指令,操作堆内存中的对象,完成程序的业务逻辑。
6. 运行时动态管理在程序运行期间,JVM 持续监控内存使用情况,通过垃圾回收机制清理不再使用的对象,释放内存空间。同时,HotSpot 虚拟机利用 ** 即时编译(JIT)** 技术,将频繁执行的字节码编译为机器码,提升程序执行效率。例如,对于循环次数较多的代码块,JIT 会将其编译为机器码,避免每次执行都进行字节码解释,从而显著提高性能。
示例场景假设我们有一个简单的 Java 程序:
public class HelloWorld { static { System.out.println("HelloWorld class is initializing"); } public static void main(String[] args) { System.out.println("Hello, World!"); } } |
当执行java HelloWorld时,JVM HotSpot 底层会先加载HelloWorld类,执行静态代码块输出 “HelloWorld class is initializing”,然后创建主线程执行main方法,输出 “Hello, World!”。在这个过程中,JVM 完成了从类加载到程序执行的全流程操作,并在后台持续管理内存和优化执行效率。
1.3 初识符号引用、静态链接与动态链接
在 Java 类加载的解析阶段,符号引用与链接过程是连接字节码和运行时内存地址的关键环节,理解它们对掌握 JVM 底层运作至关重要。
1. 符号引用(Symbolic References)
符号引用是一组符号来描述所引用的目标,这些符号以文本形式存在,与虚拟机实现无关,在.class文件中广泛使用。它可以是任何形式的字面量,只要使用时能无歧义地定位到目标即可。具体包括:
- 类和接口的全限定名:如java/util/List,用于标识类或接口的唯一性。
- 字段的名称和描述符:例如nameLjava/lang/String;,其中name是字段名,Ljava/lang/String;是描述符,表示该字段为String类型 。
- 方法的名称和描述符:像toString()Ljava/lang/String;,toString是方法名,()Ljava/lang/String;描述了方法的参数和返回值类型。
示例:在如下 Java 代码编译后的.class文件中:
public class SymbolicRefDemo { private String message; public String getMessage() { return message; } } |
.class文件会使用符号引用记录message字段和getMessage方法,如字段message记录为messageLjava/lang/String;,方法getMessage记录为getMessage()Ljava/lang/String;。这些符号引用在类加载阶段暂时无法直接访问目标内存地址,需要通过链接过程转换。
2. 静态链接(Static Linking)
静态链接发生在类加载的解析阶段,主要任务是将符号引用转换为直接引用。直接引用是指向目标的指针、相对偏移量或者是一个能间接定位到目标的句柄,它直接指向内存中的具体位置。
- 核心操作:
- 查找类、字段、方法的内存地址:JVM 通过类加载器找到对应的类元数据,获取字段和方法在内存中的具体位置。
- 验证访问权限:检查当前类是否有权限访问目标字段或方法,例如检查私有方法是否被非法调用。
- 特点:静态链接的结果在程序运行期间不会改变,适用于不会被修改的类、字段和方法,比如java.lang.Math类中的静态方法,在类加载时完成静态链接后,后续调用直接使用已确定的内存地址。
3. 动态链接(Dynamic Linking)
动态链接与静态链接不同,它发生在程序运行期间,用于处理一些无法在编译期确定的引用。
- 触发场景:
- 多态调用:当使用接口或抽象类进行方法调用时(如List list = new ArrayList(); list.add("元素");),具体调用的是ArrayList的add方法还是其他List实现类的add方法,只有在运行时才能确定。
- JIT 编译:即时编译器(JIT)在运行时将频繁执行的字节码编译为机器码,编译过程中需要将符号引用转换为直接引用。
- 实现机制:JVM 通过运行时常量池来管理动态链接。运行时常量池存储了类加载过程中解析得到的直接引用,当遇到动态链接需求时,JVM 在运行时常量池中查找或创建对应的直接引用。
4. 两者对比与应用
特性 |
静态链接 |
动态链接 |
执行阶段 |
类加载的解析阶段 |
程序运行期间 |
适用场景 |
确定的类、字段、方法引用 |
多态调用、JIT 编译等动态场景 |
性能影响 |
类加载时开销较大,运行时效率高 |
类加载时开销小,运行时可能有额外查找开销 |
示例:在如下多态代码中:
interface Shape { double calculateArea(); } class Circle implements Shape { private double radius; |