深入解析Java运行机制与JVM内存模型
Java的运行过程
Java 源文件通过编译器,能够生产相应的.class 文件,也就是字节码文件,而字节码文件又通过 Java 虚拟机中的解释器,编译成特定机器上的机器码 。
① Java 源文件—->编译器—->字节码文件
② 字节码文件—->JVM—->机器码
每一种平台的解释器是不同的,但是实现的虚拟机是相同的,这也就是 Java 为什么能够跨平台的原因了 ,当一个程序从开始运行,这时虚拟机就开始实例化了,多个程序启动就会存在多个虚拟机实例。程序退出或者关闭,则虚拟机实例消亡,多个虚拟机实例之间数据不能共享。
JVM内存模型
从类加载到程序执行的完整流程:
从类文件的加载开始,到程序的实际执行,帮助开发者全面理解JVM的工作原理。
1. 类装载子系统(Class Loader)
1.1 功能概述
类装载子系统是JVM的入口点,负责将编译后的字节码文件(.class文件)加载到JVM内存中。这个过程包括三个主要阶段:
- 加载(Loading):查找并读取类文件的二进制数据
- 链接(Linking):验证、准备和解析类文件
- 初始化(Initialization):执行类构造器和静态初始化块
1.2 类加载器层次结构
JVM采用双亲委派模型,包含三种主要的类加载器:
- 启动类加载器(Bootstrap ClassLoader):加载Java核心库
- 扩展类加载器(Extension ClassLoader):加载扩展库
- 应用程序类加载器(Application ClassLoader):加载应用程序类路径下的类
1.3 加载过程
Class files → 类装载子系统 → 运行时数据区
类装载子系统将字节码转换为JVM能够理解的格式,并在运行时数据区中为类创建相应的数据结构。
2. 运行时数据区(Runtime Data Area)
运行时数据区是JVM内存管理的核心,分为多个不同功能的区域:
2.1 方法区(Method Area)
特点与功能:
- 共享性:所有线程共享
- 持久性:从JVM启动到关闭一直存在
- 存储内容:
- 类的元数据信息(类名、父类、接口信息等)
- 方法信息(方法名、参数、返回值类型等)
- 静态变量
- 常量池
- 即时编译器编译后的代码
实现变化
- JDK 8之前:使用永久代(PermGen)实现
- JDK 8及以后:使用元空间(Metaspace)实现,使用本地内存
2.2 堆(Heap)
核心特征
- 最大内存区域:JVM中最大的内存区域
- 垃圾回收主战场:主要的垃圾收集区域
- 对象存储:几乎所有对象实例都在此分配
内存分代
堆内存
├── 新生代(Young Generation)
│ ├── Eden区
│ ├── Survivor 0区
│ └── Survivor 1区
└── 老年代(Old Generation)
新生代特点:
- 大部分对象在此创建
- 垃圾回收频繁,回收速度快
- 采用复制算法进行垃圾回收
老年代特点:
- 存放长期存活的对象
- 垃圾回收频率低,但耗时长
- 采用标记-清除或标记-整理算法
2.3 Java栈(Java Stack)
基本特性
- 线程私有:每个线程都有独立的Java栈
- 生命周期:与线程同步创建和销毁
- LIFO结构:后进先出的数据结构
栈帧结构
每个方法调用都会创建一个栈帧,包含:
栈帧
├── 局部变量表
├── 操作数栈
├── 动态链接
└── 方法返回地址
局部变量表:
- 存储方法参数和局部变量
- 以变量槽(Slot)为最小单位
- 编译期间确定大小
操作数栈:
- 用于方法执行过程中的操作数存储
- 支持各种数据类型的操作
- 栈深度在编译期确定
2.4 本地方法栈(Native Method Stack)
功能定位
- 专用性:专门为本地方法服务
- JNI支持:支持Java调用C/C++等本地代码
- 实现差异:不同JVM实现方式不同
与Java栈的区别:
特性 | Java栈 | 本地方法栈 |
服务对象 | Java方法 | 本地方法 |
实现规范 | JVM规范严格定义 | 实现相对自由 |
异常类型 | StackOverflowError | 实现相关 |
2.5 程序计数器(Program Counter Register)
核心作用
- 指令跟踪:记录当前线程执行的字节码指令地址
- 线程切换:支持多线程环境下的上下文切换
- 分支控制:支持循环、跳转、异常处理等控制流
特殊性质
- 最小内存区域:占用内存空间最小
- 无垃圾回收:不会发生内存溢出
- 线程私有:每个线程独立维护
3. 执行引擎(Execution Engine)
3.1 功能职责
执行引擎是JVM的心脏,负责执行字节码指令:
- 字节码解释:将字节码翻译为机器码
- 即时编译:将热点代码编译为本地机器码
- 垃圾回收:管理内存的自动回收
3.2 执行方式
解释执行
字节码 → 解释器 → 机器码 → 执行
- 优点:启动快,内存占用少
- 缺点:执行速度相对较慢
编译执行
字节码 → JIT编译器 → 本地代码 → 执行
- 优点:执行速度快
- 缺点:编译耗时,内存占用大
3.3 HotSpot虚拟机的优化策略
- 热点检测:识别频繁执行的代码
- 分层编译:结合解释执行和编译执行
- 内联优化:将方法调用直接替换为方法体
- 逃逸分析:优化对象分配策略
4. 本地方法接口(Native Interface)
4.1 设计目的
本地方法接口提供了Java与其他语言交互的桥梁:
- 系统调用:访问操作系统功能
- 性能提升:利用本地代码的高性能
- 遗留系统:集成现有的C/C++代码库
4.2 JNI机制
Java Native Interface(JNI)是标准的本地编程接口:
// Java声明本地方法
public native int nativeMethod(int param);// 加载本地库
static {System.loadLibrary("nativelib");
}
4.3 本地方法库
本地方法库包含了实际的本地代码实现:
- 动态链接库:Windows上的.dll文件
- 共享对象:Linux上的.so文件
- 运行时加载:通过System.loadLibrary()加载
JVM内存区域
JVM 内存区域主要分为线程私有区域【程序计数器、虚拟机栈、本地方法区】、线程共享区域【JAVA 堆、方法区】、直接内存。
线程私有数据区域生命周期与线程相同, 依赖用户线程的启动/结束 而 创建/销毁(在 Hotspot JVM 内, 每个线程都与操作系统的本地线程直接映射, 因此这部分内存区域的存/否跟随本地线程的生/死对应)。
线程共享区域随虚拟机的启动/关闭而创建/销毁。
直接内存并不是 JVM 运行时数据区的一部分, 但也会被频繁的使用: 在 JDK 1.4 引入的 NIO 提供了基于 Channel 与 Buffer 的 IO 方式, 它可以使用 Native 函数库直接分配堆外内存, 然后使用DirectByteBuffer 对象作为这块内存的引用进行操作(详见: Java I/O 扩展), 这样就避免了在 Java堆和 Native 堆中来回复制数据, 因此在一些场景中可以显著提高性能。
程序计数器( 线程私有)
程序计数器( 线程私有):一块较小的内存空间, 是当前线程所执行的字节码的行号指示器,每条线程都要有一个独立的程序计数器,这类内存也称为“线程私有”的内存。
正在执行 java 方法的话,计数器记录的是虚拟机字节码指令的地址(当前指令的地址)。如果还是 Native 方法,则为空。
这个内存区域是唯一一个在虚拟机中没有规定任何 OutOfMemoryError 情况的区域。
虚拟机栈( 线程私有)
虚拟机栈( 线程私有):是描述java方法执行的内存模型,每个方法在执行的同时都会创建一个栈帧(Stack Frame)用于存储局部变量表、操作数栈、动态链接、方法出口等信息。每一个方法从调用直至执行完成
的过程,就对应着一个栈帧在虚拟机栈中入栈到出栈的过程。
栈帧( Frame)是用来存储数据和部分过程结果的数据结构,同时也被用来处理动态链接(Dynamic Linking)、 方法返回值和异常分派( Dispatch Exception)。栈帧随着方法调用而创建,随着方法结束而销毁——无论方法是正常完成还是异常完成(抛出了在方法内未被捕获的异常)都算作方法结束。
本地方法区( 线程私有)
本地方法区和 Java Stack 作用类似, 区别是虚拟机栈为执行 Java 方法服务, 而本地方法栈则为Native 方法服务, 如果一个 VM 实现使用 C-linkage 模型来支持 Native 调用, 那么该栈将会是一个C 栈,但 HotSpot VM 直接就把本地方法栈和虚拟机栈合二为一。
JVM中的本地方法栈(Native Method Stack)和Java虚拟机栈在结构和机制上确实相似,都用于管理方法调用的栈帧,但服务对象不同:虚拟机栈专门处理Java字节码方法的调用,而本地方法栈负责处理通过JNI(Java Native Interface)调用的Native方法(如C/C++编写的方法)。在传统的JVM实现中,当需要调用Native方法时,会切换到本地方法栈,如果JVM使用C-linkage模型支持Native调用,本地方法栈实际上就是一个标准的C语言调用栈,遵循C语言的调用约定和内存管理方式。然而,HotSpot JVM为了简化实现和提高性能,采用了更加集成的设计方案:它将本地方法栈和Java虚拟机栈合并为同一个栈结构,这样在Java方法和Native方法之间切换时无需切换栈空间,减少了上下文切换的开销,同时简化了栈内存的管理,这也是HotSpot JVM高效执行的优化策略之一。
堆(Heap- 线程共享)- 运行时数据区
堆(Heap- 线程共享)是被线程共享的一块内存区域,创建的对象和数组都保存在 Java 堆内存中,也是垃圾收集器进行垃圾收集的最重要的内存区域。由于现代 VM 采用分代收集算法, 因此 Java 堆从 GC 的角度还可以细分为: 新生代( Eden 区 、 From Survivor 区 和 To Survivor 区 )和老年代。
Java堆的基本特征与设计理念 Java堆是JVM内存管理的核心区域,作为线程共享的内存空间,承担着存储所有对象实例和数组的重要职责。堆内存的设计基于分代收集理论,该理论基于"大部分对象生命周期短暂"这一观察,将堆划分为新生代和老年代两个主要区域。
新生代的内部结构与对象晋升机制 新生代进一步细分为三个子区域:Eden区作为对象的初始分配区域,占新生代的大部分空间;From Survivor和To Survivor两个大小相等的幸存者区域用于存放经过一次GC后仍存活的对象,它们在GC过程中会互相交换角色(复制算法的体现)。当对象在新生代经历多次GC后仍然存活,或者大对象直接超过阈值时,会被晋升到老年代。
分代收集策略的优化效果 这种分代设计使得垃圾收集器能够针对不同区域的特点采用不同的收集策略:新生代使用效率较高的复制算法快速清理短生命周期对象,老年代则使用标记-清除或标记-整理算法处理长生命周期对象,从而显著提升了垃圾收集的整体效率。
方法区/永久代(线程共享)
即我们常说的永久代(Permanent Generation), 用于存储被 JVM 加载的类信息、常量、静态变量、即时编译器编译后的代码等数据. HotSpot VM把GC分代收集扩展至方法区, 即使用Java堆的永久代来实现方法区, 这样HotSpot 的垃圾收集器就可以像管理 Java 堆一样管理这部分内存, 而不必为方法区开发专门的内存管理器(永久带的内存回收的主要目标是针对常量池的回收和类型的卸载, 因此收益一般很小)。
运行时常量池(Runtime Constant Pool)是方法区的一部分。Class 文件中除了有类的版本、字段、方法、接口等描述等信息外,还有一项信息是常量池(Constant Pool Table),用于存放编译期生成的各种字面量和符号引用,这部分内容将在类加载后存放到方法区的运行时常量池中。 Java 虚拟机对 Class 文件的每一部分(自然也包括常量池)的格式都有严格的规定,每一个字节用于存储哪种数据都必须符合规范上的要求,这样才会被虚拟机认可、装载和执行。
JVM运行时内存
Java 堆从 GC 的角度还可以细分为: 新生代( Eden 区 、 From Survivor 区 和 To Survivor 区 )和老年代。
新生代
是用来存放新生的对象。一般占据堆的1/3空间。由于频繁创建对象,所以新生代会频繁触发MinorGC 进行垃圾回收。新生代又分为 Eden 区、ServivorFrom、ServivorTo 三个区。
1、Eden 区
Java新对象的出生地(如果新创建的对象占用内存很大,则直接分配到老
年代)。当Eden区内存不够的时候就会触发MinorGC,对新生代区进行
一次垃圾回收。
2、ServivorFrom
上一次 GC 的幸存者,作为这一次 GC 的被扫描者。
3、ServivorTo
保留了一次 MinorGC 过程中的幸存者。
4、MinorGC 的过程(复制->清空->互换)
MinorGC 采用复制算法。
1、 eden 、 servicorFrom 复制到 ServicorTo,年龄+1
首先,把 Eden和 ServivorFrom区域中存活的对象复制到 ServicorTo区域(如果有对象的年龄以及达到了老年的标准,则赋值到老年代区),同时把这些对象的年龄+1(如果 ServicorTo 不够位置了就放到老年区);
2、清空 eden 、 servicorFrom
然后,清空 Eden 和 ServicorFrom 中的对象;
3、 ServicorTo 和 ServicorFrom 互换
最后,ServicorTo 和 ServicorFrom 互换,原 ServicorTo 成为下一次 GC 时的 ServicorFrom区。
老年代
主要存放应用程序中生命周期长的内存对象。
老年代的对象比较稳定,所以 MajorGC 不会频繁执行。在进行 MajorGC 前一般都先进行了一次 MinorGC,使得有新生代的对象晋身入老年代,导致空间不够用时才触发。当无法找到足够大的连续空间分配给新创建的较大对象时也会提前触发一次 MajorGC 进行垃圾回收腾出空间。
MajorGC 采用标记清除算法:首先扫描一次所有老年代,标记出存活的对象,然后回收没有标记的对象。MajorGC 的耗时比较长,因为要扫描再回收。MajorGC 会产生内存碎片,为了减少内存损耗,我们一般需要进行合并或者标记出来方便下次直接分配。当老年代也满了装不下的时候,就会抛出 OOM(Out of Memory)异常。
永久代
指内存的永久保存区域,主要存放 Class 和 Meta(元数据)的信息,Class 在被加载的时候被放入永久区域,它和和存放实例的区域不同,GC 不会在主程序运行期对永久区域进行清理。所以这也导致了永久代的区域会随着加载的 Class 的增多而胀满,最终抛出 OOM 异常。
JAVA8 与元数据
在Java8中,永久代已经被移除,被一个称为“元数据区”(元空间)的区域所取代。元空间的本质和永久代类似,元空间与永久代之间最大的区别在于:元空间并不在虚拟机中,而是使用本地内存。因此,默认情况下,元空间的大小仅受本地内存限制。类的元数据放入 native memory, 字符串池和类的静态变量放入 java 堆中,这样可以加载多少类的元数据就不再由MaxPermSize 控制, 而由系统的实际可用空间来控制。
垃圾回收与算法
如何确定垃圾?
引用计数法
在 Java 中,引用和对象是有关联的。如果要操作对象则必须用引用进行。因此,很显然一个简单的办法是通过引用计数来判断一个对象是否可以回收。简单说,即一个对象如果没有任何与之关联的引用,即他们的引用计数都不为 0,则说明对象不太可能再被用到,那么这个对象就是可回收对象。
可达性分析
为了解决引用计数法的循环引用问题,Java 使用了可达性分析的方法。通过一系列的“GC roots”对象作为起点搜索。如果在“GC roots”和一个对象之间没有可达路径,则称该对象是不可达的。
要注意的是,不可达对象不等价于可回收对象,不可达对象变为可回收对象至少要经过两次标记过程。两次标记后仍然是可回收对象,则将面临回收。
标记清除算法( Mark-Sweep )
最基础的垃圾回收算法,分为两个阶段,标注和清除。标记阶段标记出所有需要回收的对象,清除阶段回收被标记的对象所占用的空间。如图
从图中我们就可以发现,该算法最大的问题是内存碎片化严重,后续可能发生大对象不能找到可利用空间的问题。
复制算法(copying )
为了解决 Mark-Sweep 算法内存碎片化的缺陷而被提出的算法。按内存容量将内存划分为等大小的两块。每次只使用其中一块,当这一块内存满后将尚存活的对象复制到另一块上去,把已使用的内存清掉,如图:
这种算法虽然实现简单,内存效率高,不易产生碎片,但是最大的问题是可用内存被压缩到了原本的一半。且存活对象增多的话,Copying 算法的效率会大大降低。
标记整理算法(Mark-Compact)
结合了以上两个算法,为了避免缺陷而提出。标记阶段和 Mark-Sweep 算法相同,标记后不是清理对象,而是将存活对象移向内存的一端。然后清除端边界外的对象。如图:
分代收集算法
分代收集法是目前大部分 JVM 所采用的方法,其核心思想是根据对象存活的不同生命周期将内存划分为不同的域,一般情况下将 GC 堆划分为老生代(Tenured/Old Generation)和新生代(Young Generation)。老生代的特点是每次垃圾回收时只有少量对象需要被回收,新生代的特点是每次垃圾回收时都有大量垃圾需要被回收,因此可以根据不同区域选择不同的算法。
新生代与复制算法
目前大部分 JVM 的 GC 对于新生代都采取 Copying 算法,因为新生代中每次垃圾回收都要回收大部分对象,即要复制的操作比较少,但通常并不是按照 1:1 来划分新生代。一般将新生代划分为一块较大的 Eden 空间和两个较小的 Survivor 空间(From Space, To Space),每次使用Eden 空间和其中的一块 Survivor 空间,当进行回收时,将该两块空间中还存活的对象复制到另一块 Survivor 空间中。
老年代与标记复制算法
而老年代因为每次只回收少量对象,因而采用 Mark-Compact 算法。
1. JAVA 虚拟机提到过的处于方法区的永生代(Permanet Generation),它用来存储 class 类,常量,方法描述等。对永生代的回收主要包括废弃常量和无用的类。
2. 对象的内存分配主要在新生代的 Eden Space 和 Survivor Space 的 From Space(Survivor 目前存放对象的那一块),少数情况会直接分配到老生代。
3. 当新生代的 Eden Space 和 From Space 空间不足时就会发生一次 GC,进行 GC 后,Eden Space 和 From Space 区的存活对象会被挪到 To Space,然后将 Eden Space 和 From Space 进行清理。
4. 如果 To Space 无法足够存储某个对象,则将这个对象存储到老生代。
5. 在进行 GC 后,使用的便是 Eden Space 和 To Space 了,如此反复循环。
6. 当对象在 Survivor 区躲过一次 GC 后,其年龄就会+1。默认情况下年龄到达 15 的对象会被移到老生代中。
JAVA中四种引用类型
1. 强引用(Strong Reference)
基本概念
强引用是Java中最常见和最基本的引用类型。当我们使用new关键字创建对象或者直接赋值时,就建立了强引用关系。
在 Java 中最常见的就是强引用,把一个对象赋给一个引用变量,这个引用变量就是一个强引用。当一个对象被强引用变量引用时,它处于可达状态,它是不可能被垃圾回收机制回收的,即使该对象以后永远都不会被用到 JVM 也不会回收。因此强引用是造成 Java 内存泄漏的主要原因之一。
String str = "Hello World";
List<String> list = new ArrayList<>();
特点分析
强引用具有以下关键特征:
不可回收性:只要强引用存在,被引用的对象就不会被垃圾回收器回收,即使系统内存严重不足也是如此。这种特性保证了程序运行的稳定性,但也可能导致内存泄漏。
可达性保证:强引用确保对象始终处于可达状态,这意味着程序可以随时访问该对象。
内存泄漏风险:当强引用指向的对象不再需要使用,但引用关系没有被及时清除时,就会发生内存泄漏。
实际应用场景
强引用适用于程序核心逻辑中必须保持存活的对象,如配置信息、核心业务对象等。
2. 软引用(Soft Reference)
基本概念
软引用通过java.lang.ref.SoftReference
类实现,它提供了一种相对灵活的引用方式,允许垃圾回收器在内存压力下回收对象。
软引用需要用 SoftReference 类来实现,对于只有软引用的对象来说,当系统内存足够时它不会被回收,当系统内存空间不足时它会被回收。软引用通常用在对内存敏感的程序中。
// 创建软引用
String str = "Hello World";
SoftReference<String> softRef = new SoftReference<>(str);
str = null; // 清除强引用// 获取软引用对象
String retrievedStr = softRef.get();
回收机制
软引用的回收遵循以下规则:
内存充足时:垃圾回收器不会回收仅被软引用指向的对象,对象可以正常使用。
内存不足时:当JVM检测到内存空间不足时,会回收软引用指向的对象以释放内存空间。
回收策略:JVM会根据内存使用情况和对象的访问频率来决定回收优先级,最近使用过的软引用对象通常会被优先保留。
典型应用场景
缓存系统:软引用非常适合实现内存敏感的缓存系统,如图片缓存、数据缓存等。
public class ImageCache {private Map<String, SoftReference<BufferedImage>> cache = new HashMap<>();public BufferedImage getImage(String path) {SoftReference<BufferedImage> ref = cache.get(path);BufferedImage image = (ref != null) ? ref.get() : null;if (image == null) {image = loadImageFromDisk(path);cache.put(path, new SoftReference<>(image));}return image;}
}
3. 弱引用(Weak Reference)
基本概念
弱引用通过java.lang.ref.WeakReference
类实现,它比软引用具有更短的生存期,提供了最宽松的引用约束。
弱引用需要用 WeakReference 类来实现,它比软引用的生存期更短,对于只有弱引用的对象来说,只要垃圾回收机制一运行,不管 JVM 的内存空间是否足够,总会回收该对象占用的内存。
String str = "Hello World";
WeakReference<String> weakRef = new WeakReference<>(str);
str = null; // 清除强引用// 在下次GC后,weakRef.get()可能返回null
String retrievedStr = weakRef.get();
回收特性
弱引用具有以下独特的回收特性:
立即回收:只要垃圾回收器运行,无论内存是否充足,仅被弱引用指向的对象都会被回收。
生存期短暂:弱引用对象的生存期完全依赖于垃圾回收的时机,无法保证对象的持续可用性。
内存敏感:弱引用对内存占用的影响最小,适合在内存受限的环境中使用。
实际应用场景
WeakHashMap:Java标准库中的WeakHashMap使用弱引用作为键,当键对象不再被其他地方引用时,对应的键值对会自动从Map中移除。
WeakHashMap<UniqueImageName, BigImage> map = new WeakHashMap<>();
BigImage bigImage = new BigImage("image1.jpg");
UniqueImageName imageName = new UniqueImageName("image1.jpg");map.put(imageName, bigImage);
imageName = null; // 清除强引用// 在GC后,这个键值对可能会自动从map中移除
观察者模式优化:在观察者模式中使用弱引用可以避免观察者对象无法被正常回收的问题。
4. 虚引用(Phantom Reference)
基本概念
虚引用通过java.lang.ref.PhantomReference
类实现,它是最特殊的引用类型,主要用于跟踪对象的垃圾回收状态。
虚引用需要 PhantomReference 类来实现,它不能单独使用,必须和引用队列联合使用。虚引用的主要作用是跟踪对象被垃圾回收的状态。
Object obj = new Object();
ReferenceQueue<Object> queue = new ReferenceQueue<>();
PhantomReference<Object> phantomRef = new PhantomReference<>(obj, queue);
obj = null;// phantomRef.get()总是返回null
// 但可以通过引用队列监控对象回收状态
独特特性
虚引用具有以下独特特性:
无法访问对象:get()
方法总是返回null,无法通过虚引用访问被引用的对象。
必须配合引用队列:虚引用必须与ReferenceQueue
一起使用,当对象被回收时,虚引用会被加入到引用队列中。
回收通知机制:虚引用主要用于接收对象被回收的通知,而不是用于访问对象本身。
应用场景分析
资源清理:虚引用常用于管理堆外内存或其他系统资源的清理工作。
public class DirectMemoryManager {private static final ReferenceQueue<DirectBuffer> queue = new ReferenceQueue<>();private static final Map<PhantomReference<DirectBuffer>, Long> refs = new ConcurrentHashMap<>();static {// 启动清理线程Thread cleanupThread = new Thread(() -> {while (true) {try {PhantomReference<DirectBuffer> ref = (PhantomReference<DirectBuffer>) queue.remove();Long address = refs.remove(ref);if (address != null) {// 清理堆外内存freeDirectMemory(address);}} catch (InterruptedException e) {break;}}});cleanupThread.setDaemon(true);cleanupThread.start();}public static void trackDirectBuffer(DirectBuffer buffer, long address) {PhantomReference<DirectBuffer> ref = new PhantomReference<>(buffer, queue);refs.put(ref, address);}
}
引用类型比较
强度对比
从强到弱的引用强度排序:强引用 > 软引用 > 弱引用 > 虚引用
使用场景建议
强引用:用于核心业务对象,确保对象不被意外回收。
软引用:适合实现内存敏感的缓存系统,在内存紧张时自动释放。
弱引用:用于打破循环引用或实现内存友好的映射关系。
虚引用:用于对象生命周期的监控和资源清理工作。
GC 垃圾收集器
垃圾收集器是Java虚拟机内存管理的核心组件,负责自动回收不再使用的对象所占用的内存空间。随着Java技术的发展和应用场景的多样化,JVM提供了多种不同特性的垃圾收集器,以满足不同应用的性能需求。本文将深入解析七种主要的垃圾收集器,帮助开发者理解它们的工作原理、适用场景和性能特点。
垃圾收集器的分类与发展历程
分代收集理论基础
现代垃圾收集器大多基于分代收集理论,该理论基于以下观察:
- 大部分对象生命周期很短,很快就会变得不可达
- 很少有引用从老年代指向新生代对象
- 存活时间长的对象,未来继续存活的概率很大
基于这些特点,垃圾收集器将堆内存划分为新生代和老年代,针对不同区域采用不同的回收策略。
收集器性能指标
评估垃圾收集器性能主要关注以下指标:
- 吞吐量:运行用户代码时间占总运行时间的比例
- 停顿时间:执行垃圾收集时用户线程暂停的时间
- 内存占用:垃圾收集器自身运行时占用的内存大小
不同的垃圾收集器在这些指标上有不同的侧重点,适用于不同的应用场景。
1. Serial收集器:单线程的经典收集器
基本特征
Serial收集器是最基础的垃圾收集器,采用单线程进行垃圾收集工作。它是Client模式下默认的新生代收集器,也是许多其他收集器的基础。
工作原理
收集算法:新生代使用标记-复制算法,老年代使用标记-整理算法。
执行过程:
- 暂停所有用户线程(Stop The World)
- 使用单线程执行垃圾收集
- 收集完成后恢复用户线程执行
// JVM参数启用Serial收集器
-XX:+UseSerialGC
性能特点
优势:
- 实现简单,代码量小,运行稳定
- 在单CPU环境下效率较高
- 内存消耗最小,没有线程交互开销
- 对于内存较小的应用,停顿时间可以接受
劣势:
- 收集时间与堆大小成正比
- 在多CPU环境下无法充分利用硬件资源
- 对于大型应用,停顿时间可能过长
适用场景
Serial收集器适用于以下场景:
- 桌面应用程序
- 内存较小的服务端应用(几十MB到一两百MB)
- 单CPU或CPU核数较少的环境
- 对停顿时间要求不高的应用
2. ParNew收集器:Serial的多线程版本
基本特征
ParNew收集器本质上是Serial收集器的多线程并行版本,除了使用多线程进行垃圾收集外,其他行为包括算法、Stop The World、对象分配规则等都与Serial收集器完全相同。
工作原理
并行执行:使用多个线程同时进行垃圾收集工作,默认开启的收集线程数与CPU核心数相同。
线程协调:多个收集线程之间需要协调工作,共同完成垃圾收集任务。
// JVM参数配置
-XX:+UseConcMarkSweepGC // 启用CMS时自动使用ParNew
-XX:+UseParNewGC // 直接指定使用ParNew
-XX:ParallelGCThreads=4 // 设置并行收集线程数
性能分析
多线程加速:在多CPU环境下,ParNew可以显著缩短垃圾收集时间。理论上,如果CPU核心数为N,收集时间可以缩短到1/N。
实际效果:由于线程切换和同步开销,实际加速比会小于理论值。当CPU核心数较少时,这种开销可能抵消并行带来的好处。
适用场景
ParNew收集器特别适合以下环境:
- 多CPU的Server模式应用
- 配合CMS收集器使用的场景
- 注重停顿时间但也关心吞吐量的应用
- 中等规模的服务端应用
3. Parallel Scavenge收集器:吞吐量优先
核心理念
Parallel Scavenge收集器的目标是达到一个可控制的吞吐量,因此被称为"吞吐量优先收集器"。它更关注CPU的有效利用率,而不是单纯缩短停顿时间。
独特特性
自适应调节:Parallel Scavenge提供了自适应的大小调节策略,可以动态调整堆的各项参数以达到最佳的停顿时间或吞吐量。
吞吐量控制:提供了精确的吞吐量控制参数,可以设置期望的吞吐量目标。
// 关键参数配置
-XX:+UseParallelGC // 启用Parallel Scavenge
-XX:MaxGCPauseMillis=200 // 设置最大停顿时间目标
-XX:GCTimeRatio=19 // 设置吞吐量目标(99/(1+19)=95%)
-XX:+UseAdaptiveSizePolicy // 启用自适应调节策略
自适应调节机制
动态参数调整:
- 自动调节新生代大小(-Xmn)
- 自动调节Eden与Survivor区比例(-XX:SurvivorRatio)
- 自动调节晋升老年代对象年龄(-XX:PretenureSizeThreshold)
优化目标平衡:在最大停顿时间和吞吐量目标之间找到平衡点。
适用场景
Parallel Scavenge收集器非常适合:
- 后台运算较多而交互较少的应用
- 批处理任务
- 科学计算应用
- 注重整体处理能力的服务端应用
4. Serial Old收集器:老年代的单线程收集
基本特征
Serial Old是Serial收集器的老年代版本,使用单线程执行,采用标记-整理算法。它是Client模式下虚拟机的默认老年代收集器。
工作机制
标记-整理算法:
- 标记阶段:从GC Roots开始,标记所有可达对象
- 整理阶段:将存活对象向内存一端移动,清理边界外的内存
执行特点:
- 完全单线程执行
- 需要Stop The World
- 整理后的内存空间连续,无碎片
使用场景
Serial Old收集器主要在以下情况使用:
- Client模式下与Serial收集器搭配
- Server模式下作为CMS收集器的备用方案
- 在Parallel Scavenge收集器无法处理的情况下作为后备
// 典型配置
-XX:+UseSerialGC // 新生代和老年代都使用串行收集器
5. Parallel Old收集器:老年代的并行收集
发展背景
Parallel Old收集器是Parallel Scavenge收集器的老年代版本,在JDK 6中开始提供。它的出现弥补了Parallel Scavenge收集器的不足,提供了真正意义上的"吞吐量优先"收集器组合。
技术特点
多线程标记-整理:使用多线程同时执行标记-整理算法,充分利用多CPU资源。
与Parallel Scavenge的协同:两者配合使用可以在新生代和老年代都保持高吞吐量。
// 启用Parallel Old
-XX:+UseParallelGC // 新生代使用Parallel Scavenge
-XX:+UseParallelOldGC // 老年代使用Parallel Old
-XX:ParallelGCThreads=8 // 设置并行线程数
性能优势
吞吐量提升:在多CPU环境下可以显著提高老年代收集的吞吐量。
停顿时间控制:虽然主要关注吞吐量,但也可以通过参数控制最大停顿时间。
最佳搭配
Parallel Scavenge + Parallel Old组合特别适合:
- 计算密集型应用
- 多CPU服务器环境
- 对吞吐量要求高的系统
- 可以容忍较长停顿时间的应用
6. CMS收集器:并发低延迟的先驱
设计目标
CMS(Concurrent Mark Sweep)收集器以获取最短回收停顿时间为目标,非常适合互联网站或B/S系统的服务端应用,这些应用都非常重视服务的响应速度。
工作流程
CMS收集器的运行过程分为四个主要阶段:
1. 初始标记(Initial Mark)
- 需要Stop The World
- 仅标记GC Roots能直接关联的对象
- 速度很快
2. 并发标记(Concurrent Mark)
- 与用户线程同时执行
- 从GC Roots开始遍历整个对象图
- 耗时最长但不停顿用户线程
3. 重新标记(Remark)
- 需要Stop The World
- 修正并发标记期间因用户程序运行而导致的标记变动
- 停顿时间比初始标记稍长,但远短于并发标记
4. 并发清除(Concurrent Sweep)
- 与用户线程同时执行
- 清理删除标记阶段判断的已死亡对象
- 不需要移动存活对象
// CMS相关参数
-XX:+UseConcMarkSweepGC // 启用CMS收集器
-XX:CMSInitiatingOccupancyFraction=75 // 老年代使用率达到75%时触发CMS
-XX:+UseCMSCompactAtFullCollection // 在Full GC时进行内存整理
-XX:CMSFullGCsBeforeCompaction=0 // 每次Full GC都进行内存整理
优势与缺陷
主要优势:
- 并发收集,停顿时间短
- 响应时间优秀
- 适合对延迟敏感的应用
主要缺陷:
- CPU资源敏感:并发收集虽然不会导致用户停顿,但会占用一部分CPU资源
- 浮动垃圾:并发清理阶段用户线程还在运行,会产生新的垃圾,只能留到下次清理
- 内存碎片:使用标记-清除算法会产生大量内存碎片
适用场景
CMS收集器特别适合:
- 互联网应用的服务端
- 对响应时间要求严格的系统
- 老年代较大的应用
- 可以接受一定吞吐量损失以换取低延迟的场景
7. G1收集器:面向未来的收集器
革命性设计
G1(Garbage First)收集器是JDK 7中正式引入的商用收集器,代表了收集器技术的最新发展方向。它改变了传统的堆内存布局,具有以下创新特点:
内存布局创新
Region化堆内存:G1将整个Java堆划分为多个大小相等的独立区域(Region),每个Region可以根据需要扮演新生代的Eden空间、Survivor空间或老年代空间。
动态角色分配:Region的角色不是固定的,可以根据实际需要动态变化,这种灵活性是G1的重要优势。
// G1相关参数
-XX:+UseG1GC // 启用G1收集器
-XX:MaxGCPauseMillis=200 // 设置期望停顿时间目标
-XX:G1HeapRegionSize=16m // 设置Region大小
-XX:G1NewSizePercent=5 // 新生代占堆空间最小比例
-XX:G1MaxNewSizePercent=60 // 新生代占堆空间最大比例
收集过程详解
G1收集器的运行过程可以分为两个大的收集模式:
Young GC(年轻代收集):
- 当Eden区用满时触发
- 收集所有年轻代Region
- 存活对象复制到Survivor区或老年代
- 整个过程STW,但时间可控
Mixed GC(混合收集): 包含并发标记周期和混合收集周期:
- 初始标记(Initial Mark):标记GC Roots直接关联的对象
- 并发标记(Concurrent Mark):并发标记整个堆中的存活对象
- 最终标记(Final Mark):处理并发标记阶段遗留的少量SATB记录
- 筛选回收(Live Data Counting and Evacuation):对各Region的回收价值排序,根据用户期望的停顿时间制定回收计划
核心优势
可预测的停顿时间:G1允许使用者明确指定在一个长度为M毫秒的时间片段内,消耗在垃圾收集上的时间不得超过N毫秒。
高效的空间整合:G1从整体来看是基于标记-整理算法实现,从局部(两个Region之间)来看是基于复制算法实现,这意味着运行期间不会产生内存空间碎片。
并发与并行:G1能充分利用多CPU、多核环境下的硬件优势,使用多个CPU来缩短Stop-The-World停顿时间。
内存管理策略
记忆集优化:每个Region都有一个对应的Remembered Set,用于记录其他Region中的对象到本Region的引用,避免全堆扫描。
SATB算法:使用Snapshot-At-The-Beginning算法确保并发标记的正确性。
回收优先级:优先回收垃圾最多的Region,这也是Garbage First名称的由来。
适用场景
G1收集器特别适合以下应用场景:
- 大内存应用(6GB以上)
- 对延迟敏感的应用
- 需要可预测停顿时间的系统
- 替代CMS收集器的场景
- 现代大型互联网应用
收集器选择与配置指南
选择策略
选择合适的垃圾收集器需要考虑以下因素:
应用类型:
- 桌面应用:Serial
- 服务端应用:ParNew + CMS 或 G1
- 批处理应用:Parallel Scavenge + Parallel Old
内存大小:
- 小内存(<100MB):Serial
- 中等内存(几百MB到几GB):Parallel或CMS
- 大内存(>6GB):G1
性能要求:
- 吞吐量优先:Parallel系列
- 延迟优先:CMS或G1
- 平衡考虑:G1
参数调优建议
基础配置:
# G1收集器推荐配置
-XX:+UseG1GC
-XX:MaxGCPauseMillis=200
-XX:+G1PrintRegionRememberedSetInfo# CMS收集器推荐配置
-XX:+UseConcMarkSweepGC
-XX:+UseParNewGC
-XX:CMSInitiatingOccupancyFraction=75
-XX:+UseCMSInitiatingOccupancyOnly
监控参数:
-XX:+PrintGC
-XX:+PrintGCDetails
-XX:+PrintGCTimeStamps
-Xloggc:gc.log