JVM类加载子系统与运行时内存结构全解析
2026/9/11 14:42:25 网站建设 项目流程

1. 类加载子系统:JVM 的"前置处理器"

1.1 类加载到底做了什么

很多人学 JVM 第一个接触的就是"类加载子系统"这六个字,但真正被问到时,能讲清楚的不多。我先说个结论:类加载子系统的作用,就是把编译好的 .class 字节码文件从磁盘(或者网络、内存等任意源头)读取进来,经过一串处理之后,变成 JVM 能直接使用的运行时结构。它不是一个"加载完就结束"的模块,而是从字节码到对象实例之间那条必经之路。

我之前带过几个新人,经常有这种场景:Java 代码明明写得没问题,编译也过了,一运行报 ClassNotFoundException,第一反应是去检查依赖,结果查了半天发现是类加载器层级不对。这种问题如果不理解类加载子系统的运作方式,排查起来会非常痛苦,因为你看到的错误信息和真正的根源往往隔着好几层。

类加载机制包含三个大的阶段:加载、连接、初始化。连接阶段内部又细分为验证、准备、解析三步。面试里追问的什么"双亲委派""全盘负责""缓存机制",全都是在这个流程上衍生出来的规则。这套机制的设计初衷很朴素:让 JVM 以统一、安全、可靠的方式把类变成可执行状态,同时保证核心类库不被篡改。

什么场景下需要深入理解类加载子系统?我列几个常见的:

  • 排查 ClassNotFoundException、NoClassDefFoundError、LinkageError 这类 JVM 层报错
  • 做应用容器(比如 Tomcat 这类 Web 容器)的类加载隔离
  • 热部署、插件化框架、动态代理等运行时生成和加载类的场景
  • 理解 JVM 调优时为什么 PermGen/Metaspace 会满
  • 面试,这个最现实,后面会专门讲

简单说,类加载子系统决定了"你的类从哪来、怎么验证、怎么初始化",而内存结构决定了"类进来之后住哪儿、怎么分区管理"。两者一前一后,是理解 JVM 底层行为的两块基石。

1.2 三个默认类加载器之间的"辈分"关系

JVM 自带了三个类加载器,层级非常清晰,我习惯用一个家庭类比来理解:

  • 启动类加载器(Bootstrap ClassLoader):祖辈。负责加载 JDK 核心类库,比如$JAVA_HOME/jre/lib(JDK 8)或$JAVA_HOME/jmods(JDK 9 以后)中的核心类,像 java.lang、java.util、java.io 这些。它比较特殊,不是 Java 类,而是由 C++ 实现的,在 Java 代码里拿到它的引用会得到 null。
  • 平台类加载器(Platform ClassLoader):父辈。JDK 8 里叫扩展类加载器(Extension ClassLoader),负责加载一些扩展目录下的类。JDK 9 模块化之后改名为平台类加载器,加载一些 JDK 内部的模块类。
  • 应用程序类加载器(Application ClassLoader):我们这一辈。也叫系统类加载器,负责加载 classpath(也就是我们日常配的 CLASSPATH 环境变量或工程依赖路径)下的所有类。我们写的业务代码绝大多数由它加载。

这里有一个在 JDK 9 前后的重要变化:老的扩展类加载器被移除了,取而代之的是平台类加载器,并且加载逻辑从基于目录变成了基于模块。新手如果还在网上看 JDK 8 时代的老资料,会看到"启动、扩展、应用"这种说法,但在 JDK 11、JDK 17 上跑,看到的实际对象名是不同的。这个细节在面试里是一个非常容易暴露基础是否扎实的点。

有一个非常直观的验证方法。写一段代码打印当前类的类加载器链:

public class ClassLoaderDemo { public static void main(String[] args) { ClassLoader app = ClassLoaderDemo.class.getClassLoader(); System.out.println("Application ClassLoader: " + app); System.out.println("Parent: " + app.getParent()); System.out.println("Grand Parent: " + app.getParent().getParent()); } }

JDK 8 下运行,输出大概是:应用类加载器是 sun.misc.Launcher$AppClassLoader,父级是 sun.misc.Launcher$ExtClassLoader,祖父级是 null(因为启动类加载器是 C++ 写的,Java 层拿不到引用)。JDK 11 下运行,父级就变成了 jdk.internal.loader.ClassLoaders$PlatformClassLoader。一行代码,两个 JDK 版本,输出差异直接反映出 JVM 版本的演进,这个细节建议自己跑一遍记住。

1.3 双亲委派模型:为什么要"先让爹来"

双亲委派模型是类加载子系统最重要的规则,简单讲就一句话:当一个类加载器收到类加载请求时,它不会自己去加载,而是先把请求委派给父加载器,逐层向上,直到启动类加载器;只有当父加载器反馈自己无法加载时,子加载器才会尝试自己加载。

注意我上面说的是"父加载器",这里的父子关系不是继承关系,而是组合关系,通过 ClassLoader 对象里的 parent 字段维护。所以正确的说法是"父加载器",不是"父类"。

为什么这么设计?核心就四个字:避免重复。假设我们自己写了一个 java.lang.String,如果不用双亲委派,应用类加载器直接自己加载了,那 JVM 里就会出现两份 String,一份是 JDK 自带的,一份是自定义的。程序里引用 String 到底用哪个?完全没有一致性,整个类型体系就崩了。

再往深一层说,双亲委派还有一个安全上的作用。沙箱安全机制依赖于核心类必须由启动类加载器加载。如果在 JDK 类库中注册一个恶意类,由启动类加载器加载后就会获得核心库的信任级别。双亲委派保证了核心 API 类不会被应用层覆盖,这也是为什么即使你写了一个 java.lang.Integer,在正常的双亲委派机制下也永远不会被加载。

这里我给一个在实际排查中最常见的误区:很多初学者以为双亲委派会让代码"层层向上查找",所以加载会很慢。实际上每一级的加载器通常都会先检查自己已经加载过的类缓存,命中就直接返回,根本没到"找文件"这一步。JVM 对同一个类加载器实例加载过的类是有缓存记录的,下一次请求同一个类直接拿缓存,不会再走一遍完整流程。

2. 从字节码到可用实例:类加载五阶段拆解

2.1 加载阶段:字节流从哪里来

加载阶段是整个类加载过程的第一步,做三件事:通过一个类的全限定名获取定义此类的二进制字节流;将字节流所代表的静态存储结构转化为方法区的运行时数据结构;在内存中生成一个代表这个类的 java.lang.Class 对象,作为方法区这个类的各种数据的访问入口。

这里第一件事"获取二进制字节流"就没规定死来源。我们日常最常见的是文件系统里的 .class 文件,但 JVM 规范允许从任何地方读取,比如 ZIP/JAR 包(这就是大部分框架和应用服务器依赖的形态)、网络流(Applet 时代的典型用法)、运行时动态生成(Spring 的 CGLIB、JDK 动态代理)、数据库读取、加密文件读取等。

这个开放性非常重要。很多框架就是靠自定义类加载器改变字节流来源来实现各种高级特性的。比如热部署插件,每次更新都重新创建一个类加载器,指向新的 JAR 文件路径,旧的类加载器连同它加载过的类就慢慢被回收掉。从这个角度看,加载阶段是整个类加载过程中"最开放"的一步,也是框架玩出花样的地方。

加载阶段完成后,类的字节流已经变成 JVM 内部的数据结构,存放在方法区(或者叫 Metaspace 中对应的结构),同时生成了一个 Class 对象。注意,这个 Class 对象存放在堆里,和我们 new 出来的普通对象一样,是 Java 对象。所以一个类被加载之后,可以简单理解为:方法区放的是"类型信息",堆里放的是"Class 对象"。

2.2 验证与准备:安全检查和内存分配

验证阶段,说白了就是 JVM 在"收货"时做的一次安全检查。字节码本身是二进制数据,如果不检查就执行,恶意构造的字节码可以直接让 JVM 崩溃或者绕过安全限制。验证包括文件格式验证、元数据验证、字节码验证、符号引用验证四个方面。

文件格式验证看的是魔数是不是 0xCAFEBABE、版本号是否支持;元数据验证看的是类是否有父类、是否继承了被 final 修饰的类、抽象方法是否都实现了;字节码验证是整个验证阶段最复杂的部分,通过数据流分析和控制流分析确定语义合法;符号引用验证发生在解析阶段,看引用的类、字段、方法是否真的存在、是否有权限访问。

验证阶段在实际排错中的意义:绝大多数 ClassFormatError、VerifyError 都是这个阶段抛出来的。我见过一个项目,打包时没有执行 clean,旧的 class 文件和新的源码混在一起,运行后出现 VerifyError,排查了好久,最后发现是字节码版本不匹配。所以建议编译时顺手开启-Xverify:all(虽然会拖慢启动速度,但排查复杂问题的时候能帮你早点暴露问题)。

过了验证之后是准备阶段,这个阶段是为类变量(static 修饰的变量)分配内存并设置初始值。这里有两个关键点需要反复强调:

第一,准备阶段分配的是类变量,不是实例变量。实例变量要等到对象实例化时才会在堆里分配。

第二,初始值一般是"零值",不是代码里写的那个值。比如private static int count = 100;,在准备阶段 count 的值是 0,等到初始化阶段调用<clinit>方法时才会赋成 100。但有个例外:如果类变量是常量(final 修饰),编译阶段就会生成 ConstantValue 属性,准备阶段直接赋成指定值,跳过零值这一步。

2.3 解析与初始化:符号引用变成直接引用

解析阶段是 JVM 将常量池内的符号引用替换为直接引用的过程。这里两个名词稍微解释一下:符号引用是一组字面量,比如一个类的全限定名、字段名和描述符、方法名和描述符,它没有指向实际内存地址;直接引用则是可以直接指向目标的引用,比如指向方法区的指针、偏移量或句柄。

有个比较关键的点是:解析阶段不一定要在初始化之前完成。JVM 规范允许在某些情况下延迟解析,比如一个方法里引用到的类,可以在方法第一次被调用时才去解析。这就是为什么有些时候加载一个类并不会把它的所有依赖都递归加载进来,只有当真正用到某个符号引用的时候才会触发解析和后续的链接动作。

初始化阶段是整个类加载过程的最后一步,真正开始执行类中定义的 Java 代码。这一步的核心是执行<clinit>方法,也就是类构造器方法。<clinit>方法由编译器自动生成,收集类中所有类变量的赋值动作和静态代码块中的语句合并而成,收集顺序和在源代码中出现的顺序一致。

有几个关于<clinit>的细节值得关注:

  • <clinit>和实例构造器<init>不同,它不需要显式调用父类的<clinit>,JVM 会保证父类的<clinit>在子类之前执行
  • 如果一个类没有静态变量赋值也没有静态代码块,编译器就不会生成<clinit>方法
  • 接口和类的<clinit>执行时机有一点不同:接口在初始化时并不要求父接口先完成初始化,只有真正使用到父接口里面定义的默认方法时才会触发
  • 虚拟机会保证一个类的<clinit>方法在多线程环境下被正确加锁和同步

什么情况下会触发类的初始化?JVM 规范里有一套明确的"主动引用"场景,最常见的有这么几种:遇到 new、getstatic、putstatic、invokestatic 字节码指令时;使用 java.lang.reflect 对类进行反射调用时;初始化子类时父类还没初始化则先初始化父类;JVM 启动时初始化包含 main 方法的主类。这些是面试常考点,建议背下来。

2.4 一个实际案例:从加载到实例化的完整链路

我拿一个最常见的最小例子来串一下整个流程。假设有这样一个类:

public class Student { private static String SCHOOL_NAME = "第一中学"; static { System.out.println("Student static block invoked"); } public String getName() { return "Tom"; } }

当 main 方法里执行Student stu = new Student()时,JVM 做了什么?

  1. 检查方法区中是否存在 Student 类的类型信息。如果不存在,触发类加载流程
  2. 应用类加载器收到加载请求,先检查自己缓存,没找到,委派给父加载器
  3. 一路委派到启动类加载器,启动类加载器在核心类库中找不到 Student,逐层返回
  4. 应用类加载器在自己的加载路径下找到 Student.class,执行加载
  5. 加载完成后,进入验证、准备阶段。准备阶段为 SCHOOL_NAME 分配内存并设为零值
  6. 解析阶段处理 Student 类引用到的其他类的符号引用
  7. 初始化阶段执行<clinit>,SCHOOL_NAME 被赋值为"第一中学",打印 "Student static block invoked"
  8. new指令在堆中分配内存,执行<init>方法,创建实例

这个过程每一步都有对应的可观测手段。比如启动时加-XX:+TraceClassLoading可以打印所有类加载的信息,-verbose:class也是类似效果。实测中当你想确认一个类到底是由哪个加载器加载的、是否被加载过,用这些 JVM 参数直接看输出是最高效的方式。

3. 运行时内存结构:类的"落地空间"

3.1 线程私有区域:程序计数器与虚拟机栈

说完了类的加载,接下来看类加载完成后住在哪。JVM 的内存结构,我习惯把它分成两拨:线程私有的和线程共享的。线程私有的区域随线程的创建而创建、随线程的消亡而回收,不需要垃圾回收器操心。

程序计数器(Program Counter Register),也叫 PC 寄存器,是一块很小的内存空间,可以看作是当前线程所执行的字节码的行号指示器。字节码解释器工作时就是通过改变这个计数器的值来选取下一条需要执行的字节码指令的。

这里有一个有意思的点:如果是执行 native 方法,程序计数器的值是空(Undefined),因为 native 方法不是 Java 字节码,无法通过解释器按行执行。这也是 Java 虚拟机规范里唯一一个没有规定 OutOfMemoryError 情况的区域,因为它不涉及内存动态分配。

虚拟机栈(Java Virtual Machine Stack),描述的是 Java 方法执行的线程内存模型:每个方法被执行的时候,JVM 会同步创建一个栈帧,用于存储局部变量表、操作数栈、动态连接、方法出口等信息。每一个方法从调用到执行完成的过程,就对应一个栈帧在虚拟机栈中从入栈到出栈的过程。

局部变量表是最常被问到的部分,它存放了方法参数和方法内部定义的局部变量。容量以变量槽(Slot)为最小单位,long 和 double 类型需要占用两个连续的槽位,其余类型占一个槽位。注意,局部变量表在准备阶段就会被分配好空间,即使代码里某个变量作用域已经结束了,只要栈帧还在,这些槽位就一直被占用——这也是为什么在一个大方法里,如果不再使用的大对象还"活着"引用着,就可能造成不必要的内存滞留。

最常见的溢出异常是 StackOverflowError,当线程请求的栈深度大于虚拟机允许的最大深度时就会抛出。我遇到过一个真实的案例:一个递归方法没有写终止条件,线上日志里出现大量 StackOverflowError,导致整个服务不可用。排查方式其实很简单,异常堆栈会直接打出栈深度和溢出的线程,看堆栈信息基本就能定位到是哪个方法的递归调用出问题。另外注意,虚拟机栈在动态扩展时如果无法申请到足够内存,也会抛 OutOfMemoryError,但这通常发生在极端场景。

3.2 线程共享区域:堆和方法区

堆(Heap)是 JVM 内存管理的核心区域,也是垃圾收集器的主要工作区域,几乎所有对象实例和数组都在这里分配。堆在物理上可以是不连续的内存空间,逻辑上连续即可。随着 JVM 的发展,堆的划分越来越精细:新生代(Young Generation)和老年代(Old Generation),新生代里又分 Eden 区、From Survivor、To Survivor。

不同对象的分配策略不同。绝大多数小对象直接在 Eden 区分配;大对象(比如超长数组)可能直接进入老年代;经过多轮 Minor GC(Minor GC 是针对新生代的垃圾回收)仍然存活的对象会晋升到老年代。这些策略都是可以通过 JVM 参数调整的,实际调优的时候会根据应用特性调整新生代大小的比例和晋升阈值。

针对堆内存的调优参数,最常用的几组:

  • -Xms:堆初始大小
  • -Xmx:堆最大大小
  • -Xmn:新生代大小
  • -XX:SurvivorRatio:Eden 区和 Survivor 区的比例,默认 8:1:1
  • -XX:MaxTenuringThreshold:对象晋升老年代的年龄阈值

方法区(Method Area),在 JDK 8 之前由永久代(PermGen)实现,JDK 8 开始改为元空间(Metaspace)。方法区存放的是类信息、常量、静态变量、即时编译器编译后的代码缓存等数据。很多人容易混淆方法区和堆,简单理解:堆里放的是"对象",方法区里放的是"类"本身的结构信息。

JDK 8 为什么要把永久代改成元空间?几个原因:永久代的大小很难确定,满了就抛 OutOfMemoryError: PermGen space,而且永久代大小还要受限于堆内存;永久代在垃圾收集上和年轻代、老年代耦合,容易导致 Full GC 性能问题。改成元空间后,元空间使用本地内存,默认情况下只受系统可用内存限制,大小设置更灵活,也让永久代的调优变得相对简单。

这里要给一个我曾经踩过的坑:Metaspace 虽然默认不受限制,但生产环境一定要配置-XX:MaxMetaspaceSize,不然一旦代码中有动态生成类或者大量反射类,Metaspace 会被撑爆,最终导致整台机器内存耗尽,而不是优雅地抛出 OutOfMemoryError。

3.3 运行时常量池与直接内存

运行时常量池是方法区的一部分,存放编译期间生成的各种字面量与符号引用。每一个类加载到 JVM 后,它的运行时常量池就占据了方法区的一块内存。这里有一个很重要的细节:Java 7 以后,运行时常量池中的字符串常量池被移到了堆中。也就是说,字符串字面量被解析后,实际存储的位置是堆里的字符串常量池。

这个移动对实际生产有很大影响。在 JDK 7 之前,大量使用 String.intern() 会有永久代溢出的风险;JDK 8 之后字符串常量池在堆里,由 GC 统一管理,反而缓解了这个问题。很多面试题会问"String 的 intern 方法有什么作用""字符串常量池在哪个区域",答案的关键就在这个变化里。

直接内存(Direct Memory)不是 JVM 运行时数据区的一部分,它是 Java NIO 引入的基于通道与缓冲区的 I/O 方式,可以使用 native 函数库直接分配堆外内存,然后通过存储在堆中的 DirectByteBuffer 对象作为这块内存的引用进行操作。这样做的好处是避免在 Java 堆和 native 堆之间来回复制数据,大幅度提高 I/O 性能。

但直接内存的回收机制比较特殊,它不依赖常规的 GC,而是依赖于 Cleaner 机制或 System.gc() 来触发,这在某些场景下会造成延迟回收。如果使用 NIO 大量使用直接内存,配置-XX:MaxDirectMemorySize限制大小是很有必要的。我在 Netty 的服务端调优中就遇到过因为直接内存没设上限,IO 线程频繁报 OutOfMemoryError 的情况。

3.4 内存结构面试高频问题整理

这块内容是网上搜索热度最高的部分,我整理几个面试里会被反复拷打的问题和答题要点。

JVM 内存模型和 JVM 内存结构有什么区别?这是一个非常经典的"陷阱题"。JVM 内存结构是运行时数据区的划分,即堆、栈、方法区、程序计数器、本地方法栈;而 JVM 内存模型(Java Memory Model,JMM)是 Java 并发编程中定义的一套规范,规定了线程和主内存之间怎么交互、变量怎么保证可见性和有序性,核心概念是主内存、工作内存、volatile 关键字、Happens-Before 规则。一个是空间划分,一个是并发规则,千万别混。

对象在堆里是怎么分配的?先尝试在栈上分配(如果开启了逃逸分析并且对象没有逃逸出方法),失败则判断对象大小是否可以直接进入老年代,否则往 Eden 区分配,Eden 不够触发 Minor GC 后仍放不下就进老年代。这个流程看几遍不如在线上用 jstat 观察一次 GC 日志来得直观。

什么情况会触发 Full GC?常见的触发条件:老年代空间不足、元空间不足、调用 System.gc()、CMS 的并发模式失败、堆内存分配大对象失败等。实际排查时,看 GC 日志里的 cause 字段最直接,能明确告诉你 Full GC 是因为什么被触发的。

4. 常见问题与排查实录

4.1 内存溢出 OOM 排查实录

OOM 是 JVM 应用里最让人头疼的问题之一。从我的经验看,排查看 GC 日志永远是最快的第一步。老牌工具 jstat 可以直接查看 JVM 里的 GC 情况和各个内存区域的使用量:

jstat -gcutil <pid> 1000 10

这条命令会每隔 1 秒输出一次 GC 信息,共输出 10 次。重点看 Old 区的使用率,如果曲线持续爬升且每次 Full GC 后回收效果不明显,说明有对象引用没被释放,可能存在内存泄漏。这时配合 jmap 生成堆转储文件:

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

然后用 Eclipse MAT 或者 JProfiler 打开分析,找支配树里占用最大的对象,定位到具体的业务类。我见过一个非常典型的案例:一个定时任务每次执行都会往一个静态 Map 里 put 数据,但永远不会删除,导致堆内存持续增长,最终一周后触发频繁 Full GC,接口大面积超时。用 MAT 一看就发现大量同一个业务对象堆积在静态集合里,问题根源一目了然。

排查堆 OOM 可以总结为四个步骤:确认 OOM 类型(日志里会明确打印是 Java heap space 还是 Metaspace 还是 Direct buffer memory)→ 用 jstat 观察 GC 情况 → 用 jmap 抓 dump → 用 MAT 分析对象引用链。

4.2 类加载冲突与 NoClassDefFoundError

NoClassDefFoundError 和 ClassNotFoundException 是两码事,但经常被混为一谈。ClassNotFoundException 是运行时明确找不到指定的类,通常是类路径配置有问题;NoClassDefFoundError 则是类在编译时存在、运行时加载失败,比如类初始化时抛异常导致加载中断,或者类依赖的另一个类缺失。

实际项目里最常见的情况是依赖冲突:同一个类在 classpath 里有多个版本,不同的类加载器加载了不同的版本。Spring Boot 应用里用mvn dependency:tree检查依赖树,是解决大部分依赖冲突的第一步。另外,容器类应用里父加载器和子加载器加载到同一个类的不同版本,也会出现 LinkageError 或 AbstractMethodError,这种问题最常见于 Tomcat 部署多个应用时公共库和应用内嵌库版本不一致的场景。

排查这类问题的基本思路:确认错误信息的完整堆栈 → 确认是哪个类、哪个加载器加载的 → 检查 classpath 里是否有多个版本的 jar → 最终通过依赖管理工具排除冲突版本。

4.3 双亲委派的实际应用与打破

之前说的都是标准双亲委派机制,但现实中有很多框架是打破这个模型的。最典型的就是 SPI(Service Provider Interface)机制。JDBC 的 DriverManager 是 Bootstrap 加载器加载的,但真正实现 driver 接口的类(比如 MySQL 的 com.mysql.cj.jdbc.Driver)却放在应用 classpath 下,由应用类加载器加载。如果严格遵守双亲委派,Bootstrap 加载器根本加载不到这个类,JDBC 就废了。

SPI 的解决办法是引入线程上下文类加载器(Thread Context ClassLoader)。DriverManager 通过Thread.currentThread().getContextClassLoader()拿到应用类加载器,用它来加载具体的实现类,这样就绕过了双亲委派。Tomcat 的类加载器体系也打破了双亲委派,它的 webapp 类加载器会优先加载应用自身的类,然后再委派给父加载器,这样才能做到多个应用之间类的隔离和独立热部署。

想看类的实际加载来源,启动时加-XX:+TraceClassLoading是最直接的。它会打印每一行"类名 + 来源 jar"信息,对排查"类从哪个 jar 加载的"这种问题特别有用。

4.4 我的 JVM 排查常用命令清单

最后把我平时最常用的一套命令分享出来,遇到问题可以直接抄作业:

命令作用
jps -l查看当前系统的 Java 进程和主类
jstat -gcutil <pid> 1000观察 GC 情况和内存占用
jmap -heap <pid>查看堆内存配置和当前使用情况
jmap -dump:format=b,file=xx.hprof <pid>导出堆转储文件
jstack <pid>导出线程快照,排查死锁和线程阻塞
jinfo <pid>查看 JVM 参数和系统属性
-XX:+TraceClassLoading -XX:+TraceClassUnloading启动参数,追踪类加载和卸载
-verbose:class输出类加载信息

每次排查完记得把堆转储文件和 GC 日志存好,它们是复盘的第一手材料。另外不建议在生产环境高峰期执行 jmap dump,因为 dump 过程中会触发 Stop The World,对在线服务影响较大,能安排在低峰期就安排在低峰期。

我自己的习惯是:所有 Java 服务启动参数里统一加上-XX:+PrintGCDetails -XX:+PrintGCDateStamps -Xloggc:/path/gc.log,让 GC 日志一直开着。磁盘开销很小,但关键时刻能救命。有一次线上告警内存飙升,我连上服务器第一件事就是翻 GC 日志,五分钟就定位到是某个接口产生的大对象列表把老年代打满了,而相邻的新上线的另一个服务因为没开 GC 日志,排查了整整一个下午。

最后再分享一个实战中总结的小技巧:遇到 JVM 相关的疑难杂症,不要急着调参数,先动手收集事实——GC 日志、堆转储、线程快照、类加载追踪,数据摆出来之后,大部分问题的原因已经自己浮出水面了。JVM 这套机制虽然复杂,但每一步都有迹可循,掌握了类加载子系统和内存结构这两条主线,无论是排查线上故障还是应对面试,心里都会踏实很多。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询