开始前,会这些就够了
- 认识 Java 包名与完整类名
- 能区分类路径里的资源名和操作系统中的文件路径
- 先读
ServiceLoader.load没报错,为什么真正使用插件时才失败?:先认识运行时发现与实际读取是不同阶段,再核对资源名称和加载位置。
遇到不熟悉的词,先看这 2 个解释
打开完整术语小抄 ↗同样叫 settings.txt,一份放在类路径根,另一份跟着 sample 包。使用 sample.Anchor.class.getResourceAsStream("settings.txt") 能读到包内文件;换成类加载器的同名方法,结果却可能变成另一份内容。
本文只读取程序自己生成的两份文本。一个 Main.java 会建立隔离临时目录,实际编译测试类、生成 JAR 并核对两种容器,最后关闭资源并清理自己的临时文件。
把例子带到自己的电脑上
下载完整练习包
需要 完整 JDK 17 或更新版本;实测 Windows / JDK 17.0.20.1 / 21.0.12。使用标准库,不需要安装第三方包。下载 ZIP 后选择“全部提取”,进入能看到 README.md 的文件夹。
下载 ZIP · 5 个文件包里有什么
- Main.java:完整可运行源码
- README.md:运行步骤与练习
- observed.stdout.txt:本次实际输出
- verification.json:源码和运行验证记录
- SHA256SUMS.txt:文件摘要
在这个文件夹打开 PowerShell,逐行运行
java -version
javac -version
javac -encoding UTF-8 --release 17 Main.java
java -cp . Main解压后,在含 Main.java 的目录打开终端。命令分别回车执行。单Main.java,需要完整JDK含jdk.compiler;每JDK22检查,在自建临时目录动态编译自有Anchor并测试目录与普通JAR资源;最终清理已验证。无网络,不读用户配置,未测命名模块或框架特殊加载器。
找不到命令或文件时,先按 README 检查环境和所在目录。文中标出的阅读时间只估算文章篇幅,安装环境和动手练习可按自己的节奏进行。
先看谁解释路径,再看它要找哪份资源
正在准备实验控件…
固定 sample.Anchor 所属包和两份自有资源,展示命名规则,不在浏览器启动 Java 类加载器。目录与普通 JAR 的真实读取结果见下方完整程序;模块封装、父加载器搜索和框架容器不在此模拟范围。
先把两份同名资源摆出来,不靠猜文件在哪
测试类完整名是 sample.Anchor。编译输出根下有 settings.txt,内容是 root-value;sample/settings.txt 的内容是 package-value;sample/Anchor.class 也在这个输出根下。
两个文本内容故意不同,所以“找到了一个文件”还不够,必须检查读到的究竟是哪一份。程序对每个调用同时核对内容,而不是只判断返回流不为 null。
第一遍直接从生成的目录加载;第二遍把类文件与两份文本装进真正的 fixture.jar,再用新的加载器执行同一组调用。看到 JAR 中的结果后,才说明这次例子也在打包场景验证过。
Class 入口:没有前导斜杠时,先加上这个类的包路径
对 sample.Anchor 的 Class 对象调用 getResourceAsStream("settings.txt"),Class 会构造 sample/settings.txt 这个资源名,再交给相应加载机制。因此得到 package-value。
调用 getResourceAsStream("/settings.txt") 时,Class 去掉开头的 /,使用从资源根开始的 settings.txt,得到 root-value;调用 "/sample/settings.txt" 则显式指定完整资源名,仍得到 package-value。
这里所谓绝对资源名指类路径或相应加载器资源空间中的根,不是 Windows 盘符根,也不是当前终端工作目录。不要把磁盘路径的含义直接套进这个参数。
ClassLoader 入口:直接给出从资源根开始的名称
loader.getResourceAsStream("sample/settings.txt") 读取包内资源;loader.getResourceAsStream("settings.txt") 读取根资源。加载器没有 sample.Anchor 这个调用目标的包来替你补前缀。
本文的 URLClassLoader 对 "/sample/settings.txt" 返回 null,目录与 JAR 两次实跑都是如此。Class 的前导斜杠转换规则不能照搬到 ClassLoader 入口;先把你实际调用的是哪一个 API 写清楚。
资源名使用 / 分隔。本文没有把反斜杠、磁盘绝对路径、相对目录跳转或网址作为可移植资源名来演示,也不依赖某个系统偶然接受非规范写法。
返回 null 时,先保留“没取得资源”这个事实
Class:missing.txt 输出 <missing>。这是 read 辅助函数把 null 显式转成教学标签;它没有把一个缺失流传给 InputStreamReader,等更后面的空指针异常再反推原因。
本例资源全部由代码生成,加载器的父级选择平台类加载器,避免继承当前应用类路径里的同名文本。anchor.getClassLoader()、未命名模块与资源 URL 的 file/jar 协议也有检查,确认使用了预期的测试位置。
真实系统返回 null 不必然只因路径拼错:资源可能没打进包、加载器搜索范围不同,命名模块还存在封装规则。这里用受控目录排除这些因素,以便只观察命名起点,不能把所有线上缺失都简化成加减一个斜杠。
运行一个 Main.java:无需另外准备资源文件
需要完整 JDK 17 或以上,且包含 jdk.compiler;主程序运行中通过 ToolProvider 取得编译器,编译它自己写出的极小 sample.Anchor。精简运行时若没有编译器,会得到明确错误。
程序使用 Files.createTempDirectory 创建自己的目录,仅在该目录下生成测试源码、类文件、文本和 JAR;不读取用户配置,不访问网络。流和 URLClassLoader 都通过 try-with-resources 关闭,清理函数先确认每个目标仍在这次创建的目录里。
2026-10-23,在 Windows、Microsoft OpenJDK 17.0.20.1 与 21.0.12 分别以 --release 17 编译运行;每版 22 项检查,stdout 统一换行后相同。末尾 temporary-fixture-removed=true 也经过断言确认。
展开收起代码 · 小实验
长行可在代码区域内左右滚动,按 Tab 可把焦点移入代码区。
import java.io.IOException;
import java.io.InputStream;
import java.net.URL;
import java.net.URLClassLoader;
import java.nio.charset.StandardCharsets;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.Comparator;
import java.util.List;
import java.util.jar.JarEntry;
import java.util.jar.JarOutputStream;
import java.util.stream.Stream;
import javax.tools.JavaCompiler;
import javax.tools.ToolProvider;
public final class Main {
private static int checks;
private static void check(boolean ok, String message) {
checks++;
if (!ok) throw new AssertionError(message);
}
private static String read(InputStream stream) throws IOException {
if (stream == null) return "<missing>";
try (stream) {
return new String(stream.readAllBytes(), StandardCharsets.UTF_8);
}
}
private static void observe(String label, String name, InputStream stream, String expected) throws IOException {
String value = read(stream);
check(value.equals(expected), label + " " + name + " returned " + value);
System.out.println(label + " " + name + " -> " + value);
}
private static void inspect(String label, URL location) throws Exception {
// The platform parent avoids inheriting application class-path resources.
try (URLClassLoader loader = new URLClassLoader(new URL[]{location}, ClassLoader.getPlatformClassLoader())) {
Class<?> anchor = Class.forName("sample.Anchor", true, loader);
check(anchor.getClassLoader() == loader, "fixture loaded by the isolated loader");
check(!anchor.getModule().isNamed(), "fixture uses an unnamed module");
URL resource = anchor.getResource("settings.txt");
check(resource != null && resource.getProtocol().equals(label.equals("directory") ? "file" : "jar"),
"resource comes from the intended directory or JAR");
observe(label, "Class:settings.txt", anchor.getResourceAsStream("settings.txt"), "package-value");
observe(label, "Class:/settings.txt", anchor.getResourceAsStream("/settings.txt"), "root-value");
observe(label, "Class:/sample/settings.txt", anchor.getResourceAsStream("/sample/settings.txt"), "package-value");
observe(label, "Loader:sample/settings.txt", loader.getResourceAsStream("sample/settings.txt"), "package-value");
observe(label, "Loader:settings.txt", loader.getResourceAsStream("settings.txt"), "root-value");
observe(label, "Loader:/sample/settings.txt", loader.getResourceAsStream("/sample/settings.txt"), "<missing>");
observe(label, "Class:missing.txt", anchor.getResourceAsStream("missing.txt"), "<missing>");
}
}
private static void cleanOwnDirectory(Path root) throws IOException {
Path boundary = root.toAbsolutePath().normalize();
try (Stream<Path> walk = Files.walk(boundary)) {
List<Path> paths = walk.sorted(Comparator.reverseOrder()).toList();
for (Path path : paths) {
Path normalized = path.toAbsolutePath().normalize();
if (!normalized.startsWith(boundary)) throw new IOException("Cleanup escaped its own temporary root");
Files.deleteIfExists(normalized);
}
}
}
public static void main(String[] args) throws Exception {
JavaCompiler compiler = ToolProvider.getSystemJavaCompiler();
if (compiler == null) throw new IllegalStateException("Run with a full JDK including jdk.compiler");
Path temp = Files.createTempDirectory("qinghuan-resource-demo-").toAbsolutePath().normalize();
try {
Path classes = Files.createDirectories(temp.resolve("classes"));
Path source = temp.resolve("Anchor.java");
Files.writeString(source, "package sample; public final class Anchor {}\n", StandardCharsets.UTF_8);
int result = compiler.run(null, null, null, "-encoding", "UTF-8", "--release", "17",
"-d", classes.toString(), source.toString());
check(result == 0, "fixture compilation succeeded");
Files.writeString(classes.resolve("settings.txt"), "root-value", StandardCharsets.UTF_8);
Files.writeString(classes.resolve("sample/settings.txt"), "package-value", StandardCharsets.UTF_8);
inspect("directory", classes.toUri().toURL());
Path archive = temp.resolve("fixture.jar");
try (JarOutputStream jar = new JarOutputStream(Files.newOutputStream(archive))) {
for (String name : List.of("sample/Anchor.class", "settings.txt", "sample/settings.txt")) {
jar.putNextEntry(new JarEntry(name));
Files.copy(classes.resolve(name), jar);
jar.closeEntry();
}
}
inspect("jar", archive.toUri().toURL());
} finally {
cleanOwnDirectory(temp);
}
check(!Files.exists(temp), "only the generated temporary fixture was removed");
System.out.println("temporary-fixture-removed=true");
System.out.println("PASS " + checks);
}
}
怎样运行:在独立练习文件夹中,依次执行下载区的命令。2026-10-23 使用 完整 JDK 17 或更新版本;实测 Windows / JDK 17.0.20.1 / 21.0.12 复跑这份源码并核对完整输出。单Main.java,需要完整JDK含jdk.compiler;每JDK22检查,在自建临时目录动态编译自有Anchor并测试目录与普通JAR资源;最终清理已验证。无网络,不读用户配置,未测命名模块或框架特殊加载器。
运行后对照结果
directory Class:settings.txt -> package-value
directory Class:/settings.txt -> root-value
directory Class:/sample/settings.txt -> package-value
directory Loader:sample/settings.txt -> package-value
directory Loader:settings.txt -> root-value
directory Loader:/sample/settings.txt -> <missing>
directory Class:missing.txt -> <missing>
jar Class:settings.txt -> package-value
jar Class:/settings.txt -> root-value
jar Class:/sample/settings.txt -> package-value
jar Loader:sample/settings.txt -> package-value
jar Loader:settings.txt -> root-value
jar Loader:/sample/settings.txt -> <missing>
jar Class:missing.txt -> <missing>
temporary-fixture-removed=true
PASS 22
逐行对照两组相同实验,不把“能打开”当成全部验证
directory 和 jar 各有七行。Class:settings.txt 是 package-value;Class:/settings.txt 是 root-value。这两行只换一个前导斜杠,却指向不同的合法资源。
Loader:sample/settings.txt 得到 package-value;Loader:/sample/settings.txt 则是 <missing>。这两行保持加载器不变,只改变资源名,说明不能因为 Class 入口允许斜杠就推断加载器入口也会替你去掉它。
两组结果相同,表示本次目录和 JAR 中的命名实验一致;并不说明资源在所有容器、模块或自定义加载器里都必然具有相同搜索范围。程序还检查了内容和容器协议,避免误读到外部同名资源。
把目录验证和打包验证都留下,再扩展到框架
如果 IDE 中能读取、打包后不能读取,先检查产物里是否真的包含资源,再核对类所属包、调用入口与资源名。这里的成功条件不是“源码目录里看得见文件”,而是实际运行时加载器能找到它。
本文生成的是普通 JAR 和未命名模块,用独立 URLClassLoader 载入。没有运行 Spring Boot 嵌套 JAR、应用服务器、自定义父子委派、模块 opens 配置或多发布版本资源选择。框架的资源抽象还要按其自己的接口验证。
这份测试编译的是主程序写死的自有类,不接受外部 Java 源码,也不是通用插件执行器。下载项目只需 Main.java;运行时生成的测试资源不属于用户需要手工维护的配置。
记住这三点
- Class 的相对资源名会加上该类包路径,前导 / 会改从资源根开始。
ClassLoader直接接收资源名,不按调用类补包路径;不要照搬 Class 的斜杠规则。- 返回
null先明确记录缺失,再分别核对资源打包、加载器范围与模块规则。
别把例子用过头
- 把资源根等同于磁盘根或终端当前目录。
- 更换 Class
/ClassLoader入口后照抄参数,却不核对实际内容。 - 只在 IDE 目录中验证,没有检查最终 JAR。
- 把任意
null都归因于资源名,忽略未打包或不可访问。
自己试一小步
同一个文件名在根目录和两个包里各有一份,你怎样让每个调用都能说明自己要的是哪一份?
- 先画出两个包和三份同名资源的布局,为三份文本写不同内容。
- 逐个列出 Class 相对名、Class 绝对名和
ClassLoader名称的预测结果。 - 在目录与普通 JAR 两种形式下执行相同调用,记录入口、参数、实际内容和缺失状态。
- 再移除一份资源重新打包,判断哪些调用应变化;不要通过读取源码目录来补回缺失的打包资源。
做到这些,就算完成
- 区分资源名称和文件系统路径。
- 同时核对内容身份与是否存在。
- 只声明实际验证过的打包方式和加载器。
查原始资料
想确认一个细节,先看这份资料对应的部分,不必从头读完所有文档。
- Java SE 17 Class.getResourceAsStream (新标签页)↗
Class 入口的包相对名称、前导斜杠转换、缺失与模块访问边界。
- Java SE 17 ClassLoader:资源查找 (新标签页)↗
资源名与查找范围、返回
null,以及命名模块相关规则。 - Java SE 17 URLClassLoader:目录、JAR 与关闭 (新标签页)↗
本实验实际从目录和普通 JAR 加载自有类与资源,并关闭加载器。
- Java SE 17 ToolProvider:运行时取得编译器 (新标签页)↗
临时测试类依赖完整 JDK 的
jdk.compiler;缺失时编译器可能为null。