JVM基本知识(三):类加载机制与调优排障
JVM基本知识(三):类加载机制与调优排障
导语:第三篇收尾,讲两块「工程味」更重的内容:一是一个 .class 文件怎么变成能用的类(类加载生命周期、双亲委派及其打破),二是出了问题怎么查(解释器与 JIT、常用参数、线上排障路线)。
一、一个类是怎么被「装进」JVM 的
类加载遵循固定的生命周期:加载 → 验证 → 准备 → 解析 → 初始化 → 使用 → 卸载。注意「加载」只是其中第一步,日常口语里的「类加载」往往指整个生命周期。
三个最容易答错的细节:
- 准备阶段赋的是「零值」:
static int x = 5在准备阶段 x 仍是 0,5 是在初始化阶段才写入的(static final编译期常量例外,会直接赋初值); - 初始化阶段由 JVM 保证线程安全:多线程同时初始化同一个类时,只有一个线程执行类构造器,其余线程阻塞等待——这正是「静态内部类单例」线程安全的底层依据;
- 只有「主动引用」才触发初始化:
new、读写非 final 静态字段、调用静态方法、反射、子类初始化时先初始化父类、主类启动都算;而访问static final编译期常量、定义数组、通过子类访问父类静态字段都不会触发初始化。
二、类加载器与双亲委派
类加载器负责「从哪找、由谁加载」,而双亲委派规定了「先让谁试」。
为什么要有双亲委派,三条理由:
- 避免重复加载:父加载器加载过的类,子加载器不会再加载一遍;
- 安全:用户自定义的
java.lang.String会被委派给 Bootstrap,永远无法顶替核心类,保证类型体系唯一; - 类型一致:核心类只有一份,程序中所有地方拿到的都是同一个
Class对象。
版本提醒:JDK 9 起「扩展类加载器」被「平台类加载器(PlatformClassLoader)」取代。因为模块化后 JDK 自身的类被拆成模块,不再需要
lib/ext这个目录。只答「启动 / 扩展 / 应用」三种会被认为知识过时。
什么时候需要打破双亲委派
双亲委派是推荐实践而非强制约束,现实中至少有三类场景必须打破:
| 方式 | 做法 | 典型场景 |
|---|---|---|
重写 loadClass() | 自定义加载顺序,不再先向上委派 | Tomcat 的 WebAppClassLoader(先加载应用自身类) |
重写 findClass() | 保留委派流程,只在父加载器找不到时按自定义路径查找 | 自定义加载器(加解密 class、从网络/数据库加载) |
| 线程上下文类加载器 TCCL | 把「子加载器」挂到线程上,让父加载器运行期反向调用它 | SPI 机制:JDBC、JNDI、Spring 的 SpringFactoriesLoader |
TCCL 是理解 SPI 的钥匙,用一句话概括它的困境与出路:
顺带记住 Tomcat 的类加载器分层:Common / Catalina / Shared / WebApp。每个 WebApp 一个 WebAppClassLoader,优先加载自己 WEB-INF/classes 和 WEB-INF/lib 的类(但 java.* 等核心类强制走 Bootstrap)。这样既做到应用间同名类隔离(不同应用可用不同版本的 Spring),又做到容器库共享,还是热部署的基础。
三、类真正「跑」起来:解释器与 JIT
类加载完,字节码怎么执行?答案是解释器 + JIT 编译器协作:
要点:
- 为什么不全用 JIT:编译本身耗时且吃内存,边编译边跑才能兼顾启动速度与峰值性能,这也是「Java 程序跑久了会变快」的原因;
- 热点探测:靠方法调用计数器 + 回边(循环)计数器,超过
-XX:CompileThreshold即编译,循环体则用 OSR(栈上替换) 边跑边换; - 分层编译(JDK 8+ 默认):热点先经 C1 快速编译拿到基本优化,收集到足够的运行剖面信息后再交给 C2 做激进优化;
- JIT 的代表性优化:方法内联、逃逸分析(进而做标量替换、锁消除)、锁粗化、循环展开。其中逃逸分析是「对象一定分配在堆上吗」这个问题的答案来源。
四、常用参数速查
不需要背,但要能对上号:
| 目标 | 关键参数 |
|---|---|
| 堆大小 | -Xms / -Xmx(生产建议设相等,避免扩缩容抖动) |
| 分代比例 | -XX:NewRatio(老年代:新生代)、-XX:SurvivorRatio(Eden:Survivor) |
| 晋升控制 | -XX:MaxTenuringThreshold、-XX:PretenureSizeThreshold(仅 Serial/ParNew) |
| 线程栈 | -Xss |
| 元空间 | -XX:MetaspaceSize / -XX:MaxMetaspaceSize(必须设上限) |
| 直接内存 | -XX:MaxDirectMemorySize |
| 收集器 | -XX:+UseG1GC / -XX:+UseZGC / -XX:+UseParallelGC |
| 停顿目标 | -XX:MaxGCPauseMillis(G1/ZGC,是「目标」而非「保证」) |
| GC 日志 | JDK 8:-XX:+PrintGCDetails;JDK 9+:统一用 -Xlog:gc* |
| 排障兜底 | -XX:+HeapDumpOnOutOfMemoryError -XX:HeapDumpPath=/data/dump |
| 显式 GC | -XX:+DisableExplicitGC(禁掉误调用的 System.gc()) |
容器(Docker/K8s)环境务必显式指定
-Xmx或-XX:MaxRAMPercentage,否则 JVM 可能按宿主机内存推算堆大小,导致被 OOMKilled。
五、线上出问题,按这个顺序查
三条实战经验:
- 先分清是 CPU 问题还是内存问题,两者的排查链路完全不同,混着查最浪费时间;
-XX:+HeapDumpOnOutOfMemoryError一定要提前配,OOM 现场的堆快照比任何日志都值钱;- 调优顺序永远是「先改代码、再调参数、最后换机器」。内存泄漏、无界集合、一次性加载全量数据这类问题,调参只能把故障往后推。
六、一页速记
- 类加载:加载 → 验证 → 准备(赋零值)→ 解析 → 初始化(执行类构造器),初始化由 JVM 保证线程安全;
- 双亲委派:向上委派、向下尝试,好处是防重复、防篡改、保一致;JDK 9 起扩展类加载器 → 平台类加载器;
- 打破双亲委派:重写
findClass是扩展,TCCL 才是真正的反向通道(SPI 的基础); - 执行引擎:解释器起步、JIT 提速,热点靠计数器发现,分层编译 C1 → C2;
- 排障:CPU 用 jstack/Arthas,内存用 jstat/dump/MAT,事前把
HeapDumpOnOutOfMemoryError配上。
到这里,JVM 的「知识地图」就画完了:内存结构 → 对象 → 垃圾回收 → 类加载 → 调优排障。接下来可以进入《JVM(一)~(四)》面试题系列,逐题打磨细节。
