qinghuan
← 回到知识探索

后端现场 / 可下载小项目

资源路径前多一个斜杠,为什么换个 API 就读不到配置?

在自建目录与 JAR 中分别放入根目录、包内的同名资源,核对 Class 与 ClassLoader 从哪里开始查找。

展开本文目录
  1. 下载完整练习包
  2. 先动手看看
  3. 先把两份同名资源摆出来,不靠猜文件在哪
  4. Class 入口:没有前导斜杠时,先加上这个类的包路径
  5. ClassLoader 入口:直接给出从资源根开始的名称
  6. 返回 null 时,先保留“没取得资源”这个事实
  7. 运行一个 Main.java:无需另外准备资源文件
  8. 逐行对照两组相同实验,不把“能打开”当成全部验证
  9. 把目录验证和打包验证都留下,再扩展到框架
  10. 记住这三点
  11. 别把例子用过头
  12. 自己试一小步
  13. 查原始资料
开始前,会这些就够了
遇到不熟悉的词,先看这 2 个解释
包相对资源名
从一个Class入口查找时,不带前导斜杠的名字先接在该类的包路径后面。
类加载器资源名
加载器按资源根开始的名称查找,不替调用方加类的包路径。
打开完整术语小抄 ↗

同样叫 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/settings.txt
本示例读到的内容
package-value

Class 在相对名之前加上 sample/,以调用类的包为起点。

固定 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 也经过断言确认。

展开收起代码 · 小实验java · 93 行

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

小实验 · java
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 都归因于资源名,忽略未打包或不可访问。

自己试一小步

同一个文件名在根目录和两个包里各有一份,你怎样让每个调用都能说明自己要的是哪一份?

  1. 先画出两个包和三份同名资源的布局,为三份文本写不同内容。
  2. 逐个列出 Class 相对名、Class 绝对名和 ClassLoader 名称的预测结果。
  3. 在目录与普通 JAR 两种形式下执行相同调用,记录入口、参数、实际内容和缺失状态。
  4. 再移除一份资源重新打包,判断哪些调用应变化;不要通过读取源码目录来补回缺失的打包资源。

做到这些,就算完成

  • 区分资源名称和文件系统路径。
  • 同时核对内容身份与是否存在。
  • 只声明实际验证过的打包方式和加载器。

查原始资料

想确认一个细节,先看这份资料对应的部分,不必从头读完所有文档。

  • Java SE 17 Class.getResourceAsStream (新标签页)↗Oracle Java 官方文档 · 英文

    Class 入口的包相对名称、前导斜杠转换、缺失与模块访问边界。

    2026-10-23 已实际搜索并打开正文,核读getResourceAsStream 的名称构造算法、委派及返回值说明;未声称通读整页或所有实现。

  • Java SE 17 ClassLoader:资源查找 (新标签页)↗Oracle Java 官方文档 · 英文

    资源名与查找范围、返回 null,以及命名模块相关规则。

    2026-10-23 已实际搜索并打开正文,核读getResource 与 getResourceAsStream 正文;未声称通读整页或所有实现。

  • Java SE 17 URLClassLoader:目录、JAR 与关闭 (新标签页)↗Oracle Java 官方文档 · 英文

    本实验实际从目录和普通 JAR 加载自有类与资源,并关闭加载器。

    2026-10-23 已实际搜索并打开正文,核读类说明、构造器、getResourceAsStream 与 close 正文;未声称通读整页或所有实现。

  • Java SE 17 ToolProvider:运行时取得编译器 (新标签页)↗Oracle Java 官方文档 · 英文

    临时测试类依赖完整 JDK 的 jdk.compiler;缺失时编译器可能为 null。

    2026-10-23 已实际搜索并打开正文,核读getSystemJavaCompiler 的返回值和 Implementation Note;未声称通读整页或所有实现。