JVM内存模型与垃圾回收

33 阅读38分钟

1. 理论知识与核心概念

1.1 什么是JVM?

JVM(Java Virtual Machine,Java虚拟机) 是Java程序运行的核心基础设施。它是一个虚拟的计算机,在真实的物理机器上模拟各种计算机功能。JVM最大的特点是"一次编译,到处运行"——Java源代码被编译成字节码(.class文件),这些字节码可以在任何安装了JVM的平台上运行,无需重新编译。

1.2 JVM的跨平台原理

Java的跨平台特性并不是Java语言本身的跨平台,而是JVM的跨平台。不同的操作系统需要不同的JVM实现,但它们都能执行相同的字节码文件。这种设计让Java程序员无需关心底层操作系统的差异,只需面向JVM编程即可。

Java源代码(.java) → 编译器 → 字节码(.class) → JVM → 机器码 → 操作系统

1.3 JVM规范 vs JVM实现

JVM规范 是由Oracle发布的技术标准,定义了JVM的行为规范,包括类文件格式、运行时数据区域、垃圾回收等。但规范只是标准,具体实现由不同厂商完成:

  • HotSpot VM: Oracle官方实现,最广泛使用,OpenJDK和Oracle JDK的默认JVM
  • OpenJ9: IBM开源的JVM,启动速度快,内存占用小
  • GraalVM: 新一代高性能JVM,支持多语言,具备AOT编译能力
  • Azul Zing: 商业JVM,专注于低延迟场景

本文以HotSpot VM为主要研究对象,因为它是生产环境中使用最广泛的实现。

1.4 为什么需要了解JVM内存模型?

在实际生产环境中,理解JVM内存模型至关重要:

  1. 性能调优: 合理配置堆大小、新生代老年代比例,可显著提升系统性能
  2. 故障排查: OutOfMemoryError、StackOverflowError等问题的根本原因都与内存模型相关
  3. 资源规划: 准确评估应用内存需求,避免资源浪费或不足
  4. 代码优化: 理解对象分配、逃逸分析等机制,编写更高效的代码

1.5 垃圾回收的必要性

与C/C++等需要手动管理内存的语言不同,Java通过自动垃圾回收(Garbage Collection, GC)机制自动释放不再使用的对象,极大降低了内存泄漏和野指针的风险。但自动并不意味着无脑——不合理的对象使用、不当的GC配置都可能导致:

  • 频繁GC: 影响系统吞吐量
  • 长时间Stop-The-World: 导致请求超时
  • 内存溢出: 系统崩溃

因此,深入理解垃圾回收机制,对于构建高性能、高可用的Java应用至关重要。


2. JVM内存结构详解

JVM运行时数据区域是Java程序执行期间使用的内存空间,根据《Java虚拟机规范》,运行时数据区域分为线程私有区域和线程共享区域

jvm-runtime-data-areas.svg

2.1 线程私有区域

2.1.1 程序计数器 (Program Counter Register)

程序计数器是当前线程所执行字节码的行号指示器。字节码解释器工作时通过改变这个计数器的值来选取下一条需要执行的字节码指令。

核心特性:

  • 线程私有: 每个线程都有独立的程序计数器,互不影响
  • 记录执行位置: 记录正在执行的虚拟机字节码指令地址(如果执行Native方法则为空)
  • 线程切换恢复: 多线程环境下,线程轮流切换执行,程序计数器用于记录每个线程的执行位置,确保切换后能恢复到正确位置
  • 唯一不会OOM的区域: 此内存区域是JVM规范中唯一没有规定任何OutOfMemoryError情况的区域

2.1.2 Java虚拟机栈 (VM Stack)

Java虚拟机栈描述的是Java方法执行的内存模型:每个方法在执行时都会创建一个栈帧(Stack Frame),用于存储局部变量表、操作数栈、动态链接、方法返回地址等信息。

栈帧结构:

  1. 局部变量表: 存放方法参数和局部变量

    • 存储基本数据类型(boolean、byte、char、short、int、float、long、double)
    • 对象引用(reference类型,不等于对象本身)
    • returnAddress类型(指向字节码指令地址)
    • Slot复用: 局部变量表的容量以变量槽(Slot)为最小单位,当一个局部变量的作用域结束后,其占用的Slot可以被其他变量复用
  2. 操作数栈: 用于存放方法执行过程中的中间计算结果

  3. 动态链接: 指向运行时常量池中该栈帧所属方法的引用

  4. 方法返回地址: 方法退出后返回到哪里执行

两种异常:

  • StackOverflowError: 线程请求的栈深度大于虚拟机所允许的深度(典型场景:无限递归)
  • OutOfMemoryError: 虚拟机栈动态扩展时无法申请到足够内存

参数配置:

-Xss256k  # 设置每个线程的栈大小为256KB,默认1MB

2.1.3 本地方法栈 (Native Method Stack)

本地方法栈与虚拟机栈作用相似,区别在于:

  • 虚拟机栈为Java方法服务
  • 本地方法栈为Native方法服务

在HotSpot虚拟机中,本地方法栈和虚拟机栈合二为一,不再单独区分。

2.2 线程共享区域

2.2.1 Java堆 (Heap)

Java堆是JVM管理的最大一块内存区域,也是垃圾收集器管理的主要区域。所有线程共享Java堆,在虚拟机启动时创建。

核心特性:

  • 对象分配: 几乎所有的对象实例和数组都在堆上分配内存(也有例外:栈上分配、标量替换)
  • 垃圾回收: GC的主要工作区域
  • 可扩展: 可以通过-Xms和-Xmx参数配置初始大小和最大大小

分代设计 (JDK 8及之前):

Java堆按照对象的生命周期分为新生代(Young Generation)老年代(Old Generation):

新生代 (Young Generation):

  • Eden区: 新对象分配的主要区域,占新生代的80%(默认)
  • Survivor区: 分为S0(From)和S1(To)两个区域,各占10%
  • Minor GC: 新生代GC,频繁但速度快

老年代 (Old Generation):

  • 存放长期存活的对象
  • 对象晋升条件:
    • 经历多次Minor GC仍存活(年龄达到阈值,默认15)
    • Survivor区空间不足
    • 大对象直接进入老年代
  • Major GC / Full GC: 老年代GC,速度慢,需要尽量避免

默认比例:

新生代:老年代 = 1:2  (-XX:NewRatio=2)
Eden:S0:S1 = 8:1:1   (-XX:SurvivorRatio=8)

G1的Region设计 (JDK 9+默认):

G1收集器打破了固定的分代布局,将堆划分为多个大小相等的Region,每个Region可以是Eden、Survivor或Old,动态调整,更灵活。

堆参数配置:

-Xms4g     # 堆初始大小4GB
-Xmx4g     # 堆最大大小4GB (生产环境建议与Xms相同,避免动态扩容)
-Xmn2g     # 新生代大小2GB
-XX:NewRatio=2           # 新生代:老年代=1:2
-XX:SurvivorRatio=8      # Eden:S0:S1=8:1:1
-XX:MaxTenuringThreshold=15  # 对象晋升年龄阈值

2.2.2 方法区 (Method Area)

方法区用于存储已被虚拟机加载的类信息、常量、静态变量、即时编译器编译后的代码等数据。

JDK 7及之前: 永久代 (PermGen)

  • 使用虚拟机内存
  • 存储类信息、常量池、静态变量、JIT编译后的代码
  • 容易OOM: 加载大量类(如大量使用反射、动态代理、JSP等)容易导致永久代OOM
  • 参数: -XX:PermSize-XX:MaxPermSize

JDK 8及之后: 元空间 (Metaspace)

  • 使用本地内存(Native Memory): 不再占用虚拟机内存
  • 动态扩展: 默认不限制大小,直到耗尽系统内存
  • 字符串常量池移至堆: 减少永久代/元空间压力
  • 静态变量移至堆: 与所属Class对象一起存放在堆中
  • 参数:
    -XX:MetaspaceSize=256m      # 元空间初始大小
    -XX:MaxMetaspaceSize=512m   # 元空间最大大小
    

为什么移除永久代?

  1. 永久代大小难以确定: 不同应用加载的类数量差异巨大,难以设置合适的大小
  2. 永久代GC复杂: 类卸载条件苛刻,GC效率低
  3. 统一内存管理: 元空间使用本地内存,与堆内存独立管理
  4. 融合HotSpot与JRockit: 为了与JRockit虚拟机融合,JRockit没有永久代概念

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

运行时常量池是方法区的一部分,用于存放编译期生成的各种字面量和符号引用。

  • Class文件常量池 → 运行时常量池: 类加载后,Class文件中的常量池信息放入运行时常量池
  • 动态性: 运行期间也可以将新的常量放入池中,如String.intern()方法

String.intern()详解:

String s1 = new String("hello");  // 堆中创建对象
String s2 = s1.intern();          // 如果常量池没有"hello",则添加并返回常量池引用

// JDK 6: intern()会在永久代创建字符串对象
// JDK 7+: intern()在堆中创建,常量池存储引用

2.2.4 直接内存 (Direct Memory)

直接内存并不是JVM运行时数据区的一部分,也不是《Java虚拟机规范》中定义的内存区域,但这部分内存被频繁使用。

使用场景:

  • NIO: ByteBuffer.allocateDirect()分配堆外内存
  • 避免复制: 减少Java堆和Native堆之间的数据复制,提升性能

参数配置:

-XX:MaxDirectMemorySize=1g  # 最大直接内存1GB

注意事项:

  • 直接内存不受JVM堆大小限制
  • 受限于本机总内存
  • OOM时需要检查直接内存使用情况

2.3 对象创建过程与内存布局

object-memory-layout.svg

2.3.1 对象创建流程

当虚拟机遇到new指令时,对象创建流程如下:

1. 检查类是否加载

检查指令参数能否在常量池中定位到类的符号引用,并检查类是否已被加载、解析和初始化。如果没有,先执行类加载过程。

2. 为对象分配内存

类加载完成后,对象所需内存大小已确定,从堆中划分一块对应大小的内存。

内存分配方式:

  • 指针碰撞 (Bump the Pointer): 如果Java堆内存绝对规整(已使用和空闲内存中间有个分界指针),分配内存就是把指针向空闲空间移动对象大小的距离。适用于Serial、ParNew等带压缩整理的收集器

  • 空闲列表 (Free List): 如果Java堆内存不规整,虚拟机维护一个列表,记录哪些内存块可用,分配时从列表中找到足够大的空间。适用于CMS等基于标记-清除算法的收集器

并发安全:

  • CAS + 失败重试: 保证更新操作的原子性
  • TLAB(Thread Local Allocation Buffer): 每个线程预先在Eden区分配一小块内存,线程在自己的TLAB上分配对象,只有TLAB用完分配新的TLAB时才需要同步

3. 初始化零值

内存分配完成后,虚拟机将分配到的内存空间初始化为零值(不包括对象头),保证对象的实例字段在Java代码中可以不赋初值就直接使用。

4. 设置对象头

设置对象的元数据信息:对象是哪个类的实例、如何找到类的元数据信息、对象的哈希码、对象的GC分代年龄等。

5. 执行<init>方法

执行构造函数,对象才真正可用。

2.3.2 对象内存布局

在HotSpot虚拟机中,对象在内存中分为3个区域:

1. 对象头 (Object Header)

Mark Word(标记字):

  • 存储对象运行时数据:哈希码、GC分代年龄、锁状态标志、线程持有的锁、偏向线程ID等
  • 动态定义的数据结构,根据对象状态复用存储空间
  • 32位JVM: 32bit,64位JVM: 64bit

类型指针 (Class Pointer / Klass Word):

  • 指向对象的类元数据,JVM通过这个指针确定对象是哪个类的实例
  • 开启指针压缩(-XX:+UseCompressedOops)后,64位JVM中类型指针占4字节,否则8字节

数组长度 (仅数组对象):

  • 如果对象是数组,对象头还需要4字节记录数组长度

2. 实例数据 (Instance Data)

对象真正存储的有效信息,包括代码中定义的各种字段(包括从父类继承的)。

字段存储顺序:

  • 相同宽度的字段总是分配到一起
  • 父类定义的变量会出现在子类之前
  • HotSpot默认分配顺序: long/double > int/float > short/char > byte/boolean > reference

3. 对齐填充 (Padding)

不是必然存在,仅起占位符作用。HotSpot要求对象大小必须是8字节的倍数,对象头已经是8字节的倍数,如果实例数据不够,需要对齐填充补全。

示例: new Object()对象大小

Mark Word:    8字节 (64位JVM)
类型指针:     4字节 (开启指针压缩)
实例数据:     0字节 (Object无字段)
对齐填充:     4字节 (12→16字节)
-----------------------------------
总计:        16字节

2.3.3 对象访问定位

Java程序通过栈上的reference数据来操作堆上的具体对象,访问方式主要有两种:

1. 句柄访问

Java堆中划分出一块内存作为句柄池,reference存储对象的句柄地址,句柄包含对象实例数据和类型数据的具体地址。

优点: 对象移动(GC时)只需改变句柄中的实例数据指针,reference本身不变。

2. 直接指针 (HotSpot使用)

reference直接存储对象地址,对象头中存储类型数据的指针。

优点: 速度快,节省一次指针定位的开销。


3. 垃圾回收算法详解

3.1 如何判断对象已死?

gc-roots.svg

3.1.1 引用计数法

原理: 给对象添加一个引用计数器,每当有一个地方引用它时,计数器+1;引用失效时,计数器-1;计数器为0的对象即可回收。

优点:

  • 实现简单
  • 判定效率高

缺点:

  • 无法解决循环引用问题: 对象A引用对象B,对象B引用对象A,但实际上都无法访问,计数器永远不为0
public class ReferenceCountingGC {
    public Object instance = null;

    public static void main(String[] args) {
        ReferenceCountingGC objA = new ReferenceCountingGC();
        ReferenceCountingGC objB = new ReferenceCountingGC();

        objA.instance = objB;  // A引用B
        objB.instance = objA;  // B引用A

        objA = null;  // 断开外部引用
        objB = null;  // 断开外部引用

        // objA和objB形成循环引用,引用计数法无法回收
        System.gc();
    }
}

使用场景: Python、PHP、微软COM使用引用计数法,但都有额外的循环检测机制。

3.1.2 可达性分析算法

Java、C#、Go等主流语言都采用可达性分析算法判断对象是否存活。

核心思路: 通过一系列称为**"GC Roots"的根对象作为起始节点集,从这些节点开始,根据引用关系向下搜索,搜索过程走过的路径称为"引用链"(Reference Chain)**。如果某个对象到GC Roots之间没有任何引用链相连,则证明此对象不可达,判定为可回收对象。

GC Roots包括:

  1. 虚拟机栈(栈帧中的局部变量表)中引用的对象

    public void method() {
        User user = new User();  // user是GC Root
    }
    
  2. 方法区中类静态属性引用的对象

    public class UserService {
        private static User staticUser = new User();  // staticUser是GC Root
    }
    
  3. 方法区中常量引用的对象

    public class Constants {
        public static final String CONSTANT = "Hello";  // CONSTANT是GC Root
    }
    
  4. 本地方法栈中JNI(Native方法)引用的对象

  5. JVM内部的引用: 如基本类型对应的Class对象、常驻的异常对象、系统类加载器等

  6. 被同步锁(synchronized关键字)持有的对象

  7. 反映JVM内部情况的JMXBean、JVMTI中注册的回调、本地代码缓存等

不可达对象的回收流程:

即使对象在可达性分析后被判定为不可达,也并非"非死不可",至少要经历两次标记过程:

第一次标记: 可达性分析后发现对象不可达,进行第一次标记。

筛选: 判断对象是否有必要执行finalize()方法:

  • 对象没有覆盖finalize()方法,或finalize()已被JVM调用过 → 直接回收
  • 对象覆盖了finalize()且未被调用 → 放入F-Queue队列,由低优先级Finalizer线程执行

第二次标记:finalize()方法中,对象可以"自救"——重新与引用链上的任何一个对象建立关联(如把this赋值给某个类变量)。如果自救成功,第二次标记时移出"即将回收"集合;否则回收。

finalize()机制的问题:

  • 运行代价高: 需要单独的线程执行
  • 不确定性强: finalize()何时执行、是否执行都不确定
  • 已被废弃: JDK 9开始标记为@Deprecated,不建议使用

最佳实践: 使用try-finally或try-with-resources管理资源,不要依赖finalize()

3.2 引用类型详解

JDK 1.2之后,Java对引用的概念进行了扩充,将引用分为4种强度:

3.2.1 强引用 (Strong Reference)

最普遍的引用方式,如Object obj = new Object()。只要强引用关系存在,垃圾收集器永远不会回收被引用的对象。

User user = new User();  // 强引用
// 只要user变量还在作用域内,User对象就不会被回收

3.2.2 软引用 (Soft Reference)

描述有用但非必需的对象。系统内存充足时,软引用对象不会被回收;内存不足时,会回收软引用对象

使用场景: 实现内存敏感的缓存。

import java.lang.ref.SoftReference;

public class SoftReferenceDemo {
    public static void main(String[] args) {
        // 创建软引用
        SoftReference<byte[]> softRef = new SoftReference<>(new byte[1024 * 1024 * 10]);

        System.out.println("第一次GC前: " + softRef.get());  // 不为null

        System.gc();
        System.out.println("GC后(内存充足): " + softRef.get());  // 仍不为null

        // 分配大量内存,造成内存不足
        byte[] largeArray = new byte[1024 * 1024 * 15];
        System.out.println("内存不足时: " + softRef.get());  // 为null,已被回收
    }
}

实战应用: 图片缓存

public class ImageCache {
    private Map<String, SoftReference<Image>> cache = new HashMap<>();

    public Image getImage(String path) {
        SoftReference<Image> ref = cache.get(path);
        Image image = (ref == null) ? null : ref.get();

        if (image == null) {
            // 缓存未命中或已被回收,重新加载
            image = loadImage(path);
            cache.put(path, new SoftReference<>(image));
        }
        return image;
    }
}

3.2.3 弱引用 (Weak Reference)

比软引用更弱,只要发生GC,无论内存是否充足,都会回收弱引用对象。

使用场景: 实现规范化映射,防止内存泄漏。

import java.lang.ref.WeakReference;

public class WeakReferenceDemo {
    public static void main(String[] args) {
        WeakReference<User> weakRef = new WeakReference<>(new User());

        System.out.println("GC前: " + weakRef.get());  // 不为null

        System.gc();
        System.out.println("GC后: " + weakRef.get());  // 为null,已被回收
    }
}

ThreadLocal防止内存泄漏:

ThreadLocalMap的Entry继承自WeakReference<ThreadLocal<?>>:

static class Entry extends WeakReference<ThreadLocal<?>> {
    Object value;
    Entry(ThreadLocal<?> k, Object v) {
        super(k);  // key是弱引用
        value = v;
    }
}

当ThreadLocal外部强引用断开,GC时ThreadLocal对象被回收,Entry的key变为null,ThreadLocal提供remove()方法清理value,防止内存泄漏。

WeakHashMap:

WeakHashMap<Key, Value> map = new WeakHashMap<>();
Key key = new Key("myKey");
map.put(key, new Value());

key = null;  // 断开强引用
System.gc();  // GC后,map中的entry自动移除

3.2.4 虚引用 (Phantom Reference)

最弱的引用关系,对象是否有虚引用,完全不影响其生命周期,也无法通过虚引用获取对象实例。

唯一作用: 在对象被回收时收到系统通知。虚引用必须与ReferenceQueue联合使用。

import java.lang.ref.PhantomReference;
import java.lang.ref.ReferenceQueue;

public class PhantomReferenceDemo {
    public static void main(String[] args) throws InterruptedException {
        ReferenceQueue<User> queue = new ReferenceQueue<>();
        PhantomReference<User> phantomRef = new PhantomReference<>(new User(), queue);

        System.out.println("phantomRef.get(): " + phantomRef.get());  // 始终为null

        System.gc();
        Thread.sleep(100);

        // 对象回收后,虚引用进入队列
        System.out.println("队列中的引用: " + queue.poll());  // 不为null
    }
}

使用场景: 跟踪对象回收,管理堆外内存(NIO的DirectByteBuffer使用Cleaner,继承自PhantomReference)。

四种引用对比:

引用类型GC回收时机使用场景示例
强引用永不回收(除非无引用链)正常对象引用Object obj = new Object()
软引用内存不足时回收内存敏感的缓存SoftReference<T>
弱引用GC时立即回收规范化映射,防止内存泄漏WeakReference<T>, ThreadLocal
虚引用不影响回收,仅通知跟踪对象回收,管理堆外内存PhantomReference<T>

3.3 垃圾回收算法

gc-algorithms.svg

3.3.1 标记-清除算法 (Mark-Sweep)

最基础的垃圾回收算法,分为两个阶段:

标记阶段: 标记所有需要回收的对象(或标记所有存活的对象)。

清除阶段: 统一回收被标记的对象(或回收未被标记的对象)。

优点:

  • 实现简单

缺点:

  1. 执行效率不稳定: 如果堆中大量对象需要回收,标记和清除效率都随对象数量增长而降低
  2. 内存碎片化: 清除后产生大量不连续的内存碎片,可能导致分配大对象时无法找到连续内存而触发GC

适用场景: 老年代,对象存活率高但内存充足时。

3.3.2 复制算法 (Copying)

为了解决标记-清除算法的效率问题,复制算法将内存分为两块相等的区域,每次只使用其中一块。

回收过程:

  1. 当一块内存用完,将存活的对象复制到另一块
  2. 将已使用的内存一次清空
  3. 两块内存角色互换

优点:

  • 无内存碎片: 每次都是对整个半区回收,分配内存时只需移动堆顶指针,按顺序分配
  • 效率高: 只需遍历存活对象,适合存活对象少的场景

缺点:

  • 浪费内存: 可用内存缩减为原来的一半

改进: Appel式回收

新生代对象"朝生夕死",大部分对象第一次GC就会被回收,不需要1:1划分内存。HotSpot的Serial、ParNew等收集器采用Appel式回收:

  • 将新生代分为: 1个Eden区 + 2个Survivor区(S0、S1)
  • 比例: Eden:S0:S1 = 8:1:1
  • 每次使用Eden和其中一个Survivor,回收时将存活对象复制到另一个Survivor
  • 可用内存达到90%(Eden 80% + Survivor 10%)

空间分配担保:

如果Survivor空间不够,需要依赖老年代进行分配担保(Handle Promotion),存活对象直接进入老年代。

3.3.3 标记-整理算法 (Mark-Compact)

复制算法在对象存活率高时效率降低,且需要额外空间作分配担保,老年代不能使用。标记-整理算法是针对老年代特点设计的。

回收过程:

  1. 标记阶段: 与标记-清除算法一样,标记所有存活对象
  2. 整理阶段: 将所有存活对象向内存一端移动,然后清理边界以外的内存

优点:

  • 无内存碎片: 整理后内存连续
  • 无额外空间: 不需要复制算法的额外空间

缺点:

  • 移动对象成本高: 需要移动大量对象并更新所有引用这些对象的地方
  • Stop-The-World时间长: 移动过程必须暂停用户线程

适用场景: 老年代,对象存活率高,需要连续内存空间。

3.3.4 分代收集算法

generational-gc.svg

当前商业虚拟机的垃圾收集都采用分代收集(Generational Collection)理论,根据对象生命周期的不同,将堆分为新生代和老年代,针对不同代的特点采用不同的收集算法。

分代假说:

  1. 弱分代假说: 绝大多数对象都是朝生夕死
  2. 强分代假说: 熬过越多次GC的对象越难消亡

分代策略:

新生代 (Young Generation):

  • 特点: 每次GC都有大量对象死去,少量对象存活
  • 算法: 复制算法
  • GC类型: Minor GC / Young GC
  • 频率: 频繁
  • 耗时: 短(几十毫秒)

老年代 (Old Generation):

  • 特点: 对象存活率高,没有额外空间分配担保
  • 算法: 标记-清除 或 标记-整理
  • GC类型: Major GC / Full GC
  • 频率: 低
  • 耗时: 长(几百毫秒到几秒)

对象晋升规则:

  1. 年龄阈值: 对象在Survivor区每"熬过"一次Minor GC,年龄+1,当年龄达到阈值(默认15,-XX:MaxTenuringThreshold),晋升到老年代

  2. 动态年龄判定: 如果Survivor中相同年龄所有对象大小总和 > Survivor空间的一半,年龄 >= 该年龄的对象直接进入老年代

  3. 大对象直接进入老年代: 大于-XX:PretenureSizeThreshold的对象直接分配在老年代,避免在Eden和Survivor之间大量复制

  4. 空间分配担保: Minor GC前,检查老年代最大可用连续空间是否大于新生代所有对象总空间,如果不满足且不允许担保失败,提前触发Full GC

Minor GC、Major GC、Full GC区别:

  • Minor GC: 新生代GC,频繁,速度快
  • Major GC: 老年代GC,目前只有CMS收集器有单独的老年代回收行为
  • Full GC: 整堆回收(新生代+老年代+方法区),慢,需要尽量避免

3.4 垃圾收集器对比

3.4.1 Serial收集器

最古老的收集器,单线程,采用复制算法。

  • 优点: 简单高效,适合单CPU环境
  • 缺点: Stop-The-World,用户线程全部暂停
  • 适用: Client模式,桌面应用

3.4.2 ParNew收集器

Serial的多线程版本,新生代收集器,采用复制算法。

  • 特点: 除了多线程,其他行为与Serial一致
  • 优势: 多CPU环境下效率高
  • 搭配: 可以与CMS配合使用

3.4.3 Parallel Scavenge收集器

关注吞吐量的新生代收集器,采用复制算法。

  • 吞吐量优先: 吞吐量 = 运行用户代码时间 / (运行用户代码时间 + GC时间)
  • 自适应调节: -XX:+UseAdaptiveSizePolicy自动调节新生代大小、Eden与Survivor比例等
  • 适用: 后台计算任务,不需要太多交互

参数:

-XX:MaxGCPauseMillis=200      # 最大GC停顿时间
-XX:GCTimeRatio=99            # GC时间占比(1/(1+99)=1%)
-XX:+UseAdaptiveSizePolicy    # 自适应调节

3.4.4 Serial Old收集器

Serial的老年代版本,单线程,采用标记-整理算法。

3.4.5 Parallel Old收集器

Parallel Scavenge的老年代版本,多线程,采用标记-整理算法。

3.4.6 CMS收集器 (Concurrent Mark Sweep)

以获取最短回收停顿时间为目标,适合重视响应速度的应用。

四个阶段:

  1. 初始标记 (Initial Mark): STW,标记GC Roots直接关联的对象,速度快
  2. 并发标记 (Concurrent Mark): 从GC Roots直接关联对象开始遍历整个对象图,耗时长但与用户线程并发
  3. 重新标记 (Remark): STW,修正并发标记期间用户程序继续运行导致的标记变动,停顿时间稍长于初始标记
  4. 并发清除 (Concurrent Sweep): 清除标记为死亡的对象,与用户线程并发

优点:

  • 并发收集: 大部分时间与用户线程并发
  • 低停顿: 停顿时间短

缺点:

  1. CPU敏感: 并发阶段占用CPU资源,降低应用吞吐量
  2. 无法处理浮动垃圾: 并发标记和清除阶段用户线程继续产生新垃圾,无法在本次GC中处理,可能导致"Concurrent Mode Failure",触发Full GC
  3. 内存碎片: 采用标记-清除算法,产生大量碎片,可能提前触发Full GC

参数:

-XX:+UseConcMarkSweepGC              # 使用CMS
-XX:CMSInitiatingOccupancyFraction=80 # 老年代使用80%时触发GC
-XX:+UseCMSCompactAtFullCollection   # Full GC后整理内存

3.4.7 G1收集器 (Garbage-First)

JDK 9+的默认收集器,面向服务端,目标是在延迟可控的情况下获得尽可能高的吞吐量。

特点:

  • Region设计: 将堆划分为多个大小相等的Region,不再固定分代
  • 可预测停顿: 可以指定期望停顿时间(-XX:MaxGCPauseMillis)
  • 适合大堆: 适用于堆内存≥4GB的场景

详细内容将在下一篇文章讲解。

3.4.8 ZGC收集器

JDK 11引入的低延迟垃圾收集器

  • 亚毫秒级停顿: 停顿时间不超过10ms
  • 支持TB级堆: 支持8MB-16TB的堆
  • 染色指针技术: 通过指针元数据实现并发

详细内容将在下一篇文章讲解。

垃圾收集器组合对比:

新生代收集器老年代收集器特点适用场景
SerialSerial Old单线程,STWClient模式,桌面应用
ParNewCMS并发,低延迟互联网应用,堆<4GB
Parallel ScavengeParallel Old吞吐量优先后台计算,批处理
G1G1可预测停顿,适合大堆互联网应用,堆≥4GB
ZGCZGC超低延迟实时交易,延迟敏感

4. 实战场景应用

4.1 如何为不同业务选择垃圾收集器

场景1: Web应用 (电商平台、API服务)

业务特点:

  • 高并发、低延迟要求
  • 请求量大,峰值QPS可达数万
  • 要求RT(响应时间)稳定,TP99 < 100ms
  • 堆内存通常4GB-16GB

垃圾收集器选择:

  • 堆≥4GB: 推荐G1收集器
  • 堆<4GB: 推荐ParNew + CMS

G1配置示例:

# 启动参数
java -Xms8g -Xmx8g \
     -XX:+UseG1GC \
     -XX:MaxGCPauseMillis=200 \
     -XX:G1HeapRegionSize=16m \
     -XX:InitiatingHeapOccupancyPercent=45 \
     -XX:+PrintGCDetails \
     -XX:+PrintGCDateStamps \
     -Xloggc:/var/log/gc.log \
     -jar application.jar

# 参数说明:
# -Xms8g -Xmx8g: 堆大小固定8GB,避免动态扩容
# -XX:+UseG1GC: 使用G1收集器
# -XX:MaxGCPauseMillis=200: 期望最大停顿200ms
# -XX:G1HeapRegionSize=16m: Region大小16MB
# -XX:InitiatingHeapOccupancyPercent=45: 堆使用45%时触发并发标记

CMS配置示例:

java -Xms4g -Xmx4g \
     -Xmn2g \
     -XX:+UseConcMarkSweepGC \
     -XX:+UseParNewGC \
     -XX:CMSInitiatingOccupancyFraction=75 \
     -XX:+UseCMSInitiatingOccupancyOnly \
     -XX:+UseCMSCompactAtFullCollection \
     -XX:CMSFullGCsBeforeCompaction=3 \
     -XX:+PrintGCDetails \
     -Xloggc:/var/log/gc.log \
     -jar application.jar

# 参数说明:
# -Xmn2g: 新生代2GB (新生代:老年代=1:1)
# -XX:CMSInitiatingOccupancyFraction=75: 老年代75%时触发CMS
# -XX:+UseCMSCompactAtFullCollection: Full GC后整理碎片
# -XX:CMSFullGCsBeforeCompaction=3: 3次Full GC后压缩

场景2: 大数据计算 (Spark、Flink作业)

业务特点:

  • 吞吐量优先,对延迟不敏感
  • 计算密集型,大量对象创建
  • 批处理任务,允许长时间GC停顿

垃圾收集器选择: Parallel Scavenge + Parallel Old

配置示例:

java -Xms16g -Xmx16g \
     -XX:+UseParallelGC \
     -XX:+UseParallelOldGC \
     -XX:ParallelGCThreads=8 \
     -XX:MaxGCPauseMillis=1000 \
     -XX:GCTimeRatio=99 \
     -XX:+UseAdaptiveSizePolicy \
     -XX:+PrintGCDetails \
     -Xloggc:/var/log/gc.log \
     -jar spark-application.jar

# 参数说明:
# -XX:ParallelGCThreads=8: 并行GC线程数
# -XX:MaxGCPauseMillis=1000: 最大停顿1秒
# -XX:GCTimeRatio=99: GC时间占比1%
# -XX:+UseAdaptiveSizePolicy: 自适应调节

场景3: 实时交易系统 (支付、证券交易)

业务特点:

  • 极低延迟要求,TP99 < 10ms
  • 不能容忍长时间STW
  • 数据一致性要求高

垃圾收集器选择: ZGC 或 Shenandoah

ZGC配置示例:

java -Xms32g -Xmx32g \
     -XX:+UseZGC \
     -XX:ZCollectionInterval=120 \
     -XX:ZAllocationSpikeTolerance=5 \
     -XX:+PrintGCDetails \
     -Xlog:gc*:file=/var/log/gc.log \
     -jar trading-system.jar

# 参数说明:
# -XX:+UseZGC: 使用ZGC
# -XX:ZCollectionInterval=120: GC间隔最少120秒
# -XX:ZAllocationSpikeTolerance=5: 分配速率容忍度

垃圾收集器选择决策树:

是否对延迟极度敏感(TP99<10ms)?
├── 是 → ZGC / Shenandoah
└── 否 → 堆内存大小?
    ├── ≥4GB → G1
    ├── <4GB → ParNew + CMS
    └── 批处理/吞吐量优先 → Parallel Scavenge + Parallel Old

5. 生产案例与故障排查

5.1 案例1: Metaspace内存泄漏排查

故障现象:

  • 生产环境Java进程频繁OOM崩溃
  • 错误日志: java.lang.OutOfMemoryError: Metaspace
  • 监控显示元空间使用持续增长,Full GC无效

排查过程:

1. 查看元空间使用情况

# 查看进程号
jps -l

# 监控元空间
jstat -gc <pid> 1000 10
# 输出:
# MC(元空间容量) MU(元空间使用) CCSC(压缩类空间容量) CCSU(压缩类空间使用)
# 512000.0      509876.0       65536.0             64231.0
# 512000.0      510234.0       65536.0             64389.0
# 512000.0      510678.0       65536.0             64512.0
# 持续增长!

2. 查看类加载情况

# 查看类加载统计
jmap -clstats <pid>

# 输出显示:
# loaded classes = 45678 (持续增长)
# Metaspace       used 498M, capacity 512M, committed 512M

3. Dump内存快照分析

# Dump堆内存
jmap -dump:format=b,file=heap.hprof <pid>

# 使用MAT(Memory Analyzer Tool)分析
# 发现:大量Groovy相关的Class对象,未被卸载

问题分析:

  1. 动态类加载: 应用使用Groovy脚本引擎动态加载业务规则
  2. ClassLoader泄漏: 每次加载脚本都创建新的ClassLoader,但没有释放
  3. 类无法卸载: ClassLoader被其他对象持有引用,导致加载的类无法卸载

定位代码:

// 问题代码
public class RuleEngine {
    public Object executeScript(String script) {
        // 每次都创建新的GroovyClassLoader!!!
        GroovyClassLoader loader = new GroovyClassLoader();
        Class<?> clazz = loader.parseClass(script);
        return clazz.newInstance();
    }
}

解决方案:

// 修复后代码
public class RuleEngine {
    // 复用ClassLoader
    private static final GroovyClassLoader LOADER = new GroovyClassLoader();

    // 使用脚本缓存
    private static final Map<String, Class<?>> SCRIPT_CACHE = new ConcurrentHashMap<>();

    public Object executeScript(String script) {
        Class<?> clazz = SCRIPT_CACHE.computeIfAbsent(script, s -> {
            try {
                return LOADER.parseClass(s);
            } catch (Exception e) {
                throw new RuntimeException("Script compilation failed", e);
            }
        });

        try {
            return clazz.newInstance();
        } catch (Exception e) {
            throw new RuntimeException("Script execution failed", e);
        }
    }

    // 定期清理缓存
    public void clearCache() {
        SCRIPT_CACHE.clear();
    }
}

JVM参数调整:

# 增加Metaspace大小
-XX:MetaspaceSize=512m
-XX:MaxMetaspaceSize=1024m

# 启用类卸载
-XX:+CMSClassUnloadingEnabled
-XX:+ExplicitGCInvokesConcurrent

预防措施:

  1. 避免频繁创建ClassLoader
  2. 动态代理、Groovy脚本等使用缓存
  3. 监控元空间使用趋势
  4. 合理设置MaxMetaspaceSize,避免无限增长

5.2 案例2: 软引用缓存导致频繁Full GC

业务场景:

某电商平台使用SoftReference实现商品详情本地缓存,提升查询性能。

故障现象:

  • 系统突然变慢,接口RT飙升
  • 监控显示频繁Full GC,每次GC耗时2-3秒
  • CPU使用率高,大量时间消耗在GC上

排查过程:

1. 查看GC日志

# GC日志片段
2024-01-15T10:23:45.123+0800: [Full GC (Ergonomics)
[PSYoungGen: 512M->0K(1024M)]
[ParOldGen: 3072M->2890M(4096M)]
3584M->2890M(5120M), 2.3456789 secs]

# 每隔几秒就Full GC一次!

2. Dump堆内存分析

jmap -dump:live,format=b,file=heap.hprof <pid>

使用MAT分析,发现:

  • 堆内存被大量SoftReference对象占据
  • 这些软引用指向的是商品详情对象(每个约100KB)
  • 总共约3万个软引用,占用约3GB内存

问题分析:

SoftReference的回收时机:

// HotSpot的SoftReference回收算法(简化)
long ms = SoftRefLRUPolicyMSPerMB * freeMB;
if (currentTime - timestamp > ms) {
    // 回收软引用
}
  • SoftRefLRUPolicyMSPerMB: 默认1000ms
  • freeMB: 堆剩余空间(MB)
  • 问题: 内存充足时,软引用不会被回收,一直占用大量内存
  • 当内存不足时,大量软引用同时被回收,触发Full GC

代码示例:

// 问题代码
public class ProductCache {
    private Map<Long, SoftReference<Product>> cache = new ConcurrentHashMap<>();

    public Product getProduct(Long productId) {
        SoftReference<Product> ref = cache.get(productId);
        Product product = (ref == null) ? null : ref.get();

        if (product == null) {
            product = loadFromDB(productId);
            cache.put(productId, new SoftReference<>(product));
        }
        return product;
    }
}

问题:

  1. 缓存无上限,软引用对象可能积累数万个
  2. 内存充足时不回收,占用大量空间
  3. 内存不足时集中回收,Full GC耗时长

解决方案:

方案1: 使用Guava Cache (推荐)

import com.google.common.cache.Cache;
import com.google.common.cache.CacheBuilder;

public class ProductCache {
    private Cache<Long, Product> cache = CacheBuilder.newBuilder()
        .maximumSize(10000)              // 限制最大条目数
        .expireAfterWrite(30, TimeUnit.MINUTES)  // 写入后30分钟过期
        .expireAfterAccess(10, TimeUnit.MINUTES) // 访问后10分钟过期
        .weakValues()                    // 使用弱引用
        .recordStats()                   // 记录统计信息
        .build();

    public Product getProduct(Long productId) {
        try {
            return cache.get(productId, () -> loadFromDB(productId));
        } catch (ExecutionException e) {
            throw new RuntimeException("Failed to load product", e);
        }
    }

    // 监控缓存命中率
    public void printStats() {
        CacheStats stats = cache.stats();
        System.out.println("命中率: " + stats.hitRate());
        System.out.println("驱逐数: " + stats.evictionCount());
    }
}

方案2: 使用Redis分布式缓存

@Service
public class ProductCache {
    @Autowired
    private RedisTemplate<String, Product> redisTemplate;

    private static final String CACHE_KEY_PREFIX = "product:";
    private static final long CACHE_TTL = 30 * 60; // 30分钟

    public Product getProduct(Long productId) {
        String key = CACHE_KEY_PREFIX + productId;

        // 先查缓存
        Product product = redisTemplate.opsForValue().get(key);
        if (product != null) {
            return product;
        }

        // 缓存未命中,查DB
        product = loadFromDB(productId);
        if (product != null) {
            redisTemplate.opsForValue().set(key, product, CACHE_TTL, TimeUnit.SECONDS);
        }

        return product;
    }
}

效果:

  • Full GC频率从每分钟数次降低到每小时1次
  • 接口RT从300ms降低到50ms
  • 堆内存使用稳定在50%左右

经验总结:

  1. SoftReference不适合做通用缓存: 回收时机不可控,可能积累大量对象
  2. 使用专业缓存框架: Guava Cache、Caffeine可以精确控制大小和过期策略
  3. 分布式缓存: Redis等可以减轻JVM内存压力
  4. 监控缓存命中率: 定期检查,调整策略

6. 常见问题与避坑指南

6.1 为什么需要分代?

分代的本质是根据对象的生命周期特点采用不同的GC策略。

  1. 弱分代假说: 大部分对象朝生夕死 → 新生代使用复制算法,高效回收
  2. 强分代假说: 老对象难以消亡 → 老年代使用标记-整理,减少移动成本
  3. 性能优化: 分代后Minor GC只需扫描新生代,速度快;Full GC才扫描整堆,频率低

6.2 什么对象会直接进入老年代?

  1. 大对象: 大于-XX:PretenureSizeThreshold(默认0,即不限制),避免在新生代频繁复制

    -XX:PretenureSizeThreshold=1048576  # 1MB
    
  2. 长期存活对象: 年龄达到-XX:MaxTenuringThreshold(默认15)

  3. 动态年龄判定: Survivor中相同年龄所有对象大小总和 > Survivor空间一半,年龄≥该年龄的对象直接晋升

  4. 空间分配担保: Minor GC前,如果老年代最大连续空间 < 新生代所有对象,且不允许担保失败,提前Full GC腾出空间

6.3 Full GC和Minor GC的区别?

维度Minor GCFull GC
回收区域新生代整堆(新生代+老年代+方法区)
触发条件Eden区满老年代满、System.gc()、空间分配担保失败
频率高(几秒到几十秒)低(几分钟到几小时)
耗时短(几ms到几十ms)长(几百ms到几秒)
影响大,STW时间长

避免Full GC的策略:

  • 合理分配新生代和老年代大小
  • 避免创建大对象
  • 及时释放无用对象
  • 调优晋升阈值

6.4 为什么要有两个Survivor区?

如果只有一个Survivor区:

Eden (满) → GC → Survivor (存活对象)
下次GC:
Eden (满) + Survivor (混合新老对象) → GC → ?
  • 无法区分哪些是新对象、哪些是老对象
  • 无法统计对象年龄
  • 无法实现分代晋升

两个Survivor区的作用:

Eden (满) → GC → S0 (存活对象,年龄1)
下次GC:
Eden (满) + S0 (年龄1) → GC → S1 (年龄1和年龄2混合)
S0清空,角色互换
  • 明确区分: 一个From区(有对象),一个To区(空)
  • 年龄计数: 每次GC,存活对象年龄+1
  • 减少碎片: 复制算法保证Survivor无碎片

6.5 对象一定在堆上分配吗?

不一定!JVM有优化:

1. 栈上分配 (Stack Allocation)

如果逃逸分析证明对象不会逃逸出方法(不会被外部访问),可以直接在栈上分配,方法结束后自动回收,无需GC。

public void method() {
    User user = new User();  // 不逃逸,可能栈上分配
    user.setName("Alice");
    // 方法结束,user自动回收
}

2. 标量替换 (Scalar Replacement)

如果对象不逃逸且可以拆解为基本类型,直接用局部变量代替对象。

// 原始代码
public int sum() {
    Point p = new Point(1, 2);
    return p.x + p.y;
}

// 标量替换后
public int sum() {
    int x = 1;
    int y = 2;
    return x + y;
    // 没有创建Point对象!
}

3. TLAB (Thread Local Allocation Buffer)

每个线程在Eden区预先分配一小块内存(TLAB),线程在自己的TLAB上分配对象,无需同步,只有TLAB用完时才需要同步分配新的TLAB。

启用参数:

-XX:+DoEscapeAnalysis        # 启用逃逸分析(默认开启)
-XX:+EliminateAllocations    # 启用标量替换(默认开启)
-XX:+UseTLAB                 # 启用TLAB(默认开启)

6.6 什么是Stop-The-World?

Stop-The-World(STW) 是指GC时暂停所有用户线程,只有GC线程工作。

为什么需要STW?

  • 对象图遍历: GC需要遍历对象引用关系,如果用户线程继续运行,对象引用不断变化,无法得到一致的快照
  • 移动对象: 复制算法和标记-整理算法需要移动对象,移动时必须暂停用户线程,否则引用会失效

如何减少STW影响?

  1. 并发GC: CMS、G1、ZGC将大部分工作与用户线程并发
  2. 减少GC频率: 合理配置堆大小,减少不必要的GC
  3. 降低单次GC停顿: 分批处理,如G1的Region设计

6.7 永久代为什么被移除?

JDK 8用元空间(Metaspace)替代永久代的原因:

  1. 大小难以确定: 永久代大小需要提前设置,类加载数量差异大,容易OOM或浪费
  2. GC效率低: 永久代的Full GC条件苛刻,类卸载困难
  3. 融合HotSpot与JRockit: JRockit没有永久代概念,为了统一
  4. 简化GC: 元空间使用本地内存,不受JVM堆大小限制,GC更简单

6.8 什么是类卸载?如何触发?

类卸载: 从方法区移除不再使用的类信息。

卸载条件(同时满足):

  1. 该类的所有实例都已被回收
  2. 加载该类的ClassLoader已被回收
  3. 该类的java.lang.Class对象没有任何地方被引用

触发场景:

  • 自定义ClassLoader加载的类(如Tomcat的WebappClassLoader)
  • 应用重新部署
  • 动态代理类

查看类卸载:

-XX:+TraceClassUnloading  # 打印类卸载信息

7. 最佳实践与总结

7.1 JVM参数配置模板

7.1.1 Web应用 (4核8GB服务器)

java -Xms4g -Xmx4g \
     -Xmn2g \
     -Xss256k \
     -XX:+UseG1GC \
     -XX:MaxGCPauseMillis=200 \
     -XX:G1HeapRegionSize=8m \
     -XX:InitiatingHeapOccupancyPercent=45 \
     -XX:MetaspaceSize=256m \
     -XX:MaxMetaspaceSize=512m \
     -XX:+HeapDumpOnOutOfMemoryError \
     -XX:HeapDumpPath=/var/log/heapdump.hprof \
     -XX:+PrintGCDetails \
     -XX:+PrintGCDateStamps \
     -Xloggc:/var/log/gc.log \
     -XX:+UseGCLogFileRotation \
     -XX:NumberOfGCLogFiles=10 \
     -XX:GCLogFileSize=100M \
     -jar application.jar

7.1.2 微服务 (2核4GB服务器)

java -Xms2g -Xmx2g \
     -XX:+UseG1GC \
     -XX:MaxGCPauseMillis=100 \
     -XX:MetaspaceSize=128m \
     -XX:MaxMetaspaceSize=256m \
     -XX:+HeapDumpOnOutOfMemoryError \
     -XX:HeapDumpPath=/var/log/heapdump.hprof \
     -Xloggc:/var/log/gc.log \
     -jar microservice.jar

7.1.3 大数据计算 (16核32GB服务器)

java -Xms24g -Xmx24g \
     -XX:+UseParallelGC \
     -XX:ParallelGCThreads=16 \
     -XX:MaxGCPauseMillis=1000 \
     -XX:GCTimeRatio=99 \
     -XX:+UseAdaptiveSizePolicy \
     -XX:MetaspaceSize=512m \
     -XX:MaxMetaspaceSize=1g \
     -Xloggc:/var/log/gc.log \
     -jar spark-app.jar

7.2 堆大小设置建议

基本原则:

  1. -Xms = -Xmx: 避免动态扩容,减少性能抖动
  2. 堆大小 = 物理内存的60-80%: 留给操作系统和其他进程
  3. 新生代 = 堆的30-40%: 根据对象创建频率调整
  4. 元空间根据应用: 简单应用128-256MB,复杂应用512MB-1GB

示例:

  • 8GB物理内存 → 堆4-6GB → 新生代2GB → 元空间256MB
  • 16GB物理内存 → 堆10-12GB → 新生代4GB → 元空间512MB
  • 32GB物理内存 → 堆20-24GB → 新生代8GB → 元空间1GB

7.3 垃圾收集器选择指南

1. 堆内存大小?
   ├── ≥16GB → 考虑ZGC (超低延迟)
   ├── ≥4GB  → G1 (平衡性能和延迟)
   └── <4GB  → ParNew + CMS 或 G1

2. 业务类型?
   ├── 低延迟要求(RT<100ms)  → G1 或 ZGC
   ├── 吞吐量优先(批处理)    → Parallel Scavenge + Parallel Old
   ├── 实时交易(RT<10ms)     → ZGC
   └── 一般Web应用          → G1

3. JDK版本?
   ├── JDK 11+ → 优先考虑ZGC
   ├── JDK 9+  → 默认G1
   └── JDK 8   → G1 或 CMS

7.4 内存监控指标

关键指标:

  1. 堆内存使用率: 不超过80%
  2. Minor GC频率: 每分钟不超过10次
  3. Minor GC耗时: <50ms
  4. Full GC频率: 每小时不超过1次
  5. Full GC耗时: <1秒
  6. 元空间使用率: 不超过80%

监控工具:

  • jstat: jstat -gc <pid> 1000
  • jmap: jmap -heap <pid>
  • JConsole / VisualVM: 可视化监控
  • Prometheus + Grafana: 生产环境监控

7.5 常见内存泄漏场景

  1. 静态集合类持有对象引用

    public class Cache {
        private static Map<String, Object> cache = new HashMap<>();
    
        public void put(String key, Object value) {
            cache.put(key, value);  // 永不清理,内存泄漏!
        }
    }
    
  2. 监听器未注销

    component.addListener(listener);  // 添加监听器
    // 组件销毁时,忘记移除监听器,导致组件无法回收
    
  3. ThreadLocal未清理

    ThreadLocal<User> userThreadLocal = new ThreadLocal<>();
    userThreadLocal.set(user);  // 使用后忘记remove(),线程池场景会泄漏
    
  4. JDBC连接未关闭

    Connection conn = dataSource.getConnection();
    // 使用连接...
    // 忘记conn.close(),连接泄漏
    

解决方法:

  • 使用弱引用包装缓存
  • 显式注销监听器
  • ThreadLocal使用后调用remove()
  • 使用try-with-resources自动关闭资源

总结

JVM内存模型和垃圾回收是Java性能优化的核心。理解运行时数据区域的划分、掌握不同垃圾回收算法的适用场景、合理选择垃圾收集器,是构建高性能Java应用的基础。

核心要点:

  1. 内存模型: 线程私有(程序计数器、虚拟机栈、本地方法栈) + 线程共享(堆、方法区)
  2. 对象生命周期: GC Roots可达性分析 → 不可达对象回收
  3. GC算法: 新生代复制算法,老年代标记-整理,分代收集提升效率
  4. 垃圾收集器: G1适合大多数场景,ZGC适合低延迟,Parallel适合吞吐量优先
  5. 调优原则: 合理配置堆大小,监控GC指标,避免频繁Full GC

参考资料:

  • 《深入理解Java虚拟机:JVM高级特性与最佳实践(第3版)》- 周志明
  • 《Java性能权威指南》- Scott Oaks
  • Oracle官方JVM文档
  • OpenJDK源码