Skip to content

Java虚拟机笔记

让我们通过一个贯穿全文的示例来理解JVM的内存模型:

java
// 示例代码:一个简单的学生成绩管理系统
public class Student {
    // 静态字段:存储在元空间的类信息中
    private static String schoolName = "清华大学";
    private static int totalStudents = 0;
    
    // 实例字段:存储在堆内存中
    private String name;
    private int score;
    
    // 构造方法
    public Student(String name, int score) {
        this.name = name;
        this.score = score;
        totalStudents++;
    }
    
    // 实例方法:方法本身存储在元空间,执行时在虚拟机栈中创建栈帧
    public void updateScore(int newScore) {
        // 局部变量:存储在虚拟机栈的局部变量表中
        int oldScore = this.score;
        this.score = newScore;
        System.out.println(name + "的分数从" + oldScore + "更新为" + newScore);
    }
    
    // 静态方法:方法本身存储在元空间,执行时在虚拟机栈中创建栈帧
    public static void printSchoolInfo() {
        // 字符串字面量:存储在字符串常量池(堆)中
        System.out.println("学校名称:" + schoolName);
        System.out.println("学生总数:" + totalStudents);
    }
}

public class Main {
    public static void main(String[] args) {
        // main方法的参数args是局部变量,存储在main方法对应栈帧的局部变量表中
        
        // new操作符在堆内存中创建对象,student1是局部变量,存储在栈中的引用
        Student student1 = new Student("张三", 80);
        
        // 同上,student2也是存储在栈中的引用
        Student student2 = new Student("李四", 90);
        
        // 调用实例方法时,会在虚拟机栈中创建新的栈帧
        student1.updateScore(85);
        
        // 调用静态方法
        Student.printSchoolInfo();
    }
}

这个示例包含了JVM中各个内存区域的重要概念,我们将在后续章节中详细解释每个部分是如何在JVM中存储和运行的。

JVM内存模型

🏗️ JVM内存架构的演进:从JDK1.7到JDK1.8的华丽转身

JDK1.7时代:传统的三足鼎立

在JDK1.7的时代,JVM的内存布局就像一个传统的三层建筑:

📊 JDK1.7 内存布局
├── 🏢 运行时数据区域
│   ├── 👥 线程共享区域
│   │   ├── 🗃️ 堆内存 [包含字符串常量池]
│   │   └── 📚 方法区 [包含运行时常量池]
│   └── 🔒 线程私有区域
│       ├── 📚 虚拟机栈
│       ├── 🌐 本地方法栈
│       └── 📍 程序计数器
└── 💾 本地内存
    └── ⚡ 直接内存

JDK1.8革命:元空间的诞生

到了JDK1.8,Oracle工程师们做了一个大胆的决定——彻底移除永久代,引入了元空间(Metaspace):

📊 JDK1.8 内存布局
├── 🏢 运行时数据区域
│   ├── 👥 线程共享区域
│   │   └── 🗃️ 堆内存 [包含字符串常量池]
│   └── 🔒 线程私有区域
│       ├── 📚 虚拟机栈
│       ├── 🌐 本地方法栈
│       └── 📍 程序计数器
└── 💾 本地内存
    ├── 🌌 元空间 [原方法区,包含运行时常量池]
    └── ⚡ 直接内存

为什么要这样改? 就像把书籍从有限的书架移到无限大的图书馆一样,元空间使用本地内存,不再受JVM设定的固定大小限制,大大减少了内存溢出的风险。


🔒 线程私有区域

每个线程都拥有自己独立的内存空间,就像每个人都有自己的私人办公桌

🧭 线程私有的程序计数器:JVM的"GPS导航系统"

线程私有的程序计数器(Program Counter Register)是JVM中最小的内存区域,也是每个线程独有的"导航仪"。它时刻记录着当前线程正在执行的字节码指令地址。

为什么需要线程私有?

想象一下多线程就像多个人同时在不同的书上阅读:

  • 每个人都需要一个书签记录自己读到哪一页
  • 这个线程私有的书签就是程序计数器
  • 如果共享书签,每个人的阅读进度就会混乱

线程切换与上下文恢复

程序计数器在线程切换时扮演着关键角色:

java
public class ThreadSwitchDemo {
    public void method1() {
        int a = 1;
        method2();    // 程序计数器记录这里的位置
        System.out.println(a);  // 线程切换回来后,继续从这里执行
    }
    
    public void method2() {
        // 方法2的执行过程中可能发生线程切换
        try {
            Thread.sleep(1000);  // 线程切换点
        } catch (InterruptedException e) {
            e.printStackTrace();
        }
    }
}
  1. 线程切换场景

    • 当前线程的时间片用完
    • 当前线程进入阻塞状态(如IO操作)
    • 当前线程主动让出CPU(如调用yield())
  2. 上下文保存

    • 程序计数器保存当前线程的执行位置
    • 保存当前线程的局部变量等状态
    • 这些信息构成了线程的执行上下文
  3. 上下文恢复

    • 线程重新获得CPU时间片
    • 根据程序计数器的值,知道从哪里继续执行
    • 恢复线程的局部变量等状态

💡 关键点:程序计数器的线程私有特性,是实现线程切换后能准确恢复执行位置的关键。它就像一个"保存进度"的功能,确保线程切换后能继续之前的工作。

核心概念

程序计数器(Program Counter Register)就像是JVM的GPS导航系统,时刻记录着当前线程执行到了哪一行字节码指令。

在我们的示例中,当执行student1.updateScore(85)时,程序计数器会依次指向:

🔍 点击查看:程序计数器如何跟踪代码执行 ➡️
java
// 从示例代码中提取的相关部分
public class Main {
    public static void main(String[] args) {
        Student student1 = new Student("张三", 80);
        student1.updateScore(85);  // 程序计数器指向这行
    }
}

public class Student {
    public void updateScore(int newScore) {  // 程序计数器指向这行
        int oldScore = this.score;           // 程序计数器指向这行
        this.score = newScore;               // 程序计数器指向这行
        System.out.println(name + "的分数从" + oldScore + "更新为" + newScore);  // 程序计数器指向这行
    }                                        // 程序计数器返回到main方法
}
  1. main方法中调用updateScore的位置
  2. updateScore方法中的各条指令
  3. 执行完毕后返回main方法的下一条指令

关键特性

  1. 线程私有:每个线程都有自己独立的程序计数器,互不干扰
  2. 生命周期:与线程同生共死
  3. 唯一的"无忧区域":这是JVM内存区域中唯一不会发生OutOfMemoryError的地方

💡 小贴士:程序计数器为什么要线程私有?试想如果多个线程共享一个计数器,就像多个司机共用一个GPS,必然会乱套!


🎯 核心收获:程序计数器是最小的内存区域,线程私有,是JVM中唯一不会发生OOM的区域。它就像线程的"指挥棒",时刻指向当前要执行的字节码指令。


📚 线程私有的Java虚拟机栈:方法调用的"记忆宫殿"

栈的基本结构

线程私有的虚拟机栈(JVM Stack)就像一个优雅的"方法调用记忆宫殿",每个线程都拥有自己独立的记忆宫殿。每次方法调用都会在这个线程私有的栈内存中创建一个栈帧,就像在宫殿中开辟一个新房间。

🏰 虚拟机栈结构

├── 📦 栈帧3 (当前方法)
│   ├── 📋 局部变量表
│   ├── 🎯 操作数栈  
│   ├── 🔗 动态链接
│   └── 📍 方法返回地址
├── 📦 栈帧2 (调用者方法)
├── 📦 栈帧1 (main方法)
└── ⬇️ 栈底

栈帧的四大组件

1. 局部变量表:线程私有栈中的"私人储物柜"

🔍 点击展开:虚拟机栈中的局部变量表实例 ➡️
java
// 从示例代码中提取的相关部分
public void updateScore(int newScore) {
    // 局部变量表内容:
    // slot 0: this (对当前Student对象的引用)
    // slot 1: newScore (参数,值为85)
    int oldScore = this.score;  // slot 2: oldScore (局部变量)
    this.score = newScore;
    System.out.println(name + "的分数从" + oldScore + "更新为" + newScore);
}

线程私有的虚拟机栈中,每个栈帧都有自己的局部变量表,存储方法参数和局部变量:

  • 基本类型booleanbytecharshortintfloatlongdouble
  • 引用类型(reference):这里是重点!存储的是指向线程共享堆内存中对象的"门牌号",而不是对象本身

关键概念:reference(对象引用)就像你手里的电影票,票上有座位号,但票本身不是座位。线程私有栈中的reference指向线程共享堆中的具体对象

2. 操作数栈:线程私有栈中的计算"工作台"

线程私有的虚拟机栈中临时存储计算过程中的数据,就像数学计算时的草稿纸。

3. 动态链接:连接私有与公共的"桥梁"

连接当前栈帧和运行时常量池的桥梁。在JDK1.8后,运行时常量池从永久代移到了元空间,但动态链接的工作机制保持不变。

java
public class DynamicLinkingDemo {
    private int value;
    
    public void setValue(int newValue) {
        // 动态链接过程
        this.value = newValue;  // 这里的value字段引用需要在运行时解析
    }
    
    public int calculate(int x) {
        // 方法调用的动态链接
        return getValue() + x;  // getValue方法的符号引用在运行时解析为直接引用
    }
    
    private int getValue() {
        return value;
    }
}
动态链接的工作过程
  1. 符号引用到直接引用的解析

    • 类加载时,常量池中的符号引用(如字段名、方法名)还未转换为实际内存地址
    • 运行时,通过动态链接将符号引用解析为直接引用(实际内存地址)
    • 这个解析过程是懒加载的,只在首次使用时进行
  2. 与运行时常量池的关系

    • 运行时常量池存储在元空间中
    • 包含字段、方法的符号引用
    • 动态链接通过这些符号引用找到实际的内存地址
  3. 优化机制

    • 方法的符号引用在首次解析后会被缓存
    • 后续调用可以直接使用缓存的直接引用
    • 这种机制显著提升了方法调用的性能

💡 性能提示:动态链接虽然增加了灵活性,但也带来了性能开销。JVM会通过内联缓存等技术来优化这个过程。

4. 方法返回地址:回家的"路标"

记录方法执行完毕后应该返回到哪里继续执行。

以我们的示例代码为例,当执行到student1.updateScore(85)时,虚拟机栈中会有这样的结构:

🏰 虚拟机栈结构

├── 📦 栈帧2 (updateScore方法)
│   ├── 📋 局部变量表
│   │   ├── this = 对student1对象的引用
│   │   ├── newScore = 85
│   │   └── oldScore = 80
│   ├── 🎯 操作数栈
│   ├── 🔗 动态链接
│   └── 📍 方法返回地址

└── 📦 栈帧1 (main方法)
    ├── 📋 局部变量表
    │   ├── args = String[]引用
    │   ├── student1 = Student对象引用
    │   └── student2 = Student对象引用
    ├── 🎯 操作数栈
    ├── 🔗 动态链接
    └── 📍 方法返回地址

常见异常

java
// StackOverflowError 示例
public void infiniteRecursion() {
    infiniteRecursion(); // 无限递归,栈溢出!
}
  • StackOverflowError:栈内存固定时,无限递归导致栈空间耗尽
  • OutOfMemoryError:栈内存可动态扩展时,扩展到系统内存极限

🎯 核心收获:虚拟机栈是方法执行的内存模型,每个线程都有自己独立的栈空间。它通过栈帧存储方法的局部变量、操作数栈等信息,是连接其他内存区域的桥梁:

  1. 通过局部变量表中的reference,连接到堆内存中的对象实例
  2. 通过动态链接,连接到元空间中的类型信息和方法字节码
  3. 面试重点:栈帧结构、线程私有特性、异常类型(StackOverflowError vs OOM)

🌐 线程私有的本地方法栈:Native方法的"专属领地"

线程私有的本地方法栈线程私有的虚拟机栈结构类似,但专门为Native方法(用C/C++编写的方法)服务。可以把它理解为JVM的"多语言翻译部门",每个线程都有自己独立的翻译官。

📝 回顾加强:到这里我们已经介绍了三个线程私有区域:程序计数器、虚拟机栈、本地方法栈。它们都是每个线程独有的,互不干扰。


运行时数据区:指令集与执行引擎——JVM的"中央处理器"

JVM的**执行引擎(Execution Engine)是其核心部件,负责将经过类加载器加载到内存中的字节码(Bytecode)**指令翻译并执行为操作系统能够理解的机器指令。它就好比JVM这座城市的"中央处理器",而字节码就是它能够理解的"机器语言"。

JVM的执行引擎主要有两种工作模式:

  1. 解释器(Interpreter)

    • 工作原理:逐行读取字节码指令,并逐条解释执行。
    • 特点:启动速度快,因为无需编译过程。但执行效率相对较低,因为每次执行都需要重新解释。就像你每次读一句话都要查字典一样。
    • 作用:在程序刚启动时,解释器会立即开始执行代码,提供快速的启动响应。
  2. 即时编译器(Just-In-Time Compiler, JIT)

    • 工作原理:在程序运行期间,将热点代码(Hot Spot Code)(即被频繁执行的代码)直接编译为本地机器码,并缓存起来,后续直接执行编译后的机器码。
    • 特点:启动相对慢(需要编译时间),但一旦编译完成,执行效率极高。就像你把常查的单词都背下来了,阅读速度自然快。
    • 作用:负责对运行中的代码进行优化,是Java"一次编译,到处运行"的效率保证。


运行时数据区:JIT (Just-In-Time) 编译器与方法内联

JIT (Just-In-Time) 编译器:HotSpot的"智慧大脑"

JIT编译器是HotSpot JVM的核心优化技术,它负责在程序运行时动态地将字节码编译成高效的本地机器码,显著提升Java应用的执行性能。这就像一支聪明的建筑队,在发现某些施工步骤(代码块)特别频繁时,会立即优化施工方案,使其效率倍增。

HotSpot JVM采用了**分层编译(Tiered Compilation)**的策略,这是一种权衡启动速度和运行时性能的优化方案。它将JIT编译过程分为多个层级:

  1. C1 编译器(Client Compiler)

    • 编译速度:快。
    • 优化程度:低。主要进行一些简单、快速的优化,如方法内联、消除锁等。
    • 适用场景:适用于客户端应用或启动性能要求高的场景。
    • 比喻:就像一个快速响应的"初级优化师",能快速提升代码性能,但优化深度有限。
  2. C2 编译器(Server Compiler)

    • 编译速度:慢。
    • 优化程度:高。会进行更复杂的全局优化,如逃逸分析、循环优化、向量化等,生成高质量的机器码。
    • 适用场景:适用于服务器端应用或长时间运行的、对吞吐量要求高的场景。
    • 比喻:就像一个经验丰富的"高级优化师",虽然耗时较长,但能生成极致优化的代码。

分层编译的协作流程

  • 初期:解释器和C1编译器协同工作,快速响应程序启动。
  • 中期:对于热点代码,C1编译器会首先对其进行轻度优化编译。
  • 后期:如果某个代码块变得"更热"(执行频率更高),C2编译器会介入,进行深度优化编译,生成最高性能的机器码。

这种分层策略确保了程序在启动时能快速响应,同时在长时间运行后能达到接近原生代码的执行效率。

JIT如何影响GC? JIT编译优化可能通过以下方式影响GC:

  • 减少对象分配:例如,逃逸分析(Escape Analysis)可以判断一个对象是否只在当前方法内部使用而不会"逃逸"到外部。如果不会逃逸,JIT可能将这个对象分配在栈上而非堆上,从而避免GC的开销。
  • 提高GC效率:JIT编译后的代码执行速度更快,这可能使得程序更快地达到GC触发条件,或者在GC期间更快地完成扫描工作。但本身JIT并不会直接进行垃圾回收。

方法内联 (Method Inlining):JIT的重要优化利器

方法内联是JIT编译器最重要的优化手段之一。它将目标方法的代码直接复制到调用它的地方,而不是通过传统的函数调用机制(创建栈帧、参数传递、跳转等)。

比喻:就像你在做饭时,如果发现某个小步骤(比如"切葱花")你经常用到,并且它足够简单,你就不再每次都去查菜谱(调用方法),而是直接把它融入到你的主流程中。

优势

  1. 减少方法调用开销:避免了栈帧的创建与销毁、参数传递、返回地址保存等开销。
  2. 为其他优化创造条件:方法内联后,原来在不同方法中的代码现在处于同一个代码块中,这使得JIT编译器能进行更深度的跨方法优化(如死代码消除、常量传播、逃逸分析等),这是其核心价值。
java
// 优化前:
public int add(int a, int b) {
    return a + b;
}

public int calculate(int x, int y) {
    return add(x, y) * 2;
}

// JIT编译器可能将其内联优化为:
public int calculate(int x, int y) {
    return (x + y) * 2; // add方法的代码直接内联到calculate方法中
}

👥 线程共享区域

所有线程都可以访问的公共内存空间,是多线程协作的基础

🗃️ 堆内存:对象的"温馨家园"

堆的使命

Java堆(Java Heap)是运行时数据区中最重要的线程共享区域,承担着所有对象实例的存储任务。如果说JVM是一座城市,那么堆就是这座城市中最大的居民区。

重要提醒:以我们的示例来说,student1student2这两个引用存储在各自方法的栈帧中,而这些引用指向的Student对象实例(包含name和score字段的值)则存储在堆内存中。

堆内存的分代设计智慧

现代JVM采用分代垃圾收集算法,将堆内存划分为不同区域。以我们的示例来说:

🏘️ 堆内存分区(分代设计)
├── 🌱 新生代 (Young Generation)
│   ├── 🌿 Eden区
│   │   └── 新创建的Student对象最初在这里
│   ├── 🔄 Survivor0区
│   └── 🔄 Survivor1区
└── 🏛️ 老年代 (Old Generation)
    └── 🗂️ 长期存活的对象
        例如:经常使用的"清华大学"字符串

堆的物理结构与逻辑结构

物理结构

从物理内存的角度来看,堆是一块连续的内存空间。JVM通过指针碰撞(Bump The Pointer)或空闲列表(Free List)等算法在这片连续空间中分配对象。

📊 堆的物理结构
┌─────────────────────────────────────────┐
│            连续的内存空间               │
├─────────────┬──────────────┬───────────┤
│  已分配对象  │  空闲空间   │ 已分配对象 │
└─────────────┴──────────────┴───────────┘

逻辑结构

虽然物理上是连续的,但JVM为了更好地管理内存和进行垃圾回收,在逻辑上将堆空间划分为不同的区域:

📊 堆的逻辑结构
┌─────────────────────────────────────────┐
│                  堆空间                 │
├───────────────────┬─────────────────────┤
│     新生代        │      老年代         │
├────────┬──────────┤                     │
│ Eden区 │Survivor区│                     │
└────────┴──────────┴─────────────────────┘

这种物理连续但逻辑分区的设计带来了几个重要优势:

  1. 内存分配效率高:物理空间连续,使用指针碰撞可以快速分配内存
  2. GC更高效:逻辑分代便于针对不同特征的对象采用不同的回收策略
  3. 内存利用率高:虽然逻辑上分区,但物理上连续避免了内存碎片

字符串常量池与intern()机制

字符串常量池是一个特殊的存储区域,用于实现字符串驻留(String Interning)机制。让我们通过一个例子来理解:

java
String str1 = "清华大学";  // 字面量,直接进入常量池
String str2 = new String("清华大学");  // 在堆中创建对象
String str3 = str2.intern();  // 尝试将字符串添加到常量池并返回常量池中的引用

// 以下代码演示了字符串常量池的复用机制
System.out.println(str1 == str3);  // true,都指向常量池中的同一个对象
System.out.println(str1 == str2);  // false,一个指向常量池,一个指向堆

intern()方法的工作机制

  1. JDK 1.7之前

    • 常量池存在于永久代
    • intern()会把首次遇到的字符串实际内容复制到永久代的常量池中
    • 返回的是永久代中的引用
  2. JDK 1.7之后

    • 常量池移到堆内存中
    • intern()不再复制实例,而是在常量池中记录首次出现的实例的引用
    • 如果字符串已经存在于常量池,则返回池中的引用
java
// JDK 1.7+ 的intern()行为示例
String str1 = new String("清华") + new String("大学");  // 在堆中创建对象
String str2 = str1.intern();  // 在常量池中记录str1的引用
System.out.println(str1 == str2);  // true,因为常量池中记录的就是str1的引用

💡 性能提示:适当使用intern()可以节省内存,但过度使用可能会导致性能问题。建议只对频繁出现的字符串使用intern()。

运行时常量池:类的"资料档案库"

运行时常量池是方法区的一部分,在JDK1.8后随方法区一起移到了元空间。它在类加载后,将class文件中的常量池转化为运行时常量池。

从符号引用到直接引用的转换过程

java
public class RuntimeConstantPoolDemo {
    private static final String CONSTANT = "Hello";  // 字面量
    private Student student;  // 符号引用
    
    public void example() {
        System.out.println(CONSTANT);  // 访问字面量
        student.updateScore(100);      // 符号引用转直接引用
    }
}

1. 符号引用阶段(类加载时)

在class文件中,常量池存储的是符号引用:

// class文件中的常量池(简化表示)
CONSTANT_Class        #2     // 指向"Student"
CONSTANT_Fieldref    #3     // 指向"student"字段
CONSTANT_String      #4     // 指向"Hello"字符串
CONSTANT_Methodref   #5     // 指向"updateScore"方法

2. 解析阶段(类链接时)

在类的链接阶段,符号引用会被解析为直接引用:

  1. 类和接口的解析

    java
    // 将"Student"符号引用解析为Class对象的内存地址
    Class<?> studentClass = Class.forName("com.example.Student");
  2. 字段解析

    java
    // 将"student"字段符号引用解析为内存偏移量
    Field studentField = studentClass.getDeclaredField("student");
  3. 方法解析

    java
    // 将"updateScore"方法符号引用解析为方法表的索引
    Method updateScoreMethod = studentClass.getMethod("updateScore", int.class);

3. 直接引用(运行时)

解析后的直接引用可能是:

  • 指向方法区的指针
  • 偏移量
  • 数组的索引
  • 其他与内存位置相关的直接地址
java
// 运行时的直接引用(示意代码)
public class RuntimeReference {
    public void showReference() {
        Student student = new Student();  // student变量存储堆内存地址
        int score = 100;                  // score直接存储在栈中
        student.updateScore(score);       // 方法调用使用方法表索引
    }
}

💡 性能提示

  1. 符号引用转直接引用是一个耗时操作,但只需要进行一次
  2. JVM会缓存解析结果,提高后续访问效率
  3. 动态链接使用这些直接引用来提高方法调用性能

对象的"人生历程"

对象在堆内存中的生命周期就像一个不断成长的过程。让我们通过一个图示来详细了解这个过程:

📊 对象的内存区域流转
新对象创建

┌─── Eden区 ───┐     ┌── Survivor0 ──┐     ┌── 老年代 ───┐
│  [新对象]    │  ↘  │ [存活对象]   │  ↘  │[长期存活对象]│
│  [新对象]    │──→  │ Age = 1      │──→  │ Age > 15    │
│  [新对象]    │  ↗  │ Age = 2      │  ↗  │[大对象直接进入]│
└──────────────┘     └──────────────┘     └─────────────┘
       ↑                    ↕
       └────────────────────┘
         Survivor1与Survivor0
         之间复制存活对象

对象的生命周期详解

  1. 出生阶段(Eden区)

    java
    Student student = new Student("张三", 80);  // 在Eden区分配
    • 绝大多数对象在Eden区诞生
    • Eden区满时触发Minor GC
    • 存活对象被转移到Survivor区,年龄记为1
  2. 青年阶段(Survivor区)

    java
    // 经过一次Minor GC后,student对象在Survivor区
    student.setScore(85);  // 对象仍然存活
    • 在两个Survivor区之间来回复制
    • 每次GC存活,年龄+1
    • 默认年龄阈值是15(可通过-XX:MaxTenuringThreshold调整)
  3. 成年阶段(老年代)

    java
    // 以下两种情况的对象会直接进入老年代
    byte[] hugeArray = new byte[4 * 1024 * 1024];  // 大对象直接进入老年代
    // 或者经过多次GC后,年龄达到阈值的对象
    • 达到年龄阈值的对象
    • 大对象直接进入(可通过-XX:PretenureSizeThreshold设置阈值)
    • Survivor区放不下的对象
  4. 特殊情况

    java
    // 动态年龄判断
    if(当前Survivor区相同年龄对象大小总和 > Survivor区一半) {
        // 大于等于该年龄的对象直接进入老年代
    }
    • 动态年龄判断:Survivor区中相同年龄对象大小总和超过Survivor区一半
    • 空间分配担保:Minor GC前检查老年代最大可用连续空间

💡 优化建议

  1. 对象优先在Eden区分配,避免创建过大对象
  2. 合理设置各个区域大小比例(-XX:SurvivorRatio)
  3. 注意观察对象的生命周期,避免对象过早进入老年代

🎯 核心收获:堆内存是JVM中最大的内存区域,是所有线程共享的内存空间,主要用于存储对象实例。它的核心特点是:

  1. 分代设计(新生代、老年代)提高GC效率
  2. 与虚拟机栈通过reference协作,实现对象访问
  3. 与元空间通过类型指针协作,实现类型相关操作

📚 方法区/元空间:类信息的"图书馆"

🔗 伏笔揭晓:还记得我们在讲虚拟机栈时提到的"动态链接"吗?它连接的另一端就在这里——存储类型信息的区域!

JDK1.8前的方法区(运行时数据区)

🔍 点击探索:元空间中的类信息存储结构 ➡️
java
// 从示例代码中提取的相关部分
public class Student {
    // 静态字段:存储在元空间的类信息中
    private static String schoolName = "清华大学";  // 类变量
    private static int totalStudents = 0;          // 类变量
    
    // 实例字段:字段信息存储在元空间,实际值在堆中
    private String name;   // 实例变量的描述信息
    private int score;     // 实例变量的描述信息
    
    // 方法信息:存储在元空间
    public void updateScore(int newScore) {
        // 方法的字节码指令存储在元空间
    }
    
    public static void printSchoolInfo() {
        // 静态方法的字节码指令也存储在元空间
    }
}

方法区是运行时数据区中的一个线程共享区域,存储类的元数据信息:

  • 类信息(Class Info)
  • 字段信息(Field Info)
  • 方法信息(Method Info)
  • 运行时常量池

元空间的优势:从"运行时数据区"到"本地内存"的飞跃

JDK1.8后,Oracle对JVM内存模型进行了一次重要的改革:运行时数据区中的永久代被本地内存中的元空间(Metaspace)取代

🔑 核心变化:这个改革最关键的变化,就是将方法区的物理实现从JVM的运行时数据区域(永久代)挪到了本地内存(元空间)。这意味着它不再由JVM的堆内存管理,而是直接向操作系统申请内存。

永久代的痛点: 永久代最大的问题是它有固定的大小限制,当应用动态加载大量类时(比如使用Spring、Hibernate等框架),很容易出现 java.lang.OutOfMemoryError: PermGen space。而且永久代的垃圾收集效率很低,经常成为性能瓶颈。

元空间的改进: 元空间最大的优势是使用本地内存而不是运行时数据区的内存,可以根据需要动态扩展,理论上只受限于机器的物理内存。这大大减少了OOM的风险,让开发者不再需要为类元数据的大小而烦恼。

bash
# 设置元空间最大值
-XX:MaxMetaspaceSize=256m

对象访问定位:多内存区域协作的最终体现

让我们通过示例代码来理解对象访问的过程:

java
Student student1 = new Student("张三", 80);
student1.updateScore(85);

当执行这段代码时,涉及三个内存区域的协作:

  1. 虚拟机栈:存储student1这个引用
  2. 堆内存:存储Student对象的实例数据(name="张三", score=80)
  3. 元空间:存储Student类的类型信息、方法代码等

元空间:类元数据的"永久图书馆"

元空间是JDK1.8后用于存储类元数据的区域,它位于本地内存(Native Memory)中,而不是JVM堆内存。

元空间的内存管理

java
// 元空间配置示例
public class MetaspaceConfig {
    public static void main(String[] args) {
        // 查看元空间信息
        System.out.println("初始元空间大小: " + 
            ManagementFactory.getMemoryPoolMXBean("Metaspace").getInit());
        System.out.println("最大元空间大小: " + 
            ManagementFactory.getMemoryPoolMXBean("Metaspace").getMax());
    }
}
  1. 内存分配

    • 直接使用本地内存
    • 默认无上限(受系统内存限制)
    • 可通过参数设置上限:
      bash
      -XX:MetaspaceSize=21m      # 初始大小
      -XX:MaxMetaspaceSize=100m  # 最大大小
  2. 垃圾回收

    • 类卸载时回收
    • 当达到MetaspaceSize阈值时触发GC
    • 比永久代更容易回收类元数据

💡 优化建议

  1. 合理设置初始大小,避免频繁GC
  2. 监控元空间使用情况,防止OOM
  3. 注意动态类加载和卸载的场景

类元数据的具体组成

java
// 一个完整的类元数据示例
public class MetaspaceDemo {
    // 类的基本信息
    private static final String CLASS_VERSION = "1.0";  // 类的版本信息
    
    // 字段信息
    private int id;            // 实例字段
    private static int count;  // 静态字段
    
    // 方法信息
    public void method1() {}   // 实例方法
    public static void method2() {}  // 静态方法
    
    // 接口信息
    interface Inner {}  // 内部接口
    
    // 注解信息
    @Deprecated
    public void oldMethod() {}
}

元空间中存储的类元数据详解

  1. 类的基本信息

    • 类的全限定名
    • 类的访问修饰符
    • 类的版本信息
    • 父类和接口信息
    • 类加载器引用
  2. 字段信息(Field Metadata)

    java
    // 字段元数据示例
    class FieldMetadata {
        private int value;      // 字段名、类型、修饰符
        static final int MAX;   // 静态字段、常量信息
        volatile double price;  // 特殊标记(volatile等)
    }
    • 字段名称
    • 字段类型
    • 访问修饰符
    • 特殊标记(volatile、transient等)
  3. 方法信息(Method Metadata)

    java
    // 方法元数据示例
    class MethodMetadata {
        public synchronized void process() {  // 方法签名、修饰符
            int local = 0;                    // 局部变量表信息
            try {                             // 异常表信息
                // 方法字节码
            } catch (Exception e) {}
        }
    }
    • 方法名称
    • 方法签名
    • 访问修饰符
    • 字节码指令
    • 局部变量表
    • 异常表
    • 其他特殊属性
  4. 常量池信息

    java
    // 常量池示例
    class ConstantPoolInfo {
        static final String COMPANY = "TechCorp";  // 字符串常量
        static final int MAX_USERS = 100;          // 数值常量
        static final Class<?> TYPE = String.class; // 类引用常量
    }
    • 字符串常量
    • 数值常量
    • 类引用常量
    • 字段引用
    • 方法引用
  5. 其他元数据

    • 注解信息
    • 内部类信息
    • 泛型信息
    • 方法参数名称(如果开启-parameters编译选项)

💡 优化建议

  1. 合理设置初始大小,避免频繁GC
  2. 监控元空间使用情况,防止OOM
  3. 注意动态类加载和卸载的场景

🎯 核心收获:元空间是JDK 1.8后方法区的实现,它是一个本地内存区域,存储类的元数据信息。其核心特点是:

  1. 使用本地内存,可动态扩展,降低OOM风险
  2. 存储内容包括:类信息、方法信息、运行时常量池
  3. 通过类型指针与堆内存中的对象实例建立联系

🏊 运行时常量池:动态的"资源库"

运行时常量池存储编译期的各种字面量符号引用

字面量(Literal)

java
int number = 42;           // 整数字面量
double pi = 3.14159;       // 浮点数字面量  
String msg = "Hello";      // 字符串字面量

符号引用(Symbolic Reference)

  • 类符号引用:java/lang/String
  • 字段符号引用:System.out
  • 方法符号引用:println(Ljava/lang/String;)V

🎯 字符串常量池:String的"专属仓库"

设计初衷

为了避免重复创建相同的字符串,JVM专门开辟了字符串常量池。

java
String s1 = "hello";        // 放入常量池
String s2 = "hello";        // 复用常量池中的对象
System.out.println(s1 == s2); // true

位置变迁

JDK1.7之前:位于方法区 JDK1.7之后:迁移到堆内存

为什么要迁移? 方法区的GC频率很低,只有在Full GC时才会回收,导致字符串常量池内存浪费严重。


⚡ 直接内存:NIO的"高速通道"

直接内存不属于JVM运行时数据区,但在NIO操作中频繁使用:

java
// DirectByteBuffer 使用直接内存
ByteBuffer buffer = ByteBuffer.allocateDirect(1024);

优势

  • 避免Java堆和Native堆之间的数据复制
  • 提高IO操作性能

风险

  • 不受JVM内存管理控制
  • 可能导致OutOfMemoryError

🎭 HotSpot中的对象生命周期

对象创建的五个阶段:多内存区域的精密协作

让我们通过示例中的这行代码来详细了解对象创建过程:

java
Student student1 = new Student("张三", 80);

当JVM遇到这个new指令时,这是一个涉及多个内存区域协作的复杂过程:检查 → 分配 → 初始化 → 设置 → 调用

🎯 五个核心阶段的内存区域协作

🎬 第一幕:类加载检查
┌─────────────────────────┐
│    [线程私有:虚拟机栈]    │     
│     遇到new Student     │              
└─────────────────────────┘              

┌─────────────────────────┐                        
│   [本地内存:元空间]     │                        
│   检查Student类是否已加载│  
└─────────────────────────┘

🎬 第二幕:内存分配
┌─────────────────────────┐
│   [线程共享:堆内存]     │
│   为Student对象分配内存  │
│   包括name和score字段    │
└─────────────────────────┘

🎬 第三幕:初始化零值
┌─────────────────────────┐
│   [线程共享:堆内存]     │
│   name = null          │
│   score = 0           │
└─────────────────────────┘

🎬 第四幕:设置对象头
┌─────────────────────────┐
│   [线程共享:堆内存]     │
│   设置对象的类型指针     │
│   指向Student类元数据    │
└─────────────────────────┘

🎬 第五幕:执行初始化
┌─────────────────────────┐
│   [线程共享:堆内存]     │
│   执行构造器方法        │
│   name = "张三"        │
│   score = 80          │
└─────────────────────────┘

📋 分配策略的技术细节

在第二幕"内存分配"中,堆内存会根据垃圾收集器选择不同的分配策略:

指针碰撞法(整齐的内存环境):

[已使用内存|      空闲内存      ]

       指针直接移动

空闲列表法(碎片化的内存环境):

维护一个记录可用内存块的清单
从清单中挑选合适大小的空间

对象的内存布局

以我们的Student对象为例:

📦 Student对象在内存中的布局
├── 🏷️ 对象头 (Object Header)
│   ├── 📊 标记字段 (Mark Word)
│   │   ├── 哈希码
│   │   ├── GC年龄
│   │   └── 锁状态
│   └── 🎯 类型指针 (Class Pointer)
│       └── 指向元空间中的Student类元数据
├── 💾 实例数据 (Instance Data)
│   ├── String name = "张三"  // 引用类型,存储的是指向字符串常量池中"张三"的引用
│   └── int score = 80      // 基本类型,直接存储值
└── 🎯 对齐填充 (Padding)
    └── 确保对象大小是8字节的倍数

对象访问定位:多内存区域协作的最终体现

🔄 知识串联:现在让我们通过示例代码来看看这些内存区域是如何协作完成对象访问的。

为什么要把类型数据和对象实例分开存储?

分离存储的原因:

  1. 内存优化:多个同类型对象可以共享同一份类型数据(方法信息、字段定义等)
  2. 动态性支持:支持多态、反射等特性,需要在运行时查找类型信息
  3. GC友好:对象实例频繁创建销毁,类型数据相对稳定

方式一:句柄访问 🎟️

句柄访问就像"电影票系统":你手里的票(reference,也就是栈中的对象引用)指向售票处(句柄池),售票处告诉你具体的座位号(对象实例)和电影信息(类型数据)。

[局部变量表] → [句柄池] → [对象实例]

              [类型数据]

句柄池中的每个句柄包含:

  • 对象实例数据的指针
  • 对象类型数据的指针

优势

  • reference稳定:对象在GC时移动,只需修改句柄池中的指针,栈中的reference(对象引用)保持不变
  • 间接访问优势:局部变量表中的reference永远不变,即使堆中的对象被GC移动了位置

劣势

  • 性能开销:每次访问需要两次指针跳转
  • 内存开销:需要额外的句柄池空间

方式二:直接指针访问 🎯

让我们通过示例代码来理解直接指针访问:

java
// 在main方法中:
Student student1 = new Student("张三", 80);
student1.updateScore(85);

这段代码的执行涉及三个内存区域的协作:

  1. 线程私有的虚拟机栈

    • 存储student1这个引用变量
    • 存储updateScore方法调用的参数85
  2. 线程共享的堆内存

    • 存储Student对象的实例数据
    • name字段(存储指向"张三"的引用)
    • score字段(直接存储值80,后来更新为85)
  3. 本地内存的元空间

    • 存储Student类的完整定义
    • 存储updateScore方法的字节码
    • 存储静态字段schoolName和totalStudents的定义

优势

  • 访问速度快:只需一次指针跳转,栈中的reference直接指向堆中的对象
  • 节省空间:不需要额外的句柄池

💡 HotSpot的选择:HotSpot虚拟机选择了直接指针访问方式。这是一个典型的空间换时间的权衡,因为在Java中对象访问操作十分频繁,因此这种设计对于主流应用来说是更合适的。


Java内存模型(JMM):多线程的“一致性守则”

在深入探索了JVM的运行时数据区域(如堆、栈、元空间)之后,我们还需要理解一个更深层次的概念:Java内存模型(Java Memory Model,JMM)。如果说JVM内存模型描述了内存的“物理布局”,那么JMM则定义了在多线程环境下,线程如何通过内存进行交互,以及这些交互需要遵循哪些“一致性守则”,以确保并发程序的正确性。

JMM 旨在解决处理器高速缓存与主内存速度不匹配的问题,以及为了提高性能而导致的编译器和处理器指令重排序问题。它规范了所有变量的访问方式,定义了程序中对变量的读写操作在内存中产生的影响。

核心三要素:可见性、原子性、有序性

JMM 的核心围绕着并发编程中必须保障的三个特性:

  1. 🌟 可见性(Visibility) 当一个线程修改了共享变量的值,其他线程能够立即得知这个修改。这就像你更新了公司的共享文档,所有同事都能立即看到最新版本。

    • 问题来源:每个线程有自己的工作内存(或称本地内存,并非JVM运行时数据区的本地方法栈,而是对高速缓存的抽象),共享变量的副本可能存在于工作内存中。一个线程修改了工作内存中的变量,如果不同步回主内存,其他线程可能读到的是旧值。
    • JMM保障:JMM通过volatilesynchronizedfinal关键字,以及Lock接口等机制来保障可见性。
    java
    // 可见性示例:使用volatile
    public class VisibilityDemo {
        private volatile boolean ready = false;
        private int number;
    
        public void writer() {
            number = 42;
            ready = true; // 修改ready,确保number的写入对其他线程可见
        }
    
        public void reader() {
            while (!ready) {
                // 等待ready变为true
            }
            System.out.println(number); // 此时number的值能被正确读取到
        }
    }
  2. 🔒 原子性(Atomicity) 一个或多个操作,要么全部执行成功,要么全部不执行,中间不会被任何因素(比如线程切换、中断)打断。这就像银行转账,转出和转入必须是同时完成的,不能只转出没转入。

    • 问题来源:即使是看似简单的i++操作,在底层也可能被拆分为“读取i”、“i加1”、“写入i”三个独立的CPU指令,这在多线程环境下是非原子性的,可能导致数据不一致。
    • JMM保障:JMM通过synchronized关键字和Lock接口来保障原子性,确保代码块或方法在同一时间只能被一个线程执行。对于单个变量的读写,JMM也提供了基本的原子性保障(long和double的非原子性读写在最新的JVM实现中也已基本解决)。
    java
    // 原子性示例:使用synchronized
    public class AtomicityDemo {
        private int count = 0;
    
        public synchronized void increment() {
            count++; // 原子操作,多个线程调用时不会出现错乱
        }
    
        public int getCount() {
            return count;
        }
    }
  3. 🔄 有序性(Ordering) 程序执行的顺序,不应受到编译器优化和处理器重排序的影响,即在单线程内表现为串行语义,但在多线程间,可能发生指令重排序。

    • 问题来源:为了提高执行效率,编译器和处理器可能会对代码指令进行重排序,即不严格按照代码的书写顺序执行。在单线程下这通常没有问题,因为它们会保证重排序后的结果与原代码一致(as-if-serial语义)。但在多线程并发时,重排序可能导致意想不到的结果。
    • JMM保障:JMM通过volatile关键字(禁止特定重排序)、synchronized关键字(保证临界区内的代码有序)、final关键字(保证对象构造的有序性),以及内存屏障(Memory Barrier)来保障有序性。
    java
    // 有序性示例:指令重排序风险
    public class ReorderingDemo {
        int a = 0;
        boolean flag = false;
    
        public void writer() {
            a = 1;      // 1. 写操作
            flag = true; // 2. 写操作
        }
    
        public void reader() {
            while (!flag) {
                // 等待
            }
            // 假设指令重排序发生,2可能在1之前执行
            // 此时a可能仍然是0,导致逻辑错误
            System.out.println(a);
        }
    }
    // 为了避免上述问题,可以使用volatile修饰flag
    // private volatile boolean flag = false;

主内存与工作内存的交互

JMM 定义了线程与主内存之间的抽象关系:

  • 主内存(Main Memory):所有共享变量都存储在主内存中,类似于JVM堆内存中的实例数据。
  • 工作内存(Working Memory):每条线程都有自己的工作内存,其中保存了该线程使用到的变量在主内存的副本。线程对变量的所有操作(读取、赋值等)都必须在工作内存中进行,而不能直接读写主内存中的变量。

JMM 规定了八种操作来完成主内存和工作内存之间的交互,例如read(读取)、load(载入)、use(使用)、assign(赋值)、store(存储)、write(写入)、lock(锁定)、unlock(解锁)。这些操作都是原子性的,但实际应用中我们通过volatilesynchronized等更高层次的关键字来使用它们。

Happens-Before 原则:JMM的“裁判规则”

除了三大特性,JMM 通过 Happens-Before 原则 来定义并发操作之间的偏序关系。如果一个操作 Happens-Before 另一个操作,那么前一个操作的结果对后一个操作是可见的,并且前一个操作的执行顺序在后一个操作之前。这是JMM判断数据可见性和有序性最核心的规则。

常见的Happens-Before规则包括:

  1. 程序次序规则:在一个线程内,按照程序的代码顺序,前面的操作Happens-Before后面的操作。
  2. 管程锁定规则(Monitor Lock Rule):一个unlock操作Happens-Before后续对同一个锁的lock操作。
  3. volatile变量规则:对一个volatile变量的写操作Happens-Before后续对这个volatile变量的读操作。
  4. 线程启动规则Thread.start()Happens-Before启动线程中的任何操作。
  5. 线程终止规则:线程中所有操作Happens-Before其他线程检测到这个线程已经终止。
  6. 线程中断规则:对Thread.interrupt()的调用Happens-Before被中断线程检测到中断事件。
  7. 对象终结规则:一个对象的初始化完成Happens-Before它的finalize()方法的开始。
  8. 传递性:如果A Happens-Before B,且B Happens-Before C,那么A Happens-Before C。

🎯 核心收获:Java内存模型(JMM)是并发编程的基石,它定义了多线程环境下共享变量的访问规则,核心在于保障并发操作的可见性、原子性和有序性。通过理解主内存与工作内存的交互,以及Happens-Before原则,可以更好地编写出正确、高效的并发程序,并能够分析并发场景下的潜在问题。


JVM垃圾回收全景解析:从城市管理看内存自动化之道

作为Java开发者,我们享受着自动化内存管理的便利,无需像C++程序员那样手动申请和释放每一寸内存。这份便利的背后,是JVM(Java虚拟机)中一套精密、复杂且不断进化的垃圾回收(Garbage Collection, GC)系统。

要真正理解这套系统,我们不妨将JVM的内存世界想象成一座巨大的现代化都市。本文将以这个贯穿始终的比喻,带你从城市规划、市民管理、垃圾清理策略,一路探索到G1、ZGC等顶尖"城市管理公司"的内部运作奥秘。

城市基础建设——内存布局与公民身份

在深入GC的自动化系统前,我们必须先了解其管辖区——JVM堆内存——的城市规划,以及这座城市的市民——Java对象——的户籍管理制度。

城市规划:高效的分代管理模型

想象一下,如果对一座城市的所有市民(对象)进行无差别的人口普查(垃圾回收),每次都需全城动员,其效率之低可想而知。因此,现代城市管理者(JVM)借鉴了人口学统计规律,做出了一项关键观察:绝大多数市民(Java对象)都是"短期居民",生命周期极短,而少数市民会长久居住。

基于这一"分代假说",城市被精心划分为不同功能的区域,以实现差异化的高效管理。

城市管理类比JVM对应概念职责说明
繁华市区 (Downtown)新生代 (Young Gen)绝大多数市民(新对象)的出生地和主要活动区。这里人口流动性极高,因此人口普查(Minor GC)也最为频繁。
高档郊区 (Suburb)老年代 (Old Gen)经过多次考验、长期居住的"荣誉市民"(长寿对象)的定居区。这里居民稳定,普查(Major GC / Full GC)频率远低于市区。
城市档案馆 (Archive)元空间 (Metaspace)独立于市民居住区(堆内存),专门存放城市的设计蓝图(类信息)、法规法典(常量)等核心数据。

其核心设计哲学,一言以蔽之,就是区别对待,把宝贵的管理资源投入到最需要的地方:对流动性高的新人区勤加检查,对安逸稳定的养老区则减少打扰。

市民们遵循着一套晋升机制:一个新市民在"市区"内每经历一次人口普查并幸存,其"城市贡献值"(年龄)就会增加。当贡献值达到一个阈值(通常由 -XX:MaxTenuringThreshold=15 控制)后,他便能获批迁入宁静的"郊区"安享晚年。

此外,市政厅为城市规划了两种高效的市民入驻(对象分配)优化策略:

  1. VIP通道(大对象直接入驻):市政厅为那些体型庞大的"巨无霸"市民(大对象)开设了VIP通道。任何尺寸超过特定标准(由 -XX:PretenureSizeThreshold 定义)的对象,都会被直接安置到"郊区"的独栋别墅,以避免其在拥挤的"市区"内反复搬家(复制),影响整体市政效率。
  2. 专属接待区(TLAB):为了避免新市民(小对象)在"繁华市区"(Eden)办理出生登记时发生拥堵(多线程竞争锁),市政厅非常贴心地为每个接待窗口(线程)都预留了一小块专属登记区(Thread Local Allocation Buffer, TLAB),新市民可以快速在此完成登记,大大提高了城市的人口吸纳效率。

户籍管理:如何识别"失联市民"?

城市管理者需要一套精准的方案来识别哪些是已经与社会网络脱节、需要被清理回收资源的"失联市民"(可回收对象)。

历史上曾出现过一种看似简单的**"邻里关系统计法"(引用计数法)**。然而,这种方法存在一个致命缺陷:它无法处理"社交小圈子"的问题(循环引用),最终导致市政资源(内存)的永久性浪费。

因此,现代JVM普遍采用的是更为睿智的**"市政厅关系网排查法"(可达性分析算法)**。此算法的逻辑是:顺藤摸瓜,从"根"上断了联系的,就是孤魂野鬼。

市政厅(JVM)会从一系列直属的核心部门(GC Roots)出发,沿着所有市民的社会关系网(引用链)进行深度遍历。所有能被触及到的,都是"活跃市民"。反之,任何无法从GC Roots这条线索追溯到的市民,都将被判定为"失联"。

那么,哪些是"市政厅"的直属核心部门(GC Roots)呢?它们包括:

  • 各级办公室的在职人员 (虚拟机栈/本地方法栈中引用的对象)
  • 档案馆的公共财产 (方法区中类静态属性、常量引用的对象)
  • 持有"特殊通行令牌"的人 (被synchronized锁持有的对象)

终极关怀: 一个对象被可达性分析判定为"失联"后,并非立即被执行"死刑"。它还有一次"缓刑"机会。如果该对象重写了finalize()方法且尚未被执行过,它会被放入一个待处理队列。若在此期间它能奇迹般地重新与GC Roots建立联系,就能"复活",否则将被真正回收。

社会关系强度:引用的四种形态

一个市民与"市政厅"的关系强度,直接决定了其在人口普查中的命运。这种关系,在Java中体现为四种引用类型:

关系强度技术术语市民身份类比被清理策略
永久居留强引用 (Strong)城市的奠基人或核心支柱。只要关系存在,绝不回收。
长期居住证软引用 (Soft)城市需要的高级人才。内存紧张时,才考虑回收。
临时通行证弱引用 (Weak)来去自由的游客。只要人口普查,无论内存是否紧张,一律回收。
访问预约虚引用 (Phantom)特殊的"观察对象"。不决定生死,仅用于在该市民被清理前收到一个系统通知。

清理的艺术——三大垃圾清理策略

确定了需要清理的"失联市民"后,市政厅(JVM)需要选择具体的清理策略来回收他们占用的土地(内存)。

  1. [策略一] 就地拆除 (标记-清除 / Mark-Sweep): 直接标记、直接清除,但会产生大量内存碎片。
  2. [策略二] 新城迁移 (标记-复制 / Mark-Copy): 将存活对象复制到新区域,无碎片但浪费一半空间。
  3. [策略三] 社区重整 (标记-整理 / Mark-Compact): 将存活对象向一端移动,无碎片、不浪费空间但性能开销大。

集大成者:分区治理的智慧 (分代收集 / Generational Collection) 现代JVM的智慧结晶,一套基于上述基础策略的组合拳

  • 对于繁华市区(新生代):市民流动极快,存活者少。采用**"新城迁移"(复制算法)**最高效。
  • 对于高档郊区(老年代):市民非常稳定,存活者多。采用**"社区重整"(标记-整理)"就地拆除"(标记-清除)**更合适。

并发时代的难题:三色标记的危险游戏

上面的策略都离不开"标记"这一步。如果"标记"时,整个城市(JVM)能按下"暂停键"(STW),让所有市民(对象)原地不动,那事情很简单。但对于追求极致响应速度的现代应用来说,长时间的"全城静止"是不可接受的。因此,"标记"工作必须和市民的日常活动(用户线程)并行执行。

这就好比市政部门想在市民们正常上下班、社交、搬家的同时,完成一次精准的人口普查。为了应对这种动态变化的挑战,普查员们(GC线程)普遍采用了一种名为"三色标记法"的工作流程,将所有市民在普查过程中分为三种状态:

  • ⚪️ 白色 (White): "未访问"市民。普查的初始阶段,所有市民都是白色的。在普查结束时,仍然保持白色的市民,将被认定为"失联市民",需要被清理。
  • 灰色 (Grey): "待处理"市民。表示普查员已经找到了这位市民,并把他加入了待办清单,但还没来得及走访他的所有社交关系(还没扫描完他的所有引用)。
  • ⚫️ 黑色 (Black): "已处理"市民。表示普查员不仅找到了这位市民,还把他所有的直接社会关系(直接引用的对象)都走访了一遍。一个市民一旦变成黑色,本轮普查中就不会再被重新访问。

普查工作流程就是:从"市政厅直属部门"(GC Roots)出发,找到第一批市民,将他们从白色变为灰色。然后,不断从灰色市民中挑选一个,访问他的所有邻居(引用的对象),将邻居们从白色变为灰色,然后将自己变为黑色。当再也找不到灰色市民时,普查就结束了。

这个流程在"全城静止"时完美无缺。但在并发执行时,它隐藏着一个致命的缺陷——"市民凭空消失",技术上称为"对象漏标"(Floating Garbage是小事,漏标是致命bug)。

这个bug的触发条件非常苛刻,必须同时满足以下两个动作:

  1. 动作一:一位"已处理"的黑色市民,与一位"未访问"的白色市民建立了新的联系。

    • 比喻: 普查员刚走访完张三(黑色),张三立刻收养了一个孩子李四(白色)。因为普查员不会再回头访问张三,所以他永远不会知道李四的存在。
  2. 动作二:与此同时,唯一知道那个白色市民存在的"待处理"灰色市民,断绝了和他的联系。

    • 比喻: 在张三收养李四的同时,唯一认识李四的王五(灰色,普查员正准备去他家),也和李四断绝了关系。

这两个条件并发,灾难便发生了:李四(白色对象)与整个"灰色"待办清单的联系被切断,而新的监护人张三(黑色对象)又已经普查完毕。最终,无辜的李四会被当成"失联孤儿"错误地清理掉,这会导致系统崩溃。

因此,所有现代并发垃圾收集器的核心设计之一,就是必须破坏上述两个条件中的至少一个,来防止"漏标"惨剧的发生。

接下来我们将看到的G1和ZGC,它们那些看似复杂的机制,如写屏障、SATB、读屏障、染色指针,本质上都是为了在这场危险的并发游戏中,优雅地解决这个核心矛盾而设计的精妙对策。

城市管理的多种模式——解读GC事件类型

在深入了解各大"城市管理公司"(垃圾收集器)之前,我们必须先弄清楚它们执行任务时的不同模式,即GC的事件类型。

  • 日常市区巡查 (Minor GC / Young GC) 这是一种高频但影响小的例行检查,专门针对"繁华市区"(新生代)。由于新生代对象大多"来去匆匆",Minor GC的效率非常高,停顿时间极短。所有收集器都会频繁执行此操作。

  • 全城大扫除 (Major GC / Full GC) 这是一项重量级的、波及全城的深度清理行动,堪称"全城戒严"。它会清理整个城市,包括"繁华市区"(新生代)、"高档郊区"(老年代),甚至"城市档案馆"(元空间)。由于涉及范围广、对象多,其导致的停顿时间(Stop-The-World, STW,即JVM为了执行GC而暂停所有用户线程的执行)通常很长,是性能优化的重点关注对象。传统收集器(如Serial Old, Parallel Old, CMS)在特定条件下会触发这种全局暂停。

  • G1的精准打击 (Mixed GC) 这是G1收集器独有的一种智能化清理模式。它并非全城动员,而是采取"精准打击"策略。在执行常规的"市区巡查"(Young GC)的同时,它会**评估并选择一部分垃圾最多的"郊区"(老年代Region)**也纳入清理范围。它聪明地在"完全不理会郊区"和"对整个郊区大动干戈"之间找到了一个平衡点,旨在用可控的、可预测的短暂停顿,逐步回收老年代的垃圾。Mixed GC不是Full GC,是G1实现低延迟目标的关键武器。

伟大的工匠——七大垃圾收集器的对决

如果说清理策略是宏伟的蓝图,那么垃圾收集器就是真正手持工具、实现了这些蓝图的"建筑公司"。

施工理念核心代表技术选型(新生代/老年代)GC模式用户画像
单线程作业Serial / Serial Old复制 / 整理Minor GC, Full GC个人电脑,小型应用。
吞吐量优先Parallel Scavenge / Parallel Old复制 / 整理Minor GC, Full GC后台计算、数据分析。(JDK 8默认组合)
低延迟优先CMS复制 / 标记-清除Minor GC, Concurrent Mark, Full GC (作为后备)对响应时间有较高要求的Web服务。
全能平衡手G1 (Garbage-First)整体基于复制+整理Young GC, Mixed GC, Full GC (作为后备)大内存(4G以上)通用服务器。
极致低延迟ZGC整体基于颜色指针+读屏障Concurrent GC对停顿极度敏感的系统。

G1收集器:可预测停顿的精密城市规划学

G1(Garbage-First)彻底颠覆了传统分代模型中新生代、老年代物理连续的布局,其所有精妙设计都围绕着一个核心概念展开:Region化网格布局

它要解决的核心痛点是: 传统分代模型中,一旦老年代需要清理,动辄就是一次波及整个区域的大规模、长时间的Full GC,导致的停顿时间(Stop-The-World, STW)难以预测和控制。

G1的解决方案是一套复杂的协同系统:

  1. 网格化管理 (Region): G1将整个城市(Java堆)划分为约2048个大小均等的标准网格(Region)。每个网格都可以动态地扮演市区(Eden)、幸存区(Survivor)或郊区(Old)的角色。

  2. 垃圾雷达系统 (Remembered Set, RSet): G1为每个网格都配备了一个高科技雷达(RSet)。这个雷达精确地记录着"有哪些外部网格的市民正引用着我这个网格内的市民"。具体来说,当城市中的一位市民A要与另一位B市民建立新的社会关系时(即程序执行 a.field = b),如果A和B分属不同网格(Region),'户籍警'(写屏障)会立刻介入,将这条新的'跨区关系'记录到B市民所在网格的'雷达'(RSet)上。 这使得G1在清理部分网格时,无需扫描全城,只需查询这几个网格的RSet即可。

  3. 并发快照技术 (SATB - Snapshot-At-The-Beginning): 为了在并发标记时,不被市民们瞬息万变的社会关系(引用修改)所困扰,G1在标记开始时,先用一种技术给全城所有活跃市民拍一张"集体照"(Snapshot),后续的标记都基于这张快照进行。

G1的施工流程:一场由Young GC和Mixed GC交替上演的连续剧 第一幕:市区清理 (Young GC - STW) 当"市区"(Eden Regions)住满后,G1会短暂地暂停城市运作,快速完成新生代的回收。

第二幕:混合清理 (Mixed GC - 多阶段大戏) 当整座城市的居住率达到某个阈值(由 -XX:InitiatingHeapOccupancyPercent 控制)时,G1将启动一轮包含"郊区"(Old Regions)在内的混合清理。这场大戏,就如同一次精密的城市改造工程,分为以下几个阶段:

初始标记 (Initial Mark - STW) 工程的奠基仪式,借着一次常规"市区清理"的短暂暂停(STW)同步举行。市政厅(JVM)抓住这个瞬间,给全城所有活跃市民(对象)拍下了一张"集体大合照"。这张"大合照"在技术上由**SATB(Snapshot-At-The-Beginning)**机制实现,它通过设立两个内存地址指针——prevTAMS和nextTAMS——如同在地图上画下边界,将当前这一刻的内存状态定格为一个基准快照,后续的普查都将以此为准。

根分区扫描 (Root Region Scan) 奠基仪式结束,城市立即恢复运作。普查员(GC线程)的首要任务,是快速扫描刚刚在"市区清理"中幸存下来的市民(Survivor区),找出他们之中有哪些人与"郊区"的市民(Old区)保持着联系。这个无需暂停的过程,正是为了精确处理跨代引用,确保后续普查不会遗漏任何重要的社会关系。

并发标记 (Concurrent Mark)

接下来是最漫长的"人口普查"阶段,普查员(GC线程)与市民的日常活动(应用线程)并行展开。这立刻带来了一个根本性的挑战:如何在一座人际关系(对象引用)瞬息万变的城市里,得到一份绝对准确的人口关系图?

这好比普查员刚确认市民A和市民B是邻居(A引用B),转身还没走远,市民A就和B绝交了,并立刻认识了新朋友C(A不再引用B,转而引用C)。如果普查员没能及时发现这个变化,而市民C又是一个"社交新人",之前没有任何线索能追踪到他,那么市民C就可能被错误地当成"失联市民"处理掉。这就是并发标记中最经典的"对象丢失"(漏标)问题。

为了破解这个难题,G1的"城市管理者"采取了一种名为**"原始快照"(SATB - Snapshot-At-The-Beginning)**的智慧策略。它并不试图去追赶市民们每一个微小的关系变化,而是在"初始标记"那一刻,就基于那张"集体大合照"定下了一个原则:凡是在拍照那一刻还是活跃的市民,在本次普查中,我都默认你继续活跃。

那么,如何执行这个原则呢?G1派出了无处不在的"户籍警"(写屏障 / Write Barrier)。在并发标记期间,每当一位"已被普查过的市民"(黑色对象)要和一位"快照上的老朋友"(白色对象)断绝关系时,"户籍警"会立刻介入,不是去阻止,而是将这位"老朋友"的信息悄悄记录到一个特殊的"待处理档案"(satb_mark_queue)中。

这样一来,G1就优雅地解决了问题:它不关心城市后续发生了多少变化,只需保证所有在"快照"上出现过的市民,要么被正常扫描到,要么被"户籍警"救下来。这种做法虽然可能让一些在并发期间本应"失联"的市民(浮动垃圾)多活一轮,但它以极小的代价,确保了没有任何一个活跃市民会被错杀。

最终标记 (Remark - STW)

在并发普查基本完成后,全城需要再次短暂暂停。这好比一次"最终核对"。市政厅(JVM)需要让所有普查员(GC线程)把自己手头的"待处理档案"(线程私有的satb_mark_queue)全部汇总并处理干净。这个过程必须暂停城市活动,以确保在最终确认"失联名单"前,不会再有任何新的关系变化干扰统计,保证了人口普查的最终准确性。

筛选与清理 (Cleanup & Evacuation - STW) 这是G1**"Garbage-First"理念的精髓时刻。手握精确"垃圾地图"的市政规划部门,会根据用户设定的最大停顿时间目标(-XX:MaxGCPauseMillis),像一位精明的投资者一样,贪婪地从"郊区"中挑选出最有回收价值的一批网格(Region),与所有"市区"网格共同组成一个回收集合(Collection Set, CSet)**。随后,在一次最终的STW中,G1会发动多线程"搬家公司",将CSet中所有存活的市民,高效地迁移到全新的、干净的网格中,然后将旧网格一次性推平。

G1通过Mixed GC,努力避免进行昂贵的Full GC,从而实现了对停顿时间的有效控制。


ZGC:追求极致低延迟的并发艺术

ZGC(Z Garbage Collector)是未来城市的终极形态。它的目标是将所有停顿时间压缩到亚毫秒级,让GC停顿对应用来说几乎无感知。

它要解决的核心痛点是: 即便是G1,在最终迁移市民(Evacuation)时,依然需要短暂地"暂停城市运作"(STW)。对于延迟极其敏感的应用,这种停顿仍然是不可接受的。

ZGC的秘诀在于将几乎所有繁重的工作都并发化,其核心是两大革命性技术:

  1. 时空信标 (染色指针Colored Pointers): ZGC的城市管理者拥有一种"黑科技"。他们不满足于在市民的档案(对象头)上做标记,而是直接给每位市民的**家庭住址本身(64位指针)**涂上了特殊的"颜色"。这些颜色(如Marked0, Marked1, Remapped)直接表明了该地址所对应市民的GC状态。这好比每个地址门牌号自身就包含了"此户已普查"、"此户待搬迁"等信息,效率极高。

  2. 全城广播系统 (读屏障Read Barrier): ZGC在城市的每个路口(所有从堆中读取对象引用的地方)都安装了一个由JIT编译器植入的、几乎无开销的"广播喇叭"(读屏障)。当有市民(应用线程)试图根据地址拜访他人时,广播会立刻检查地址门牌的"颜色",并根据颜色触发相应的动作。

ZGC的施工流程:一场围绕"时空信标"和"全城广播"的高度并发之舞

ZZGC的施工流程:一场围绕"时空信标"和"全城广播"的高度并发之舞 ZGC的GC循环是一场精妙绝伦的、多阶段并发的舞蹈,几乎将所有STW压缩到了极致。

初始标记 (Initial Mark - STW) 舞蹈以一次几乎无法感知的瞬间暂停开始。市政厅(GC线程)仅标记与直属部门(GC Roots)直接关联的核心市民。其核心技术动作是切换全局视图的颜色标准(good_mask),比如从"未勘探色(Remapped)"切换到"一期勘探色(Marked1)",并立即将那些核心市民的"家庭住址"(指针)染上这个新颜色。从此,地址颜色符合good_mask的市民,就被认为是活跃的。

并发标记 (Concurrent Mark)

城市恢复喧嚣,一场盛大的"涂色行动"随之展开。ZGC同样面临着G1那个"在变化的城市里搞普查"的难题,但它采用了更为激进和巧妙的实时策略。

如果说G1的SATB像是一位依赖"期初快照"和"事后补录"的严谨历史学家,那么ZGC的"全城广播系统"(读屏障 / Read Barrier)就是一位能够"掌控当下"的未来派大师。

这场"涂色行动"由GC普查员和市民的"全城广播"协同完成。当任何一个市民(应用线程)试图根据他手中的"地图"(指针)去拜访另一位市民时,都必须经过路口的"广播站"(读屏障)。广播站会立刻检查这张地图上目的地地址的"门牌颜色":

  • 如果颜色是旧的"未勘探色"(说明这张地图过时了,指向的对象还未被本轮GC扫描),广播站会当场为这张地图上的地址涂上新的"勘探中"颜色,并顺便将这个新发现的对象加入待办列表,然后再予以放行。

这个机制从根源上解决了"对象丢失"问题。任何一个黑色对象(已扫描)想指向一个白色对象(未扫描),只要应用线程去"读取"这个白色对象,就会被读屏障捕获并正确染色。它不再需要一个独立的队列来记录谁和谁"分手"了,而是通过"访问即标记"的方式,实时地维护着关系网的正确性。

重新标记 (Remark - STW) 又一次短暂的暂停,如同乐队的一个休止符。用于处理并发标记期间的各种"疑难杂症"和竞态条件,确保"涂色"工作完美收官。

并发预备重分配 (Concurrent Prepare for Relocate) 城市依旧运转。市政规划部门根据"涂色"结果,并发地筛选出垃圾成灾的街区(Page),将它们圈入重分配集(Relocation Set)。同时,为这个集合中的每个街区都提前准备好一个空的"新址问询处",也就是转发表(Forwarding Table),为即将到来的大迁徙做好准备。

初始迁移 (Relocate Start - STW) 第三次,也是最后一次微乎其微的暂停。这是一次精准的"斩首行动",市政厅将所有居住在"待改造区"、且被核心部门(GC Roots)直接联络的市民,瞬间完成迁移,并立即在核心部门的档案中更新其新地址,同时将新旧地址的映射关系填入"新址问询处"(转发表)。这一步确保了城市的核心运作在改造期间绝不迷路。

并发迁移 (Concurrent Relocate)

这是ZGC的封神时刻,也是它与G1停顿模型拉开决定性差距的地方。G1在最后的迁移阶段仍需STW,而ZGC在这里也实现了并发。

当"搬家公司"(GC线程)在后台将市民从"待改造区"迁往新区时,市民们(应用线程)的日常活动完全不受影响。这怎么可能做到呢?秘密依然在那个无处不在的"全城广播系统"(读屏障)和为改造而设的"新址问询处"(转发表 / Forwarding Table)上。

在并发迁移期间:

  1. 后台:"搬家公司"将市民从旧地址A搬到新地址B,并在"问询处"记下:A -> B
  2. 前台:此时,若有市民拿着指向旧地址A的"老地图"想去拜访,路口的"广播"会再次发挥神力。它会先去"问询处"查询,发现A已经搬到了B。于是,它会做两件事:
    • 重定向:引导来访市民直接前往新地址B。
    • 地图自愈:当场修改这位市民手中的"老地图",将地址A永久更新为B。

通过这种方式,GC的搬家工作(生产者)和市民的正常访问(消费者/修复者)围绕着转发表和读屏障,达成了一次完美的实时协同。旧世界的引用被逐渐、动态地、在每一次访问中"自愈"为新世界的引用。整个城市改造的核心环节,都在市民的无感知中悄然完成了。

最终,GC的搬家工作(生产者)和市民的正常访问(消费者/修复者)围绕着转发表和读屏障,达成了一次完美的协同。整个城市改造过程,除了三次几乎无法感知的"瞬间指令",全程都在与市民的日常活动并行,实现了惊人的亚毫秒级低延迟。

🚚 JVM类加载机制:从蓝图到实体的"建筑与物流"体系

在之前的章节中,我们把JVM想象成了一座城市,讨论了它的内存规划和垃圾清理系统。但是,这座城市里的建筑(对象)和市民(类)最初是从何而来的呢?它们的设计蓝图(.class文件)又是如何被安全、有序地施工,最终成为城市一部分的呢?

这就是类加载机制要回答的问题。我们可以把它想象成这套城市背后,一套精密、高效且安全的**"建筑与物流"体系**。

📜 第一步:建筑的生命周期

一个建筑蓝图(.class文件)从被发现到最终可以被使用,再到被拆除,其完整的生命周期分为七个阶段。其中,前五个阶段是类加载的核心。

[蓝图.class] ➡️ [1. 加载] ➡️ [2. 验证] ➡️ [3. 准备] ➡️ [4. 解析] ➡️ [5. 初始化] ➡️ [可使用]
                     \_______________________ ____________________/
                                      |
                                  [链接阶段]
  • 加载 (Loading):建筑公司(ClassLoader)找到蓝图文件。
  • 链接 (Linking):将蓝图信息整合进城市规划总署(JVM运行时)的过程。
    • 验证 (Verification):审核蓝图,确保设计安全、合规,不会导致"豆腐渣工程"。
    • 准备 (Preparation):为建筑的"公共设施"(静态变量)在档案馆(方法区)规划场地、分配空间,并设置默认值(如0, null)。
    • 解析 (Resolution):将蓝图上的"代号"(如"隔壁的老王")翻译成档案馆里明确的门牌号(直接内存地址)。
  • 初始化 (Initialization):进行"通电仪式",执行建筑的静态初始化代码(static代码块和静态变量赋值)。

💡 关键区别类的加载是把"建筑蓝图"本身加载进档案馆存档的过程,整个城市只会有一份。而对象的实例化 (new Student()) 是施工队按照这份蓝图,在居民区(堆内存)建造一座座具体房屋的过程,可以建造无数座。


🏗️ 第二步:三大官方建筑公司(内置类加载器)

城市(JVM)为了保证建筑工程的秩序和安全,设立了一套等级森严的官方建筑公司体系。这套体系,就是大名鼎鼎的双亲委派模型的组织基础。

我们可以把它想象成一个**"总公司-分公司-子公司"**的三级结构。

👑 总公司:启动类加载器 (Bootstrap ClassLoader)

  • 身份:最顶级的"皇家建筑公司",不是Java类,而是由C++编写,是城市管理系统(JVM)自身的一部分。
  • 职责:专门负责建造城市最核心的基础设施,如政府大楼 (java.lang.Object)、银行 (java.lang.String) 等。这些蓝图存放在最核心的区域 (JAVA_HOME/lib)。
  • 特点:地位超然。如果你去问政府大楼是谁建的(Object.class.getClassLoader()),你得到的回答是null。这不是因为它没有建筑公司,而是因为它的建筑公司级别太高,在普通的"企业名录"(Java世界)里查不到。

🏢 分公司:平台/扩展类加载器 (Platform/Extension ClassLoader)

  • 身份:由总公司直接管理的大型国企。
  • 职责:负责建造城市的公共服务设施,如邮局、学校等标准模块。在Java 9之前,它负责加载JAVA_HOME/lib/ext目录下的"扩展包",但这种方式容易造成混乱(JAR Hell)。Java 9之后,升级为平台类加载器,通过模块化系统(JPMS)进行更规范的管理。
  • 上级:它的上级是启动类加载器

👷 子公司:应用程序类加载器 (Application ClassLoader)

  • 身份:我们最常打交道的"本地建筑公司",也叫系统类加载器。
  • 职责:负责建造我们自己编写的业务建筑,比如Student类、Main类等。它负责加载用户类路径(Classpath)上的所有蓝图。
  • 上级:它的上级是平台类加载器
👑 启动类加载器 (Bootstrap)  <-- 看不见的顶层
       ^
       | (上级)
       |
🏢 平台类加载器 (Platform)
       ^
       | (上级)
       |
👷 应用程序类加载器 (Application)

📜 第三步:施工的黄金法则(双亲委派模型)

这套"总公司-分公司-子公司"的体系,遵循着一条严格的、不可轻易动摇的黄金法则——双亲委派模型 (Parent-Delegation Model)

核心思想: 凡事都先问老板,老板干不了的才自己干。

<details> <summary>🔍 点击查看:一次建筑请求(类加载)的完整流程 ➡️</summary>

假设"本地建筑公司"(应用类加载器)收到了一个建造com.mycorp.project.MyClass的请求:

  1. 自己不先动手:本地公司不会立刻翻找自己的蓝图库。
  2. 请求上报给"分公司":它会把请求完整地交给它的上级"平台类加载器",说:"老板,这个活你接吗?"
  3. 请求继续上报给"总公司":分公司也不会先动手,它会继续把请求交给它的上级"启动类加载器",说:"大老板,这个活是您的吗?"
  4. 自上而下尝试施工
    • "总公司"首先在自己的"皇家蓝图库"里查找。如果找到了(比如请求的是java.lang.Object),它就亲自施工,任务结束。
    • 如果总公司没找到,它会告诉分公司:"这个我不管,你来吧。"
    • "分公司"于是在自己的"公共设施蓝图库"里查找。如果找到了,它就施工,任务结束。
    • 如果分公司也没找到,它会告诉子公司:"这个我也管不了,你看着办吧。"
  5. 自己动手,丰衣足食:直到此时,当所有的上级都表示"干不了"之后,"本地建筑公司"才会启动自己的工程队,在自己的地盘(Classpath)上查找并建造com.mycorp.project.MyClass

</details>

双亲委派模型的巨大优势

  • 安全,防止核心API被篡改:这是最重要的作用。想象一下,如果没有这条法则,一个黑客可以自己写一个恶意的java.lang.Object类,并让本地建筑公司去加载。但有了双亲委派,所有加载java.lang.Object的请求,最终都会被"总公司"(启动类加载器)拦截,它会加载JDK官方的、安全的版本,黑客的恶意版本根本没有机会被加载。
  • 高效,避免重复加载:由于所有请求都先向上委托,一个类最终只会被其体系中最顶层的、能加载它的那个加载器加载一次。这保证了在城市档案馆(JVM)中,任何一个建筑蓝图(如Student.class)只存在一份独一无二的存档,节约了内存。

类的唯一身份:建筑蓝图 + 建筑公司

在JVM这座城市里,判断两个建筑是否为同一个的身份标准极为严格:

唯一身份 = 建筑蓝图的全名 (包名+类名) + 负责施工的建筑公司 (ClassLoader实例)

这意味着,即使是完全相同的蓝图文件,只要是由两家不同的建筑公司(不同的ClassLoader实例)施工的,JVM也会认为它们是两个完全不同的建筑(类),它们之间不能互相转型,否则会抛出ClassCastException。这个特性是实现模块化隔离(如Tomcat中不同Web应用)的基石。


💣 第四步:打破规则的"特殊工程"

双亲委派模型的设计堪称完美,但"规则就是用来被打破的"。在某些特殊的"逆向工程"需求下,这条自上而下的单行道必须被打开一个"后门"。

SPI的困境:总公司需要子公司的"特供"零件

最经典的案例就是SPI (Service Provider Interface),例如JDBC。

  • 标准:JDBC的接口(如java.sql.Driver)是由"总公司"或"分公司"定义的标准件,由启动/平台类加载器加载。
  • 实现:具体的数据库驱动(如mysql-connector-java.jar里的com.mysql.cj.jdbc.Driver)是我们自己提供的"特供零件",由"子公司"(应用类加载器)加载。

矛盾出现了DriverManager(标准件,由上级加载)需要去发现并加载com.mysql.cj.jdbc.Driver(实现,由下级加载)。按照双亲委派,上级根本"看不见"下级加载的类,这条路是死的。

解决方案:线程上下文类加载器 (TCCL)

为了解决这个"逆向"难题,Java设计者引入了一个巧妙的机制——线程上下文类加载器 (Thread Context ClassLoader, TCCL)

这就像是"总公司"的CEO虽然自己不能去子公司的仓库拿货,但他可以委托当前正在执行这项任务的"项目经理"(线程),并使用这位项目经理的工作证(TCCL,通常就是应用类加载器)去子公司的仓库里把"特供零件"拿出来。

这是一种优雅的"违规",它没有破坏双亲委派的整体结构,只是在需要的时候,通过Thread.currentThread().getContextClassLoader()"借用"了下级加载器的能力,打通了向上和向下的通道。

终极破局者:自定义类加载器

在某些场景下,我们需要更彻底地改变加载规则。最著名的就是Tomcat

为了实现多个Web应用(A和B)之间的类库隔离(比如A用Log4j 1.0,B用Log4j 2.0),Tomcat为每个Web应用都创建了一个专属的WebAppClassLoader

这个专属的建筑公司重写了loadClass方法,彻底颠覆了黄金法则:

Tomcat的新规则:接到建筑请求后,我先在自己的地盘(WEB-INF/classes和WEB-INF/lib)上找。只有我自己找不到的时候,我才去问我老板(上级加载器)能不能干。

这种"反向"的双亲委派,确保了每个Web应用优先使用自己的类库,从而实现了完美的隔离,也使得应用的热部署和热重载成为可能。