一、概述

JVM(Java Virtual Machine,Java虚拟机)是Java技术体系的核心,也是实现Java语言“一次编写,到处运行”(Write Once, Run Anywhere)承诺的基石。根据Oracle官方定义,JVM是一个为执行Java字节码而设计的虚拟机规范。它提供了一个独立于底层硬件和操作系统的运行时环境,使得编译后的Java字节码(.class文件)可以在任何安装了相应JVM的设备上运行。

正是由于JVM的存在,Java开发者无需为不同的操作系统(如Windows、Linux、macOS)编写不同版本的代码。他们只需要使用Java编译器(javac)将源代码编译成标准的、与平台无关的字节码。然后,针对不同操作系统(如Windows、Linux、macOS)和硬件架构(如x86、ARM)开发的、各不相同的JVM实现(如HotSpot for Windows, HotSpot for Linux)会负责加载这些字节码,并将其解释或即时编译(JIT)成当前平台能够理解的本地机器指令来执行。因此,是“不同操作系统的JVM不一样”,而不是Java程序本身需要改变,这构成了Java跨平台能力的根本。

二、JVM组成概述

一个完整的JVM实现主要由三个核心子系统构成:

  • 类加载子系统(ClassLoader Subsystem):负责加载、链接和初始化Java类文件。
  • 运行时数据区(Runtime Data Areas):JVM在运行程序时管理的内存区域,是Java内存模型的具体体现。主要包括:
    • 方法区(Method Area):存储已被加载的类信息、常量、静态变量等。
    • 堆(Heap):所有对象实例和数组分配内存的区域,是垃圾收集器管理的主要区域。
    • Java虚拟机栈(Java Virtual Machine Stacks):每个线程私有,用于存储局部变量表、操作数栈、动态链接、方法出口等信息。
    • 本地方法栈(Native Method Stack):为JVM使用到的本地(Native)方法服务。
    • 程序计数器(Program Counter Register):当前线程所执行的字节码的行号指示器。
  • 执行引擎(Execution Engine):负责执行字节码。它包含解释器(Interpreter)和即时编译器(Just-In-Time Compiler, JIT Compiler),以及垃圾回收器(Garbage Collector, GC)。

此外,JVM还包括本地方法接口(JNI)和本地库,用于与操作系统交互。

三、类加载机制

1. 类加载流程

类从被加载到虚拟机内存开始,到卸载出内存为止,它的整个生命周期包括:加载(Loading)链接(Linking)初始化(Initialization)、使用和卸载。其中链接又细分为验证(Verification)、准备(Preparation)、解析(Resolution)三个阶段。

  1. 加载:通过类的全限定名获取定义此类的二进制字节流,并将这个字节流所代表的静态存储结构转化为方法区的运行时数据结构,最后在堆中生成一个代表这个类的java.lang.Class对象,作为方法区这个类的各种数据的访问入口。
  2. 链接
    • 验证:确保被加载的类的字节流符合JVM规范,不会危害虚拟机安全。
    • 准备:为类的静态变量分配内存并设置默认初始值(零值)。
    • 解析:将常量池内的符号引用替换为直接引用。
  3. 初始化:执行类的<clinit>()方法(即静态变量赋值和静态代码块合并生成的方法)的过程,真正为静态变量赋予程序设定的初始值。

Class.forName()和ClassLoader.loadClass()对比:

Class.forName()ClassLoader.loadClass() 都是 Java 中用于动态加载类的核心方法,但它们在加载行为、初始化时机和适用场景上存在显著差异。下面从多个维度进行详细对比:

对比维度 Class.forName() ClassLoader.loadClass()
所属类 java.lang.Class 的静态方法 java.lang.ClassLoader 的实例方法
类初始化 默认执行类的初始化(执行 <clinit>() 方法,即静态代码块和静态变量赋值) 默认不执行类的初始化,仅完成加载和链接阶段
参数控制 提供三个重载版本:forName(String className)forName(String name, boolean initialize, ClassLoader loader),可通过 initialize 参数控制是否执行初始化 提供 loadClass(String name)loadClass(String name, boolean resolve)resolve 参数控制是否执行链接阶段的解析(Resolution),而非初始化
类加载器 单参数版本使用调用者所在类的类加载器;三参数版本可显式指定类加载器 使用当前 ClassLoader 实例及其委派链进行加载
返回类型 返回 Class<?> 对象 返回 Class<?> 对象
抛出异常 ClassNotFoundException ClassNotFoundException

代码示例对比:

示例类:

package com.xzc.stu.jvm;
public class ComTest {
public static int a = 10;
private String b;
static {
System.out.println("执行静态代码块");
a = 100;
}
public int getA() {
return a;
}
}

Class.forName():

ClassLoader.loadClass():

核心区别详解:

  1. 初始化行为差异Class.forName(className) 默认会执行类的初始化,即触发静态代码块和静态变量的赋值操作。这在加载 JDBC 驱动时至关重要——驱动类的静态代码块中会调用 DriverManager.registerDriver() 完成注册。而 ClassLoader.loadClass(name) 默认只完成加载和链接,不会执行初始化,因此加载 JDBC 驱动后驱动不会被注册。
  2. 类加载器选择Class.forName(String name) 使用调用者所在类的类加载器,这在某些场景下可能不是期望的加载器。而 ClassLoader.loadClass() 由具体的 ClassLoader 实例调用,可以精确控制使用哪个类加载器进行加载。
  3. 参数含义不同Class.forName(String name, boolean initialize, ClassLoader loader)initialize 参数控制是否执行初始化;ClassLoader.loadClass(String name, boolean resolve)resolve 参数控制是否执行链接阶段的解析(将符号引用替换为直接引用),两者含义完全不同。

典型应用场景:

  • Class.forName():适用于需要执行类初始化的场景,如加载 JDBC 驱动(Class.forName("com.mysql.cj.jdbc.Driver"))、加载需要执行静态代码块的类。
  • ClassLoader.loadClass():适用于延迟初始化、框架内部类加载、热部署等场景,如 Spring 框架中大量使用 ClassLoader.loadClass() 来加载 Bean 类,避免过早触发静态代码块。

代码示例对比:

// 示例类:包含静态代码块
public class DemoClass {
    static {
        System.out.println("DemoClass 静态代码块执行了!");
    }
}
// 使用 Class.forName() —— 会执行静态代码块
Class<?> clazz1 = Class.forName("com.example.DemoClass");
// 输出:DemoClass 静态代码块执行了!
// 使用 ClassLoader.loadClass() —— 不会执行静态代码块
ClassLoader classLoader = DemoClass.class.getClassLoader();
Class<?> clazz2 = classLoader.loadClass("com.example.DemoClass");
// 无输出,静态代码块未执行
// 如果需要 loadClass 后也执行初始化,可以手动调用
Class.forName(clazz2.getName(), true, classLoader);
// 输出:DemoClass 静态代码块执行了!

总结:选择使用哪个方法取决于是否需要执行类的初始化。如果加载类后需要立即使用其静态资源或触发静态代码块中的注册逻辑,应使用 Class.forName();如果仅需获取 Class 对象进行反射操作或延迟初始化,使用 ClassLoader.loadClass() 更为合适。

2. 四种类加载器

JVM的类加载是通过类加载器(ClassLoader)来完成的。主要有以下四类:

  • 启动类加载器(Bootstrap ClassLoader):由C++实现,是JVM自身的一部分。负责加载<JAVA_HOME>/lib目录下,或被-Xbootclasspath参数指定的路径中的,且被虚拟机识别的核心类库(如rt.jar)。它是最顶层的加载器。
  • 扩展类加载器(Extension ClassLoader):由sun.misc.Launcher$ExtClassLoader实现。负责加载<JAVA_HOME>/lib/ext目录下,或由java.ext.dirs系统变量指定的路径中的所有类库。
  • 应用程序类加载器(Application ClassLoader / System ClassLoader):由sun.misc.Launcher$AppClassLoader实现。是ClassLoader.getSystemClassLoader()的返回值,负责加载用户类路径(ClassPath)上所指定的类库。开发者编写的代码通常由它加载。
  • 自定义类加载器(User-Defined ClassLoader):由开发者继承java.lang.ClassLoader类自行实现的类加载器,用于实现特殊的加载需求,如从网络、数据库、加密文件等非标准来源加载类。

对应位置获取类加载器结果如下:

3. 双亲委派模型(Parents Delegation Model)

上述类加载器之间的层次关系(非继承关系,而是通过parent属性形成的父子关系)构成了"双亲委派模型"。其工作流程是:

  1. 当一个类加载器收到类加载请求时,它首先不会自己去尝试加载,而是将这个请求委派给父类加载器去完成。
  2. 每一层的类加载器都是如此,因此所有的加载请求最终都应该传送到顶层的启动类加载器。
  3. 只有当父加载器反馈自己无法完成这个加载请求(在其搜索范围中没有找到所需的类)时,子加载器才会尝试自己去加载。

源码如下:

protected Class<?> loadClass(String name, boolean resolve)
        throws ClassNotFoundException
    {
        synchronized (getClassLoadingLock(name)) {
            // First, check if the class has already been loaded
            Class<?> c = findLoadedClass(name);
            if (c == null) {
                long t0 = System.nanoTime();
                try {
                    if (parent != null) {
                        c = parent.loadClass(name, false);
                    } else {
                        c = findBootstrapClassOrNull(name);
                    }
                } catch (ClassNotFoundException e) {
                }
            if (c == null) {
                long t1 = System.nanoTime();
                c = findClass(name);
                sun.misc.PerfCounter.getParentDelegationTime().addTime(t1 - t0);
                sun.misc.PerfCounter.getFindClassTime().addElapsedTimeFrom(t1);
                sun.misc.PerfCounter.getFindClasses().increment();
            }
        }
        if (resolve) {
            resolveClass(c);
        }
        return c;
    }
}

方法参数说明

  • name:要加载的类的全限定名(Fully Qualified Name)。例如要加载 java.lang.String,则传入的 name 参数就是字符串 "java.lang.String"。这个名称遵循 Java 类的标准命名规范,使用点号(.)分隔包路径和类名。
  • resolve:一个布尔值,指示是否在加载类后立即进行链接阶段的解析(Resolution)。当 resolve=true 时,方法会在返回 Class 对象前调用 resolveClass(c),将常量池中的符号引用转换为直接引用;当 resolve=false 时,只进行加载和验证,解析可以延迟到首次使用时再进行(延迟解析)。通常,类加载器在内部递归调用时会传递 false 以避免重复解析,只有在最终返回给调用者时才可能传递 true

优点

  • 保证Java核心库的类型安全:防止用户自定义一个与核心类库同名的类(如 java.lang.Object)被加载,确保核心API不被篡改。
  • 避免类的重复加载:当父加载器已经加载了该类时,子加载器就没有必要再加载一次。

4. 打破双亲委派模型

4.1 需要打破双亲委派模型的场景

在某些场景下,标准的双亲委派模型无法满足需求,需要打破这一机制:

  • JDBC SPI(Service Provider Interface):Java核心库(如 java.sql.DriverManager)需要加载由不同厂商(如MySQL、Oracle)实现并放在ClassPath下的驱动类。这些驱动类由启动类加载器加载的核心接口调用,但启动类加载器无法"向下"加载应用类加载器路径的类。解决方案是使用线程上下文类加载器(Thread Context ClassLoader),将加载动作"逆向"委派给应用程序类加载器。
  • OSGi、Tomcat等模块化/热部署框架:为了实现模块间的类隔离、版本共存或热替换,每个模块(Bundle/WebApp)使用独立的类加载器,并优先从自己的模块路径加载类,只有在找不到时才委派给父加载器。这实质上是先自己加载,再委派给父加载器,与标准双亲委派顺序相反。
  • 用户自定义需求:实现热部署、代码加密解密加载、动态代码生成等特殊场景。
4.2 打破双亲委派模型的方法

打破双亲委派模型主要有两种方法:

方法一:自定义类加载器,不调用上层类加载器

通过继承 java.lang.ClassLoader 并重写 loadClass() 方法,跳过父类加载器的委派逻辑,直接调用自己的 findClass() 方法。

自定义类加载器:

package com.xzc.stu.jvm;
import cn.hutool.core.io.FileUtil;
import java.io.File;
public class MyClassLoader1 extends ClassLoader {
private final String CLASS_PATH;
public MyClassLoader1(String classPath) {
// 不指定父类加载器,默认使用系统类加载器
CLASS_PATH = classPath;
}
@Override
protected Class&lt;?&gt; loadClass(String name, boolean resolve) throws ClassNotFoundException {
Class&lt;?&gt; c = findLoadedClass(name);
if (c != null) {
System.out.printf("【%s】已经加载过==========%n", name);
return c;
}
if (name.startsWith("java.")
        || name.startsWith("javax.")
        || name.startsWith("sun.")
        || name.startsWith("com.sun.")
        || name.startsWith("jdk.")) {
    System.out.printf("【%s】是系统类,使用系统类加载器加载==========%n", name);
    return super.loadClass(name, resolve);
}
// 直接当前类加载器加载,打破双亲委派
Class&amp;lt;?&amp;gt; c1 = findClass(name);
if (resolve) {
resolveClass(c1);
}
System.out.printf("【%s】是自定义类,使用自定义类加载器加载==========%n", name);
return c1;
}
@Override
protected Class&lt;?&gt; findClass(String name) throws ClassNotFoundException {
String path = CLASS_PATH + File.separator
name.replace(".", File.separator)
".class";
File file = new File(path);
if (!file.exists()) {
throw new ClassNotFoundException(name);
}
byte[] data = FileUtil.readBytes(file);
return defineClass(name, data, 0, data.length);
}
}

测试:

public static void main(String[] args) throws ClassNotFoundException, InstantiationException, IllegalAccessException, InvocationTargetException, NoSuchMethodException, NoSuchFieldException {
        String dir = "E:\\MyWorkSpace\\test\\target\\classes";
        MyClassLoader1 myClassLoader1 = new MyClassLoader1(dir);
        Class<?> clazz = myClassLoader1.loadClass("com.xzc.model.TextDto");
    System.out.println(Thread.currentThread().getContextClassLoader());
System.out.println("\n反射创建对象,调用方法===================");
Object o = clazz.newInstance();
Field text = clazz.getDeclaredField("text");
text.setAccessible(true);
text.set(o, "hello world");
Method toString = clazz.getMethod("toString");
Object invoke = toString.invoke(o);
System.out.println(invoke);
}
输出信息:
【java.lang.Object】是系统类,使用系统类加载器加载==========
【com.xzc.model.TextDto】是自定义类,使用自定义类加载器加载==========
sun.misc.Launcher$AppClassLoader@255316f2
反射创建对象,调用方法===================
【java.lang.String】是系统类,使用系统类加载器加载==========
【java.lang.StringBuilder】是系统类,使用系统类加载器加载==========
TextDto(text=hello world)

输出信息可以看到:

1:懒加载,只有需要使用的时候才会加载类,

2:当有父类的时候会先加载父类,

3:跟此类相关的其他类会用同一个类加载器加载。

类在jvm里唯一标识不只是全限定路径,还要包括类加载器,同一个类,不同类加载器加载进来,判定是不同类的。

    public static void main(String[] args) throws ClassNotFoundException, InstantiationException, IllegalAccessException, InvocationTargetException, NoSuchMethodException, NoSuchFieldException {
        String dir = "E:\\MyWorkSpace\\test\\target\\classes";
        MyClassLoader1 myClassLoader1 = new MyClassLoader1(dir);
        Class<?> clazz = myClassLoader1.loadClass("com.xzc.model.TextDto");
        System.out.println(clazz.getClassLoader());
        Class<?> clazz2 = myClassLoader1.loadClass("com.xzc.model.TextDto");
        System.out.println(clazz2.getClassLoader());
        System.out.println(clazz == clazz2);
    TextDto textDto = new TextDto();
    Class&lt;? extends TextDto&gt; aClass = textDto.getClass();
    System.out.println(aClass.getClassLoader());
System.out.println(clazz == aClass);
}
// 输出:
【java.lang.Object】是系统类,使用系统类加载器加载==========
【com.xzc.model.TextDto】是自定义类,使用自定义类加载器加载==========
com.xzc.stu.jvm.MyClassLoader1@27f674d
【com.xzc.model.TextDto】已经加载过==========
com.xzc.stu.jvm.MyClassLoader1@27f674d
true
sun.misc.Launcher$AppClassLoader@255316f2
false

关键点说明:

  • loadClass() 方法:重写此方法可以改变委派逻辑。示例中跳过了父类委派,直接调用 findClass()
  • findClass() 方法:实现具体的类加载逻辑,从文件系统读取字节码并调用 defineClass()
  • 核心类保护:示例中对 java. 开头的类仍然使用父类加载器,避免破坏Java核心库。

方法二:利用SPI(Service Provider Interface)机制

Java的SPI机制通过 java.util.ServiceLoader类实现,它使用线程上下文类加载器来加载服务实现类,从而打破了双亲委派。

public final class ServiceLoader<S>
    implements Iterable<S>
{
private static final String PREFIX = "META-INF/services/"; // 此处就是扫描的包路径
...
// Private inner class implementing fully-lazy provider lookup
//
private class LazyIterator
implements Iterator&lt;S&gt;
{
Class&amp;lt;S&amp;gt; service;
ClassLoader loader;
Enumeration&amp;lt;URL&amp;gt; configs = null;
Iterator&amp;lt;String&amp;gt; pending = null;
String nextName = null;
private LazyIterator(Class&amp;lt;S&amp;gt; service, ClassLoader loader) {
this.service = service;
this.loader = loader;
}
private boolean hasNextService() {  // 内部懒迭代器,扫描META-INF/services/下的包
if (nextName != null) {
return true;
}
if (configs == null) {
try {
String fullName = PREFIX + service.getName();
if (loader == null)
configs = ClassLoader.getSystemResources(fullName);
else
configs = loader.getResources(fullName);
} catch (IOException x) {
fail(service, "Error locating configuration files", x);
}
}
while ((pending == null) || !pending.hasNext()) {
if (!configs.hasMoreElements()) {
return false;
}
pending = parse(service, configs.nextElement());
}
nextName = pending.next();
return true;
}
...
}
...
// 关键使用方法,通过此方法获取对应服务接口的实现类
public static &lt;S&gt; ServiceLoader&lt;S&gt; load(Class&lt;S&gt; service) {
ClassLoader cl = Thread.currentThread().getContextClassLoader();  // Thread线程私有的类加载器,也是打破双亲委派的关键
return ServiceLoader.load(service, cl);
}
...
}

spi本质是通过Thread类的contextClassLoader记录的本线程使用的类加载器来打破的,实现在启动类加载器里可以使用系统类加载器加载对应的类,以jdbc为例:

JDBC的 java.sql.DriverManager 类位于 rt.jar 中,由启动类加载器(Bootstrap ClassLoader)加载。而具体的数据库驱动实现类(如 com.mysql.cj.jdbc.Driver)位于应用的ClassPath中,由应用程序类加载器(AppClassLoader)加载。按照双亲委派模型,启动类加载器无法"向下"加载应用类加载器路径中的类。DriverManager通过线程上下文类加载器解决了这一矛盾。

以下是 DriverManager 中加载驱动的核心源码分析:

// java.sql.DriverManager 部分源码
public class DriverManager {
// 注册的驱动列表
private final static CopyOnWriteArrayList&lt;DriverInfo&gt; registeredDrivers
        = new CopyOnWriteArrayList&lt;&gt;();
// 静态初始化块 —— 类加载时触发驱动加载
static {
loadInitialDrivers();
println("JDBC DriverManager initialized");
}
private static void loadInitialDrivers() {
String drivers;
try {
// 1. 从系统属性 "jdbc.drivers" 获取驱动类名
drivers = AccessController.doPrivileged(
new PrivilegedAction&lt;String&gt;() {
public String run() {
return System.getProperty("jdbc.drivers");
}
});
} catch (Exception ex) {
drivers = null;
}
// 2. 通过 SPI 机制加载驱动 —— 关键打破双亲委派的地方
AccessController.doPrivileged(
    new PrivilegedAction&amp;lt;Void&amp;gt;() {
        public Void run() {
            // 这里使用 ServiceLoader.load(Driver.class)
            // 而 ServiceLoader.load() 内部会获取
            // Thread.currentThread().getContextClassLoader()
            // 作为类加载器来加载 META-INF/services/java.sql.Driver
            // 文件中声明的驱动实现类
            ServiceLoader&amp;lt;Driver&amp;gt; loadedDrivers
                    = ServiceLoader.load(Driver.class);
            Iterator&amp;lt;Driver&amp;gt; driversIterator = loadedDrivers.iterator();
            try {
                while (driversIterator.hasNext()) {
                    driversIterator.next();
                }
            } catch (Throwable t) {
                // 忽略加载失败的驱动
            }
            return null;
        }
    });
// 3. 加载系统属性中指定的驱动
println("DriverManager.initialize: jdbc.drivers = " + drivers);
if (drivers == null || drivers.equals("")) {
return;
}
String[] driversList = drivers.split(":");
println("number of Drivers:" + driversList.length);
for (String aDriver : driversList) {
try {
// 这里也使用了线程上下文类加载器来加载驱动类
println("DriverManager.Initialize: loading " + aDriver);
Class.forName(aDriver, true,
ClassLoader.getSystemClassLoader());
} catch (Exception ex) {
println("DriverManager.Initialize: load failed: " + ex);
}
}
}
// 获取连接时遍历已注册的驱动
public static Connection getConnection(String url,
java.util.Properties info) throws SQLException {
// 校验权限
return (getConnection(url, info, Reflection.getCallerClass()));
}
private static Connection getConnection(
String url, java.util.Properties info, Class&lt;?&gt; caller)
throws SQLException {
// 获取调用者的类加载器
ClassLoader callerCL = caller != null ? caller.getClassLoader() : null;
synchronized (DriverManager.class) {
// 如果调用者的类加载器为空(由启动类加载器加载),
// 则使用线程上下文类加载器
if (callerCL == null) {
callerCL = Thread.currentThread().getContextClassLoader();
}
}
// 遍历所有已注册的驱动,尝试建立连接
for (DriverInfo aDriver : registeredDrivers) {
// 检查驱动是否可以被调用者的类加载器访问
if (isDriverAllowed(aDriver.driver, callerCL)) {
try {
Connection con = aDriver.driver.connect(url, info);
if (con != null) {
return (con);
}
} catch (SQLException ex) {
if (reason == null) {
reason = ex;
}
}
} else {
println("    skipping: " + aDriver.getClass().getName());
}
}
throw new SQLException("No suitable driver found for " + url, reason);
}
// 判断驱动是否可以被指定类加载器访问
private static boolean isDriverAllowed(Driver driver, ClassLoader classLoader) {
boolean result = false;
if (driver != null) {
// 用目标类加载器尝试加载驱动类,如果能加载到同一个Class对象则允许
Class&lt;?&gt; aClass = null;
try {
aClass = Class.forName(driver.getClass().getName(),
true, classLoader);
} catch (Exception e) {
result = false;
}
result = (aClass == driver.getClass()) ? true : false;
}
return result;
}
}

上述源码中,打破双亲委派的关键点在于:

  • ServiceLoader.load(Driver.class):在 loadInitialDrivers() 中调用时,ServiceLoader.load() 内部会获取 Thread.currentThread().getContextClassLoader()。在典型的JDBC使用场景中,调用线程(如main线程)的上下文类加载器默认是应用程序类加载器(AppClassLoader)。因此,虽然 DriverManager 本身由启动类加载器加载,但它通过线程上下文类加载器"逆向"获取了应用程序类加载器,从而能够扫描ClassPath下的 META-INF/services/java.sql.Driver 文件并加载具体的驱动实现类。
  • getConnection() 中的类加载器判断:当调用者的类加载器为null(即由启动类加载器加载的代码调用)时,同样使用 Thread.currentThread().getContextClassLoader() 来校验驱动是否可访问,确保应用类加载器路径下的驱动能够被正确识别和使用。

以MySQL驱动为例,查看对应jar包如下:

ServiceLoader 扫描到该文件后,会使用线程上下文类加载器(AppClassLoader)加载 com.mysql.cj.jdbc.Driver 类。该驱动类的静态初始化块中会调用 DriverManager.registerDriver() 将自己注册到 DriverManager 的驱动列表中,从而完成整个SPI驱动的加载流程。这一过程完全绕过了双亲委派模型"自下而上委派"的规则,实现了"自上而下"的逆向委派。

实现SPI的步骤:

  1. 定义服务接口:创建一个Java接口作为服务契约。
  2. 创建服务实现:实现该接口的具体类。
  3. 注册服务提供者:在 META-INF/services/ 目录下创建以接口全限定名命名的文件,文件内容为实现类的全限定名。
  4. 加载服务:使用 ServiceLoader.load() 方法加载所有服务实现。

示例:

接口类:

package com.xzc.stu.jvm.spi;
public interface IMsgService {
String sendMsg(String msg);
}

实现类:

package com.xzc.stu.jvm.spi;
public class MsgServiceImpl implements IMsgService {
@Override
public String sendMsg(String msg) {
return "第三方实现jdk接口: " + msg;
}
}

注册:

加载测试类:

package com.xzc.stu.jvm.spi;
import java.util.ServiceLoader;
public class SpiTest {
public static void main(String[] args) {
    ServiceLoader&lt;IMsgService&gt; serviceLoader = ServiceLoader.load(IMsgService.class);
    for (IMsgService msgService : serviceLoader) {
        String str = msgService.sendMsg("hello");
        System.out.println(str);
    }
}
}
// 输出:
第三方实现jdk接口: hello

两种方法的对比

对比维度 自定义类加载器 SPI机制
实现复杂度 较高,需要继承ClassLoader并重写关键方法 较低,Java标准库已提供ServiceLoader
灵活性 高,可以完全控制加载逻辑和来源 中,遵循标准SPI规范,扩展点固定
适用场景 热部署、代码加密、模块隔离、自定义类来源 插件化系统、服务发现、厂商驱动加载
对双亲委派的打破方式 主动重写loadClass(),跳过父类委派 被动使用线程上下文类加载器,逆向委派
标准化程度 非标准,各框架实现不一 Java标准,有明确规范
典型应用 Tomcat、OSGi、Spring Boot DevTools JDBC驱动、日志框架、序列化框架

四、运行时数据区

1. 概述

运行时数据区(Runtime Data Areas)是JVM在运行Java程序时管理的内存区域,是Java内存模型的具体体现。JVM将其管理的内存划分为多个不同的区域,每个区域有各自的用途、创建和销毁时机。主要包括以下五个核心部分:

  • 虚拟机栈:线程私有,存栈帧(局部变量表、操作数栈、动态链接、方法出口)。
  • 本地方法栈:线程私有,存本地方法调用信息。HotSpot将其与虚拟机栈合并。
  • 程序计数器:线程私有,存当前线程执行的字节码行号。
  • 方法区:线程共享,存类信息、常量、静态变量、JIT编译缓存。JDK 8前为永久代(堆内),JDK 8后为元空间(本地内存)。
  • :线程共享,存所有对象实例和数组,是GC管理的主要区域。

默认栈和堆的大小:JVM各内存区域的默认大小因JDK版本、操作系统和JVM实现(如HotSpot)而异。以HotSpot在64位Linux/Windows上的常见默认值为例:的默认初始大小为物理内存的1/64,最大大小为物理内存的1/4(可通过-Xms-Xmx分别设置初始和最大堆大小);虚拟机栈的默认大小为1024KB(1MB),可通过-Xss参数调整;元空间(JDK 8+)默认无上限,仅受本地内存限制,可通过-XX:MetaspaceSize-XX:MaxMetaspaceSize设置初始和最大值;永久代(JDK 7及之前)默认大小约82MB(64位),可通过-XX:PermSize-XX:MaxPermSize调整。

常量池说明:JVM中的常量池分为两类。一是字符串常量池(String Pool),JDK 7之前位于永久代,JDK 7及之后移至堆内存中,用于缓存字符串字面量以节省内存。二是运行时常量池(Runtime Constant Pool),它是方法区的一部分,在类加载后由类文件常量池(Class Constant Pool)转换而来,存储编译期生成的字面量和符号引用。JDK 8之后方法区由元空间实现,运行时常量池也随之位于元空间中。

永久代与元空间对比:

对比维度 永久代(PermGen) 元空间(Metaspace)
JDK版本 JDK 8 之前 JDK 8 及之后
内存位置 堆内存中,属于JVM管理 本地内存(Native Memory),由操作系统管理
默认大小限制 有固定默认值(如64位下约82MB) 无上限,仅受物理内存限制
可配置参数 -XX:PermSize、-XX:MaxPermSize -XX:MetaspaceSize、-XX:MaxMetaspaceSize
OOM风险 容易因类加载过多导致PermGen OOM 大幅降低,但可能因元空间膨胀导致本地内存耗尽
GC行为 与堆一起进行Full GC,回收效率低 元空间中的类元数据由专门的元空间垃圾回收器管理,卸载条件更灵活
字符串常量池 位于永久代 移至堆内存
静态变量 位于永久代 移至堆内存


2. 堆

堆(Heap)是JVM管理的最大内存区域,也是垃圾收集器(GC)管理的主要区域。几乎所有对象实例和数组都在堆上分配内存。堆在虚拟机启动时创建,所有线程共享。

堆内部空间结构:

Java堆从GC(垃圾回收)的角度可以细分为以下区域:

  • 新生代(Young Generation):用于存放新创建的对象。新生代又细分为:
    • Eden区:大多数对象最初在Eden区分配。当Eden区空间不足时,会触发Minor GC(Young GC)。
    • Survivor区:分为两个大小相等的区域,即From Survivor(S0)To Survivor(S1)。每次Minor GC后,存活的对象会从Eden区和当前使用的Survivor区复制到另一个Survivor区,并增加其年龄(Age)。
  • 老年代(Old Generation / Tenured Generation):存放经过多次Minor GC后仍然存活的对象(年龄达到阈值,默认15)。老年代空间较大,GC频率较低,触发Major GC(Full GC)时通常伴随较长的停顿。
  • 元空间(Metaspace):JDK 8之后,方法区的实现从永久代迁移到元空间,元空间使用本地内存,不再属于堆的一部分。但为了便于理解,常与堆一起讨论。

分代比例(默认配置,以HotSpot为例):

  • 新生代与老年代的比例:默认 1:2(可通过 -XX:NewRatio 调整)。例如堆大小为600MB,则新生代200MB,老年代400MB。
  • 新生代内部比例:Eden : S0 : S1 = 8:1:1(可通过 -XX:SurvivorRatio 调整)。例如新生代200MB,则Eden约160MB,每个Survivor区约20MB。

创建一个对象的流程:

  1. 类加载检查:当虚拟机遇到一条new指令时,首先检查该指令的参数能否在常量池中定位到一个类的符号引用,并检查这个符号引用代表的类是否已被加载、解析和初始化。如果没有,则先执行相应的类加载过程。
  2. 分配内存:类加载检查通过后,虚拟机将为新生对象分配内存。对象所需内存的大小在类加载完成后便可完全确定。分配方式有指针碰撞(Bump the Pointer)空闲列表(Free List)两种,取决于堆内存是否规整(由GC收集器是否带有压缩整理功能决定)。
  3. 初始化零值:内存分配完成后,虚拟机需要将分配到的内存空间(不包括对象头)都初始化为零值。这保证了对象的实例字段在Java代码中可以不赋初始值就直接使用(对应默认值)。
  4. 设置对象头:虚拟机对对象进行必要的设置,例如这个对象是哪个类的实例、如何找到类的元数据信息、对象的哈希码(实际上延迟到调用System.identityHashCode()时才生成)、对象的GC分代年龄等信息。这些信息存储在对象的对象头(Object Header)中。
  5. 执行<init>方法:从虚拟机的视角来看,一个新的对象已经产生了。但从Java程序的视角来看,对象创建才刚刚开始——<init>方法(即构造方法)尚未执行。执行new指令之后会接着执行<init>方法,按照程序员的意愿对对象进行初始化,这样一个真正可用的对象才算完全创建出来。

3. OOM与内存泄露

OOM(OutOfMemoryError,内存溢出)和内存泄露(Memory Leak)是JVM内存管理中两个密切相关但本质不同的概念。理解它们的区别、触发场景和排查方法,是Java开发者必备的技能。

概念对比:

对比维度 OOM(内存溢出) 内存泄露(Memory Leak)
本质 JVM内存空间不足,无法为新对象分配内存,抛出 java.lang.OutOfMemoryError 异常 已分配的对象不再被程序使用,但由于存在未被释放的引用,GC无法回收这些对象,导致内存被无效占用
表现形式 直接抛出异常,程序通常立即终止 内存占用持续增长,GC回收效果差,最终可能演变为OOM
因果关系 可以是内存泄露的最终结果,也可能是瞬间大对象分配导致 通常是OOM的根本原因之一,但并非所有OOM都由内存泄露引起
GC表现 GC后可用内存仍然不足,频繁Full GC但回收量极少 GC回收效率逐渐下降,老年代使用率持续上升,Full GC后下降不明显

常见OOM类型及触发场景:

OOM类型 触发场景 典型原因
Java heap space 堆内存不足,无法分配新对象 内存泄露导致堆被占满;堆空间配置过小(-Xmx 设置不足);大对象频繁创建(如一次性加载大量数据到内存)
Metaspace 元空间(方法区)内存不足 类加载过多(如热部署、动态代理生成大量类);元空间大小未设置上限或上限过低
Unable to create new native thread 无法创建新的操作系统线程 线程数过多(如线程池配置过大、无限制创建线程);操作系统线程数限制(ulimit)过低
Direct buffer memory 直接内存(Direct Memory)不足 NIO中大量使用 DirectByteBuffer 但未释放;直接内存大小未通过 -XX:MaxDirectMemorySize 合理配置
GC overhead limit exceeded GC耗时超过98%,但回收内存不到2% 堆内存几乎被占满,GC反复尝试但回收效果极差,通常由内存泄露引起
Requested array size exceeds VM limit 请求分配的数组大小超过JVM限制 代码中尝试创建超大数组(如 new int[Integer.MAX_VALUE]

常见内存泄露场景:

  • 集合类未清理HashMapArrayList 等集合中不断添加对象,但未及时移除不再使用的元素。典型如全局缓存、监听器列表、Session对象等。
  • 静态集合持有对象引用:静态 MapList 中持有大量对象引用,导致这些对象无法被GC回收,因为静态变量的生命周期与JVM一致。
  • 未关闭的资源:数据库连接(Connection)、文件流(InputStream/OutputStream)、网络连接(Socket)等资源使用后未正确关闭,导致底层句柄和关联对象无法释放。
  • 内部类/匿名类持有外部类引用:非静态内部类或匿名类隐式持有外部类的引用,如果内部类对象长期存活(如作为回调注册到全局列表),会导致外部类对象无法被回收。
  • ThreadLocal 使用不当ThreadLocal 中设置了值但未在任务结束后调用 remove() 清理。在线程池场景下,线程复用会导致 ThreadLocal 中的值一直存在,造成内存泄露。
  • 缓存设计不合理:使用 HashMap 等简单结构做缓存,未设置过期策略或大小限制,导致缓存无限增长。
  • 类加载器泄露:在热部署或动态加载类的场景中,旧的类加载器无法被GC回收,导致其加载的所有类和静态变量都驻留在内存中。

五、执行引擎

1. 执行引擎简介

执行引擎(Execution Engine)是JVM的核心组件之一,负责将字节码解释/编译为本地机器指令并执行。它主要由以下四大组件构成:

  • 解释器(Interpreter):逐条读取字节码指令并立即解释执行,启动速度快,但执行效率较低。在JVM启动初期,解释器负责快速执行代码,无需等待编译。
  • 即时编译器(JIT Compiler):将热点代码(Hot Spot Code,即频繁执行的方法或循环)编译为本地机器码,大幅提升执行效率。HotSpot VM默认采用混合模式(Mixed Mode),即解释器与JIT编译器协同工作。JIT编译器又分为C1编译器(Client Compiler)C2编译器(Server Compiler),前者编译速度快但优化程度低,后者编译速度慢但优化程度高。JDK 8及之后还引入了Graal编译器作为实验性替代。
  • 垃圾回收器(Garbage Collector, GC):自动管理堆内存,回收不再使用的对象,释放内存空间。GC是执行引擎中与内存管理最密切的部分,将在下一节详细展开。
  • 本地方法接口(JNI):允许Java代码调用C/C++等本地方法,实现与操作系统底层交互。执行引擎通过JNI调用本地库,完成Java无法直接实现的操作(如文件I/O、网络通信等)。

2. 代码执行流程

阶段1:

.java 源文件 → javac 编译 → .class 字节码文件

阶段2:

.class 字节码 → 类加载器加载 → 链接(验证+准备+解析)→ 初始化
→ 类信息存入方法区/元空间

阶段3:

方法调用 → 分配栈帧 → 存入虚拟机栈
对象创建 → 分配堆内存 → 存入堆
native 调用 → 使用本地方法栈
执行位置 → 程序计数器记录

阶段4:

字节码 → 解释器逐行读取 → 翻译为机器码 → 立即执行 → 执行完丢弃
(不缓存,启动快但运行慢)

阶段5:

方法调用计数器:每次调用 +1
回边计数器:每次循环跳转 +1
达到阈值 → 触发 JIT 编译(异步)

阶段6:

达到 C1 阈值(~2000次)→ C1 编译 → 基础优化 → 机器码存入 CodeCache
达到 C2 阈值(~10000次)→ C2 编译 → 深度优化 → 更新 CodeCache 机器码
后续调用 → 直接从 CodeCache 读取机器码执行

阶段7:

GC Roots 可达性分析 → 标记不可达对象
Eden 满 → Young GC → 回收新生代(短暂 STW)
老年代/元空间满 → Full GC → 回收全局(较长 STW)

阶段8:

Java 调用 native 方法 → JNI 接口 → 本地方法栈 → C/C++ 库 → CPU 执行

3. GC(垃圾回收)

垃圾回收(Garbage Collection, GC)是JVM自动管理堆内存的核心机制。它负责识别并回收不再被程序引用的对象,释放内存空间,避免内存泄漏和内存溢出。

GC回收的对象:

GC主要回收堆内存中不再被任何存活对象引用的对象。判断对象是否可回收的算法主要有两种:

  • 引用计数法(Reference Counting):为每个对象维护一个引用计数器,当计数器为0时表示对象可回收。但该方法无法解决循环引用问题,主流的HotSpot VM未采用此算法。
  • 可达性分析算法(Reachability Analysis):从一组称为GC Roots的根对象(如虚拟机栈中引用的对象、静态变量引用的对象、JNI引用的对象等)出发,向下搜索引用链。如果一个对象到GC Roots没有任何引用链相连(即不可达),则判定该对象可回收。这是HotSpot VM采用的主流算法。

GC算法:

垃圾回收算法是GC执行内存回收的具体实现策略。不同的算法在吞吐量、停顿时间和内存利用率之间做出不同的权衡。以下是主流的几种GC算法:

1. 标记-清除算法(Mark-Sweep)

标记-清除算法是最基础的GC算法,分为两个阶段:

  • 标记阶段:从GC Roots出发,遍历所有可达对象,并标记它们为存活对象。
  • 清除阶段:遍历堆中所有对象,回收未被标记的对象所占用的内存空间。

优点:实现简单,不需要移动对象。

缺点

  • 内存碎片化:清除后会产生大量不连续的内存碎片,导致后续大对象分配时因找不到连续空间而提前触发GC。
  • 效率问题:标记和清除两个阶段都需要遍历所有对象,效率随堆大小增长而下降。

2. 标记-复制算法(Mark-Copy)

标记-复制算法将可用内存按容量划分为大小相等的两块,每次只使用其中一块。当这一块用完了,就将还存活的对象复制到另一块上,然后再把已使用的内存空间一次清理掉。

优点

  • 实现简单,运行高效。
  • 每次只对一块内存进行回收,分配时只需移动堆顶指针,按顺序分配即可。
  • 没有内存碎片问题。

缺点

  • 可用内存缩小为原来的一半,空间利用率低。
  • 当对象存活率较高时,需要执行较多的复制操作,效率会降低。

应用场景:HotSpot虚拟机的新生代回收采用此算法。新生代中Eden区和两个Survivor区(S0、S1)的默认比例为8:1:1,每次使用Eden和一个Survivor区,另一个Survivor区作为复制目标,空间利用率达到90%。

3. 标记-整理算法(Mark-Compact)

标记-整理算法结合了标记-清除和标记-复制两种算法的优点。标记阶段与标记-清除算法相同,但后续不是直接清除,而是将所有存活对象向内存空间的一端移动,然后直接清理掉边界以外的内存。

优点

  • 避免了标记-清除算法的内存碎片问题。
  • 空间利用率高于标记-复制算法(不需要预留一半空间)。

缺点

  • 整理阶段需要移动大量对象,会增加停顿时间。
  • 需要更新所有引用被移动对象的指针,复杂度较高。

应用场景:老年代回收通常采用此算法(如Parallel Old收集器),因为老年代对象存活率高,不适合复制算法。

4. 分代收集算法(Generational Collection)

分代收集算法并非一种独立的算法,而是综合运用上述算法的一种策略。它基于两个经验法则(弱分代假说和强分代假说):

  • 绝大多数对象都是朝生夕死的(在新生代即被回收)。
  • 熬过多次GC的对象越难被回收(趋向于长期存活)。

基于此,Java堆被划分为新生代和老年代:

  • 新生代:对象存活率低,采用标记-复制算法,每次GC只回收少量存活对象,效率高。
  • 老年代:对象存活率高,采用标记-清除标记-整理算法,避免大量复制开销。

这是当前主流JVM(如HotSpot)普遍采用的收集策略。

5. 三色标记算法(Tri-Color Marking)

三色标记算法是并发标记阶段使用的改进版可达性分析算法,用于解决GC线程与应用线程并发执行时产生的对象引用变化问题。它将对象分为三种颜色:

  • 白色:尚未被GC访问过的对象。在标记结束后,仍为白色的对象表示不可达,将被回收。
  • 灰色:已被GC访问过,但其引用的子对象尚未被全部扫描完的对象。
  • 黑色:已被GC访问过,且其所有引用的子对象都已被扫描完的对象。

三色标记算法在并发标记过程中,通过增量更新(Incremental Update,CMS采用)原始快照(SATB,G1采用)机制来解决"对象消失"问题(即并发期间黑色对象引用了白色对象,导致白色对象被误回收)。

应用场景:CMS、G1、ZGC等并发垃圾收集器均基于三色标记算法实现并发标记。

回收时机:

GC的触发时机因GC类型而异:

  • Minor GC(Young GC):当新生代Eden区空间不足时自动触发。频率较高,停顿时间短。
  • Major GC / Full GC:当老年代空间不足、调用System.gc()(建议性调用,不保证立即执行)、堆空间分配失败、方法区(元空间)空间不足等情况下触发。频率较低,但停顿时间长。
  • CMS GC(并发标记清除):当老年代使用率达到阈值(默认92%)时触发,追求低停顿。
  • G1 GC:根据停顿时间预测模型,在满足用户设定的停顿时间目标的前提下,选择回收收益最高的Region集合进行回收。

回收流程:

一个对象从创建到被回收的典型流程如下:

  1. 对象创建:对象在新生代Eden区分配内存。
  2. Minor GC:当Eden区空间不足时触发Minor GC。存活的对象从Eden区和From Survivor区复制到To Survivor区,对象年龄+1。年龄达到阈值(默认15)的对象晋升到老年代。
  3. 老年代回收:当老年代空间不足时触发Major GC / Full GC,对老年代进行回收。Full GC还会回收新生代和方法区(元空间)。
  4. 标记-清除/复制/整理:GC根据采用的算法执行具体回收操作。标记存活对象,清除不可达对象,必要时进行内存整理以避免碎片化。
  5. 内存释放:回收后的内存空间被标记为空闲,可供后续对象分配使用。

GC分类:

根据回收的区域和实现方式,GC主要分为以下几类:

  • 按回收区域分类:
    • Minor GC(Young GC):只回收新生代(Eden + Survivor区),频率高、速度快。
    • Major GC(Old GC):只回收老年代,通常与Full GC混用。
    • Full GC:回收整个堆(新生代 + 老年代)和方法区(元空间),停顿时间长。
    • Mixed GC:G1 GC特有,回收新生代和部分老年代Region。
  • 按实现方式分类(HotSpot常见垃圾收集器):
垃圾收集器 JDK版本 GC特点 GC基本原理概述 适用场景
Serial GC

JDK 1.3+

(最早期)

单线程回收,GC时暂停所有应用线程(STW);简单高效,无线程交互开销 采用标记-复制(新生代)+ 标记-整理(老年代)算法,单线程串行执行所有GC阶段

单核CPU、小内存(<100MB)、客户端模式、桌面应用;适合低并发、对停顿不敏感的场景

Parallel GC

JDK 1.4.2+

(JDK 8默认)

多线程并行回收,追求高吞吐量;GC时STW,但利用多核加速回收过程 新生代采用标记-复制(多线程并行),老年代采用标记-整理(多线程并行);通过 -XX:ParallelGCThreads 控制线程数

多核CPU、后台批处理、科学计算、数据分析等对吞吐量要求高、

对停顿时间不敏感的场景

CMS GC

JDK 1.5+

(JDK 9废弃,JDK 14移除)

并发标记清除,追求低停顿;大部分GC阶段与应用线程并发执行,减少STW时间 基于标记-清除算法,分为初始标记(STW)、并发标记、重新标记(STW)、并发清除四个阶段;采用增量更新解决并发标记对象消失问题 响应时间敏感的Web应用、交互式系统;适合中等规模堆(4GB~16GB),对停顿时间有严格要求的场景
G1 GC

JDK 7u4+

(JDK 9+默认)

面向服务端,将堆划分为多个Region,可预测停顿时间;通过停顿预测模型选择回收收益最高的Region集合 基于Region化堆布局,采用标记-复制算法;并发标记阶段使用原始快照(SATB)解决对象消失问题;通过 -XX:MaxGCPauseMillis 设定目标停顿时间 大堆内存(4GB+)、服务端应用、需要可预测停顿时间的场景;JDK 9起替代CMS成为默认GC
ZGC

JDK 11引入

(实验),JDK 15+生产可用

超低延迟,停顿时间不超过10ms,与堆大小无关;支持TB级堆内存 基于Region化布局,采用染色指针(Colored Pointers)和读屏障(Load Barrier)实现并发标记、并发重定位;几乎全部GC阶段与应用线程并发执行,STW时间极 大堆内存(几十GB~TB级)、低延迟要求极高的场景(如金融交易、实时推荐、在线游戏)
Shenandoah GC

JDK 12引入

(实验),JDK 15+生产可用(部分发行版)

与ZGC类似,追求低停顿;通过读屏障和并发整理实现与ZGC相近的延迟表现 基于Region化布局,采用Brooks指针(转发指针)和读屏障实现并发标记、并发整理;GC阶段几乎全部并发,STW时间与堆大小无关 大堆内存、低延迟场景;与ZGC定位相似,适用于对停顿时间有极致要求的应用

六、JVM工具

1. jps(JVM Process Status Tool)

jps 是JDK提供的一个命令行工具,用于列出当前系统中正在运行的Java进程及其相关信息。它类似于Linux的 ps 命令,但专门针对JVM进程,是排查Java应用问题最常用的入门工具之一。

jps 的主要作用包括:

  • 列出当前系统中所有Java进程的进程ID(PID)。
  • 显示每个Java进程的主类名称(Main Class)或JAR文件路径。
  • 结合不同参数,输出JVM启动参数、主类参数等附加信息。

jps 命令的常用参数及效果如下:

参数 说明 示例 输出效果
-l 输出主类的全限定名,如果进程执行的是JAR文件,则输出JAR路径 jps -l 12345 com.example.MyApp
67890 /opt/app/myapp.jar
-m 输出传递给main方法的参数 jps -m 12345 MyApp --port=8080 --config=prod
-v 输出JVM启动时显式指定的参数(如 -Xmx-D 等) jps -v 12345 MyApp -Xms256m -Xmx1024m -Dspring.profiles.active=prod
-V 输出通过文件传递给JVM的参数(通过 -XX:Flags=<filename> 指定的参数) jps -V 12345 MyApp -XX:Flags=/opt/app/jvm.flags
-q 只输出进程ID,不输出类名、JAR名或其他信息 jps -q 12345
67890

jps -l :查看进程ID、主类全限定名

jps -l -v: 查看进程ID、主类全限定名、启动参数

2. jstat(JVM Statistics Monitoring Tool)

jstat 是JDK提供的一个命令行工具,用于实时监控JVM的各类运行状态信息,包括类加载、垃圾回收、JIT编译、堆内存使用等。它是定位JVM性能问题和内存泄漏的常用工具之一。

jstat 命令的基本语法格式如下:

jstat [option] <vmid> [interval] [count]

参数说明:

  • option:监控选项,指定要查看的JVM统计信息类型。
  • vmid:目标Java进程的进程ID(PID),可通过 jps 命令获取。
  • interval:采样间隔,单位为毫秒。例如 1000 表示每隔1秒输出一次。
  • count:采样次数。如果不指定,则持续输出直到手动停止。

jstat 的常用命令参数如下:

参数 说明 示例 输出效果
-class 查看类加载统计信息,包括已加载类数量、卸载类数量、总耗时等 jstat -class 12345 Loaded Bytes Unloaded Bytes Time
1234 2345.6 0 0.0 1.23
-gc 查看堆内存各区域(Eden、Survivor、老年代、元空间)的容量、使用量和GC统计信息 jstat -gc 12345 1000 5 S0C S1C S0U S1U EC EU OC OU MC MU CCSC CCSU YGC YGCT FGC FGCT GCT
512.0 512.0 0.0 0.0 2048.0 1024.0 4096.0 2048.0 8192.0 4096.0 1024.0 512.0 10 0.123 2 0.456 0.579
-gcutil 以百分比形式显示堆内存各区域的使用率和GC时间统计,更直观 jstat -gcutil 12345 1000 S0 S1 E O M CCS YGC YGCT FGC FGCT GCT
0.00 0.00 50.00 40.00 60.00 50.00 10 0.123 2 0.456 0.579
-gccapacity 查看堆内存各区域的容量(最小值、最大值、当前值) jstat -gccapacity 12345 NGCMN NGCMX NGC S0CMN S0CMX S0C S1CMN S1CMX S1C ECMN ECMX EC OGCMN OGCMX OGC OC MCMN MCMX MC CCSMN CCSMX CCSC YGC FGC
-gccause -gcutil 类似,额外显示最近一次GC的原因和当前GC的原因 jstat -gccause 12345 S0 S1 E O M CCS YGC YGCT FGC FGCT GCT LGCC GCC
0.00 0.00 50.00 40.00 60.00 50.00 10 0.123 2 0.456 0.579 Allocation Failure No GC
-gcnew 查看新生代(Eden + Survivor)的容量和使用量详情 jstat -gcnew 12345 S0C S1C S0U S1U TT MTT DSS EC EU YGC YGCT
512.0 512.0 0.0 0.0 1 15 512.0 2048.0 1024.0 10 0.123
-gcold 查看老年代和元空间的容量和使用量详情 jstat -gcold 12345 MC MU CCSC CCSU OC OU YGC FGC FGCT GCT
8192.0 4096.0 1024.0 512.0 4096.0 2048.0 10 2 0.456 0.579
-compiler 查看JIT编译器的统计信息,包括编译任务数、失败数、耗时等 jstat -compiler 12345 Compiled Failed Invalid Time FailedType FailedMethod
1234 0 0 1.23 0
-printcompilation 查看最近一次JIT编译的方法信息 jstat -printcompilation 12345 Compiled Size Type Method
1234 123 1 java/util/ArrayList add

jstat -gcutil:以百分比查看堆内存使用率

jstat -gcutil 是最常用的 jstat 选项之一,它以百分比形式显示堆内存各区域的使用率,以及 GC 时间统计。相比 -gc 输出原始容量数值,-gcutil 更直观地反映内存压力。

命令格式:

jstat -gcutil <vmid> [interval] [count]

输出列说明:

列名 说明
S0 Survivor 0 区的使用率(百分比)
S1 Survivor 1 区的使用率(百分比)
E Eden 区的使用率(百分比)
O 老年代的使用率(百分比)
M 元空间(Metaspace)的使用率(百分比)
CCS 压缩类空间(Compressed Class Space)的使用率(百分比)
YGC Young GC 发生的次数
YGCT Young GC 累计耗时(秒)
FGC Full GC 发生的次数
FGCT Full GC 累计耗时(秒)
GCT 所有 GC 累计总耗时(秒)

示例:

jstat -gc:查看堆内存各区域容量与使用量

jstat -gc 输出堆内存各区域的容量(Capacity)使用量(Usage)的原始数值(单位 KB),以及 GC 统计信息。它比 -gcutil 提供更详细的内存分配数据,适合分析内存分配趋势和区域大小配置。

命令格式:

jstat -gc <vmid> [interval] [count]

输出列说明:

列名 说明
S0C Survivor 0 区的当前容量(KB)
S1C Survivor 1 区的当前容量(KB)
S0U Survivor 0 区的当前使用量(KB)
S1U Survivor 1 区的当前使用量(KB)
EC Eden 区的当前容量(KB)
EU Eden 区的当前使用量(KB)
OC 老年代的当前容量(KB)
OU 老年代的当前使用量(KB)
MC 元空间的当前容量(KB)
MU 元空间的当前使用量(KB)
CCSC 压缩类空间的当前容量(KB)
CCSU 压缩类空间的当前使用量(KB)
YGC Young GC 发生的次数
YGCT Young GC 累计耗时(秒)
FGC Full GC 发生的次数
FGCT Full GC 累计耗时(秒)
GCT 所有 GC 累计总耗时(秒)

示例:

判断参考:

以下是根据 jstat 输出判断 JVM 健康状况的参考标准:

  • Eden 区使用率(E):正常情况应在 Minor GC 前后呈现"快速上升→回落"的周期性波动。若 Eden 使用率持续接近 100% 且 Minor GC 频率极高(如每秒多次),说明对象分配过快,需检查是否存在大对象频繁创建或内存泄漏。
  • Survivor 区使用率(S0/S1):正常情况下每次 Minor GC 后,存活对象会从 Eden 和一个 Survivor 区复制到另一个 Survivor 区,因此两个 Survivor 区应交替为 0% 和较低使用率。若 Survivor 区使用率持续偏高(如超过 50%),说明对象存活率高,可能导致对象过早晋升到老年代。
  • 老年代使用率(O):健康状态下老年代使用率应缓慢增长,Full GC 后应明显回落。若老年代使用率持续上升且 Full GC 后下降不明显,或 Full GC 频率过高(如每小时多次),通常提示内存泄漏或堆空间配置不足。
  • 元空间使用率(M):正常情况下元空间使用率在应用启动后趋于稳定。若持续增长且 Full GC 后不回落,说明类加载过多(如热部署、动态代理生成类未卸载),需检查是否存在类加载器泄漏。
  • Young GC 频率(YGC)与耗时(YGCT):健康状态下 Young GC 频率应适中(如每秒几次以内),单次耗时通常不超过 100ms。若频率过高或单次耗时过长,说明新生代空间过小或对象分配压力大。
  • Full GC 频率(FGC)与耗时(FGCT):Full GC 应极少发生(如数小时甚至数天一次),单次耗时通常不超过 1 秒。若 Full GC 频繁(如几分钟一次)或耗时过长,属于严重告警信号,需立即排查内存泄漏或堆配置问题。
  • 告警阈值参考
指标 正常 关注 告警
老年代使用率(O) < 70% 70%–90% > 90%
Full GC 频率 数小时/天一次 每小时一次 每几分钟一次
Full GC 单次耗时 < 1s 1s–3s > 3s
Young GC 单次耗时 < 50ms 50ms–100ms > 100ms
Eden 使用率波动 周期性回落 持续 > 80% 持续接近 100%

3. jstack(Java Stack Trace Tool)

jstack 是JDK提供的一个命令行工具,用于生成JVM当前时刻所有线程的堆栈快照(Thread Dump)。它可以帮助开发者查看Java进程中所有线程的执行状态、调用栈信息,是诊断死锁、线程阻塞、CPU飙升、资源竞争等并发问题的核心工具。

jstack 命令的基本语法格式如下:

jstack [option] <vmid>

参数说明:

  • option:可选参数,用于指定输出模式或附加信息。
  • vmid:目标Java进程的进程ID(PID),可通过 jps 命令获取。

jstack 的常用命令参数如下:

参数 说明 示例
-l 长列表模式,额外输出锁的附加信息(如 java.util.concurrentAbstractOwnableSynchronizer 拥有的锁信息),用于检测死锁 jstack -l 12345
-F 强制模式,当目标进程无响应(如进程挂起)时,强制生成线程堆栈(需要操作系统权限) jstack -F 12345
-m 混合模式,同时输出Java帧和本地(C/C++)帧的堆栈信息,用于分析JVM内部或JNI调用问题 jstack -m 12345

线程状态说明:

jstack 输出的线程堆栈中,每个线程会显示其当前状态。常见的线程状态包括:

  • RUNNABLE:线程正在执行中,可能正在使用CPU或等待I/O完成。
  • BLOCKED:线程被阻塞,正在等待获取一个锁(monitor),该锁被其他线程持有。
  • WAITING:线程处于无限期等待状态,调用了 Object.wait()Thread.join()LockSupport.park() 等方法,等待被显式唤醒。
  • TIMED_WAITING:线程处于限期等待状态,调用了 Thread.sleep()Object.wait(timeout)Thread.join(timeout)LockSupport.parkNanos() 等方法,等待指定时间后自动唤醒。
  • TERMINATED:线程已执行完毕。

典型应用场景:

  • 死锁检测:使用 jstack -l 输出线程堆栈,在输出末尾会显示 Found one Java-level deadlock 信息,明确指出死锁涉及的线程、锁对象和等待关系。
  • CPU 飙升排查:当Java进程CPU使用率异常升高时,通过 top -Hp <pid> 找到CPU最高的线程ID(转换为十六进制),再使用 jstack <pid> 查找对应线程的堆栈,定位热点代码。
  • 线程阻塞分析:当应用响应变慢时,通过jstack查看大量线程是否处于 BLOCKEDWAITING 状态,分析锁竞争或资源等待的原因。
  • 系统卡顿诊断:连续多次执行 jstack 并对比堆栈变化,观察哪些线程长时间停留在同一位置,判断是否存在死循环或长时间阻塞。

示例:

4. jmap(Java Memory Map Tool)

jmap 是JDK提供的一个命令行工具,用于生成JVM堆内存的详细信息,包括堆内存使用情况、对象统计、类加载信息等。它是分析内存泄漏、查看堆内存分布、生成堆转储(Heap Dump)文件的核心工具。

jmap 命令的基本语法格式如下:

jmap [option] <vmid>

jmap 的常用命令参数如下:

参数 说明 示例
-heap 打印堆内存的概要信息,包括堆配置参数(-Xms-Xmx等)、各代(新生代、老年代、元空间)的容量和使用情况、GC算法等 jmap -heap 12345
-histo 打印堆中对象的统计信息,按类名分组显示实例数量和占用内存大小(从大到小排序),用于快速定位内存占用最多的对象类型 jmap -histo 12345
-dump 生成堆转储(Heap Dump)文件,保存当前堆内存的快照。常用格式:jmap -dump:format=b,file=heap.hprof <pid>,生成的 .hprof 文件可使用MAT、VisualVM等工具分析 jmap -dump:format=b,file=/tmp/heap.hprof 12345

典型应用场景:

  • 内存泄漏排查:使用 jmap -histo 多次对比对象数量和内存占用变化,发现持续增长的对象类型;再通过 jmap -dump 生成堆转储文件,使用MAT等工具分析GC Roots引用链,定位泄漏源头。
  • 堆配置验证:使用 jmap -heap 查看当前堆各区域的实际容量和配置参数,确认 -Xms-Xmx-XX:NewRatio 等参数是否生效。
  • 大对象分析:通过 jmap -histo:live 触发Full GC后查看存活对象分布,识别是否存在不合理的大对象或数组。

jmap -heap:查看当前堆各区域实际容量、配置参数

jmap -histo:查看当前对象占内存最多的排名

jmap -dump:导出堆快照,离线分析

注意:生产场景最好不要使用live导出,live会触发full gc,导致stw,暂停业务线程。

可以发现,使用live导出时,会触发Full GC,并且会触发Young GC。

live只会导出存活的对象。

5. jinfo(Java Configuration Info Tool)

jinfo 是JDK提供的一个命令行工具,用于实时查看和修改JVM的配置参数。它可以帮助开发者在不重启Java进程的情况下,了解JVM的运行参数和系统属性。

jinfo 命令的基本语法格式如下:

jinfo [option] <vmid>

jinfo 的常用命令参数如下:

参数 说明 示例
-flags 打印JVM的启动参数(Flags),包括显式指定的参数和默认生效的参数 jinfo -flags 12345
-sysprops 打印Java系统属性(System Properties),即通过 -D 参数设置或 System.getProperties() 获取的属性 jinfo -sysprops 12345

示例:

注意:jinfo还可以动态修改jvm参数,不过慎用

6. 可视化工具

除了命令行工具外,JDK还提供了两款图形化监控工具,适合直观地观察JVM运行状态:

  • jconsole(Java Monitoring and Management Console):JDK自带的一款基于JMX(Java Management Extensions)的图形化监控工具。它可以连接到本地或远程的Java进程,实时查看堆内存使用、线程状态、类加载信息、CPU使用率等,还支持动态修改部分MBean属性。启动命令为 jconsole,在命令行直接输入即可打开图形界面。
  • jvisualvm(Java VisualVM):功能更强大的可视化工具,集成了jstat、jstack、jmap等多个命令行工具的能力。它提供图形化的堆转储分析、线程堆栈分析、CPU和内存采样、GC可视化等功能,还支持通过插件扩展功能(如Visual GC插件可实时查看堆各代变化)。启动命令为 jvisualvm。需要注意的是,从JDK 9开始,jvisualvm不再随JDK一起发布,需要从 VisualVM官网 单独下载,JDK8后续的版本也没有了,需要单独下载。

7. Arthas(阿尔萨斯)

Arthas 是阿里巴巴开源的 Java 诊断工具,支持在线实时监控、问题定位和性能调优,无需修改代码或重启应用。它通过命令行交互方式,让开发者能够动态查看方法调用、参数、返回值、异常、线程堆栈、内存使用等信息,是生产环境问题排查的利器。

Arthas 的常用命令如下:

命令 说明 示例
dashboard 查看当前系统的实时数据面板,包括线程、内存、GC、系统信息等 dashboard
thread 查看当前 JVM 的线程堆栈信息,支持按 CPU 使用率排序 thread -n 3(显示 CPU 占用最高的前 3 个线程)
sc 查看 JVM 中已加载的类信息,支持模糊搜索 sc -d *MathUtils
sm 查看已加载类的方法信息 sm -d java.lang.String
jad 反编译指定已加载类的源码 jad java.lang.String
watch 监控方法的调用参数、返回值、异常和耗时 watch com.example.service.UserService getUser "{params,returnObj,throwExp}" -x 2
trace 跟踪方法内部调用路径,输出各节点的耗时 trace com.example.service.UserService getUser
stack 输出当前方法被调用的完整调用栈 stack com.example.service.UserService getUser
monitor 监控指定方法的调用次数、成功/失败次数、平均耗时等统计信息 monitor -c 5 com.example.service.UserService getUser
tt 记录方法调用的时间隧道,支持回放和查看调用详情 tt -t com.example.service.UserService getUser
ognl 执行 OGNL 表达式,动态调用方法或修改变量值 ognl '@java.lang.System@getProperty("java.version")'
vmtool 强制 GC、查看对象实例、执行方法等高级操作 vmtool --action getInstances --className java.lang.String --limit 10
heapdump 生成堆转储快照文件,类似 jmap -dump heapdump /tmp/heap.hprof
logger 查看和动态修改日志级别 logger --name ROOT --level DEBUG
redefine 热替换已加载的类(需提供编译后的 .class 文件) redefine /tmp/UserService.class

Arthas 的启动方式非常简单,下载 arthas-boot.jar 后执行 java -jar arthas-boot.jar,然后选择目标 Java 进程即可进入交互式命令行。更多信息可参考 Arthas 官方文档

示例:

Logo

openEuler 是由开放原子开源基金会孵化的全场景开源操作系统项目,面向数字基础设施四大核心场景(服务器、云计算、边缘计算、嵌入式),全面支持 ARM、x86、RISC-V、loongArch、PowerPC、SW-64 等多样性计算架构

更多推荐