上一篇【第05篇】项目结构与第一行代码——jvmgo 项目骨架搭建 下一篇【第07篇】Entry 接口设计——类路径的"积木块"
摘要
写一个 JVM,第一件要解决的事就是:去哪儿找 class 文件?
有意思的是,Java 虚拟机规范并没有规定虚拟机该从哪里寻找类——这是留给实现者的自由。Oracle 的 JVM 用的是"类路径(classpath)"方案,我们的 jvmgo 也照抄这套。
本文讲透类路径的三大组成部分(启动类路径 / 扩展类路径 / 用户类路径)、-classpath 选项的各种用法、Windows 分号与 Linux 冒号的分隔符差异、Java 6 引入的通配符 lib/*,以及一个反直觉的结论:为什么你可能听说过 CLASSPATH 环境变量,但绝大多数情况下不该用它。
一、为什么需要类路径:HelloWorld 背后的"隐形依赖"
先看一个你可能没注意过的事实。
我们的 HelloWorld 只有 5 行代码:
public class HelloWorld {
public static void main(String[] args) {
System.out.println("Hello, world!");
}
}
编译后只有一个 HelloWorld.class。看起来干干净净,对吧?
但 JVM 要运行它,实际上得加载几十上百个类:
【运行 HelloWorld 实际需要加载的类(部分)】
HelloWorld ← 我们自己写的,1 个
│
├─ java.lang.Object ← 所有类的父类,必须有
├─ java.lang.String ← main 的参数 String[] args
├─ java.lang.String[] ← 数组类型本身也是类!
├─ java.lang.System ← System.out 用到
│ └─ java.io.PrintStream ← System.out 的类型
│ └─ java.io.FilterOutputStream
│ └─ java.io.OutputStream
│ └─ java.lang.Object
├─ java.lang.StringBuilder ← 字符串拼接会被编译成 StringBuilder
├─ java.lang.AbstractStringBuilder
├─ java.lang.CharSequence ← 接口
└─ … 还有 JDK 内部的启动类、异常处理类等
想想看:加载 HelloWorld 之前要先加载它的父类 java.lang.Object;调用 main() 之前要准备参数数组,得加载 java.lang.String 和 String[];把字符串打印到控制台,得加载 java.lang.System、java.io.PrintStream……层层依赖,一环扣一环。
那么问题来了:这些类在磁盘的哪个角落?
- HelloWorld.class 在你当前目录,这个好找
- 但 java.lang.Object 在哪?在 JDK 的 jre/lib/rt.jar 这个压缩包里!
- 如果你用了第三方库(比如 commons-lang3),它们的 jar 包又在 lib/ 目录下
JVM 需要一个统一的机制来定位这些散落各处的 class 文件——这就是类路径。
重点:类路径本质上是一份"搜索清单"。JVM 要加载一个类时,就按这份清单挨个地方去找,找到第一个匹配的就用。
二、类路径的三大来源
Oracle 的 JVM 把类路径分成三部分,按搜索的先后顺序排列:
【类路径的三大组成部分】
┌─────────────────────────────────────────────────────────┐
│ 类路径 │
│ (Classpath) │
│ │
│ ① 启动类路径 ② 扩展类路径 ③ 用户类路径 │
│ Bootstrap Classpath Extension Classpath User Classpath│
│ ┌────────────────┐ ┌────────────────┐ ┌───────────┐ │
│ │ jre/lib/* │ │ jre/lib/ext/* │ │ 默认 "." │ │
│ │ │ │ │ │ │ │
│ │ · rt.jar │ │ · 扩展包 │ │ · 你的类 │ │
│ │ · resources │ │ · 本地化jar │ │ · 第三方库 │ │
│ │ · charsets │ │ │ │ │ │
│ │ · … │ │ │ │ │ │
│ └────────────────┘ └────────────────┘ └───────────┘ │
│ 优先级最高 优先级中 优先级最低 │
│ │
│ 搜索顺序:① → ② → ③ (找到了就停止) │
└─────────────────────────────────────────────────────────┘
| 启动类路径 | jre/lib/ | Java 标准库(主要都在 rt.jar 里) | 可以,用 -Xbootclasspath(很少用) |
| 扩展类路径 | jre/lib/ext/ | Java 扩展机制的类 | 一般不改 |
| 用户类路径 | 当前目录 . | 我们自己写的类 + 第三方库 | 用 -classpath / -cp 指定 |
rt.jar 是个什么来头?
rt.jar 是 Runtime 的缩写,位于 $JAVA_HOME/jre/lib/rt.jar。它是 Java 核心类库的打包文件,大小约 60MB(JDK 8),里面塞了大约 2 万个类:
# 看看 rt.jar 里有什么
cd $JAVA_HOME/jre/lib
jar tf rt.jar | head -20
# 输出(节选)
java/lang/Object.class
java/lang/String.class
java/lang/System.class
java/lang/Integer.class
java/lang/Thread.class
java/util/ArrayList.class
java/util/HashMap.class
java/io/PrintStream.class
...
# 统计有多少个类
jar tf rt.jar | wc -l
# 约 20000+
重点:我们的 jvmgo 运行时,必须能加载 rt.jar 里的类。所以启动类路径是三块里最关键的——没有它,连 java.lang.Object 都找不到,程序根本跑不起来。
搜索顺序很重要
为什么顺序重要?举个经典的坑:
// 你自己写了一个 java.lang.String(作死行为)
package java.lang;
public class String {
// …
}
你编译好放进 classpath,然后运行程序。你觉得 JVM 会用你写的 String 吗?
不会。 因为启动类路径(rt.jar)优先级最高,JVM 先在那里找到了正牌的 java.lang.String,就直接返回了,根本不会去你的用户类路径里找。
这就是双亲委派模型在类路径层面的体现——核心类库永远优先,防止被恶意/误操作替换。
三、-classpath / -cp 选项详解
用户类路径的默认值是当前目录 .。但通常我们需要指定更复杂的位置,这时就用 -classpath(简写 -cp)。
基本用法
# 指定目录
java -cp path\\to\\classes HelloWorld
# 指定 JAR 文件
java -cp path\\to\\lib1.jar HelloWorld
# 指定 ZIP 文件(是的,zip 也行)
java -cp path\\to\\lib2.zip HelloWorld
指定多个位置
用分隔符把多个路径串起来:
# Windows:用分号 ;
java -cp path\\to\\classes;lib\\a.jar;lib\\b.jar;lib\\c.zip HelloWorld
# Linux / Mac:用冒号 :
java -cp path/to/classes:lib/a.jar:lib/b.jar:lib/c.zip HelloWorld
重点:这是新手最容易踩的坑之一。Windows 用分号 ;,类 UNIX(Linux/Mac)用冒号 :。写错了 JVM 会把整串当成一个路径,然后报 ClassNotFoundException。
我们的 jvmgo 会用 os.PathListSeparator 来自动适配,不用硬编码:
const pathListSeparator = string(os.PathListSeparator)
// Windows 下是 ";",Linux/Mac 下是 ":"
通配符:Java 6 的黑科技
从 Java 6 开始,可以用通配符 * 一次指定某个目录下的所有 JAR 文件:
# lib 目录下的所有 jar 都会被加入类路径
java -cp classes;lib\\* HelloWorld
注意几个细节:
| 写法 | 必须是 lib\\*,不能写成 lib\\*.jar |
| 是否递归 | 不递归!只匹配 lib/ 下的 jar,不含子目录 |
| 匹配什么 | 只匹配 .jar 和 .JAR,不匹配 .zip |
| 顺序 | 匹配到的 jar 顺序不确定(依赖文件系统),别依赖顺序 |
【通配符匹配示意图】
lib/
├── a.jar ✅ 匹配(lib/* 会加载)
├── b.jar ✅ 匹配
├── c.JAR ✅ 匹配(大小写不敏感)
├── d.zip ❌ 不匹配(通配符只认 jar)
├── e.class ❌ 不匹配
└── sub/
└── f.jar ❌ 不匹配(不递归子目录)
四、CLASSPATH 环境变量:为什么"不推荐"
除了 -cp 选项,还可以设置 CLASSPATH 环境变量来指定用户类路径:
# Windows
set CLASSPATH=D:\\classes;D:\\lib\\a.jar
# Linux / Mac
export CLASSPATH=/home/user/classes:/home/user/lib/a.jar
但强烈不推荐这么做。 原因有三:
理由 1:优先级问题
-classpath / -cp 选项的优先级更高,会覆盖 CLASSPATH 环境变量:
【优先级】
-cp 选项 > CLASSPATH 环境变量 > 默认当前目录 "."
(最高) (最低)
这会造成困惑:你明明设了环境变量,但别人用 -cp 跑程序时它完全不起作用,排查起来很懵。
理由 2:全局污染
环境变量是全局的。你在机器上设了 CLASSPATH,会影响所有 Java 程序——包括那些你不该影响的程序(比如 IDE、构建工具、其他项目)。
理由 3:不可移植
你的程序换台机器跑,CLASSPATH 就得重新配一遍。而 -cp 选项通常写在启动脚本里,跟着项目走,可移植性好得多。
最佳实践:永远用 -cp 选项,别用 CLASSPATH 环境变量。如果非要用环境变量,也只在临时测试时用,别写进系统配置。
五、我们的 jvmgo 要怎么实现?
理解了概念,来看实现思路。
jvmgo 的 ch02 会做这些事:
【ch02 类路径实现规划】
┌─────────────────────────────────────────────────────┐
│ Classpath │
│ ┌───────────────┬───────────────┬────────────────┐ │
│ │ bootClasspath │ extClasspath │ userClasspath │ │
│ │ (jre/lib/*) │(jre/lib/ext/*)│ (-cp 或 ".") │ │
│ └───────┬───────┴───────┬───────┴────────┬───────┘ │
│ │ │ │ │
│ │ 每种都是 Entry 接口的某个实现 │
│ ▼ ▼ ▼ │
│ ┌────────────────────────────────────────────┐ │
│ │ Entry 接口 │ │
│ │ readClass(className) ([]byte, Entry, error)│ │
│ │ String() string │ │
│ └────────────────────────────────────────────┘ │
│ │
│ 4 种实现: │
│ · DirEntry —— 目录形式 │
│ · ZipEntry —— JAR/ZIP 文件形式 │
│ · CompositeEntry —— 多路径组合(分号/冒号分隔) │
│ · WildcardEntry —— 通配符形式(lib/*) │
└─────────────────────────────────────────────────────┘
核心 API 设计(这是 JVM 规范之外的实现细节,我们参考 Oracle 的做法):
// 解析类路径
func Parse(jreOption, cpOption string) *Classpath
// 读取 class 文件(按 启动 → 扩展 → 用户 的顺序搜索)
func (self *Classpath) ReadClass(className string) ([]byte, Entry, error)
调用示例:
// 在 startJVM 中使用
cp := classpath.Parse(cmd.XjreOption, cmd.cpOption)
data, entry, err := cp.ReadClass("java/lang/Object") // 参数用斜线分隔
if err != nil {
fmt.Printf("找不到 java.lang.Object: %v\\n", err)
return
}
fmt.Printf("找到 java/lang/Object.class,来自:%v,共 %d 字节\\n", entry, len(data))
重点:注意 ReadClass 的参数格式——用斜线 / 分隔,带 .class 后缀。比如 java.lang.Object 要写成 java/lang/Object.class。这跟磁盘路径的写法(Windows 用反斜杠)不一样,是 JVM 内部的统一表示法。
本篇小结
类路径是 JVM 定位 class 文件的机制,核心要点:
下一篇,我们正式开始写代码——设计 Entry 接口,把类路径抽象成可组合的"积木块"。这里会用到一个经典设计模式:组合模式(Composite Pattern)。
上一篇【第05篇】项目结构与第一行代码——jvmgo 项目骨架搭建 下一篇【第07篇】Entry 接口设计——类路径的"积木块"
网硕互联帮助中心

评论前必须登录!
注册