qinghuan
← JUC + JVM / 全部笔记

单元 96 / JUC + JVM

P0 · 必会主线L2 · 进阶

JVM 内存与类加载:把规范概念和实现细节分开

按职责理解堆、栈和方法区,再分清类加载、链接与初始化,避免把 JVM 规范和某种实现混为一谈。

本篇目录 · 按需要跳转

读完这篇,你能做什么

能从一个对象和静态字段示例解释数据区域,并预测是否触发类初始化。

学习理由、准备要求与知识点

为什么学它

堆、栈与类初始化是定位内存和启动问题的语言基础,规范与具体实现不能混说。

先读这些内容

知识点清单

  • 堆
  • 虚拟机栈
  • 方法区
  • 常量池
  • 类加载
  • 连接
  • 初始化触发
  • 类加载器身份

运行前先确认JVMS17运行时区域与类生命周期;ClassLoader源码固定OpenJDK17.0.12+7。对象对齐算法采用显式给定的教学布局,不声称复现HotSpot实际字段排列或引用压缩配置。

01先弄懂它是什么

JVM 执行 class 中的程序,并管理类、方法调用和对象。可以先按职责认识运行内存:每个线程有程序计数器和调用栈,堆承载对象与数组,方法区保存类相关信息和运行时常量池等。规范说明的是职责,不强制固定物理布局;HotSpot 的 Metaspace 是其中一些职责的具体实现,不能与整个方法区简单画等号。

每次方法调用都需要一份执行记录,称为栈帧。它保存局部变量、计算时临时放数值的操作数栈,以及找到相关方法和字段所需的链接信息。局部变量里可以放对象引用,但引用在栈帧里,不代表对象本身也在栈上。JIT 是运行时把代码编译得更快的机制:它有时能分析对象会被哪些代码使用,再用几个独立的值代替整个对象,从而省掉一次对象分配。这里涉及逃逸分析和标量替换,是有条件的优化,不能说成“方法里的对象都存在栈上”。

类加载器决定从哪里取得类定义,也参与类型身份。两个二进制名称相同的类,由不同定义加载器加载时仍可能是不同运行时类型,出现看似“同名类不能强转”的现象。类加载、链接与初始化又是不同阶段:找到class文件不表示静态初始化已经运行,完成初始化也不表示所有外部依赖已经健康。

文中这几个词,先认一认

栈帧 Frame
某一次方法调用的工作记录,包括局部变量、计算临时值和链接信息。
方法区
JVM 规范中负责类级运行数据的区域名称,不等于某个实现中的单一内存区名称。
定义类加载器
真正定义这个运行时类的加载器;它与类的名字一起决定类的身份。
对象对齐
按一定字节边界安排对象位置和大小,可能需要在内部或末尾补空位。

还有陌生词?到术语小抄里看一个具体例子 ↗

02看看它怎么工作

认识类的准备过程时,可以分三步:加载取得类定义;链接完成验证、准备和解析;初始化执行相应静态初始化代码。其中,准备阶段先为静态字段安排存储并建立规定的初始状态,不能把它直接当成静态初始化代码已经执行。解析又可以按规范允许推迟,所以图上的先后顺序只是帮助分清职责,不表示每个 JVM 都会在一个时刻完成全部解析。

默认 ClassLoader 会先问“这个类是否已经加载过”,再交给父加载器查找,父路径找不到时,才由自己的 findClass 尝试。这里的父加载器是被委托的另一个加载器,不是 Java 继承关系中的父类。自定义时通常优先实现 findClass,保留这套默认流程。插件或容器若采用另一套查找规则,就要说清公共接口由哪一层定义,避免双方各加载一份同名接口,却无法当作同一个类型使用。

Java 进程占用的内存不只有堆,还包括线程栈、类元数据、JIT 编译代码、直接缓冲区和其他原生分配,所以 -Xmx 不能代表操作系统看到的全部进程内存。一个对象也不只是字段大小相加:对象头、字段排列、引用压缩和对齐都会影响结果。应在实际 JDK 和启动参数下用布局工具观察,再看有多少对象、哪些引用还让其他对象留着,才能估算它们实际保留了多少内存。

正在加载流程图…

规范区域描述职责;HotSpot实际存储、优化与进程内存统计还需单独观察。
查看流程图源码(Mermaid)
flowchart TD
 C["class字节"] --> L["加载:建立运行时类"]
 L --> V["链接:验证、准备、按需解析"]
 V --> I["初始化:执行静态初始化逻辑"]
 I --> M["方法执行与对象创建"]
 M --> T["每线程:PC与调用栈帧"]
 M --> H["共享:堆对象与数组"]
 L --> K["类相关结构:方法区职责与HotSpot元数据"]
 T --> R["引用指向对象"]
 R --> H

按顺序看,每一步做了什么

  1. 确认运行JDK、VM实现与有效启动参数。
  2. 区分类加载、链接和初始化,记录触发位置。
  3. 遇同名类型问题同时检查类名与定义加载器。
  4. 按堆、线程栈、元数据、直接内存与代码缓存拆分容量。
  5. 用目标环境测量对象布局和引用保留,而非硬编码经验大小。

03对照实现代码

ClassLoader默认委派路径与加载锁 (新标签页)↗

OpenJDK17.0.12+7 · ClassLoader#loadClass(String,boolean) / getClassLoadingLock

原创委派决策模型,使用字符串代表已找到的定义来源。真实源码返回Class并包含bootstrap查找、加载锁、解析和VM检查;此模型不加载字节码。

核心机制代码 · java
import java.util.function.Supplier;

final class DelegationModel {
    static String choose(String alreadyLoaded, Supplier<String> parentLookup,
                         Supplier<String> localLookup) {
        if(alreadyLoaded!=null) return alreadyLoaded; // 已建立的类型身份优先复用。
        String parent=parentLookup.get(); // 先向委派父路径查找。
        if(parent!=null) return parent;
        String local=localLookup.get(); // 父路径未找到时再查本地定义。
        if(local==null) throw new IllegalArgumentException("class not found");
        return local;
    }
}

固定loadClass在getClassLoadingLock(name)返回的锁上同步,随后调用findLoadedClass。并行能力加载器可以按类名使用加载锁,其他路径可能锁加载器自身。锁的粒度与是否支持并行相关,不能把所有类加载都概括为一把全JVM锁。

父加载器存在时委派parent.loadClass,不存在时走bootstrap查找;仅在未找到定义后调用findClass。本路径捕获的是对应的ClassNotFoundException,不能因此把所有链接或初始化错误都当成“父加载器没找到,可以随便换一个版本继续”。错误类型反映了不同阶段。

resolve标志控制是否调用resolveClass,但这不是执行类静态初始化的通用开关。Class.forName的初始化参数、主动使用触发与具体加载API需要分别核对。排查静态块为何执行或未执行时,应从调用入口和规范触发条件追踪,不能仅根据日志出现“load”一词判断。

卸载插件后,相关类也不一定马上能被卸载。只要父层缓存、线程上下文加载器或 ThreadLocal 还持有插件对象,这些引用就可能连到插件的类加载器,让它和相关类元数据继续留在内存中。关闭 URLClassLoader 可以释放它管理的资源,但不能保证外部引用都已消失,也不能保证相关类已经卸载。

代码先调用谁,再调用谁

  1. loadClass进入对应加载锁
  2. findLoadedClass复用已加载定义
  3. 委派父加载器或bootstrap查找
  4. 未找到时调用当前加载器findClass
  5. 按resolve参数链接并返回Class,初始化由相应规则触发

04动手写一个例子

固定字段顺序的安全对齐布局器

给定头部大小、每个字段的大小与2次幂对齐要求、对象整体对齐,按输入顺序计算字段偏移和最终大小。禁止重排字段,所有大小非负,溢出报错。它只计算给定规则,不预测真实HotSpot布局。

输入 1
头12;字段(4,4),(8,8),(1,1);整体对齐8
输出 1
偏移[12,16,24],总大小32
输入 2
头1;字段(8,8);整体对齐8
输出 2
偏移[8],总大小16

对于对齐值a,当前位置offset到下一个对齐点所需填充是(a-offset%a)%a。a为2次幂时,可用位运算(-offset)&(a-1)计算相同填充。先对齐字段起点,再加字段大小,最后对齐整体结束位置,分别体现内部空洞和尾部填充。

每次加法使用Math.addExact,避免接近long上界时变成负偏移。不能写offset+a-1后直接除法而忽略中间加法溢出。所有对齐值必须先验证为正且只有一个二进制位,否则位掩码公式不成立。字段大小为零仍按契约对齐起点,但不占有效内容字节。

在固定顺序和只允许前进的前提下,每次选择最小合法对齐位置是贪心最优:把当前字段放得更晚不会让任何后续字段有机会更早结束。归纳可得最终大小在给定顺序下最小。若允许重排、继承布局或填洞,问题条件已经改变,不能用这个结论替代VM实际策略。

为什么这样写是对的每个字段偏移满足自身对齐且不早于上一字段结束,offset始终是已安排字段后的最小合法前进位置。

时间 O(n)额外空间 输出偏移O(n),额外工作状态O(1)

每字段只进行常数次验证、位运算和精确加法;模型不分配对应大小的真实内存。

保存为 Main.java,运行 javac --release 17 -encoding UTF-8 Main.java,再运行 java Main。程序包含固定输入自测,断言失败时会抛出错误。

展开收起代码 · Main.java · 完整可运行程序Java · 36 行

长行可在代码区域内左右滚动,按 Tab 可把焦点移入代码区。

Main.java · 完整可运行程序 · Java
import java.util.*;

public class Main {
    record Field(long size,long alignment) {}
    record Layout(List<Long> offsets,long total) {}
    static long align(long offset,long alignment) {
        if(offset<0 || alignment<=0 || (alignment & (alignment-1))!=0)
            throw new IllegalArgumentException("alignment");
        long padding=(-offset) & (alignment-1); // 2次幂边界到下一合法位置的距离。
        return Math.addExact(offset,padding); // 避免对齐加法溢出后出现负地址。
    }
    static Layout layout(long header,List<Field> fields,long objectAlignment) {
        align(header,objectAlignment); Objects.requireNonNull(fields);
        long offset=header; List<Long> offsets=new ArrayList<>();
        for(Field field:fields) {
            Objects.requireNonNull(field);
            if(field.size()<0) throw new IllegalArgumentException("size");
            offset=align(offset,field.alignment()); offsets.add(offset);
            offset=Math.addExact(offset,field.size()); // 只计算尺寸,不实际申请巨大数组。
        }
        return new Layout(List.copyOf(offsets),align(offset,objectAlignment));
    }
    static void check(boolean ok) { if(!ok) throw new AssertionError(); }
    public static void main(String[] args) {
        check(layout(12,List.of(new Field(4,4),new Field(8,8),new Field(1,1)),8)
                .equals(new Layout(List.of(12L,16L,24L),32)));
        check(layout(1,List.of(new Field(8,8)),8).equals(new Layout(List.of(8L),16)));
        check(layout(0,List.of(),8).total()==0);
        check(layout(12,List.of(),8).total()==16);
        check(align(16,8)==16);
        try { align(1,3); throw new AssertionError(); } catch(IllegalArgumentException expected) { }
        try { align(Long.MAX_VALUE,8); throw new AssertionError(); } catch(ArithmeticException expected) { }
        check(layout(0,List.of(new Field(0,8)),8).total()==0);
        System.out.println("PASS 8");
    }
}

这些特殊情况也要试一试

  • 已对齐位置不增加填充。
  • 空字段列表仍需对齐头部。
  • 非2次幂或非正对齐拒绝。
  • long上界对齐或字段加法溢出明确失败。
进阶选读 · 字段重排、缓存行与对象图成本

场景缓存中有数百万小对象,字段有效内容很少,但对象头、引用和填充占据大量内存;并发更新还可能出现伪共享。

做法在目标VM配置下用JOL观察实际布局,再比较对象数组、原始类型数组与分列存储。对热点字段研究缓存行共享与填充代价,结合访问局部性和GC保留图测量。

复杂度遍历n对象k字段的逻辑工作量O(nk),真实性能还依赖缓存命中、指针追踪与分配;压缩布局可减少常数但不改变同等遍历的阶。

好处与代价紧凑存储节省内存,却可能降低面向对象表达或增加索引管理;填充减少伪共享也会增加每对象空间。

05项目里怎么用

排查前先记录实际运行进程的 java -version、VM 参数、容器内存限制和进程占用,不能拿 IDE 的配置代替线上配置。堆上限是最多允许多大,已提交堆是 JVM 已为堆保证可用的容量,当前使用堆是其中已经用掉多少;RSS 则是操作系统看到的进程驻留内存,还包含堆以外的部分。把数据与具体进程、启动版本放在一起,才方便复现和比较。

出现ClassCastException且两侧类名相同时,打印各自Class.getClassLoader与代码来源,检查重复依赖、插件隔离和容器共享API位置。仅调整import无法改变运行时类型身份;应该统一需要共享的接口定义,并审查加载器委派策略。

静态初始化尽量保持短小和确定,避免持有初始化锁时做复杂远端交互或互相初始化依赖。初始化失败会影响后续使用,错误表现也可能从首次初始化异常变成后续类不可用。应保留最早异常原因,而不是只重试后来看到的错误表面。

容易踩的坑

  • 把方法区与某一版永久代或Metaspace完全等同。
  • 用-Xmx当整个Java进程内存上限。
  • 认为引用在局部变量里意味着对象必定在栈上。
  • 只比较类名而忽略定义加载器。
  • 把字段大小相加当所有JDK的真实对象大小。

几种做法,怎么选

表格有四列,窄屏可左右滑动查看代价和适用条件。

方案优势代价与限制适用条件
默认父优先委派共享基础类型身份较容易保持插件依赖隔离可能受限普通应用与共享API
自定义隔离加载器可容纳特定插件版本类型身份、资源关闭与卸载更复杂经过设计的插件容器
紧凑原始数据布局减少对象头与引用开销索引和业务表达更复杂大规模数值或列式数据
普通对象模型表达清楚,维护方便小对象数量大时空间成本明显常规业务领域建模

06检验是否读懂了

先自己想、自己写,再展开答案核对。场景题是假设的练习情境;遇到还没学过的内容,可以做完基础后再回来。

先用自己的话解释

01栈中的引用与堆中的对象如何区分?

展开参考答案与推导

栈帧局部变量可保存引用值,引用指向的对象属于另一个运行时实体;优化可能消除分配,但不是语言保证所有局部对象放栈。

02两个同名类为什么可能不能互相强转?

展开参考答案与推导

运行时类身份包含定义加载器,不同定义加载器的同名类可能是不同类型。

03为什么布局器结果不能直接当HotSpot实际大小?

展开参考答案与推导

模型固定头、字段顺序与对齐,实际VM可能重排、压缩引用、处理继承与特定对象头配置。

换个情况,还会处理吗

01堆只用了半数但进程接近容器上限,应查什么?

展开参考答案与推导

继续拆分线程栈、元数据、直接内存、代码缓存与其他原生占用,并区分保留、提交和RSS。

02热部署多次后元数据增长,可能有什么引用链?

展开参考答案与推导

父层静态缓存、线程上下文加载器、ThreadLocal或未结束线程可能持有旧加载器及其类,检查实际可达链。

03类加载成功但静态字段没有预期值,如何查?

展开参考答案与推导

区分加载与初始化触发,检查Class.forName初始化参数、主动使用路径和最早静态初始化异常。

写代码试一试

01实现固定顺序对齐布局器。

输入 / 条件
头12;4字节对齐4、8字节对齐8、1字节对齐1;整体8
输出 / 预期
偏移12,16,24;总32

提示 先对齐起点再加大小。

展开参考答案与推导

按正文精确加法与位掩码,拒绝非法对齐。

02设计类身份诊断输出。

输入 / 条件
两个同名Class对象
输出 / 预期
输出名称、定义加载器、代码来源与是否同一Class

提示 不要只打印getName。

展开参考答案与推导

逐项处理bootstrap加载器为null及代码来源不可用的情况,比较Class引用身份。

03比较一百万int的两种存储估算。

输入 / 条件
int数组与包装Integer列表
输出 / 预期
分别列出元素内容、引用、对象头与填充来源

提示 先写假设再用目标VM测量。

展开参考答案与推导

不能硬编码Integer每个实例固定大小,也要考虑缓存复用;以实际对象图与布局报告校准。

读完之后,独立完成

课后作业:自己做一遍

本篇共 12 道作业,先完成 9 道基础与综合任务,再按前置知识挑战进阶题。其中包含 2 道思维拓展,试着换个条件,看看原来的办法是否还管用。不附参考答案。

这次练什么

能从一个对象和静态字段示例解释数据区域,并预测是否触发类初始化。

本篇重点加练

初始化触发和加载器身份直接影响插件隔离、静态状态和错误恢复,需细查可见行为。

  • 初始化触发
  • 类加载器身份

重点内容会多练几次。先说清楚怎么做,再试试空数据、出错或条件改变时会发生什么。

先自己做,卡住时记下问题,再回来查。问答题写清“为什么、什么时候适用”;动手题留下输入、输出和运行步骤,方便下次重做。

基础必做

  1. 作业 01代码推演

    一个方法创建数组并把引用写入静态集合,随后递归调用并返回。画出三个执行时点的引用和栈帧关系,说明哪些内容不能由源码确定具体布局。

    提交内容
    内存关系图
    怎样算完成
    • 区分引用、对象和局部变量槽
    • 不把示意字节数当实测布局

    练到的知识点堆 · 虚拟机栈

  2. 作业 02原理问答

    同一字符串出现在字面量、运行期拼接和类字段初始化中。列出涉及的方法区、常量池与对象概念,并说明规范描述和实现观测的区别。

    提交内容
    概念映射与验证计划
    怎样算完成
    • 明确使用的 JDK
    • 区分运行时常量池和字符串对象

    练到的知识点方法区 · 常量池

  3. 作业 03代码推演

    准备一个类包含静态字段、静态初始化块和一个缺失依赖的方法。分析加载、验证、准备、解析各阶段可能观察到的事件。

    提交内容
    阶段图与假设记录
    怎样算完成
    • 不把所有错误归为初始化
    • 区分必然行为与实现时机

    练到的知识点类加载 · 连接

  4. 作业 04代码推演

    类的静态初始化依赖外部配置,首次初始化抛异常,随后同进程再次访问该类。为不同访问方式设计观察记录,不预先假定会重试初始化。

    提交内容
    最小类与预测表
    怎样算完成
    • 区分首次错误和后续访问
    • 注明触发初始化的操作

    练到的知识点初始化触发

  5. 作业 05原理问答

    两个插件共享同名接口文件但由不同加载器定义,双方都声称实现该接口。评审类型兼容判断需要哪两部分身份。

    提交内容
    类型关系与诊断字段
    怎样算完成
    • 包含数组和接口身份
    • 不只比较类全名

    练到的知识点类加载器身份

综合应用

  1. 作业 06排错实验

    本地程序分别读取编译期常量、创建实例、使用两个独立加载器加载同名类;记录初始化日志和 Class 身份。

    提交内容
    最小工程与运行日志
    怎样算完成
    • 说明加载器委派配置
    • 记录可重复执行方式

    练到的知识点初始化触发 · 类加载器身份

  2. 作业 07编程实践

    写一个类诊断小工具,输出目标类的加载器链、模块、静态初始化观测与数组类型信息,并建立异常输入样本。

    提交内容
    源码和诊断报告
    怎样算完成
    • 不反射读取敏感对象内容
    • 明确工具能观察与不能证明的事项

    练到的知识点堆 · 虚拟机栈 · 方法区 · 常量池 · 类加载 · 连接 · 初始化触发 · 类加载器身份

  3. 作业 08设计评审

    插件静态注册器将自身写入主程序全局表,初始化过程中回调主程序又访问插件。分析递归初始化与长期引用的观测风险。

    提交内容
    调用图与生命周期审查
    怎样算完成
    • 区分初始化时读到的状态和完成后状态
    • 标明引用持有者

    练到的知识点初始化触发 · 类加载器身份

  4. 作业 09编程实践

    在本地小工程用两个加载器分别运行同一插件类,注入一次静态初始化失败,记录类身份、初始化次数和异常链。

    提交内容
    源码与多次运行报告
    怎样算完成
    • 每次实验明确进程边界
    • 不需要访问或修改 JDK 内部字段

    练到的知识点初始化触发 · 类加载器身份

进阶挑战 · 掌握前置后选做

  1. 作业 10设计评审

    插件系统允许卸载后重新加载同名插件,主程序缓存了插件 Class 和回调。审查生命周期中的引用责任并设计可观测的卸载实验。

    提交内容
    引用关系图与试验方案
    怎样算完成
    • 区分逻辑卸载与实际回收
    • 不依赖一次 System.gc 调用证明卸载

    练到的知识点方法区 · 类加载 · 初始化触发 · 类加载器身份

思维拓展

先写出你的猜想,再用例子或实验验证。答案可以不同,但要说清依据、适用情况,以及还有什么没弄懂。

  1. 作业 11思维拓展 · 反例构造

    构造类名、字节码内容都相同但不能按预想相互转换的例子,并解释判断相同类型需要的证据。

    提交内容
    可运行反例与身份报告
    怎样算完成
    • 实际记录定义加载器
    • 不以源码文本相等替代运行时身份

    练到的知识点初始化触发 · 类加载器身份

  2. 作业 12思维拓展 · 条件变化

    热更新要求新旧插件同时服务在途请求,并禁止丢失旧插件状态。重新定义版本身份、初始化和卸载的验收条件。

    提交内容
    热更新约束与状态迁移评审
    怎样算完成
    • 覆盖共享接口兼容
    • 区别停止接流与类卸载

    练到的知识点初始化触发 · 类加载器身份

完成后,再检验一次

给每题标记“独立完成 / 查阅后完成 / 尚未完成”。查过资料或没做出的题,记下卡在哪里。隔一天换一组输入再试,能解释结果和出错原因,再把这个知识点标为掌握。

继续阅读

先看能帮你完成当前练习的部分。例子跑通后,再读更深入的解释。

打开资料库,找下一份学习材料 ↗