目录
- [一、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-pluginrepackage):会把依赖打进去,此时由 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:运行时"别人会提供",打了反而有害
编译时需要,但运行我的容器/环境已经有了,所以打包时排除:
- 重复:容器已有一份;
- 冲突 :两份版本不一致会导致类加载打架(
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 找类和资源的"搜索路径列表",按顺序搜索。条目只有两种形态:
- 一个目录 (按包结构放
.class)→ 通常是你的target/classes; - 一个 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 构建期依赖解析算出来的有序结果:
- 直接依赖按 pom
<dependency>声明顺序; - 传递依赖深度优先挂到父依赖后;
- 冲突 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 字节码增强。
- 坑:必须完整复刻被调用的 API (签名一致),否则运行期
- 验证类来自哪个 jar:
java
System.out.println(CollUtil.class.getProtectionDomain().getCodeSource().getLocation());
// 你的 classes / BOOT-INF/classes,或 hutool 的 jar / nested url