目录
一、JVM 内存结构:程序运行的 “空间布局”
1. 程序计数器(Program Counter Register)
2. Java 虚拟机栈(Java Virtual Machine Stack)
3. 本地方法栈(Native Method Stack)
4. Java 堆(Java Heap)
5. 方法区(Method Area)
二、垃圾回收算法:如何 “识别” 与 “清理” 垃圾
1. 垃圾识别:如何判断对象已 “死亡”
2. 引用类型:对象 “存活” 的不同状态
3. 垃圾清理算法:如何释放内存
三、垃圾回收器:算法的 “具体实现者”
1. Serial 收集器
2. ParNew 收集器
3. Parallel Scavenge 收集器
4. Serial Old 收集器
5. Parallel Old 收集器
6. CMS 收集器
7. G1 收集器
四、总结:优化 Java 程序的内存管理
在 Java 开发中,我们常常专注于业务逻辑的实现,却很少直面程序运行时的 “幕后管家”——JVM(Java 虚拟机)。它默默承担着内存分配、回收和资源调度的重任,直接影响着程序的性能与稳定性。本文将带你深入剖析 JVM 的内存结构、垃圾回收算法及垃圾回收器的工作原理,帮你揭开 Java 内存管理的神秘面纱。
一、JVM 内存结构:程序运行的 “空间布局”
JVM 的内存结构就像一个精密的 “办公大楼”,不同区域各司其职,共同支撑着 Java 程序的运行。按照《Java 虚拟机规范》,JVM 内存主要分为以下几个核心区域:
1. 程序计数器(Program Counter Register)
程序计数器是一块极小的内存空间,它可以看作是当前线程所执行的字节码的行号指示器。在 Java 多线程环境中,每个线程都有独立的程序计数器,用于记录线程切换时的执行位置,确保线程恢复运行时能回到正确的执行点。此区域是 JVM 中唯一不会发生 OutOfMemoryError 的区域。
2. Java 虚拟机栈(Java Virtual Machine Stack)
Java 虚拟机栈与线程生命周期绑定,每个线程创建时都会分配一个虚拟机栈。栈由一个个栈帧组成,每个栈帧对应一次方法调用,包含局部变量表、操作数栈、动态链接、方法出口等信息。局部变量表存储了编译期可知的各种基本数据类型(如 int、boolean)、对象引用(reference 类型)和 returnAddress 类型。
当线程请求的栈深度超过虚拟机允许的深度时,会抛出StackOverflowError;若虚拟机栈可以动态扩展(大部分实现都支持),而扩展时无法申请到足够内存,则会抛出OutOfMemoryError。
3. 本地方法栈(Native Method Stack)
本地方法栈与虚拟机栈功能类似,区别在于虚拟机栈为 Java 方法服务,而本地方法栈为虚拟机使用的本地(Native)方法服务。本地方法通常由 C/C++ 编写,如 Object 类的 clone ()、Thread 类的 start0 () 等。其异常机制与虚拟机栈一致,也会抛出 StackOverflowError 和 OutOfMemoryError。
4. Java 堆(Java Heap)
Java 堆是 JVM 管理的内存中最大的一块,被所有线程共享,在虚拟机启动时创建。此区域的唯一目的是存放对象实例,几乎所有的对象实例都在这里分配内存(随着 JIT 编译器的发展和逃逸分析技术的成熟,栈上分配、标量替换等优化使这一说法有所动摇,但堆仍是对象分配的主要区域)。
Java 堆是垃圾收集器管理的主要区域,因此也被称为 “GC 堆”。从内存回收角度看,堆可分为新生代(Eden 区、From Survivor 区、To Survivor 区)和老年代;从内存分配角度看,堆中可能存在多个线程私有的分配缓冲区(TLAB)。堆无法满足内存分配需求时,会抛出 OutOfMemoryError。
5. 方法区(Method Area)
方法区与 Java 堆一样,是所有线程共享的内存区域,用于存储已被虚拟机加载的类信息、常量、静态变量、即时编译器编译后的代码等数据。在 JDK8 之前,方法区的实现被称为 “永久代”,而 JDK8 及以后,永久代被元空间(Metaspace)取代,元空间并不在虚拟机内存中,而是使用本地内存,这使得方法区的内存管理更加灵活,减少了 OOM 的可能性。
方法区的内存回收目标主要是针对常量池的回收和对类型的卸载,当方法区无法满足内存分配需求时,会抛出 OutOfMemoryError。
二、垃圾回收算法:如何 “识别” 与 “清理” 垃圾
垃圾回收(GC)的核心任务是 “识别” 出不再被使用的对象(垃圾),并 “清理” 这些对象占用的内存,使内存可被重新利用。
1. 垃圾识别:如何判断对象已 “死亡”
- 引用计数法:给每个对象设置一个引用计数器,每当有一个地方引用它时,计数器值加 1;当引用失效时,计数器值减 1;任何时刻计数器为 0 的对象就是不可能再被使用的。但这种方法难以解决对象之间相互循环引用的问题(如对象 A 引用对象 B,对象 B 引用对象 A,此时两者计数器都为 1,但实际上已无其他引用),因此 Java 虚拟机并未采用这种方法。
- 可达性分析算法:通过一系列称为 “GC Roots” 的根对象作为起始点,从这些节点开始向下搜索,搜索走过的路径称为引用链,当一个对象到 GC Roots 没有任何引用链相连(即从 GC Roots 到该对象不可达)时,则证明此对象是不可用的。在 Java 中,GC Roots 包括:虚拟机栈中引用的对象、方法区中类静态属性引用的对象、方法区中常量引用的对象、本地方法栈中 JNI(即一般说的 Native 方法)引用的对象等。
2. 引用类型:对象 “存活” 的不同状态
Java 中的引用并非只有 “引用” 和 “未引用” 两种状态,而是分为强引用、软引用、弱引用和虚引用,不同引用类型的对象在垃圾回收时的处理方式不同:
- 强引用:最常见的引用类型,如Object obj = new Object(),只要强引用存在,垃圾回收器就不会回收被引用的对象。
- 软引用:用来描述一些还有用但非必需的对象。在系统将要发生内存溢出异常之前,会把这些对象列进回收范围进行第二次回收。若这次回收还没有足够的内存,才会抛出内存溢出异常。
- 弱引用:用来描述非必需对象,但强度比软引用更弱。被弱引用关联的对象只能生存到下一次垃圾回收发生之前,当垃圾回收器工作时,无论当前内存是否足够,都会回收掉只被弱引用关联的对象。
- 虚引用:也称为 “幽灵引用” 或 “幻影引用”,是最弱的一种引用关系。一个对象是否有虚引用的存在,完全不会对其生存时间构成影响,也无法通过虚引用来获取一个对象的实例。它的唯一目的是在这个对象被收集器回收时收到一个系统通知。
3. 垃圾清理算法:如何释放内存
- 标记 – 清除算法:首先标记出所有需要回收的对象,在标记完成后统一回收所有被标记的对象。此算法的不足是:标记和清除过程效率不高;清除后会产生大量不连续的内存碎片,导致后续需要分配较大对象时,无法找到足够的连续内存而不得不提前触发另一次垃圾回收。
- 标记 – 复制算法:将可用内存按容量划分为大小相等的两块,每次只使用其中一块。当这一块的内存用完了,就将还存活着的对象复制到另一块上面,然后再把已使用过的内存空间一次清理掉。这种算法避免了内存碎片,但代价是将内存缩小为原来的一半,比较适合新生代(新生代对象存活率低,复制成本低)。
- 标记 – 整理算法:标记过程与 “标记 – 清除” 算法一样,但后续步骤不是直接对可回收对象进行清理,而是让所有存活的对象都向一端移动,然后直接清理掉端边界以外的内存。这种算法适用于老年代(老年代对象存活率高,复制成本高),避免了内存碎片,且不需要牺牲一半内存空间。
- 分代收集算法:当前商业虚拟机的垃圾收集都采用 “分代收集” 算法,其核心思想是根据对象存活周期的不同将内存划分为几块(新生代和老年代),根据各年代的特点采用最适当的收集算法。新生代中,每次垃圾收集时都发现有大量对象死去,只有少量存活,采用 “标记 – 复制” 算法;老年代中,对象存活率高、没有额外空间对它进行分配担保,就采用 “标记 – 清理” 或 “标记 – 整理” 算法。
三、垃圾回收器:算法的 “具体实现者”
垃圾回收器是垃圾回收算法的具体实现,不同的垃圾回收器有不同的特点和适用场景。
1. Serial 收集器
Serial 收集器是最基本、历史最悠久的垃圾收集器,它是一个单线程收集器。它在进行垃圾收集时,必须暂停其他所有工作线程(“Stop The World”),直到它收集结束。虽然会带来停顿,但 Serial 收集器简单高效,对于单个 CPU 环境来说,没有线程交互的开销,专心做垃圾收集自然可以获得最高的单线程收集效率。在 Client 模式下的虚拟机中,Serial 收集器是默认的新生代收集器。
2. ParNew 收集器
ParNew 收集器是 Serial 收集器的多线程版本,除了使用多线程进行垃圾收集外,其余行为(如回收算法、Stop The World、内存分配规则、回收策略等)都与 Serial 收集器完全一样。它是许多运行在 Server 模式下的虚拟机中首选的新生代收集器,主要原因是它能与 CMS 收集器配合工作(CMS 是老年代收集器,可与 ParNew 组合使用)。
3. Parallel Scavenge 收集器
Parallel Scavenge 收集器是一个新生代收集器,使用标记 – 复制算法,也是多线程收集器。它的目标是达到一个可控制的吞吐量(吞吐量 = 运行用户代码时间 /(运行用户代码时间 + 垃圾收集时间))。高吞吐量意味着高效利用 CPU 时间,适合在后台运算而不需要太多交互的任务。Parallel Scavenge 收集器提供了两个参数用于精确控制吞吐量:-XX:MaxGCPauseMillis(控制最大垃圾收集停顿时间)和 – XX:GCTimeRatio(设置吞吐量大小)。
4. Serial Old 收集器
Serial Old 是 Serial 收集器的老年代版本,单线程收集器,使用标记 – 整理算法。主要用于 Client 模式下的虚拟机,在 Server 模式下,它可作为 CMS 收集器的后备预案,在 CMS 收集发生 Concurrent Mode Failure 时使用。
5. Parallel Old 收集器
Parallel Old 是 Parallel Scavenge 收集器的老年代版本,使用多线程和标记 – 整理算法。它的出现使得 “Parallel Scavenge + Parallel Old” 组合成为了注重吞吐量的应用程序的首选,在大型服务器端应用中表现出色。
6. CMS 收集器
CMS(Concurrent Mark Sweep)收集器是一种以获取最短回收停顿时间为目标的收集器。它基于 “标记 – 清除” 算法实现,工作过程分为四个步骤:初始标记、并发标记、重新标记、并发清除。其中,初始标记和重新标记仍需要 “Stop The World”,但初始标记仅标记 GC Roots 能直接关联到的对象,速度很快;并发标记阶段是从 GC Roots 的直接关联对象开始遍历整个对象图的过程,耗时较长但可与用户线程并发执行;重新标记阶段是为了修正并发标记期间因用户线程继续运作而导致标记产生变动的那一部分对象的标记记录,耗时比初始标记长,但远小于并发标记;并发清除阶段清理删除掉标记阶段判断的已经死亡的对象,可与用户线程并发执行。
CMS 收集器的优点是并发收集、低停顿,但也存在一些缺点:对 CPU 资源非常敏感(并发阶段会占用一部分 CPU 资源,影响用户线程执行);无法处理浮动垃圾(并发清除阶段用户线程产生的新垃圾,只能到下一次 GC 再清理);基于 “标记 – 清除” 算法,会产生大量内存碎片,可能导致频繁的 Full GC。
7. G1 收集器
G1(Garbage-First)收集器是面向服务端应用的垃圾收集器,它的特点包括:并行与并发(充分利用多 CPU、多核环境的硬件优势,缩短 Stop The World 停顿时间);分代收集(不需要其他收集器配合就能独立管理整个堆);空间整合(基于标记 – 整理算法,局部基于标记 – 复制算法,不会产生内存碎片);可预测的停顿(能让使用者明确指定在一个长度为 M 毫秒的时间片段内,消耗在垃圾收集上的时间不得超过 N 毫秒)。
G1 收集器将整个 Java 堆划分为多个大小相等的独立区域(Region),虽然还保留新生代和老年代的概念,但它们不再是物理隔离的,而是一部分 Region 的集合。G1 的运作过程大致可分为四个步骤:初始标记、并发标记、最终标记、筛选回收。筛选回收阶段会根据每个 Region 的回收价值(回收所获得的空间大小以及回收所需时间)和成本,排序出一个回收计划,优先回收价值高的 Region,这也是 “Garbage-First” 名称的由来。
四、总结:优化 Java 程序的内存管理
理解 JVM 内存结构和垃圾回收机制,是编写高性能、高稳定性 Java 程序的基础。在实际开发中,我们可以通过以下方式优化内存管理:
- 合理设置堆内存大小(-Xms 和 – Xmx),避免堆内存过小导致频繁 GC,或过大造成内存浪费。
- 根据应用特点选择合适的垃圾回收器,如对响应时间要求高的应用可选择 G1 或 CMS,对吞吐量要求高的应用可选择 Parallel Scavenge + Parallel Old。
- 避免创建过多的临时对象,减少 GC 压力。
- 正确使用对象引用类型,对于缓存等场景,可使用软引用或弱引用,避免内存泄漏。
JVM 的内存管理和垃圾回收是一个复杂而精妙的系统,深入学习和实践,才能让我们在 Java 开发的道路上走得更远。希望本文能为你打开一扇通往 JVM 底层世界的大门,让你对 Java 程序的运行机制有更清晰的认识。
评论前必须登录!
注册