Skip to content
xiaoyang 的头像xiaoyang发布于 2025-02-1919,018 字阅读约 63 分钟浏览加载中

JVM虚拟机介绍 ​

JVM跨平台的本质 ​

JVM(Java Virtual Machine)的跨平台能力本质上来源于“不同平台安装了不同的 JVM”。Java 代码并不是直接运行在操作系统上,而是运行在 JVM 上,而 JVM 会根据底层平台进行适配。这样,Java 程序只需要编译成一种统一的字节码格式(.class 文件),然后交由不同操作系统上的 JVM 来执行,就能够实现“一次编写,到处运行”。

image-20250219200213432

字节码的作用 ​

字节码的存在主要是为了在解释执行和直接编译执行之间做一个平衡。

为什么需要字节码? ​

如果直接执行 Java 源代码,那就变成了类似 Python 的解释型语言,每次运行都需要进行解析,执行效率会降低。而如果像 C/C++ 那样直接编译成机器码,就失去了跨平台的优势。因此,Java 选择了一种折中的方式:

  • 先将 Java 源代码编译成字节码(.class 文件),这一步由 Java 编译器(javac)完成。
  • 然后 JVM 负责执行字节码,具体方式包括解释执行和JIT(即时编译),其中 JIT 可以将热点代码编译为机器码,提高执行效率。

字节码的优势 ​

  1. 跨平台:字节码不是特定 CPU 架构的指令,而是面向 JVM 的指令,因此可以在不同操作系统上运行。
  2. 提高效率:虽然字节码需要被解释执行,但 JIT 编译可以动态优化热点代码,使得 Java 代码的运行效率接近原生编译的程序。
  3. 安全性:字节码可以在执行前由 JVM 进行验证,避免恶意代码执行,提高 Java 运行的安全性。
  4. 便于优化:JVM 可以针对字节码进行运行时优化,如垃圾回收(GC)、内联展开、逃逸分析等,提高执行性能。

除了 Java,很多高级语言(如 Kotlin、Scala、C#)也采用类似的字节码机制,以实现跨平台或运行时优化的目的。

image-20250219195430140

image-20250219195903524

JVM 的整体结构 ​

JVM 运行时需要处理字节码文件,将其加载到内存中,并通过各个子系统和数据区域来执行 Java 程序。JVM 的核心组件包括:

  • 类加载子系统(Class Loader Subsystem): 负责将 .class 字节码文件从磁盘加载到内存,并解析为 JVM 运行时可用的类信息。
  • 运行时数据区(Runtime Data Areas):
    • 方法区(Method Area)(也称为 元空间 Metaspace): 存储类的元数据(字段、方法、访问权限)、运行时常量池、静态变量、JIT 编译后的代码等。它是所有线程共享的区域。
    • 堆(Heap): JVM 内存中最大的一块区域,主要存放对象实例,所有线程共享。堆通常划分为 新生代(Young Generation) 和 老年代(Old Generation),并由垃圾回收器管理。
    • Java 虚拟机栈(Java Stack): 每个线程私有的栈,存储方法调用的 栈帧(Stack Frame),包括 局部变量表、操作数栈、动态链接、方法返回地址 等。栈的大小可以通过 -Xss 选项配置。
    • 本地方法栈(Native Method Stack): 主要用于 JVM 调用本地方法(Native Methods),例如通过 JNI 调用 C/C++ 代码。其作用类似于 Java 虚拟机栈,但专门用于本地代码执行。
    • 程序计数器(PC Register): 记录当前线程正在执行的字节码指令地址,用于多线程环境下的线程切换。
  • 执行引擎(Execution Engine):
    • 解释器(Interpreter): 逐行读取字节码并执行,但执行速度较慢。
    • JIT 编译器(Just-In-Time Compiler): 在运行时将 热点代码(高频调用的代码)编译为本地机器码,提高执行效率。JIT 编译器常见的优化策略包括 方法内联、逃逸分析、栈上分配 等。
    • 垃圾回收器(Garbage Collector, GC): 负责管理堆内存,回收不再使用的对象。JVM 提供多种垃圾回收算法,例如 Serial GC、Parallel GC、G1 GC、ZGC、Shenandoah GC,可通过 -XX:+UseG1GC 等参数指定。

JVM 通过这些组件协同工作,实现高效的 Java 代码执行和自动内存管理。

image-20250219201018406

类加载子系统 ​

类加载的 3 个大阶段 ​

Java 类的加载过程分为 加载(Loading)、连接(Linking)、初始化(Initialization) 三个主要阶段:

  1. **加载(Loading)**通常情况下,我们所提到的加载是类加载机制的三个阶段的总称,而这里的加载指的是类加载机制中的第一阶段。在这个阶段,虚拟机需要完成以下三件事:

    • 通过一个类的全限定名来获取定义此类的二进制字节流。
    • 将这个字节流所代表的静态存储结构转化为方法区的运行时数据结构。
    • 在内存中共生成一个代表这个类的java.lang.Class对象,作为方法区这个类的各种数据结构的访问入口。

    这里需要注意的是,虚拟机规范并没有严格的规定从哪里,以怎样的形式获取字节流。也就是说,只要最后获取到的二进制字节流是符合JVM规范的,就都是合法的。用户可以通过自定义类加载器,重写ClassLoader类中的findClass()方法,来自定义获取字节流的方式,就可以实现个性化的类加载方案。

  2. 连接(Linking) 连接过程包括 验证、准备、解析:

    • 验证(Verification):确保字节码符合 JVM 规范,防止恶意代码执行。
    • 准备(Preparation):为类的 static 变量分配内存,并赋默认值(如 int 变量赋 0,对象赋 null)。
    • 解析(Resolution) 是 JVM 类加载过程中的阶段之一,负责将 符号引用(Symbolic Reference) 转换为 直接引用(Direct Reference),提高运行时访问效率。
      • 符号引用:字节码中的间接表示,如 "java/lang/String"、方法 length()I,存储在 常量池 中。
      • 直接引用:运行时的实际内存地址,如 String.class 在方法区的地址、对象字段的偏移量、方法的执行地址。
  3. 初始化(Initialization)

    • 按照类的静态变量和静态代码块的顺序,执行初始化。

image-20250219201436137

类加载器的分类 ​

JVM 采用 双亲委派(Parent Delegation) 机制,默认提供了 3 个类加载器:

类加载器作用默认加载目录
Bootstrap 类加载器加载 Java 核心类库(rt.jar,java.base)$JAVA_HOME/lib
Extension 类加载器加载扩展类库(JCE、JSSE 等)$JAVA_HOME/lib/ext
App(System)类加载器加载用户应用的类(classpath 下的类)CLASSPATH 目录

image-20250219201642642

双亲委派机制 ​

双亲委派机制的核心逻辑:

  1. 先让 父类加载器 加载类,层层向上委托,一直到最顶层启动类加载器。
  2. 如果 启动类加载器 还无法加载(找不到类),则由 下层加载器 进行加载。

image-20250317160125895

作用:避免类的重复加载,防止核心API被篡改。这样可以避免自定义类覆盖 Java 核心类(如 java.lang.String),增强安全性。

在 ClassLoader 的 loadClass() 方法中:

java
protected Class<?> loadClass(String name, boolean resolve) throws ClassNotFoundException {
    synchronized (getClassLoadingLock(name)) {
        Class<?> c = findLoadedClass(name); // 1. 先检查类是否已加载
        if (c == null) {
            try {
                if (parent != null) {
                    c = parent.loadClass(name, false); // 2. 委派给父类加载器
                } else {
                    c = findBootstrapClassOrNull(name); // 3. 交给 Bootstrap 类加载器
                }
            } catch (ClassNotFoundException e) {
                // 父类加载器未找到,则由当前类加载器加载
            }
            if (c == null) {
                c = findClass(name); // 4. 最后当前类加载器尝试加载
            }
        }
        return c;
    }
}

防止安全漏洞:如果允许用户定义 java.lang.String 并替换系统类,可能会被黑客利用,因此 String.class.getClassLoader() 为空时表示它由 Bootstrap 加载器加载,而不是用户代码加载。

如何打破 JVM 的双亲委派机制? ​

核心思路:不委托父类加载器,自己先尝试加载。自定义 ClassLoader,覆盖 loadClass()

java
public class MyClassLoader extends ClassLoader {
    @Override
    public Class<?> loadClass(String name, boolean resolve) throws ClassNotFoundException {
        // 1️⃣ 先尝试自己加载
        if (name.startsWith("com.mycompany")) {
            return findClass(name);
        }
        // 2️⃣ 其余情况,按双亲委派机制加载
        return super.loadClass(name, resolve);
    }

    @Override
    protected Class<?> findClass(String name) throws ClassNotFoundException {
        // 自定义加载逻辑,比如从字节码文件加载类
        byte[] classData = loadClassData(name);
        return defineClass(name, classData, 0, classData.length);
    }
}

🔹 原理:

  • 先尝试自己加载(findClass(name)),如果找不到,再调用 super.loadClass(name)。
  • 这样可以优先加载自定义类,而不是由父类加载器加载。

自定义类加载器 ​

为什么 Tomcat 需要自定义类加载器? ​

Tomcat是一个Web服务器 需要运行多个 Web 应用,而这些应用可能包含相同的类(如 com.example.User)。如果直接使用 AppClassLoader,所有 Web 应用的类都会混在一起,导致:

  • 类冲突:不同应用的同名类相互影响。
  • 隔离问题:一个应用的类可能访问到另一个应用的类。

Tomcat 的类加载器模型 ​

Tomcat 采用 自定义类加载器 机制,为每个 Web 应用分配独立的类加载器,从而实现隔离。

类加载器作用
Bootstrap加载 JDK 类库
System(AppClassLoader)加载 Tomcat 自身的类
Common加载 Tomcat 共享的类
WebappClassLoader为每个 Web 应用提供独立的类加载器

如果 Tomcat 直接使用 AppClassLoader,那么不同 Web 应用的类会相互影响,导致隔离失败。

类加载隔离的最终效果 ​

  • 不同 Web 应用的 WebappClassLoader 互不干扰。
  • Web 应用可以使用自己版本的 jar,而不会影响其他应用。
  • Tomcat 核心类(如 Servlet API)被共享,不受应用影响。

运行时数据区(Runtime Data Areas) ​

JVM 在运行时将内存划分为不同的区域,以支持 Java 程序的执行。这些区域包括 方法区(Method Area)、堆(Heap)、Java 方法栈(Java Virtual Machine Stack)、本地方法栈(Native Method Stack) 和 程序计数器(Program Counter Register)。其中,方法区和堆是线程共享的,而 Java 方法栈、本地方法栈和程序计数器是线程私有的。

image-20250219210715492

方法区(Method Area) ​

方法区是 JVM 运行时数据区的一部分,用于存储已加载的类信息、运行时常量池、方法代码、静态变量、JIT 编译后的代码等。

存储内容:

  1. 类元数据(Class Metadata)
    • 类的完整结构(字段、方法、访问修饰符等)
    • 类的加载信息(父类、接口等)
  2. 运行时常量池(Runtime Constant Pool)
    • 字面量(字符串、数字等)
    • 符号引用(方法引用、字段引用、类引用)
  3. 方法信息
    • 方法字节码
    • JIT 编译后的本地代码
  4. 静态变量(Static Variables)
    • 存储 static 修饰的类变量

方法区的实现

  • JDK 8 之前(HotSpot):方法区在 永久代(PermGen),内存上限由 -XX:MaxPermSize 限制。
  • JDK 8 及以后:永久代被移除,方法区改为 元空间(Metaspace),使用 本地内存,可通过 -XX:MaxMetaspaceSize 限制。

常见问题

  • OutOfMemoryError: Metaspace:当 Metaspace 空间不足时抛出。

堆(Heap) ​

堆是 JVM 中最大的一块内存区域,用于存储对象实例和数组。所有对象都必须存放在堆中,并由垃圾回收(GC)管理。

堆的结构:

  1. 新生代(Young Generation)
    • Eden 区(大部分新对象分配在这里)
    • Survivor 0(S0)
    • Survivor 1(S1)
    • 默认比例 8:1:1(可通过 -XX:SurvivorRatio 调整)
    • 新生代 GC 频繁,使用 Minor GC(复制算法)
  2. 老年代(Old Generation)
    • 存活时间长、经历多次 GC 的对象进入老年代
    • 发生 Full GC(标记-清除/整理)
    • -XX:MaxTenuringThreshold=15 指定对象最多存活多少次 GC 进入老年代

特殊情况:

  • 大对象直接进入老年代(如大数组)
  • 晋升老年代:如果对象在 Eden 满了后,无法放入 S0/S1,会直接进入老年代
  • OOM(OutOfMemoryError):堆空间不足可通过 -Xms(最小堆大小)和 -Xmx(最大堆大小)调整

image-20250219213108108

Java 方法栈(Java Virtual Machine Stack) ​

每个线程私有的,存储方法调用信息(栈帧)。方法执行完 栈帧出栈,无需垃圾回收。

栈帧结构:

  1. 局部变量表(Local Variable Table)
    • 存储方法内的局部变量,使用 slot(槽位) 分配空间
  2. 操作数栈(Operand Stack)
    • 存放方法执行过程中计算的操作数
  3. 动态链接(Dynamic Linking)
    • 解析方法调用的符号引用
  4. 方法返回地址
    • 方法执行完后返回的地址

常见错误:

  • StackOverflowError:方法调用深度过大,导致栈溢出(如递归无终止条件)
  • OutOfMemoryError:栈空间不足(可用 -Xss 调整栈大小)

image-20250219211205471

image-20250219213144615

本地方法栈(Native Method Stack) ​

存储 native 关键字修饰的方法 调用的执行状态。

示例

java
public class NativeDemo {
    static {
        System.loadLibrary("nativeLib"); // 加载 C/C++ 实现的库
    }
    public native void nativeMethod();  // 声明本地方法
}

常见错误

  • StackOverflowError:本地方法调用层级过深
  • OutOfMemoryError:本地方法栈空间耗尽

程序计数器(PC Register) ​

JVM 中的 程序计数器(PC Register) 是一块极小的内存空间,用于存储当前线程所执行的 字节码指令地址。PC 计数器的概念来源于 CPU 的寄存器(Register),在计算机体系结构中,寄存器用于存储当前 CPU 正在执行的指令地址。JVM 通过模拟这一机制,为每个线程提供了一个独立的 PC 计数器,以便控制 Java 字节码的执行流程。

特点

  • 线程私有(Thread-Private):每个线程都有自己独立的 PC 计数器,生命周期与线程一致。
  • 存储指令地址:当线程正在执行 Java 方法 时,PC 计数器存储 当前正在执行的字节码指令地址;如果线程执行的是 Native 方法,PC 计数器的值为空(Undefined)。
  • 字节码解释器依赖:JVM 的字节码解释器会不断读取 PC 计数器的值,以确定下一条要执行的字节码指令。
  • 控制程序流:PC 计数器在 循环、分支、跳转、异常处理、线程恢复 等过程中起着至关重要的作用。
  • 不会发生 OutOfMemoryError:JVM 规范规定,PC 计数器是唯一一个在 JVM 运行时不会发生 内存溢出(OutOfMemoryError) 的区域。

image-20250219221438000

image-20250219221819600

总结 ​

运行时数据区线程共享/私有作用
方法区共享存放类信息、常量池、方法信息、JIT 编译代码
堆共享存放对象实例和数组
Java 方法栈私有存放方法调用信息(栈帧)
本地方法栈私有存储 native 方法执行状态
程序计数器私有记录当前字节码指令地址

Class类常量池、运行时常量池和字符串常量池 ​

Class 常量池(静态常量池) ​

在 Java 编译阶段,每个 .class 文件中都包含一个 常量池表(Constant Pool Table), 它可以理解为 Class 文件的“资料库”,用于存放:

  • 编译期间生成的 字面量(Literal)
  • 以及 符号引用(Symbolic Reference)

除了类的版本、字段、方法、接口等信息外,字节码文件中最重要的部分就是这个常量池。通过命令我们可以反编译 .class 文件,查看常量池内容。

bash
javap -v com.ywenrou.cn.Math

字面量(Literal)

字面量是由数字、字符等构成的常量值。 例如:

java
int a = 1;
int b = 2;
String c = "abcdefg";

其中:

  • 1、2、"abcdefg" 都是字面量;
  • 它们在编译时就被写入 .class 文件的常量池中。

符号引用(Symbolic Reference)

符号引用是编译原理中的一个概念,是相对于“直接引用”而言的。编译时,类还没有加载进内存,因此编译器无法知道方法或变量的实际内存地址,只能使用“符号”来标识这些结构。

符号引用包括三类:

  • 类和接口的全限定名(如 com/ywenrou/cn/Math)
  • 字段的名称和描述符(如 a, b)
  • 方法的名称和描述符(如 main, compute())

这些符号引用最初都保存在 .class 文件的常量池中,它们是 静态信息,还没有映射到内存地址。


运行时常量池(Runtime Constant Pool) ​

当程序启动,类被类加载器加载进内存后, JVM 会将 .class 文件中的常量池表加载到内存中的 方法区(JDK 8 以后是 Metaspace),形成 运行时常量池(Runtime Constant Pool)。

可以这样理解:

阶段常量池名称所在位置内容
编译时Class 常量池(静态).class 文件字面量 + 符号引用
运行时运行时常量池方法区 / 元空间已加载并解析的常量信息

在运行时常量池中,符号引用会被解析成直接引用(Direct Reference),也就是实际的内存地址或方法句柄,这个过程叫 动态链接(Dynamic Linking)。

例子说明动态链接:

java
public class Math {
    public int compute() {
        int a = 1;
        int b = 2;
        int c = (a + b) * 10;
        return c;
    }
}

编译时:

  • "compute" 方法名只是一个 符号引用

运行时:

  • compute() 被加载进方法区后,JVM 会把这个符号引用解析成一个 直接引用(即方法的内存地址)。
  • 对象头中包含的类型指针(Klass Pointer)会指向这个直接引用。

所以:

compute() 从“名字”变成了“地址”,通过对象头指针来找到并执行方法。


字符串常量池(String Intern Pool) ​

在 Java 中,字符串是使用最频繁的对象类型之一。频繁创建字符串对象可能导致以下问题:

  • 性能下降:对象创建需要时间和资源;
  • 内存浪费:相同内容的字符串可能被重复存储。

为了解决这些问题,JVM 引入了 字符串常量池(String Intern Pool) —— 一个全局共享的内存区域,用于缓存和复用已经存在的字符串对象。

当代码中出现字符串字面量时(例如 "abcdefg"),JVM 的处理流程如下:

java
String s1 = "abcdefg";
String s2 = "abcdefg";
  1. 第一次遇到 "abcdefg" JVM 会在字符串常量池中查找该字面量:
    • 如果不存在,则创建一个新的 String 对象并放入常量池中;
    • 将池中该对象的引用返回给变量 s1。
  2. 第二次遇到 "abcdefg" JVM 再次查找该字面量,发现其已存在于常量池中, 直接返回同一个引用给 s2。

因此,下面的比较结果为:

java
s1 == s2  // true

即两个引用指向同一个常量池中的字符串对象。这种机制有效地节省了内存并加快了字符串比较(引用比较的速度远快于内容比较)。

String.intern() 是与字符串常量池交互的核心方法。它的作用是:确保字符串在常量池中只有一个实例,并返回该实例的引用。

示例:

java
String s1 = new String("abc");
String s2 = s1.intern();
String s3 = "abc";

执行过程说明:

  1. new String("abc") 会在堆上创建一个新的字符串对象;
  2. 调用 s1.intern() 时,JVM 会检查常量池中是否存在 "abc";
    • 若存在,返回常量池中字符串的引用;
    • 若不存在,将 "abc" 添加到常量池中,并返回其引用;
  3. "abc" 字面量加载时会直接引用常量池中的对象。

最终:

java
s2 == s3  // true
s1 == s3  // false

垃圾回收(GC)机制 ​

Java的垃圾回收(Garbage Collection)解决了内存管理的问题,自动识别并清除不再使用的对象,避免内存泄漏和OOM(OutOfMemory)错误。

垃圾回收主要分为两个阶段:

  1. 垃圾标记:识别哪些对象是垃圾
  2. 垃圾清除:回收垃圾对象占用的内存空间

垃圾标记算法 ​

引用计数法 ​

  • 原理:每个对象维护一个引用计数器,当引用增加时计数+1,引用断开时计数-1,计数为0则视为垃圾
  • 优点:实现简单,判定效率高
  • 缺点:无法解决循环引用问题(A引用B,B引用A,但它们都不再被其他对象引用)

image-20250219222509725

可达性分析法 ​

  • 原理:以GC Roots为起点,沿着引用链递归搜索,被搜索到的对象标记为存活,未被搜索到的对象标记为垃圾
  • GC Roots包括:
    • 虚拟机栈中引用的对象
    • 本地方法栈中引用的对象
    • 方法区中的静态属性引用的对象
    • 方法区中的常量引用的对象

image-20250219222618401

垃圾回收算法 ​

标记-清除算法 ​

  • 过程:先标记要回收的对象,然后清除这些对象
  • 缺点:产生内存碎片,需要两个阶段操作

image-20250219223207037

复制算法 ​

  • 原理:将内存分为两块,每次只使用一块。回收时,将存活对象复制到另一块,然后清除当前内存块
  • 特点:通常用于新生代,是典型的"空间换时间"策略
  • 优点:效率高,不产生碎片
  • 缺点:需要额外的内存空间

image-20250219223437876

标记-整理算法 ​

  • 过程:先标记存活对象,然后将存活对象移动到内存一端,最后清理边界外的空间
  • 优点:不产生内存碎片,不需要额外内存空间
  • 缺点:效率低,需要修改对象引用地址

image-20250219224012029

三种算法对比 ​

算法速度空间开销是否移动对象
标记-清除中等少(有碎片)否
标记-整理最慢少(无碎片)是
复制最快最多是

对象的生命周期各不相同,针对不同生命周期的对象采用不同的收集算法可以提高效率。堆内存分为:

  1. 新生代:存活时间短的对象,适合使用复制算法
  2. 老年代:存活时间长的对象,适合使用标记-清除或标记-整理算法

常见垃圾回收器 ​

在 JVM 中,垃圾回收器(Garbage Collector, GC)负责自动管理堆内存,回收不再使用的对象。当前主流的 GC 主要分为 两大类:

  1. 分代收集器(Generational GC):基于 新生代(Young Generation) 和 老年代(Old Generation) 进行回收,不同对象生命周期不同,使用不同的回收策略。
    • 代表:Serial GC、ParNew GC、Parallel GC、CMS GC。
  2. 分区收集器(Region-Based GC):将堆划分为多个动态 Region,不再严格区分新生代和老年代,按需回收,优化 GC 停顿时间。
    • 代表:G1 GC、ZGC。

image-20250219224513062

分代收集器(Generational GC) ​

Serial GC(串行垃圾回收器) ​

工作原理

  • 适用于 单核 CPU,使用 单线程 进行垃圾回收。
  • 新生代 采用 复制算法(Copying GC),将存活对象从 Eden + 一个 Survivor 复制到另一个 Survivor。
  • 老年代 采用 标记-整理算法(Mark-Compact),先标记存活对象,再整理碎片化内存。

回收过程

  1. 新生代 GC(Minor GC):
    • 标记存活对象。
    • 复制到 Survivor,未存活的对象直接回收。
    • Survivor 空间满时,存活对象晋升到老年代。
  2. 老年代 GC(Full GC):
    • 标记存活对象并整理内存,回收无用对象。

📌 适用场景

  • 适用于 单核 CPU、小型 Java 应用(如 GUI 桌面应用)。
  • Server 端不推荐,因为 STW(Stop-The-World)时间长,影响性能。

❌ 缺点

  • 单线程执行 GC,效率低。
  • GC 停顿时间长,不适用于多线程高吞吐量应用。

image-20250317151825882


ParNew GC(多线程版 Serial GC,适用于 CMS) ​

工作原理

  • ParNew GC 是 Serial GC 的并行版本,多线程执行 GC,提高回收效率。
  • 新生代 使用 并行复制算法(Parallel Copying GC),老年代搭配 CMS GC。

** 回收过程**

  • 新生代 GC:和 Serial GC 类似,但采用 多线程 并行执行 GC。
  • 老年代 GC:只能搭配 CMS GC,不能用于 Parallel Old GC。

📌 适用场景

  • 适用于 多核 CPU,配合 CMS GC,减少 GC 停顿时间。
  • CMS GC 唯一支持的多线程新生代 GC,必须使用 ParNew。

❌ 缺点

  • 比 Parallel GC 效率低,因为要与 CMS GC 配合,无法独立使用。

Parallel GC(吞吐量优先 GC,适用于大数据应用) ​

工作原理

  • 又称 吞吐量 GC(Throughput GC),采用 多线程 并行回收垃圾,适用于 高吞吐量应用。
  • 新生代 采用 并行复制算法(Parallel Copying GC)。
  • 老年代 采用 Parallel Old GC(标记-整理算法)。

回收过程

  1. 新生代 GC(Parallel Scavenge):并行复制对象,提高吞吐量。
  2. 老年代 GC(Parallel Old GC):并行标记-整理,减少碎片化。

📌 适用场景

  • 适用于 高吞吐量的批处理任务、大数据计算(如 Spark、Hadoop)。

❌ 缺点

  • 无法精准控制 GC 停顿时间,适用于吞吐量优先,而非低延迟应用。

image-20250317152138891


CMS GC(低延迟 GC) ​

工作原理

  • 适用于 低延迟应用,目标是减少 Full GC 停顿时间。
  • 新生代 采用 ParNew GC。
  • 老年代 采用 并发标记-清除算法(Concurrent Mark-Sweep),并发回收,减少 STW 时间。

🔄 回收过程

  1. 初始标记(短暂停顿,标记直接可达对象)。
  2. 并发标记(不影响应用,标记存活对象,容易出现漏标多标)。
  3. 重新标记(短暂停顿,标记遗漏的存活对象)。
  4. 并发清除(不影响应用,回收垃圾对象)。

📌 适用场景

  • 适用于 低延迟、高响应速度的 Web 服务器。

❌ 缺点

  • 产生 内存碎片,容易触发 Full GC(标记-整理)。

image-20250317152214685


分区收集器(Region-Based GC) ​

G1 GC(面向大堆的低延迟 GC) ​

工作原理

  • 堆内存划分为多个 Region,回收时优先选择垃圾最多的区域(Garbage First),我们可以通过设置最大SWT时间,垃圾回收器内部。
  • 并发标记、增量回收,控制 GC 停顿时间。

📌 适用场景

  • 适用于 大内存应用(>4GB)、低延迟需求(如在线交易系统)。

image-20250317162842146


ZGC(超低延迟 GC,适用于超大堆) ​

工作原理

  • 停顿时间 ≤10ms,不受堆大小影响(适用于超大堆,如 100GB+)。
  • 采用 着色指针(Colored Pointers)+ 读屏障(Load Barriers) 进行并发回收。

📌 适用场景

  • 超大堆(100GB+)应用,如金融交易、云计算、高并发系统。

并发标记用的三色标记法 ​

三色标记法是垃圾回收(Garbage Collection, GC)中常用的一种 可达性分析算法(reachability analysis algorithm),用于在 并发标记阶段(concurrent marking phase) 判断对象是否存活。

它通过将对象划分为三种颜色来管理标记进度:

  • 白色(White):尚未访问的对象,默认所有对象初始为白色。如果最终停留在白色,说明是不可达对象,等待回收。
  • 灰色(Gray):已访问到但其引用的对象尚未完全扫描的对象。
  • 黑色(Black):已访问到且其所有引用都扫描完毕的对象。

img


三色标记 + 并发场景下的完整流程

  1. 初始标记(Initial Mark)
    • STW(Stop-The-World)
    • 从 GC Roots 出发,把直接可达的对象标记为灰色。
  2. 并发标记(Concurrent Marking)
    • 应用线程和 GC 线程并发执行。
    • GC 按照三色标记法规则,不断把灰色对象扫描为黑色,并把它们引用的白色对象染成灰色。
  3. 重新标记(Remark)
    • 再次进入 STW。
    • 由于并发阶段应用线程可能修改对象引用,导致“丢失标记”,所以在这里进行一次补偿:
      • 遍历写屏障记录(或增量更新 / SATB 的日志)。
      • 把遗漏的对象重新标记为灰色,确保不会错误回收存活对象。
  4. 清理(Sweep / Cleanup)
    • 灰色集合为空,剩余的白色对象即为不可达对象,安全回收。

Java内存模型(JMM) ​

Java 内存模型(Java Memory Model, JMM) 定义了 Java 程序中变量、线程与主内存及工作内存之间的交互规则。 它主要涉及多线程环境下的 共享变量可见性 和 指令重排序 问题,是理解并发编程的关键概念。


并发编程的两个核心问题 ​

在多线程编程中,线程之间需要解决两个基本问题:

  • 通信(Communication):线程之间如何交换信息?
  • 同步(Synchronization):线程之间如何协调不同操作的执行顺序?

并发模型的两种实现方式 ​

  • 消息传递并发模型 线程之间通过消息传递来交换数据,不共享内存。
  • 共享内存并发模型 线程通过读写共享变量来实现通信和同步。👉 Java 采用的是共享内存并发模型。

主内存与工作内存 ​

  • 主内存(Main Memory) 保存所有线程共享的变量(堆中的对象实例字段、静态字段、数组元素)。
  • 工作内存(Working Memory) 每个线程都有自己的工作内存,保存从主内存拷贝过来的变量副本。 线程对变量的所有操作(读取、赋值),都必须在工作内存中进行,不能直接操作主内存。
  • 交互过程
    • 当线程修改了工作内存中的变量,需要 刷新回主内存 才能让其他线程可见。
    • 其他线程要获取最新值,必须 重新从主内存加载。

注意:工作内存是 Java 内存模型中的抽象概念,在底层可能对应 CPU 缓存、寄存器、写缓冲区等硬件结构。


内存可见性问题的来源 ​

可能有人会问:既然堆是共享的,为什么还会有内存不可见的问题? 答案在于 硬件优化:

  • 为了提高效率,CPU 访问速度更快的 缓存(Cache),而不是每次都访问主内存。
  • 当一个线程修改了缓存中的变量副本,但没有及时刷新到主内存时,其他线程读取的仍然是旧值,就会产生 可见性问题。

这就是为什么在多线程环境中,需要通过 volatile、synchronized、Lock 等手段来保证内存可见性。

image-20250919200920056

指令重排序(Instruction Reordering) ​

为了提升执行速度和性能,计算机在执行代码时,指令的实际执行顺序并不一定严格按照源代码的书写顺序。 这就是 指令重排序。

其核心特点:

  • 单线程下:重排序不会改变程序的执行结果(编译器/CPU 保证串行语义一致)。
  • 多线程下:重排序可能打破线程之间的可见性和有序性,从而导致并发问题。

常见的指令重排序类型 ​

  1. 编译器优化重排 编译器(包括 JVM、JIT 编译器等)在不改变 单线程语义 的前提下,重新安排语句顺序,以提升性能。
  2. 处理器指令级并行重排(ILP)
    • 现代 CPU 使用 流水线技术 和 乱序执行(Out-of-Order Execution) 来提高执行效率。
    • 如果两条指令之间没有数据依赖关系,处理器可以改变它们的执行顺序。
  3. 内存系统重排
    • 本质上是缓存/写缓冲区导致的主内存与工作内存数据不一致。
    • 在 JMM 中表现为 主存和线程本地内存的数据不同步,线程可能看到旧值。

👉 Java 源代码从编译到运行会经历: 编译器优化重排 → CPU 指令并行重排 → 内存系统重排 最终生成 CPU 实际执行的指令序列。


如何解决指令重排序? ​

针对不同层面的重排序,解决方式也不同:

  • 编译器层面
    • 通过禁止特定的编译器优化来避免重排序。
    • 例如:volatile 关键字会告诉 JVM & JIT 禁止对该变量的相关读写进行重排。
  • 处理器层面
    • 通过 内存屏障(Memory Barrier / Memory Fence) 指令来禁止特定的 CPU 重排。
    • 内存屏障的作用:
      1. 有序性:阻止屏障前后的指令发生重排序(像一道“栅栏”)。
      2. 可见性:
        • 写屏障:强制将写缓冲区的数据刷新到主内存;
        • 读屏障:使 CPU 缓存失效,从主内存中加载最新值。

synchronized 的原理 ​

synchronized 是 Java 提供的一个关键字,它能保证:

  • 原子性:临界区的代码在同一时刻只能被一个线程执行;
  • 可见性:进入/退出同步块时,线程会与主内存进行数据同步;
  • 有序性:锁的获取与释放建立 happens-before 关系,保证指令顺序。

底层实现机制 ​

(1)对象监视器(Monitor) ​

  • Java 中的每个对象都可以作为锁(Monitor)。
  • 当线程进入 synchronized 块时,会尝试获取对象的 Monitor;
  • 成功则继续执行,失败则进入阻塞/自旋状态,直到锁可用。

(2)JVM 字节码层面 ​

  • synchronized 在编译后,会在字节码中生成 monitorenter 和 monitorexit 指令:
    • monitorenter:尝试获取对象锁;
    • monitorexit:释放对象锁。
  • 每个同步块在字节码层面都至少会有一对这样的指令。

(3)内存语义 ​

  • 加锁(monitorenter):
    • 清空线程工作内存中的变量副本,从主内存中重新加载最新值;
    • 相当于在进入临界区前插入 Load 屏障。
  • 解锁(monitorexit):
    • 将工作内存中被修改的共享变量刷新回主内存;
    • 相当于在退出临界区后插入 Store 屏障。

👉 因此,synchronized 不仅保证了 互斥性(原子性),还通过 内存屏障 机制保证了 可见性和有序性。


happens-before 语义 ​

在多线程环境下,指令可能因为 编译器优化、CPU 重排序、缓存 等原因导致实际执行顺序与代码书写顺序不一致。为了定义线程之间的可见性、有序性,Java 内存模型(JMM)提出了 happens-before 规则。

如果一个操作 happens-before 另一个操作,那么:

  • 前一个操作的结果(写入变量的值、发送的消息等),对后一个操作是可见的;
  • 并且前一个操作在时间顺序上排在后一个操作之前。

👉 注意:happens-before 是一种偏序关系,不等同于“物理时间的先后”,它定义的是内存可见性和执行顺序的约束。


happens-before 的主要规则:

  1. 程序次序规则
    • 在一个线程内,代码按照书写顺序执行。
    • 前面的操作 happens-before 于后面的操作。
  2. 锁定规则
    • 对一个锁的解锁(unlock)happens-before 于随后对该锁的加锁(lock)。
  3. volatile 变量规则
    • 对一个 volatile 变量的写操作 happens-before 于后续对该变量的读操作。
  4. 传递性
    • 如果 A happens-before B,且 B happens-before C,那么 A happens-before C。
  5. 线程启动规则
    • 在主线程中,调用 Thread.start() 方法 happens-before 于该线程的 run 方法中执行的第一个操作。
  6. 线程终止规则
    • 线程中的所有操作 happens-before 另一个线程检测到该线程已经结束(如 Thread.join() 返回)。
  7. 中断规则
    • 调用 Thread.interrupt() happens-before 被中断线程检测到中断事件。
DISCUSSION

评论区

欢迎分享你的思考、补充或问题。

基于 VitePress 搭建