Maven依赖与类加载机制

目录

  • [一、scope 基础](#一、scope 基础 "#%E4%B8%80scope-%E5%9F%BA%E7%A1%80")
  • [二、胖 jar(fat jar / uber-jar)](#二、胖 jar(fat jar / uber-jar) "#%E4%BA%8C%E8%83%96-jarfat-jar--uber-jar")
  • [三、classpath 是什么](#三、classpath 是什么 "#%E4%B8%89classpath-%E6%98%AF%E4%BB%80%E4%B9%88")
  • [四、zip/jar 内部结构:entry 与中央目录](#四、zip/jar 内部结构:entry 与中央目录 "#%E5%9B%9Bzipjar-%E5%86%85%E9%83%A8%E7%BB%93%E6%9E%84entry-%E4%B8%8E%E4%B8%AD%E5%A4%AE%E7%9B%AE%E5%BD%95")
  • [五、启动时如何加载 BOOT-INF/lib 的嵌套 jar](#五、启动时如何加载 BOOT-INF/lib 的嵌套 jar "#%E4%BA%94%E5%90%AF%E5%8A%A8%E6%97%B6%E5%A6%82%E4%BD%95%E5%8A%A0%E8%BD%BD-boot-inflib-%E7%9A%84%E5%B5%8C%E5%A5%97-jar")
  • 六、类加载器如何找到并加载指定的类

一、scope 基础

1.1 前提:能不能打进 jar,先看打包方式

  • 普通 jar (maven-jar-plugin):只装自己的 .class,任何依赖都不打进去,运行时靠 classpath。
  • Spring Boot 胖 jar (spring-boot-maven-plugin repackage):会把依赖打进去,此时由 scope 决定 ------默认只带 compile + runtime,排除 provided / test / system。

下表针对胖 jar。

1.2 scope 一览

scope 编译主代码 测试 运行 打进胖 jar 说明
compile(默认) ✅ ✅ ✅ ✅ 业务依赖,会传递给依赖方
runtime ❌ ✅ ✅ ✅ 只需运行时,如 JDBC 驱动
provided ✅ ✅ ❌ ❌ 运行环境已提供
test ❌ ✅ ❌ ❌ 只服务测试
system ✅ ✅ ✅* ❌(默认) 手动指定本地 jar 路径

1.3 test:只服务测试

只有 src/test/java 能用,主代码 import 会报错,不打包、不传递。 例:spring-boot-starter-test、blade-core-test、liquibase-core。

1.4 provided:运行时"别人会提供",打了反而有害

编译时需要,但运行我的容器/环境已经有了,所以打包时排除:

  1. 重复:容器已有一份;
  2. 冲突 :两份版本不一致会导致类加载打架(NoSuchMethodError / ClassCastException)。

例:servlet-api(Tomcat 提供)、lombok(仅编译期)、blade-core-auto。

1.5 system:手动指的本地 jar,不可移植

用 <systemPath> 硬指向磁盘上的 jar,不从仓库拉:

xml 复制代码
<dependency>
    <groupId>com.dmall</groupId>
    <artifactId>dmall-open-sdk</artifactId>
    <version>2.0.0</version>
    <scope>system</scope>
    <systemPath>${project.basedir}/lib/dmall-open-sdk-2.0.0.jar</systemPath>
</dependency>

默认不打包,因为:路径写死、机器相关、不受仓库管理、不可移植(Maven 已视其为 deprecated 用法)。

⚠️ 若 system 依赖运行时真正需要 ,胖 jar 默认不带它,运行会 ClassNotFoundException。需显式开启:

xml 复制代码
<plugin>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-maven-plugin</artifactId>
    <configuration>
        <includeSystemScope>true</includeSystemScope>
    </configuration>
</plugin>

二、胖 jar(fat jar / uber-jar)

2.1 定义与对比

胖 jar = 把项目代码 + 所有依赖 + 内嵌启动器全部打进一个 jar ,java -jar xxx.jar 就能直接跑。

普通 jar(瘦 jar) 胖 jar / fat jar
装什么 只有自己的 .class 自己代码 + 所有依赖的 class + 资源
依赖怎么来 运行时靠 classpath 另外指定 已全在包里,自带
体积 小 大(几十 MB 起)
怎么运行 java -cp 你的.jar:依赖... 主类 java -jar 你的.jar(一条命令)
典型场景 给人依赖的库 (如 blade-third、poc-api) 可独立部署的应用 (如 poc、blade-gateway)

2.2 嵌套 jar 结构

Spring Boot 打的胖 jar 用嵌套 jar 结构(repackage),不是把 class 平铺:

bash 复制代码
poc.jar
├── BOOT-INF/
│   ├── classes/          ← 你自己的代码
│   └── lib/              ← 所有依赖 jar(一个个完整的 jar 原样放进来)
├── META-INF/
└── org/springframework/boot/loader/   ← Spring Boot 自带的启动加载器

scope 决定的正是:哪些依赖会被塞进 BOOT-INF/lib(普通 jar 不塞任何依赖,所以那种情况下 scope 对"打不打包"没意义)。

2.3 依赖的 class 平时在哪(不在 target/classes)

  • target/classes 只装本模块自己的编译产物 + resources,不会有三方依赖的 class。
  • 三方依赖以 jar 形式躺在 Maven 本地仓库 ~/.m2/repository/...,编译/IDE 运行时通过 classpath 引用(不复制)。
  • 只有打胖 jar 时才被复制 进 BOOT-INF/lib。

三、classpath 是什么

classpath = JVM 找类和资源的"搜索路径列表",按顺序搜索。条目只有两种形态:

  1. 一个目录 (按包结构放 .class)→ 通常是你的 target/classes;
  2. 一个 jar 文件 → 通常是 .m2 里的三方依赖。

所以 classpath 不只是三方 jar 的路径,也包含你自己的 classes 目录。

3.1 例 1:命令行手动拼(Windows 用 ; 分隔)

powershell 复制代码
java -cp "D:\...\poc\target\classes;C:\Users\x\.m2\...\spring-boot-3.2.x.jar;C:\Users\x\.m2\...\blade-third-4.9.0.RELEASE.jar" org.springblade.order.OrderApplication

第一项是自己的 classes 目录,后面是三方 jar。

3.2 例 2:IDE 里点绿色三角

IDEA 自动拼好 classpath = 本模块 target/classes + 依赖树展开的所有 .m2 jar(几十上百个)。

3.3 例 3:胖 jar 时是"虚拟路径"

java -jar 不写 classpath,由 LaunchedURLClassLoader 用嵌套 URL:

bash 复制代码
jar:nested:/D:/.../poc.jar/!BOOT-INF/classes/!
jar:nested:/D:/.../poc.jar/!BOOT-INF/lib/blade-third-4.9.0.RELEASE.jar!/

四、zip/jar 内部结构:entry 与中央目录

jar 本质是 zip。理解后面所有机制,先搞懂两个概念:entry 和 中央目录。

4.1 entry(条目)= 包内的单个成员

包内每一个文件(或目录占位)都是一条 entry:每个 .class、每个资源、每个嵌套 jar、MANIFEST.MF 各是一条。每条 entry 登记:

字段 含义 例子
entry 名(路径) 包内"路径" cn/hutool/core/collection/CollUtil.class
压缩数据 内容(DEFLATE) 该 class 的字节
偏移 / 长度 在外层文件里的位置和大小 用于随机读
CRC、压缩方式 校验/元信息 ---

poc.jar 的 entry 列表(节选):

bash 复制代码
META-INF/MANIFEST.MF                                        ← 一条 entry
org/springframework/boot/loader/launch/JarLauncher.class    ← 一条 entry
BOOT-INF/classpath.idx                                      ← 一条 entry
BOOT-INF/classes/org/springblade/order/OrderApplication.class ← 一条 entry
BOOT-INF/lib/hutool-core-5.x.x.jar                          ← 一条 entry(嵌套 jar 也是 entry)
...(共几百上千条)

4.2 中央目录(central directory)= 所有 entry 名的索引

zip 的物理布局:数据区散落 + 末尾一张目录表。

ini 复制代码
poc.jar 字节(简化)
┌──────────────────────────────────────────────┐
│ [local header: MANIFEST.MF][压缩数据...]        │  ← entry 数据散落各处
│ [local header: .../JarLauncher.class][数据...]  │
│ [local header: BOOT-INF/lib/hutool-core.jar]   │
│   [压缩数据...]            offset = 12,345,678  │
│ ...                                            │
├──────────────────────────────────────────────┤
│ Central Directory(文件末尾,连续一块)          │
│   entry名: META-INF/MANIFEST.MF         offset=0        │
│   entry名: BOOT-INF/lib/hutool-core.jar offset=12345678 │  ← 名字→偏移
│   ... 每条 entry 一行 ...                       │
├──────────────────────────────────────────────┤
│ End of Central Directory Record (EOCD)         │  ← 指向中央目录起点
└──────────────────────────────────────────────┘
  • 为什么在末尾:zip 边写边压,写某 entry 时还不知道最终偏移,只能最后统一补目录表。
  • 怎么用(O(1)) :打开 zip 时读 EOCD → 读中央目录 → 内存建成 entry名→(offset,size) 哈希表 → 之后按名字查表直接 seek 读数据。Java 的 ZipFile/JarFile 就是这么干的。
  • 类比:书的目录页 / 数据库索引------键(entry 名)→ 物理位置(offset)。

4.3 和类加载的关系

  • "查某个类在不在某个 jar" = 查"该 jar 中央目录里有没有对应名字的 entry"(O(1) 哈希)。
  • "嵌套 jar 的内部路径" = 那条嵌套 jar entry 的名字 (如 BOOT-INF/lib/xxx.jar)。

五、启动时如何加载 BOOT-INF/lib 的嵌套 jar

JVM 原生读不了"jar 里套 jar",所以 Spring Boot 在 jar 根目录放了自己的启动器 + 自定义类加载器。

5.1 MANIFEST 入口

vbnet 复制代码
Main-Class: org.springframework.boot.loader.launch.JarLauncher   ← JVM 先跑它
Start-Class: org.springblade.order.OrderApplication              ← 你的真正主类

JarLauncher 放在 jar 根目录 (org/springframework/boot/loader/),JVM 自带 AppClassLoader 无需特殊 classpath 就能加载它。

5.2 启动流程

bash 复制代码
java -jar poc.jar
  ├─ 1. JVM 读 MANIFEST → 跑 JarLauncher
  ├─ 2. JarLauncher 读 classpath.idx / 扫描:BOOT-INF/classes/ + BOOT-INF/lib/*.jar → 生成嵌套 URL
  ├─ 3. 用这些 URL 建 LaunchedURLClassLoader(URLClassLoader 子类)
  └─ 4. 反射调用 Start-Class(OrderApplication.main),线程 contextClassLoader 设为它
         └─ 之后所有类由它从嵌套 jar 读字节加载,全程不落盘

5.3 嵌套 jar 的"路径"从哪来

  • 内部路径 :外层 jar 的中央目录 登记着每个 entry 名,BOOT-INF/lib/xxx.jar 这些嵌套 jar 本身就是 entry,名字即内部路径。
  • 磁盘路径 :java -jar <path> 启动时已知外层 jar 位置。
  • 两者拼成 nested URL(见 3.3)。

5.4 classpath.idx:有序清单 + 顺序从哪来

BOOT-INF/classpath.idx 是构建期写进胖 jar 的 classpath 条目有序清单:

yaml 复制代码
- "BOOT-INF/lib/poc-api-4.9.0.RELEASE.jar"
- "BOOT-INF/lib/blade-starter-mybatis-4.9.0.RELEASE.jar"
- "BOOT-INF/lib/mybatis-plus-3.5.14.jar"
...

作用:告诉 JarLauncher "classpath 由哪些嵌套 jar 组成、按什么顺序",免扫描、顺序可复现。

顺序咋指定 :不是手写,是 Maven 构建期依赖解析算出来的有序结果:

  1. 直接依赖按 pom <dependency> 声明顺序;
  2. 传递依赖深度优先挂到父依赖后;
  3. 冲突 nearest-wins 调解。

repackage 插件把这份已排序列表写进 BOOT-INF/lib 并记入 idx。BOOT-INF/classes(你的代码)不在 idx 里,但启动器额外置顶(所以影子类能遮蔽三方类)。

查看/影响 :mvn -pl <module> dependency:list(同序);可改声明顺序 / <exclusions> / <dependencyManagement> 间接影响,但别靠调顺序解冲突。

5.5 读嵌套 jar 里的 class = 两层 zip 定位

markdown 复制代码
1. 外层中央目录 → 圈出 "BOOT-INF/lib/blade-third.jar" 的 [偏移,长度](不解压)
2. 在该字节区间内解析内层 jar 自己的中央目录
3. 内层中央目录 → 定位某个 .class 的偏移 → 换算外层绝对偏移 → 读字节、解压

六、类加载器如何找到并加载指定的类

6.1 关键澄清:没有"先确定在哪个 jar"这一步

"确定在哪个 jar" 本身就是"挨个 jar 查中央目录"的扫描过程,二者是同一件事,不是两步。 没有任何表预先记录"类→jar"。加载器拿着类名转出的 entry 路径,按 classpath 顺序逐个问,第一个说"有"的 jar 就是"那个 jar":

bash 复制代码
cn.hutool.core.collection.CollUtil  →  cn/hutool/core/collection/CollUtil.class
  1. BOOT-INF/classes/      没有
  2. lib/spring-core.jar    没有
  ...
  n. lib/hutool-core.jar    有!✅ ← 到这一刻才"确定"是它 → 读字节 defineClass

类比图书馆找书:不是"先知道在哪排书架",而是一排排查目录卡,第一排查到的就是它所在排。

6.2 每个 jar 如何"快速回答"

打开 jar 时把中央目录 读进内存建成哈希表,于是"有没有某条 entry"是 O(1) 查找。慢的只是"可能要问几个 jar",每次问都很快。

6.3 定位到 jar 后:字节 → Class

scss 复制代码
1. 中央目录取出该 .class entry → 解压读出 byte[]
2. defineClass(byte[]) → 解析(魔数/常量池)/验证 → 生成 Class 对象(loaded)
3. link:prepare(静态字段默认值) + resolve(符号引用,多懒解析)
4. 首次主动使用时 initialize:跑 <clinit>(static 块),之后缓存复用

6.4 包命名约定:为何路径基本只在一个 jar 里

  • 约定:包名用反向互联网域名 (cn.hutool、org.springframework、com.baomidou)。域名唯一 → 包前缀唯一 → 各家只往自己前缀下放类。
  • 体现在三处:① 源码 package cn.hutool.core.collection;;② class 二进制名;③ jar 内"包名即目录" 的存放结构。
  • 正因 jar 目录=包名,按类名查路径才只会在 hutool 的 jar 命中。
  • ⚠️ 这只是约定,JVM 不强制;违反就会类遮蔽。

6.5 类遮蔽 / 影子类覆盖

  • 遮蔽 :两个 jar 含同包同名类时,classpath 靠前的赢 ,后面的永远加载不到 → 依赖冲突引发 NoSuchMethodError 的根源。
  • 主动覆盖(影子类) :在自己项目建同全限定名的类(如 src/main/java/cn/hutool/core/collection/CollUtil.java)。因 BOOT-INF/classes 排在 lib 前面 → 你的版本胜出、原版被遮蔽。
    • 坑:必须完整复刻被调用的 API (签名一致),否则运行期 NoSuchMethodError(编译期不报);顺序依赖脆弱;升级依赖易踩雷。
    • 更推荐:用自己包下的新类 / 升级依赖 / ByteBuddy 字节码增强。
  • 验证类来自哪个 jar:
java 复制代码
System.out.println(CollUtil.class.getProtectionDomain().getCodeSource().getLocation());
// 你的 classes / BOOT-INF/classes,或 hutool 的 jar / nested url
相关推荐
迪丽热爱2 小时前
HTTP Error 500.30 - ASP.NET Core app failed to start
后端·asp.net
茉莉玫瑰花茶2 小时前
GO [ 函数 ]
开发语言·后端·golang
QuZhengRong2 小时前
【Spring】后端接收的请求参数多了一个逗号的处理办法
java·后端·spring
打工仔折腾 AI2 小时前
从零写一个CAD 02:实体容器、Esc取消与键盘失灵的排查
人工智能·后端·python·性能优化
用户8356290780512 小时前
使用 Python 拆分和提取 PDF 页面
后端·python
用户8356290780512 小时前
使用 Python 操作 PowerPoint 中的图片
后端·python
对象存储与RustFS3 小时前
自建对象存储的第一个决定:单机就够,还是必须上分布式
后端·rust·开源
用户8181870627463 小时前
第37章 Java应用在K8s里的经典坑:容器内存/CPU limit与JVM参数不匹配导致的OOMKilled
java·后端
小小张说故事3 小时前
pandas 读大 CSV 太慢?6 个实测提速技巧(dtype / 分块 / 引擎选择)
后端·python·pandas