Java 虚拟机(JVM)是运行 Java 程序的引擎,它是 Java 语言 "一次编译,处处运行" 的核心技术。JVM 的主要任务是将 Java 字节码(Bytecode)解释成机器码并执行,负责内存管理、线程管理、垃圾回收等功能。JVM 主要由以下几个重要的结构组成:
类加载子系统(Class Loader Subsystem)
- 类加载 :JVM 中的类加载是将 Java 字节码(即 .class 文件)动态加载到 JVM 内存中的过程,并为这些类分配内存、解析依赖、执行初始化并为类创建对应的 Java 类对象的步骤。具体包括如下步骤:
- 加载(Loading)
- 验证(Verification)
- 准备(Preparation)
- 解析(Resolution)
- 初始化(Initialization)
有些文档也会将 "使用" 和 "卸载" 视为后续步骤,但这两个阶段并非严格的类加载步骤的一部分。
加载(Loading)
这是类加载过程的第一步,类加载器根据类的全限定名(包括包名)找到对应的 .class 文件并加载到内存中。在加载过程中,JVM 会创建一个 java.lang.Class 对象,用来表示这个类的元数据。加载阶段会通过类加载器完成。加载过程涉及:
- 通过 双亲委派模型 请求父加载器加载类,若父加载器无法加载,再由当前类加载器加载。
- 查找 .class 文件的位置(通常从文件系统、JAR 包或者网络等路径中查找)。
验证(Verification)
验证阶段是为了确保加载的字节码是符合 JVM 规范的、安全的字节码。JVM 通过一系列的校验来确保字节码不会破坏 JVM 运行的稳定性或安全性。验证主要包括以下几个方面:
- 文件格式验证:检查 .class 文件是否符合 Class 文件格式规范。
- 元数据验证:检查类中的元数据信息是否合理。例如,类是否继承了非法的父类、类的方法签名是否正确等。
- 字节码验证:对类的方法中的字节码进行验证,确保指令序列是合法的、符合逻辑的。
- 符号引用验证:对符号引用进行验证,确保所有引用的类、方法、字段都存在并且可访问。
- 验证阶段保证了字节码不会对 JVM 运行时环境构成威胁,但它可能会导致 VerifyError 错误。
准备(Preparation)
在准备阶段,JVM 会为类的静态变量 分配内存,并将其初始化为默认值(并非编写代码时的赋值)。此阶段主要是分配内存而不执行具体的初始化操作。类中的静态字段会被赋予默认值:
- 数字类型(如 int, long 等)初始化为 0。
- 布尔类型初始化为 false。
- 引用类型初始化为 null。
例如,假设类中有以下静态变量:
java
public static int a = 10;
public static boolean flag = true;
在准备阶段,a 被初始化为 0,flag 被初始化为 false。真正的值(10 和 true)将在初始化阶段赋值。非静态字段是在类被实例化时赋值的,类加载阶段不进行赋值。
解析(Resolution)
解析阶段是将类的符号引用替换为直接引用的过程。符号引用是指在字节码中通过字符串等符号来引用类、字段、方法,解析过程会将这些符号引用解析为内存地址的直接引用。解析过程会涉及以下几类引用:
- 类或接口解析:将符号引用的类或接口名解析为实际的 Class 对象。
- 字段解析:将符号引用的字段解析为实际的内存位置。
- 方法解析:将符号引用的方法解析为实际的可执行代码地址。
- 接口方法解析:针对接口中的方法引用进行解析。
解析阶段可能会导致 NoSuchFieldError 或 NoSuchMethodError 等错误,如果解析失败,类加载过程也会中断。
初始化(Initialization)
这是类加载的最后一个阶段,也是执行静态变量赋值和静态代码块的阶段。在这个阶段,JVM 会根据程序员的指令对类的静态变量进行显式初始化,并执行静态代码块。类初始化的具体顺序是:
- 父类静态初始化优先于子类静态初始化。
- 静态变量按照它们在类中的声明顺序进行初始化。
- 执行静态代码块。
类加载时机
类的加载不是在 JVM 启动时就加载所有的类,而是在类被首次主动使用时才加载。这种按需加载机制被称为 类的延迟加载(Lazy Loading)。类的主动使用场景包括:
- 创建类的实例(new 操作)。
- 调用类的静态方法。
- 访问类的静态字段。
- 通过反射调用类。
- 初始化子类时,先初始化父类。
- JVM 启动时,指定的启动类(包含 main 方法)自动初始化。
类加载器
- Bootstrap Class Loader(引导类加载器)
- 这是 JVM 自带的、最顶层的类加载器,负责加载核心类库(如 rt.jar 中的类)。
- 通过 C/C++ 代码实现,不继承自 java.lang.ClassLoader 类。
- 主要负责加载:
- 位于 JAVA_HOME/lib 目录下的类。
- 标准核心库(如 java.lang.、java.util. 等)。
- Platform Class Loader(平台类加载器)
- 从 Java 9 开始引入,用来加载 Java 平台类库(如 java.sql、java.xml 等)。
- 位于 JAVA_HOME/lib/ext 目录及 platform 模块中。
- 是引导类加载器与应用类加载器之间的一个中间层级,用于支持多种库和平台相关的类。
- Application Class Loader(应用类加载器)
- 负责加载用户类路径(classpath)下的类,包括用户自定义的类和第三方库。
- 使用 java.lang.ClassLoader 的默认实现,通常是系统类加载器。
- 加载的类包括应用程序所需的所有 .jar 文件和 .class 文件。
JVM 类加载器体系遵循 双亲委派模型,这是类加载器设计中的核心机制。它的工作原理是:当一个类加载器收到类加载请求时,它会首先将该请求委托给父加载器去处理,只有当父加载器无法加载时,才由当前加载器自己去加载。这种模型有以下特点:
- 安全性:防止核心类库(如 java.lang.String)被自定义类加载器加载,保证核心类库的唯一性和安全性。
- 类的唯一性:同一个类在 JVM 内存中只会被加载一次(除非使用不同的类加载器),保证了类的唯一性。
- 防止重复加载:通过父加载器优先机制,避免不同类加载器重复加载同一个类。
运行时数据区(Runtime Data Areas)
JVM 在运行时管理的内存区域,可以分为以下几个部分:
方法区(Method Area)
在 JVM 中,方法区(Method Area)是用于存储类结构信息的内存区域。它保存了每个类的元数据信息,例如类的名称、访问修饰符、字段、方法、常量池等。方法区是 JVM 规范中的一部分,是堆外内存的一部分,属于非堆区域。它与堆相对独立,主要存储与类和常量相关的数据。**它是线程共享的区域。**具有以下特点:
- 存储内容 :
- 类元数据:每个类的结构信息,包括类名、父类名、访问修饰符、接口信息等。
- 字段和方法信息:包括字段的名称、类型和方法的名称、签名等。
- 常量池(Constant Pool):存储编译时生成的字面量和符号引用(如字符串字面量、方法和字段的符号引用)。
- 静态变量:存储类的静态字段。
- 类的静态方法和代码:类中的字节码和方法相关的信息也存储在这里。
- 生命周期:方法区的生命周期与 JVM 一致,JVM 运行时,方法区会随着类的加载不断扩展,当 JVM 关闭时,方法区也会被销毁。
- 大小:方法区可以设置大小,但如果装载的类太多,方法区可能会导致内存不足(出现 OutOfMemoryError: Metaspace 错误)。从 Java 8 开始,方法区被替换为 元空间(Metaspace),并且其内存存储在本地内存(Native Memory)中,而不是 JVM 的堆中。
在 Java 7 及之前,方法区的实现被称为 永久代(Permanent Generation,PermGen),它是 JVM 堆的一部分。永久代有以下特点:
- 固定大小:PermGen 有一个固定的大小,可以通过 JVM 参数(如 -XX:PermSize 和 -XX:MaxPermSize)来设置。
- 内存管理问题:因为 PermGen 的内存是有限的,如果应用程序动态生成大量类或者使用了大量字符串常量,容易导致内存不足的错误,比如 OutOfMemoryError: PermGen space。
从 Java 8 开始,永久代被移除,取而代之的是 元空间(Metaspace)。元空间的存储改为使用本地内存而不是 JVM 堆内存,有以下显著变化:
- 动态扩展:元空间的大小不再像 PermGen 那样受限,可以动态扩展。默认情况下,元空间的大小是由系统内存决定的。
- 配置灵活性 :可以通过以下 JVM 参数来控制元空间的行为:
- -XX:MetaspaceSize:设置元空间的初始大小。当元空间使用达到这个值时,JVM 会触发垃圾回收来清理不再使用的类。
- -XX:MaxMetaspaceSize:设置元空间的最大大小。元空间可以动态扩展,但如果达到这个限制,就会抛出 OutOfMemoryError: Metaspace 错误。
- -XX:CompressedClassSpaceSize:指定类指针的压缩空间大小。通常默认大小为 1GB,适用于 64 位 JVM。
- -XX:+UseCompressedClassPointers:启用压缩类指针,节省内存空间。
元空间的引入使得类加载和卸载更加高效,避免了 PermGen 的内存管理问题。
方法区作为 JVM 中非常重要的内存区域,主要用于类的加载、运行时常量池的维护和类的相关信息存储。其作用包括:
- 支持类加载机制:JVM 加载的每一个类,其相关的元数据都会存储在方法区中。
- 支持常量的引用与解析:运行时常量池中的数据,如方法引用、字段引用、字符串常量,存储在方法区内。
- 支持静态变量存储:方法区中还存储类的静态变量,所有类的静态变量在内存中的唯一副本存放于方法区。
- 垃圾回收的影响:尽管方法区不属于堆内存,但 JVM 仍然对方法区的内存进行管理,并且可以对无用的类信息进行垃圾回收。
堆(Heap)
在 JVM(Java 虚拟机)中,堆(Heap)是用于存储所有 Java 对象和数组的主要内存区域。堆是 JVM 运行时数据区域中最大的一部分,所有通过 new 关键字创建的对象都会被分配在堆内存中。堆内存的管理和分配对于 Java 程序的性能有着直接的影响。
- 对象的生命周期由垃圾回收机制决定,堆上的对象不再被引用时,JVM 的垃圾回收器会自动回收这些对象所占的内存。
- 堆的生命周期与 JVM 相同,堆内存随着 JVM 启动而创建,并在 JVM 关闭时销毁。
- JVM 堆是所有线程共享的。每个线程都可以访问堆中的对象,多个线程也可能同时操作堆中的相同对象。
- JVM 堆由垃圾回收器(Garbage Collector, GC)进行管理。GC 负责清理无用对象、回收内存空间,从而使得开发人员不需要手动管理内存。
堆内存通常会被划分为多个区域,用于更高效的内存管理和垃圾回收策略。典型的划分方式如下:
-
年轻代(Young Generation):
- 年轻代主要用于存放新创建的对象 ,大多数的对象在这里分配内存。年轻代的回收频率较高,垃圾回收通常使用的是Minor GC。
- 年轻代又分为三个子区域:
- Eden 区:对象在首次创建时被分配到 Eden 区。当 Eden 区满时,触发 Minor GC。
- 两个 Survivor 区(S0 和 S1):当对象在 Eden 区存活过一次 GC 后,会被移动到 Survivor 区。两个 Survivor 区交替使用,即 S0 和 S1 中只有一个区会被使用,另一个为空,GC 时对象会从一个 Survivor 区复制到另一个区。
-
老年代(Old Generation):
- 老年代存储的是生命周期较长、在年轻代中经历过多次 GC 仍未被回收的对象。相比于年轻代,老年代的垃圾回收频率较低,但执行的是Full GC,且 Full GC 的开销要比 Minor GC 大得多。
-
JVM 的堆内存大小可以通过以下 JVM 参数来配置:
- -Xms:设置堆的初始大小。
- -Xmx:设置堆的最大大小。
- -XX:NewRatio:设置年轻代与老年代的比例。
- -XX:SurvivorRatio:设置 Eden 区与 Survivor 区的比例。
- -XX:MaxMetaspaceSize:设置元空间的最大大小(Java 8 及之后)。
堆内存分配和回收的过程
- 对象创建 :
- 当程序中使用 new 关键字创建对象时,内存首先分配在 Eden 区。
- Minor GC :
- 当 Eden 区满了,JVM 触发 Minor GC,回收不再使用的对象。存活下来的对象会被移到 Survivor 区(S0 或 S1)。
- 当对象在 Survivor 区存活多次后(通常为 15 次 GC,但可以通过 -XX:MaxTenuringThreshold 调整),这些对象会被移动到老年代。
- Full GC :
- 当老年代被填满时,会触发 Full GC。Full GC 是对整个堆(年轻代和老年代)进行垃圾回收,回收那些不再被引用的对象。这是一个相对耗时的操作,可能会导致应用暂停(即STW:Stop-The-World),所以 Full GC 频率需要尽量减少。
虚拟机栈(JVM Stacks)
在 JVM(Java Virtual Machine)中,虚拟机栈(Java Virtual Machine Stack,简称 JVM 栈)是每个线程在执行 Java 程序时创建的私有内存区域。它负责管理 Java 方法的执行,存储方法调用时的局部变量、操作数栈、动态链接、方法出口等。每个线程都有自己独立的虚拟机栈,因此线程之间的栈空间是不共享的。
虚拟机栈是用来保存线程的 栈帧(Stack Frame)的,每个方法在执行时都会创建一个栈帧。每个线程的虚拟机栈由多个 栈帧 组成,一个栈帧对应一个正在执行的 Java 方法。当一个方法被调用时,JVM 会将栈帧压入当前线程的虚拟机栈。当方法执行完成后,对应的栈帧会从栈中弹出。
栈帧主要包括:
- 局部变量表:存储方法的局部变量,包括方法参数和在方法体内定义的变量。是一组用于存放方法参数和局部变量的数组,以槽(Slot)为单位存储。一个 Slot 可以存储一个 int、float 等基本类型,或者引用类型的变量。long 和 double 类型占用两个 Slot。
- 操作数栈:用于方法执行中的各种临时数据存储,是计算过程中的"工作区"。在方法执行过程中,用来存放中间计算的结果和参与运算的操作数。类似于一个后进先出的栈。操作数栈的大小是在编译期间确定的,每个栈帧的操作数栈容量也由编译器确定。
- 动态链接:用于支持方法调用时的动态连接,每个栈帧中包含了一个指向当前方法所属的类的运行时常量池的引用。这个引用用于实现 方法调用 时的动态连接,具体来说是将常量池中的符号引用转换为方法的实际调用地址。
- 方法返回地址:用来存放方法执行完毕后需要返回的地址,以便于返回到上一个栈帧继续执行。
特点
- 线程私有:每个线程在创建时,都会创建一个虚拟机栈。栈是线程私有的,不会在线程之间共享。
- 栈的生命周期与线程一致:线程创建时分配虚拟机栈,线程结束时虚拟机栈也会被销毁。
- 栈的大小:虚拟机栈的大小可以通过 JVM 参数设置,通常使用 -Xss 参数设置每个线程的栈大小(例如:-Xss1m 设置每个线程的栈大小为 1 MB)。栈的大小影响到线程的深度(即一个线程可以调用多少次方法,特别是递归调用的深度)。
- StackOverflowError:当线程的调用深度超过虚拟机栈的限制时,会抛出该异常。通常是因为方法调用过深(例如递归方法没有合适的退出条件)。因此需要合理设置虚拟机栈的大小。
程序计数器(PC Register)
在 JVM 中,程序计数器(Program Counter Register,简称 PC 寄存器)是每个线程私有的一个小内存区域,它记录了当前线程执行的字节码指令的地址。由于 JVM 是多线程的,每个线程都需要独立执行自己的指令,因此每个线程都有一个独立的程序计数器。它的主要作用是在线程切换时能够恢复到正确的执行位置。
程序计数器的作用
- 记录当前线程执行的字节码指令地址 :程序计数器用来存放当前线程正在执行的 字节码指令的地址,每当一条字节码指令被执行完,程序计数器会更新为下一条即将执行的指令地址。
- 支持线程切换 :JVM 采用 时间片轮转 的方式进行线程切换。为了保证线程恢复后可以继续正确执行代码,每个线程都有自己的程序计数器。当线程切换时,当前线程的执行状态(包括程序计数器的值)会被保存,切换回来时可以从该位置继续执行。
- 处理 Java 和 Native 方法 :对于正在执行 Java 方法 的线程,程序计数器存储的是正在执行的字节码指令地址;而对于 本地方法(Native Method),程序计数器则为空(undefined),因为本地方法不通过字节码执行。
程序计数器的特点
- 线程私有:每个线程都有自己独立的程序计数器,彼此之间不共享。
- 生命周期与线程一致:程序计数器的生命周期与线程相同,线程创建时分配,线程结束时销毁。
- 唯一一个不会出现 OutOfMemoryError 的区域:与其他内存区域(如堆、方法区、栈)不同,程序计数器是一个非常小的区域,它不会发生内存溢出。
程序计数器的意义
- 线程隔离:程序计数器为每个线程提供了独立的指令记录机制,这对于多线程并发执行至关重要,确保每个线程能够独立执行自己的代码,而不影响其他线程。
- 线程调度的支持:程序计数器为线程调度提供了支持,当发生线程切换时,程序计数器记录了线程执行的具体位置,能够在线程恢复时继续从正确的位置执行。
本地方法栈(Native Method Stack)
在 JVM 中,本地方法栈(Native Method Stack)是为执行 本地方法(Native Methods)提供支持的内存区域。本地方法是使用其他编程语言(如 C 或 C++)编写的代码,这些代码可以直接与底层操作系统或硬件进行交互,通常通过 JNI(Java Native Interface)调用。它具有以下特点:
- 本地方法栈(Native Method Stack)用于支持本地方法的执行:存放 Native 方法调用时的局部变量、操作数栈、返回地址等。
- 与 Java 虚拟机栈类似,但专注于本地方法,例如通过 JNI 调用 C、C++ 等非 Java 代码。
- 线程私有:每个线程都有自己的本地方法栈。
- 栈溢出异常:可能抛出 StackOverflowError 或 OutOfMemoryError。
- 可选的存在:并非所有 JVM 实现都支持本地方法栈,一些 JVM 实现可能将本地方法栈与 JVM 栈合并。
- 生命周期与线程相同:本地方法栈的生命周期与线程一致,在线程创建时分配,线程结束时销毁。
本地方法栈与 JVM 虚拟机栈类似,但它为调用本地方法服务。它主要负责管理本地方法的调用状态和执行。其作用包括:
- 存储本地方法执行的上下文:在执行本地方法时,本地方法栈存储相关的局部变量和执行状态。
- 与 JNI 一起工作:Java 本地接口(JNI)用于调用非 Java 代码,本地方法栈在这其中负责管理与本地代码交互的细节。
- 桥接底层系统资源:通过本地方法,Java 程序可以调用操作系统提供的底层资源(如文件系统、网络设备、图形界面等)。
本地方法栈与 JVM 栈的区别
- JVM 栈 用于管理 Java 方法的执行,每个 Java 方法调用时都会在 JVM 栈中生成一个栈帧来存储局部变量和操作数栈等信息。
- 本地方法栈 用于管理本地方法的执行,执行本地方法时,本地方法栈记录本地方法的执行状态和局部变量。对于调用本地代码的场景,JVM 栈和本地方法栈会配合使用。
本地方法栈可能会遇到以下异常
- StackOverflowError:当本地方法栈的调用层次过深,栈空间不足时,会抛出此异常。这个与 JVM 栈的 StackOverflowError 类似,通常出现在递归调用或大量本地方法调用的情况下。
- OutOfMemoryError:如果本地方法栈无法申请到足够的内存,JVM 会抛出 OutOfMemoryError。这种情况通常是在栈的初始大小设置过小或系统内存不足时发生。
本地方法栈的工作流程如下:
- 当 JVM 调用 Java 方法时,会使用 JVM 栈进行栈帧管理;
- 当 Java 方法调用本地方法时,JVM 切换到本地方法栈,将控制权交给本地方法栈,负责管理本地方法的调用状态。调用结束后,返回到虚拟机栈,继续执行 Java 方法。
Java 可以通过 JNI 调用本地方法,以下是一个简单的本地方法调用示例:
java
public class NativeMethodExample {
// 声明一个本地方法
public native void nativeMethod();
static {
// 加载本地方法库
System.loadLibrary("NativeLib");
}
public static void main(String[] args) {
NativeMethodExample example = new NativeMethodExample();
example.nativeMethod(); // 调用本地方法
}
}
在上述代码中,nativeMethod() 是一个本地方法,通过 System.loadLibrary() 加载与之对应的本地方法库(例如,C 或 C++ 编写的动态链接库)。当 nativeMethod() 被调用时,JVM 将切换到本地方法栈进行执行。
JVM 提供了一些参数用于调整本地方法栈的大小,尽管不同的 JVM 实现可能略有不同。通过合理的参数调整,可以避免内存不足或栈溢出异常。
垃圾回收算法和垃圾回收器
在 JVM 中,垃圾回收(Garbage Collection,GC)是自动管理内存的机制,旨在回收不再使用的对象,释放内存资源。Java 提供了多种垃圾回收算法和垃圾回收器,以适应不同的应用场景和需求。
垃圾回收算法
- 标记-清除算法(Mark-Sweep)
- 原理 :该算法分为两个阶段:
- 标记阶段:从根对象开始,遍历所有可达对象并标记它们。
- 清除阶段:扫描整个堆,回收未被标记的对象。
- 优点:简单有效,能够处理对象的循环引用。
- 缺点:清理后会产生内存碎片,可能导致后续的内存分配失败。
- 原理 :该算法分为两个阶段:
- 标记-整理算法(Mark-Compact)
- 原理:与标记-清除算法类似,但在清除阶段会整理存活对象,将它们移动到堆的一端,并更新引用地址。
- 优点:避免了内存碎片问题,适合老年代。
- 缺点:移动对象需要更新引用,开销较大。
- 复制算法(Copying)
- 原理:将存活的对象从一块内存区域(源区)复制到另一块内存区域(目标区),清理源区。
- 优点:高效地回收内存,没有内存碎片。
- 缺点:需要分配两块内存,适用于年轻代,且内存利用率较低(通常只用到一半)。
- 分代收集算法(Generational Collection)
- 原理:基于对象生命周期的特点,将堆分为年轻代和老年代。年轻代中的对象经过多次垃圾回收后,晋升到老年代。
- 优点:提高了垃圾回收的效率,因为大多数对象都是短生命周期的。
- 缺点:需要处理对象晋升的逻辑,复杂度略高。
JVM 提供了多种垃圾回收器,适用于不同的场景和需求:
- Serial GC
- 垃圾回收算法:标记-清除(Mark-Sweep) 和 复制算法(Copying)
- 原理 :
- 对于年轻代使用 复制算法,将存活对象从 Eden 区复制到 Survivor 区。
- 对于老年代使用 标记-清除算法,通过标记不再使用的对象并清除它们。
- 类型:单线程的垃圾回收器。
- 特点:在进行 GC 时,会暂停所有应用线程(STW,Stop-The-World),适合单处理器系统。
- 适用场景:适用于小型应用或内存较小的应用。
- Parallel GC
- 垃圾回收算法:标记-清除-整理算法(Mark-Compact) 和 复制算法(Copying)
- 原理 :
- 对年轻代使用 复制算法,并行地回收对象,将存活的对象复制到 Survivor 区。
- 对老年代使用 标记-整理算法,在标记完成后整理堆内存,避免内存碎片。
- 类型:多线程的垃圾回收器。
- 特点:通过多线程并行进行垃圾回收,适合多核处理器系统。可以通过参数调整并行度。
- 适用场景:适用于高吞吐量的应用。
- CMS(Concurrent Mark-Sweep)GC
- 垃圾回收算法:标记-清除算法(Mark-Sweep)
- 原理 :
- CMS 是针对老年代的回收器,分为四个阶段:初始标记(STW) 、并发标记 、重新标记(STW)和并发清除。
- 年轻代使用 复制算法 进行回收。
- 类型:并发标记清除垃圾回收器。
- 特点:在垃圾回收过程中,应用线程仍然可以运行。分为标记阶段和清除阶段,后续执行部分工作与应用线程并发进行。
- 适用场景:适合对响应时间敏感的应用,但可能导致内存碎片。
- G1(Garbage-First)GC
- 垃圾回收算法:标记-整理算法(Mark-Compact) 和 复制算法(Copying)
- 原理 :
- G1 将堆分为多个区域(Region),每个区域可以存放年轻代或老年代对象。
- 对年轻代使用 复制算法。
- 对老年代使用 标记-整理算法,优先回收垃圾最多的区域。
- 在 Full GC 时,G1 使用全局的 标记-整理算法。
- 类型:分代垃圾回收器。
- 特点:将堆划分为多个区域(Region),优先回收垃圾最多的区域。适用于大内存应用,支持并行和并发回收。
- 适用场景:适合低延迟和大内存的应用,能够提供可预测的暂停时间。
- ZGC(Z Garbage Collector)
- 垃圾回收算法:标记-整理算法(Mark-Compact) 和 并发回收
- 原理 :
- ZGC 的垃圾回收过程分为:并发标记 、并发重新定位 和并发清理。
- ZGC 的最大特点是大部分工作与应用线程并发执行,最大 GC 暂停时间一般不会超过 10 毫秒。
- 它主要使用标记-整理算法,通过颜色指针(Colored Pointers)来标记对象的状态,进行内存整理时对象会重新定位到新的内存区域。
- 类型:低延迟垃圾回收器。
- 特点:支持大堆和并发回收,极大减少了 GC 暂停时间(通常不超过 10ms)。
- 适用场景:适合对延迟非常敏感的大型应用。
- Shenandoah GC
- 垃圾回收算法:标记-整理算法(Mark-Compact) 和 并发回收
- 原理 :
- Shenandoah 和 ZGC 类似,垃圾回收的主要阶段与应用线程并发执行。
- 与 G1 相比,Shenandoah 也将堆划分为多个区域,但它的目的是在最短的时间内回收任何区域的内存。
- Shenandoah 使用的是并发标记 和并发整理算法。
- 类型:低延迟垃圾回收器,类似 ZGC。
- 特点:通过并发回收和混合空间管理,旨在降低 GC 暂停时间。
- 适用场景:适用于需要低延迟的应用,尤其是大堆内存。
选择合适的垃圾回收器可以显著提高 Java 应用程序的性能。以下是一些常用的 JVM 参数,用于选择和配置垃圾回收器:
- -XX:+UseSerialGC:使用 Serial GC。
- -XX:+UseParallelGC:使用 Parallel GC。
- -XX:+UseConcMarkSweepGC:使用 CMS GC。
- -XX:+UseG1GC:使用 G1 GC。
- -XX:+UseZGC:使用 ZGC。
- -XX:+UseShenandoahGC:使用 Shenandoah GC。
在 Java 中,GC Root(垃圾回收根对象)是垃圾回收器进行内存管理的重要起点,任何从 GC Root 可达的对象都不会被垃圾回收。Java 使用 可达性分析算法(Reachability Analysis Algorithm)来确定哪些对象可以被回收,而可达性的判断始于 GC Roots。
以下是 Java 中哪些对象可以充当 GC Root:
虚拟机栈中的引用对象
- 描述:方法执行时,局部变量表中的所有引用类型变量(局部变量、方法参数等)都可以作为 GC Root。
- 实例:当方法调用时,局部变量表中的对象引用始终可达,JVM 不会回收这些对象,直到方法结束后局部变量表被销毁。
java
public void exampleMethod() {
Object obj = new Object(); // 局部变量 obj 是 GC Root
// do something...
}
方法区中的静态变量
- 描述:类的静态属性(static 修饰的变量)会随着类的加载进入方法区,并且静态变量会一直存在于内存中,直到类被卸载。因此,所有的静态变量也是 GC Root。
java
public class ExampleClass {
private static Object staticObject = new Object(); // staticObject 是 GC Root
}
方法区中的常量
- 描述:常量(如 final 修饰的常量)在类加载时就已经被初始化,它们存在于方法区中,可以作为 GC Root。
java
public class ExampleClass {
private static final Object constantObject = new Object(); // constantObject 是 GC Root
}
本地方法栈中的 JNI 引用
- 描述:JNI(Java Native Interface) 是 Java 调用本地(非 Java)代码的机制。在 JNI 中使用的引用也是 GC Root。JVM 通过本地方法栈来管理 JNI 的本地引用。
- 例子:当 Java 调用 C/C++ 代码时,通过 JNI 持有的对象引用。
活跃的线程对象
- 描述:所有当前正在执行的线程对象也是 GC Root,线程不被垃圾回收器回收,直到它们运行结束。
java
Thread thread = new Thread(() -> {
Object obj = new Object(); // thread 是 GC Root,持有 obj 的引用
// do something...
});
thread.start();
Java 虚拟机内部的 GC Root
- 描述:JVM 内部的一些系统级对象,如类加载器(ClassLoader)等,也可以作为 GC Root,通常这些对象与应用的执行息息相关。
JMX Beans、JVMTI 中的注册对象
- 描述:通过 JMX(Java Management Extensions)管理的 MBeans 对象,以及通过 JVMTI(Java Virtual Machine Tool Interface)注册的对象,也可以作为 GC Root,因为 JVM 需要对这些对象进行管理和监控。
判断对象是否可以被回收的方法
引用计数法(Reference Counting)
- 每个对象都维护一个引用计数器,当有一个地方引用该对象时,计数器加一;当引用失效时,计数器减一。
- 当计数器的值为零时,说明该对象不再被引用,系统就会认为它是垃圾,可以被回收。
- 实现简单,效率较高,能快速判断对象是否可以被回收。
- 循环引用问题:如果两个对象互相引用(形成循环依赖),它们的引用计数器不会为零,即使它们都无法被访问,引用计数法也无法回收它们。
可达性分析法(Reachability Analysis)
- JVM 采用可达性分析算法 来判断对象是否可以被回收。这个方法从一组称为 GC Roots 的根对象开始,沿着引用链进行遍历,能够到达的对象被认为是"存活"的,无法到达的对象被认为是不可达的,可以被回收。
- 如果某个对象在从 GC Root 的引用路径上是不可达的,说明它可以被回收。
- 没有循环引用问题:因为是通过可达性分析来判断对象是否可以回收,循环引用不会影响对象的回收。
- 更加准确,现代垃圾回收器大多基于这种方法。
引用类型
强引用(Strong Reference)
- 这是最常见的引用类型。通过正常的赋值创建的引用,只要有强引用指向一个对象,垃圾回收器就不会回收该对象。
java
Object obj = new Object(); // obj 是一个强引用
- 特点:
- 垃圾回收:强引用所指向的对象在任何情况下都不会被垃圾回收。只有当引用失效时,垃圾回收器才会考虑回收这个对象。
- 如果一个对象被强引用所引用,即使内存不足,JVM 也不会回收它。
- 适用场景:强引用适用于对必须存在的对象进行引用,比如大多数普通对象的引用方式。
软引用(Soft Reference)
- 用于描述一些还有用但并非必需的对象,内存不足时会回收这些对象。软引用可以通过 SoftReference 类来实现。
java
SoftReference<Object> softRef = new SoftReference<>(new Object());
- 特点:
- 垃圾回收:当 JVM 发现内存不足时,会回收软引用指向的对象,避免内存溢出(OOM)。
- 软引用常用于实现内存敏感的缓存。例如缓存中存储的数据在内存充足时保留,当内存不足时进行回收。
- 适用场景:适用于缓存设计。当系统内存充足时,缓存数据不会被回收,但在内存不足时,缓存数据会被回收以避免 OOM。
弱引用(Weak Reference)
- 用于描述非必需对象,GC 扫描时一旦发现只有弱引用指向的对象就会回收。弱引用可以通过 WeakReference 类来实现。它用于描述非必须的对象。
java
WeakReference<Object> weakRef = new WeakReference<>(new Object());
- 特点:
- 垃圾回收:无论内存是否充足,垃圾回收器在进行可达性分析时,只要发现对象只被弱引用所引用,便会立即回收该对象。
- 弱引用通常用于实现规范化映射(canonicalizing mappings),例如 WeakHashMap,用来处理缓存或对象池中的弱引用对象。
- 适用场景:适用于那些希望对象在不被强引用时可以随时被回收的场景,比如弱引用缓存,避免对象长时间占用内存。
虚引用(Phantom Reference)
- 最弱的引用,不能通过虚引用获取对象实例,唯一的作用是能在对象被回收时收到系统通知。虚引用可以通过 PhantomReference 类来实现。
java
PhantomReference<Object> phantomRef = new PhantomReference<>(new Object(), referenceQueue);
- 特点:
- 垃圾回收:虚引用的存在主要用于跟踪对象的生命周期,它不能阻止对象被回收。与虚引用关联的对象在垃圾回收时被标记为可回收,回收之前会将虚引用对象加入到一个 ReferenceQueue 队列中。
- 虚引用与 ReferenceQueue 联合使用,主要用于在对象被回收时进行一些后续处理,比如资源释放。
- 适用场景:虚引用适用于管理直接内存的回收、监控对象生命周期或执行对象销毁前的清理工作。例如,当使用 DirectByteBuffer 时,可以通过虚引用在对象被回收前执行内存释放操作。
Younggc 为什么比 fullgc 快很多
在 JVM 中,Young GC (Minor GC)比 Full GC 快很多,主要是因为两者在内存区域 、回收对象的数量 、算法复杂度等方面存在本质上的区别。
- 内存区域的区别
- Young GC(Minor GC) :只发生在新生代(Young Generation) 。新生代分为三个区域:Eden 区 和两个Survivor 区(S0 和 S1)。当 Eden 区填满时,JVM 会触发 Young GC,回收新生代的短命对象(大多数对象在创建后很快就会被回收)。新生代的区域较小,通常只包含一些存活时间较短的对象,所以回收的时间较短。
- Full GC :涉及整个堆内存 ,包括新生代 、老年代 (Old Generation)以及永久代(Metaspace)。Full GC 会回收整个堆中的所有对象,包括长寿命的对象,这些对象通常分布在老年代。老年代区域较大,回收时需要扫描和处理的对象更多,涉及到的区域更广。
- 回收对象的数量和对象生命周期
- Young GC :新生代主要存储短生命周期的对象,大多数对象在进入 Eden 区后很快就会变成垃圾。由于新生代的大部分对象都可以很快被回收,存活对象较少,因此 Young GC 回收速度较快。
- Full GC :在 Full GC 中,除了新生代的对象外,老年代中的长生命周期对象也需要被回收。由于老年代中存放了很多长期存活的对象(甚至包括存活了多个 GC 周期的对象),需要花费更多时间去检查这些对象是否可以被回收。老年代的对象比较多、比较稳定,垃圾回收的复杂度也更高。
- 垃圾回收算法的复杂度
- Young GC :新生代通常采用复制算法(Copying Algorithm),即将存活的对象从 Eden 区和一个 Survivor 区复制到另一个 Survivor 区。复制算法的特点是简单、高效,只需要扫描存活的对象,未存活的对象直接被清除,因此回收速度很快。
- Full GC :老年代通常采用的是标记-清除算法 (Mark-Sweep)或标记-整理算法 (Mark-Compact)。这些算法首先需要标记出所有的存活对象,然后再执行清除或整理。相比复制算法,标记-清除和标记-整理算法的执行过程复杂得多,尤其是标记和整理阶段会导致 Full GC 变慢。
- GC 频率和触发条件
- Young GC:新生代空间较小,Eden 区填满时频繁触发 Young GC,但因为新生代回收的是短命对象,并且区域小,所以尽管频繁发生,单次执行的时间较短。
- Full GC:Full GC 触发的条件更为复杂,通常是在老年代空间不足时触发。Full GC 的开销大,JVM 会尽量避免频繁进行 Full GC。
- GC 停顿时间
- Young GC:停顿时间较短,因为回收的新生代区域较小,存活的对象少,复制算法效率高。
- Full GC:停顿时间较长,回收整个堆内存,尤其是涉及到标记和整理阶段,老年代中对象的数量和生命周期都较长,导致停顿时间长。
- 内存整理(Compaction)
- Young GC:因为采用的是复制算法,在 Young GC 中不存在内存碎片的问题。新生代中没有使用的内存会被连续的清理和整理。
- Full GC:老年代在标记-清除算法后可能会产生内存碎片。如果老年代存在内存碎片,则需要进行内存整理(Compaction),这会导致回收耗时增加。内存碎片会影响大对象的分配,因为即使有足够的总内存,但由于碎片化,可能没有足够连续的空间来存储大对象。
Minor GC(Young GC)的触发条件
- Eden 区满:当新生代中的 Eden 区被填满时,JVM 会触发 Minor GC。这是最常见的触发条件。JVM 会检查新生代中的对象,回收那些不再使用的短生命周期对象。
- 手动调用:虽然不推荐,但可以通过 System.gc() 手动请求垃圾回收,可能会导致 Minor GC 的发生。
Full GC(Major GC)的触发条件
- 老年代满:当老年代的空间不足以容纳新分配的对象时,JVM 会触发 Full GC。这是 Full GC 最常见的触发条件。
- 永久代(Metaspace)满:在 Java 8 之前,Java 使用永久代来存放类的元数据。如果永久代满了,会触发 Full GC。在 Java 8 之后,永久代被 Metaspace 替代,Metaspace 的满也是触发 Full GC 的条件之一。
- Minor GC 后老年代未能释放足够内存:当进行 Minor GC 后,如果老年代没有足够的空间来容纳新对象,JVM 会触发 Full GC。
- 调用 System.gc():通过调用 System.gc(),JVM 会建议执行 Full GC,尽管并不保证会执行。
- JVM 参数设置:一些 JVM 参数设置可能会影响 Full GC 的触发,如 -XX:+UseG1GC 或其他垃圾收集器的特定配置。
在生产环境中,JVM 调优是确保 Java 应用程序性能和稳定性的重要步骤。调优的目标通常是减少垃圾回收的时间、降低内存使用和提高应用程序的吞吐量。以下是一些常见的 JVM 调优策略和方法:
- 选择合适的垃圾收集器,具体命令如上
- 调整堆内存大小,通过调整堆内存的大小,可以控制应用程序的性能。
- 设置初始堆大小:-Xms512m
- 设置最大堆大小:-Xmx2048m
- 设置年轻代大小:-Xmn256m
- 一般推荐将初始堆和最大堆的比值设置为 1:2 或 1:3。
- 调整垃圾收集参数
- 设置新生代和老年代的比例:-XX:NewRatio=3 # 新生代与老年代的比例
- 设置 Survivor 区的大小:-XX:SurvivorRatio=8 # Eden 区与 Survivor 区的比例
- 设置最大 GC 停顿时间(对于 G1 GC):-XX:MaxGCPauseMillis=200
- 定期监控和分析 JVM 的运行状态,使用各种工具来观察性能和内存使用情况。
- JVisualVM:Java 自带的可视化监控工具,可以用来查看内存、线程、CPU 使用情况。
- JConsole:用于监控 Java 应用的图形界面工具。
- GC 日志:启用 GC 日志以分析垃圾收集的性能,-XX:+PrintGCDetails -XX:+PrintGCDateStamps -Xloggc:gc.log。
- 使用 Java Flight Recorder:这是一个强大的性能监控工具,可以提供深入的性能分析。
- 有时,代码的优化可以显著减少内存使用和垃圾回收的压力。
- 减少对象创建:尽量复用对象,避免频繁创建短命对象。
- 使用合适的数据结构:选择合适的集合类,例如 ArrayList vs LinkedList,并根据需求选择合适的实现。
- 避免内存泄漏:定期检查代码中是否存在内存泄漏,例如未清理的缓存、静态集合中的对象引用等。
- 设置线程数 ,在多线程应用中,合理配置线程数可以提高性能。
- 设置最大线程数(取决于应用和服务器的具体情况)。-XX:ParallelGCThreads=4 # 设置并行 GC 线程数`
- 使用 JDK 8 及之后的版本的特性
- Metaspace:Java 8 之后,类元数据存储在本地内存中,避免了旧版本中永久代的限制。可以通过设置 Metaspace 大小来优化性能。-XX:MetaspaceSize=128m -XX:MaxMetaspaceSize=512m
- 测试与迭代 :调优是一个迭代过程。在生产环境中,测试是非常重要的步骤:
- 负载测试:在类似生产环境中进行负载测试,以观察系统在高负载情况下的表现。
- 逐步调整:每次只调整一个参数,观察性能变化,再进行下一步调整。
Full GC 排查
在生产环境中,排查 Java 应用的 Full GC 问题是确保系统稳定性和性能的关键步骤。以下是一些有效的排查方法和工具:
- 启用 GC 日志:启用 GC 日志可以帮助你分析 Full GC 的发生频率、持续时间和触发原因。
bash
-Xloggc:gc.log -XX:+PrintGCDetails -XX:+PrintGCDateStamps -XX:+PrintGCTimeStamps
- 如果是 Java 9 及以上版本,可以使用以下参数:
bash
-Xlog:gc*:file=gc.log:time
- 分析 GC 日志 :使用工具或脚本分析生成的 GC 日志,查找 Full GC 的详细信息。
- GCViewer:一个可视化工具,可以帮助分析 GC 日志。
- GCEasy:一个在线工具,可以上传 GC 日志进行分析。
- 通过这些工具,你可以查看 Full GC 的时间、频率、回收的内存量以及各个阶段的耗时。
- 监控应用性能 :使用监控工具观察应用的性能指标,找出与 Full GC 相关的趋势。
- JVisualVM:Java 自带的可视化监控工具,可以查看内存使用情况、线程情况和 CPU 使用情况。
- JConsole:可监控 Java 应用的性能。
- Prometheus + Grafana:可以实时监控 JVM 的指标,包括 GC 相关的指标。
- 检查内存配置:确认 JVM 的内存配置是否合理,避免内存不足导致频繁的 Full GC。
- 分析应用的内存使用 :使用内存分析工具。
- Eclipse Memory Analyzer (MAT):可以帮助分析堆转储文件,找出内存泄漏和长生命周期对象。
- 生成堆转储:jmap -dump:live,format=b,file=heapdump.hprof
- 然后使用 MAT 等工具进行分析。
- 检查对象生命周期 :通过分析代码,检查是否存在内存泄漏的情况,可能导致 Full GC 频繁发生。
- 静态集合:检查是否有静态集合中引用的对象未被清理。
- 长生命周期对象:分析老年代中存活的对象,找出那些不再使用的对象。
- 应用程序代码优化
- 减少对象创建:避免频繁创建短命对象。
- 使用合适的数据结构:根据需要选择合适的集合类。
- 测试与调整
- 负载测试:在生产环境中进行负载测试,观察 Full GC 的发生情况。
- 逐步调整参数:调整 JVM 参数后,观察效果,逐步进行调整。
- 查看 JVM 版本和参数:确保使用的是最新的稳定版本,并查看 JVM 的启动参数,某些参数可能会影响 GC 行为。
- 根据 GC 日志中的信息,识别 Full GC 的原因,如:
- 老年代不足。
- PermGen/Metaspace 区域不足。
- 对象的存活时间过长。
- 系统内存压力。
JVM GC 日志 是帮助开发人员分析和调优 Java 应用内存管理的重要工具。通过解析 GC 日志,可以了解 JVM 垃圾收集的行为,包括垃圾回收频率、持续时间、回收的内存大小、各代(新生代、老年代、元空间等)的变化情况等。通过以下 JVM 参数可以启用并配置 GC 日志:
bash
-XX:+PrintGCDetails # 打印详细的 GC 日志
-XX:+PrintGCDateStamps # 打印 GC 发生的时间戳
-XX:+PrintGCTimeStamps # 打印 GC 发生的相对时间
-XX:+PrintHeapAtGC # 打印 GC 前后的堆状态
-Xloggc:<file_path> # 将 GC 日志输出到指定文件
bash
java -Xms512m -Xmx1024m -XX:+UseG1GC -XX:+PrintGCDetails -XX:+PrintGCDateStamps -XX:+PrintGCApplicationStoppedTime -Xloggc:gc.log MyApplication
Minor GC (新生代 GC) 日志示例
bash
2024-10-11T15:30:24.123+0000: 0.197: [GC (Allocation Failure) [PSYoungGen: 15360K->1984K(19456K)] 15360K->2000K(62976K), 0.0043510 secs] [Times: user=0.01 sys=0.00, real=0.00 secs]
- 时间戳:2024-10-11T15:30:24.123+0000 表示 GC 发生的实际时间。
- 相对时间:0.197 表示从 JVM 启动开始经过的时间(秒)。
- GC 类型:GC (Allocation Failure) 表示 GC 触发的原因是内存分配失败。
- Young Generation :PSYoungGen: 15360K->1984K(19456K) 表示 GC 发生时,新生代(Young Generation)的内存使用情况:
- GC 前:新生代占用了 15360K。
- GC 后:新生代占用了 1984K。
- 总空间:新生代的容量是 19456K。
- Heap Usage :15360K->2000K(62976K) 表示整个堆的使用情况:
- GC 前:堆总使用 15360K。
- GC 后:堆总使用 2000K。
- 总容量:堆的总容量是 62976K。
- GC 耗时:0.0043510 secs 表示此次 GC 持续了 4.351 毫秒。
- CPU 时间 :user=0.01 sys=0.00, real=0.00 secs 表示:
- 用户态时间(user):0.01 秒。
- 内核态时间(sys):0.00 秒。
- 实际时间(real):0.00 秒。
Full GC 日志示例
bash
2024-10-11T15:31:15.789+0000: 10.456: [Full GC (Allocation Failure) [PSYoungGen: 1024K->0K(19456K)] [ParOldGen: 20480K->18400K(20480K)] 21504K->18400K(39936K), [Metaspace: 3072K->3072K(1056768K)], 0.1234560 secs] [Times: user=0.10 sys=0.02, real=0.12 secs]
- GC 类型:Full GC (Allocation Failure) 表示发生了 Full GC,原因是内存分配失败。
- Young Generation :PSYoungGen: 1024K->0K(19456K) 表示:
- GC 前:新生代占用了 1024K。
- GC 后:新生代占用了 0K。
- 总空间:新生代的容量是 19456K。
- Old Generation :ParOldGen: 20480K->18400K(20480K) 表示:
- GC 前:老年代占用了 20480K。
- GC 后:老年代占用了 18400K。
- 总空间:老年代的容量是 20480K。
- Heap Usage :21504K->18400K(39936K) 表示:
- GC 前:堆使用了 21504K。
- GC 后:堆使用了 18400K。
- 总容量:堆的总容量是 39936K。
- Metaspace:Metaspace: 3072K->3072K(1056768K) 表示 Metaspace 空间的使用量没有变化,依然是 3072K。
- GC 耗时:0.1234560 secs 表示 Full GC 持续了 123.456 毫秒。
- CPU 时间:user=0.10 sys=0.02, real=0.12 secs。
G1 GC 日志示例
bash
2024-10-11T15:32:45.234+0000: 45.678: [GC pause (G1 Evacuation Pause) (young) (to-space exhausted), 0.0211234 secs]
[Parallel Time: 18.9 ms, GC Workers: 8]
[Other: 2.2 ms]
[Eden: 8192.0K(8192.0K)->0.0B(7168.0K) Survivors: 1024.0K->2048.0K Heap: 18.0M(28.0M)->12.0M(28.0M)]
- GC 类型:GC pause (G1 Evacuation Pause) 表示 G1 GC 的 Evacuation Pause(即新生代 GC)。
- GC 耗时:0.0211234 secs 表示 GC 持续了 21.123 毫秒。
- 并行时间:Parallel Time: 18.9 ms, GC Workers: 8 表示 8 个 GC 工作线程花费了 18.9 毫秒。
- Eden 区 :Eden: 8192.0K(8192.0K)->0.0B(7168.0K) 表示:
- GC 前:Eden 区使用了 8192K。
- GC 后:Eden 区的内存被清空。
- 总容量:Eden 区从 8192K 调整为 7168K。
- 堆内存 :Heap: 18.0M(28.0M)->12.0M(28.0M) 表示:
- GC 前堆使用了 18M。
- GC 后堆使用了 12M。
- 总容量保持不变,28M。
常见的 GC 日志分析
- GC 频率过高 :
- 如果 Minor GC 频率过高,可能表明 Eden 区容量不足,可以通过增大新生代空间或调优对象分配策略来优化。
- Full GC 频率过高 :
- 如果 Full GC 频繁发生,可能是老年代空间不足或碎片化严重。可以通过增大老年代空间或优化对象的生命周期来减少 Full GC 发生。
- GC 时间过长 :
- 如果 GC 持续时间较长,可能影响应用的响应时间。可以通过增加并行 GC 线程数(如 -XX:ParallelGCThreads)或调整垃圾收集器类型(如使用 G1 或 ZGC)来优化。
栈日志
在 JVM 中,栈日志(Stack Trace Logs)是用于记录线程执行过程中的方法调用栈信息的日志文件。栈日志通常在出现异常或错误时生成,并为开发人员和运维人员提供调试和问题诊断的依据。通过分析栈日志,可以了解线程的执行路径、方法调用的顺序、异常发生的位置等信息,从而定位性能瓶颈、线程问题或代码缺陷。栈日志的主要作用是帮助开发者和运维人员分析和解决以下问题:
- 异常定位:当 JVM 抛出异常(如 NullPointerException、ArrayIndexOutOfBoundsException 等)时,会生成栈日志,描述从异常发生点到当前方法调用栈的完整路径。这可以帮助开发者快速定位问题的根源。
java
Exception in thread "main" java.lang.NullPointerException
at com.example.MyClass.myMethod(MyClass.java:10)
at com.example.MyClass.main(MyClass.java:5)
通过栈日志可以看到 NullPointerException 是在 MyClass 的 myMethod 方法的第 10 行发生的,而 myMethod 是从 main 方法调用的。
- 线程状态分析:栈日志可以显示每个线程当前的状态(如运行、等待、阻塞等),帮助分析线程问题。例如,通过线程栈信息,可以发现死锁、线程饥饿、线程阻塞等问题。
java
"Thread-1" prio=5 tid=0x00007f8c28010000 nid=0x2f03 waiting on condition [0x00007f8c9b500000]
java.lang.Thread.State: BLOCKED (on object monitor)
at com.example.MyClass.synchronizedMethod(MyClass.java:20)
- waiting to lock <0x000000076b2e46d0> (a java.lang.Object)
- locked <0x000000076b2e4700> (a java.lang.Object)
这里可以看出线程 "Thread-1" 处于 阻塞状态,并且等待获取对象锁。
- 性能分析:栈日志可以记录方法调用的深度和频率。当性能问题(如 CPU 使用率过高或方法调用栈过深)出现时,通过分析栈日志,可以发现哪个方法或代码段占用了过多的资源。
- 内存泄漏定位:栈日志在处理内存泄漏问题时也很有帮助。通过 OutOfMemoryError 相关的栈日志,可以发现哪些对象没有被正确释放,或在哪些地方频繁分配内存。
java
Exception in thread "main" java.lang.OutOfMemoryError: Java heap space
at com.example.MyClass.allocateMemory(MyClass.java:25)
at com.example.MyClass.main(MyClass.java:10)
这里显示 OutOfMemoryError 是在 allocateMemory 方法中发生的,可以用作内存分析的起点。
栈日志通常在以下情况下生成:
- 异常抛出时:当 Java 程序抛出未捕获的异常时,JVM 会自动生成栈日志,显示异常的原因和发生位置。
- 死锁检测时:如果 JVM 检测到死锁,栈日志会显示参与死锁的线程信息。
- 显式打印栈信息:可以通过程序显式地调用 Thread.dumpStack() 方法打印当前线程的栈日志。
- 性能监控:一些监控工具(如 JVisualVM、JProfiler)可以生成线程的栈信息,用于性能调优和分析。
栈日志的结构通常包括以下几个部分:
- 线程名称:显示线程的名称和优先级。
- 线程状态:显示线程的当前状态(如 RUNNABLE、BLOCKED、WAITING 等)。
- 方法调用栈:记录每个方法的调用顺序,包括类名、方法名和行号。
- 锁信息:如果线程被阻塞或等待锁,栈日志中会包含锁相关的信息。
java
"main" prio=5 tid=0x00007f8c20002800 nid=0x2c03 runnable [0x00007f8c9b507000]
java.lang.Thread.State: RUNNABLE
at java.io.FileInputStream.readBytes(Native Method)
at java.io.FileInputStream.read(FileInputStream.java:233)
at java.io.BufferedInputStream.fill(BufferedInputStream.java:246)
at java.io.BufferedInputStream.read1(BufferedInputStream.java:286)
at java.io.BufferedInputStream.read(BufferedInputStream.java:345)
at sun.nio.cs.StreamDecoder.readBytes(StreamDecoder.java:284)
at sun.nio.cs.StreamDecoder.implRead(StreamDecoder.java:326)
at sun.nio.cs.StreamDecoder.read(StreamDecoder.java:178)
at java.io.InputStreamReader.read(InputStreamReader.java:184)
at java.io.BufferedReader.fill(BufferedReader.java:161)
at java.io.BufferedReader.readLine(BufferedReader.java:324)
at java.io.BufferedReader.readLine(BufferedReader.java:389)
at com.example.MyClass.readFile(MyClass.java:35)
at com.example.MyClass.main(MyClass.java:20)
线程的状态可以帮助确定线程当前的执行情况。栈日志中的线程状态通常包括:
- RUNNABLE:线程正在运行或等待 CPU 时间片。
- BLOCKED:线程被阻塞,等待获取某个锁。
- WAITING:线程在等待其他线程的通知(例如通过 Object.wait() 或 Thread.join())。
- TIMED_WAITING:线程在等待一定时间(例如 Thread.sleep() 或带超时的 wait()、join())。
- TERMINATED:线程已经结束。
如何分析线程状态:
- 如果大多数线程处于 RUNNABLE 状态,而 CPU 使用率较高,可能是系统出现了 高负载 或 CPU 密集型操作。
- 如果大量线程处于 BLOCKED 状态,可能是 锁争用 问题,某些线程长时间持有锁,导致其他线程无法继续执行。
- 如果线程处于 WAITING 或 TIMED_WAITING 状态,通常表示线程在等待外部事件(如 I/O 操作或其他线程的通知),可以检查等待时间是否过长。
死锁通常表现为多个线程互相等待彼此持有的锁,导致线程无法继续执行。栈日志中的死锁信息通常表现为:
- 两个或多个线程的状态为 BLOCKED。
- 线程显示 "waiting to lock" 和 "locked" 同时存在,形成循环。
示例死锁日志:
java
"Thread-1" prio=5 tid=0x00007f8c28010000 nid=0x2f03 waiting for monitor entry [0x00007f8c9b500000]
java.lang.Thread.State: BLOCKED (on object monitor)
at com.example.MyClass.method1(MyClass.java:20)
- waiting to lock <0x000000076b2e46d0> (a java.lang.Object)
- locked <0x000000076b2e4700> (a java.lang.Object)
"Thread-2" prio=5 tid=0x00007f8c28020000 nid=0x2f04 waiting for monitor entry [0x00007f8c9b507000]
java.lang.Thread.State: BLOCKED (on object monitor)
at com.example.MyClass.method2(MyClass.java:30)
- waiting to lock <0x000000076b2e4700> (a java.lang.Object)
- locked <0x000000076b2e46d0> (a java.lang.Object)
锁争用是性能问题的常见来源,当多个线程争夺同一个锁时,可能会导致线程阻塞,降低系统的并发性能。栈日志中的锁争用信息通常显示为:
- 线程处于 BLOCKED 状态。
- 日志中显示 "waiting to lock" 和 "locked" 关键字。
示例锁争用日志:
java
"Thread-3" prio=5 tid=0x00007f8c28010000 nid=0x2f05 waiting for monitor entry [0x00007f8c9b500000]
java.lang.Thread.State: BLOCKED (on object monitor)
at com.example.MyClass.synchronizedMethod(MyClass.java:40)
- waiting to lock <0x000000076b2e46d0> (a java.lang.Object)
"Thread-4" prio=5 tid=0x00007f8c28020000 nid=0x2f06 runnable [0x00007f8c9b507000]
at com.example.MyClass.synchronizedMethod(MyClass.java:40)
- locked <0x000000076b2e46d0> (a java.lang.Object)
在这个例子中,Thread-3 被阻塞,等待 Thread-4 释放锁。在实际生产环境中,如果发现多个线程被长时间阻塞,可以通过栈日志找到持有锁的线程,从而优化锁的使用,减少争用。
如果栈日志显示了大量线程,尤其是大量线程处于相同状态(如 WAITING 或 TIMED_WAITING),这可能表明系统存在 线程过多 或 线程泄漏 问题。通常的表现是:
- 有成百上千的线程在栈日志中,可能是由于线程池配置不当或没有及时销毁线程。
- 线程的栈信息重复,表明某些线程在进行重复的任务。
这种情况下,可以考虑减少线程池的最大线程数,或者更高效地使用异步任务。
当 JVM 中的 CPU 使用率高时,栈日志可以帮助确认哪些线程消耗了大量的 CPU。通常表现为:
- 多个线程处于 RUNNABLE 状态。
- 线程栈深度较大,且在执行 CPU 密集型任务,如复杂的计算或循环操作。
示例:
java
"Thread-5" prio=5 tid=0x00007f8c28010000 nid=0x2f07 runnable [0x00007f8c9b500000]
java.lang.Thread.State: RUNNABLE
at com.example.MyClass.compute(MyClass.java:50)
at com.example.MyClass.main(MyClass.java:10)
如何有效地分析 JVM 栈日志
- 关注线程状态:查看是否有大量线程处于 BLOCKED、WAITING 或 TIMED_WAITING 状态。线程的状态信息是栈日志分析的关键。
- 检查锁争用情况:如果线程被阻塞,检查是否有锁争用问题,锁的使用是否合理,锁等待时间是否过长。
- 定位异常:如果程序抛出异常(如 NullPointerException、OutOfMemoryError 等),通过栈日志定位异常发生的位置,确定异常的原因。
- 检查线程数量:观察是否有过多的线程创建,线程数是否超过合理的范围。
- 结合上下文分析:栈日志分析不应孤立进行,结合应用的运行环境(如 CPU、内存使用情况、I/O 状况等)和日志中的异常或性能信息,才能得到更加准确的分析结果。
如何生成 JVM 栈日志
- 使用 jstack 工具:jstack 是一个常用的命令行工具,用于打印 JVM 中所有线程的栈信息。可以通过以下命令生成栈日志:
bash
jstack <pid> > thread_dump.txt
其中 是目标 JVM 进程的 ID。
- 通过 Java 代码生成:可以使用 Thread.getAllStackTraces() 或 Thread.dumpStack() 生成当前线程的栈信息并输出到日志中:
java
public class StackTraceExample {
public static void main(String[] args) {
Thread.dumpStack(); // 打印当前线程的栈日志
}
}
- 在异常捕获时打印:可以在 catch 块中通过 Exception.printStackTrace() 方法打印异常的栈信息。
java
try {
// 可能抛出异常的代码
} catch (Exception e) {
e.printStackTrace(); // 打印栈日志
}