qinghuan
← 回到知识探索

后端现场 / 可下载小项目

UTF-8 已读对,properties 里的 Windows 路径为什么还是变了?

同一份配置同时放中文和路径,分开观察字节解码与属性转义,再用标准 API 完成复杂键值的写回往返。

展开本文目录
  1. 下载完整练习包
  2. 先分开两个症状:中文乱码,路径也变了
  3. 第一层:谁把字节变成字符
  4. 第二层:字符已经正确,properties 仍会解释反斜线
  5. 程序生成配置时,让 store(Writer) 负责格式转义
  6. 运行主程序:12 项检查拆开解码与解析
  7. 运行写回程序:5 项检查确认四组复杂键值往返
  8. 不是所有“看起来非法的转义”都采用同一种处理
  9. 落到配置读写:成对指定编码,并明确文件格式边界
  10. 记住这些要点
  11. 别把例子用过头
  12. 自己试一小步
  13. 查原始资料
开始前,会这些就够了
  • 先读 IO:字节、字符、缓冲与资源关闭,知道字符流接收的是字符。
  • 会使用 Properties.setProperty/getProperty 和 try-with-resources。
  • 知道 Java 源码字符串本身有转义;这里还会经过 properties 格式的另一轮解析。

一份配置同时写了 name=中文 和 Windows 路径 C:\temp\logs。你改用 UTF-8 Reader 后,中文终于正常,但路径仍不对:反斜线消失,其中一处还成了制表符。

继续换编码不能解决第二个问题。下面让同一份字节分别通过两个 load 重载,再用可见标记展示字符差别;最后把复杂键值交给 Properties.store 写出,验证读取后能否完整还原。

把例子带到自己的电脑上

下载完整练习包

需要 JDK 17 或更新版本;2026-10-18 首次验证,2026-10-20 最终源码在 Windows / JDK 17.0.20.1 与 21.0.12 复跑。仅用标准库,无第三方依赖。仅使用标准库。实际运行日期、环境和范围见随包核验记录。下载 ZIP 后选择“全部提取”,进入能看到 README.md 的文件夹。

下载 ZIP · 7 个文件
包里有什么
  • PropertiesLayersDemo.java:教学源码
  • PropertiesLayersDemo.observed.stdout.txt:说明或核验记录
  • PropertiesRoundTripProbe.java:教学源码
  • PropertiesRoundTripProbe.observed.stdout.txt:说明或核验记录
  • README.md:说明或核验记录
  • verification.json:说明或核验记录
  • SHA256SUMS.txt:说明或核验记录

在这个文件夹打开 PowerShell,逐行运行

java -version
javac -version
javac -encoding UTF-8 --release 17 PropertiesLayersDemo.java
java -cp . PropertiesLayersDemo
javac -encoding UTF-8 --release 17 PropertiesRoundTripProbe.java
java -cp . PropertiesRoundTripProbe

解压到独立练习文件夹,在包含源码的目录打开终端,按下方顺序运行。示例运行记录不代表独立练习已完成;修改代码后需重新验证。

找不到命令或文件时,先按 README 检查环境和所在目录。文中标出的阅读时间只估算文章篇幅,安装环境和动手练习可按自己的节奏进行。

先分开两个症状:中文乱码,路径也变了

实际 properties 文本中,路径的每个目录分隔符只有一个反斜线。示例用字符编号 92 拼出反斜线,再与 temp、logs 拼接;这样不用先猜 Java 字符串字面量里写了几层斜线。

相同 UTF-8 字节传给 load(InputStream),中文变成另一组字符;传给显式 UTF-8 的 InputStreamReader 后,中文正确还原。但两种调用中的 path 都得到 C:、一个制表符和 emplogs。

输出用 U+4E2D、U+6587 表示中文的两个码元,用 <TAB> 表示一个真实制表符。路径输出里的双反斜线则是 visible 方法的显示方式;不要把显示标记当成实际属性值。

第一层:谁把字节变成字符

Properties.load(InputStream) 有固定约定:把每个字节按 ISO 8859-1,也就是 Latin-1,解释成字符。它不会因为输入本来采用 UTF-8 就自动识别中文。

原型把中文先编码成 UTF-8,然后检查 byte 重载读到的结果,确认它恰好等于“这些 UTF-8 字节再按 Latin-1 解码”的字符串。这比只凭终端上看起来像乱码更能定位问题。

load(Reader) 接收的是已经解码好的字符。使用 new InputStreamReader(in, StandardCharsets.UTF_8),就把 UTF-8 解码明确交给了 Reader;如果文件确实采用 UTF-8,这一步能得到期望的中文。

固定版本的 OpenJDK Properties 实现也能对应上:字节入口把 byte 与 0xFF 做与运算后转成 char,Reader 入口直接取字符;两个入口随后进入共同的属性解析流程。

第二层:字符已经正确,properties 仍会解释反斜线

当 path 的原始字符是 C:\temp\logs,属性解析器把反斜线 t 当成一个制表符。反斜线 l 不是这里定义的特殊转义,但方法也不会因此报错,而是丢掉反斜线、保留 l。最终就得到 C:<TAB>emplogs。

因此 UTF-8 Reader 只能改变前面的解码步骤,不能关闭后面的 properties 语法。在实际 properties 文本中把每个反斜线写成两个,解析后才得到一个字面反斜线。

主程序的 escapedText 使用 replace 把每个原始反斜线加倍,作为单一问题的对照。它说明路径为什么恢复,却不是一个完整的 properties 写出器;复杂键名还可能包含空格、冒号、等号和注释标记。

程序生成配置时,让 store(Writer) 负责格式转义

更完整的流程是:把业务需要的原始 String 键值放入 Properties,用 OutputStreamWriter 明确选择 UTF-8,再调用 store(writer, null)。这样编码由 Writer 决定,properties 格式所需的转义交给库方法。

补充实验准备四组数据:普通路径、含中文和反斜线的键、带冒号与 # 的键、前导或尾随空格,以及值末尾的反斜线。先写成 UTF-8 byte[],再经 UTF-8 Reader 调用 load,比较原始键集合与每个值。

四组键值完整还原。这个建议的依据是 API 的格式契约加上本次往返实测:避免只补反斜线,却漏掉键里的语法字符或值开头的空格。不要先把业务值手工转义一遍,再交给 store 重复处理。

store(Writer) 文档还区分了空格位置:键中的空格需要转义;值的前导空格需要转义,内部和尾随空格不按同一规则处理。#、!、=、: 也有写出规则,手工拼完整配置比单独修一条路径复杂。

运行主程序:12 项检查拆开解码与解析

主程序的输入全部在内存里创建,不读取用户配置。它比较两个 load 重载、原始路径和加倍反斜线的路径,还核对 ASCII Unicode 转义及畸形 Unicode 输入。

2026 年 10 月 18 日在 Windows、Microsoft OpenJDK 17.0.20.1 与 21.0.12 上分别编译运行;2026 年 10 月 20 日用相同两套 JDK 按本页最终源码复跑,每个版本仍通过 12 项检查,输出一致。PASS 12 不包含下一节的写回实验。

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

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

小实验 · java
import java.io.ByteArrayInputStream;
import java.io.IOException;
import java.io.InputStreamReader;
import java.io.StringReader;
import java.nio.charset.StandardCharsets;
import java.util.Objects;
import java.util.Properties;

/** Teaching example. All fixtures are in memory and output is ASCII. */
public final class PropertiesLayersDemo {
    private static int checks;

    private static void check(boolean condition, String message) {
        checks++;
        if (!condition) throw new AssertionError(message);
    }

    private static Properties viaBytes(byte[] bytes) throws IOException {
        Properties p = new Properties();
        try (ByteArrayInputStream in = new ByteArrayInputStream(bytes)) {
            p.load(in);
        }
        return p;
    }

    private static Properties viaUtf8Reader(byte[] bytes) throws IOException {
        Properties p = new Properties();
        try (InputStreamReader reader = new InputStreamReader(
                new ByteArrayInputStream(bytes), StandardCharsets.UTF_8)) {
            p.load(reader);
        }
        return p;
    }

    private static Properties viaCharacters(String text) throws IOException {
        Properties p = new Properties();
        try (StringReader reader = new StringReader(text)) {
            p.load(reader);
        }
        return p;
    }

    private static String visible(String value) {
        StringBuilder out = new StringBuilder();
        for (int i = 0; i < value.length(); i++) {
            char c = value.charAt(i);
            if (c == '\\') out.append("\\\\");
            else if (c == '\t') out.append("<TAB>");
            else if (c < 32 || c > 126) out.append(String.format("U+%04X", (int) c));
            else out.append(c);
        }
        return out.toString();
    }

    public static void main(String[] args) throws IOException {
        String chinese = "\u4e2d\u6587";
        String slash = Character.toString((char) 92);
        String wantedPath = "C:" + slash + "temp" + slash + "logs";
        // Concatenating a backslash character separates Java literal syntax
        // from the actual properties text passed to load().
        String rawText = "name=" + chinese + "\npath=" + wantedPath + "\n";
        byte[] utf8 = rawText.getBytes(StandardCharsets.UTF_8);
        Properties byteLoaded = viaBytes(utf8);
        Properties readerLoaded = viaUtf8Reader(utf8);

        check(!Objects.equals(chinese, byteLoaded.getProperty("name")),
                "load(InputStream) does not decode UTF-8 Chinese");
        check(Objects.equals(new String(chinese.getBytes(StandardCharsets.UTF_8),
                        StandardCharsets.ISO_8859_1), byteLoaded.getProperty("name")),
                "The wrong result is exactly UTF-8 bytes interpreted as Latin-1");
        check(Objects.equals(chinese, readerLoaded.getProperty("name")),
                "Explicit UTF-8 decoding restores Chinese");

        String parsedRawPath = "C:" + '\t' + "emplogs";
        check(Objects.equals(parsedRawPath, byteLoaded.getProperty("path")),
                "Properties grammar consumes the raw path backslashes");
        check(Objects.equals(parsedRawPath, readerLoaded.getProperty("path")),
                "Choosing UTF-8 does not disable properties escapes");
        check(!Objects.equals(wantedPath, readerLoaded.getProperty("path")),
                "Correct decoding alone is insufficient for the path");

        String escapedText = "name=" + chinese + "\npath="
                + wantedPath.replace(slash, slash + slash) + "\n";
        Properties escaped = viaUtf8Reader(escapedText.getBytes(StandardCharsets.UTF_8));
        check(Objects.equals(chinese, escaped.getProperty("name")), "Escaped fixture retains Chinese");
        check(Objects.equals(wantedPath, escaped.getProperty("path")),
                "Two backslashes in properties text produce one literal backslash");

        String asciiText = "name=" + slash + "u4e2d" + slash + "u6587\n";
        check(Objects.equals(chinese, viaBytes(asciiText.getBytes(StandardCharsets.US_ASCII))
                .getProperty("name")), "ASCII Unicode escapes work with byte overload");
        check(Objects.equals(chinese, viaUtf8Reader(asciiText.getBytes(StandardCharsets.US_ASCII))
                .getProperty("name")), "ASCII Unicode escapes work with Reader overload");
        check(Objects.equals("qb", viaCharacters("value=" + slash + "q" + slash + "b\n")
                .getProperty("value")), "Unknown escape and backslash-b drop the backslash");

        boolean rejected = false;
        try {
            viaCharacters("value=" + slash + "u12G4\n");
        } catch (IllegalArgumentException expected) {
            rejected = true;
        }
        check(rejected, "Malformed Unicode escape is rejected, unlike unknown ordinary escapes");

        System.out.println("input.raw-path=" + visible(wantedPath));
        System.out.println("bytes.name=" + visible(byteLoaded.getProperty("name")));
        System.out.println("utf8-reader.name=" + visible(readerLoaded.getProperty("name")));
        System.out.println("bytes.raw-path=" + visible(byteLoaded.getProperty("path")));
        System.out.println("utf8-reader.raw-path=" + visible(readerLoaded.getProperty("path")));
        System.out.println("utf8-reader.escaped-path=" + visible(escaped.getProperty("path")));
        System.out.println("ascii-unicode.name=" + visible(viaBytes(
                asciiText.getBytes(StandardCharsets.US_ASCII)).getProperty("name")));
        System.out.println("malformed-unicode.rejected=" + rejected);
        System.out.println("PASS " + checks);
    }
}

怎样运行:保存为 PropertiesLayersDemo.java,在该目录先单独执行 javac -encoding UTF-8 --release 17 PropertiesLayersDemo.java;编译成功后另起一条命令执行 java -cp . PropertiesLayersDemo。2026-10-18 首次在 Windows、Microsoft OpenJDK 17.0.20.1 和 21.0.12 分别编译运行;2026-10-20 按本页最终源码在两套 JDK 上复跑,每个版本仍通过 12 项检查,stdout 与下方输出按统一换行后相同。夹具全部在内存;输出中的双反斜线、U+ 码元与 <TAB> 是显示规则,不是要复制进配置的文本。

运行后对照结果
input.raw-path=C:\\temp\\logs
bytes.name=U+00E4U+00B8U+00ADU+00E6U+0096U+0087
utf8-reader.name=U+4E2DU+6587
bytes.raw-path=C:<TAB>emplogs
utf8-reader.raw-path=C:<TAB>emplogs
utf8-reader.escaped-path=C:\\temp\\logs
ascii-unicode.name=U+4E2DU+6587
malformed-unicode.rejected=true
PASS 12

运行写回程序:5 项检查确认四组复杂键值往返

这份程序独立运行:一项检查原始键集合,另外四项逐个检查值。它使用完整的原始 byte[] 重新加载;不会先删注释、排序或清理文本再送入 load。

为了让人容易比较两次 stdout,显示 stored-data 时才去掉注释行并排序,空格显示为 <SP>。实际 store 输出含日期注释,因此不能要求整份文件在不同时刻或不同 JDK 下字节相同。

默认无参数运行只在内存中往返。需要检查完整写出内容时,可额外传入自己新建的专用输出文件路径;程序会将完整 UTF-8 字节写入该路径。请选择尚不存在的文件,避免覆盖现有配置。

2026 年 10 月 18 日和 10 月 20 日,两套 JDK 每次各通过 5 项检查,语义诊断输出一致。10 月 20 日使用下方无参命令复跑。主程序为 12 项,写回程序独立为 5 项;两份都运行后,每个 JDK 共 17 项检查。

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

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

小实验 · java
import java.io.ByteArrayInputStream;
import java.io.ByteArrayOutputStream;
import java.io.InputStreamReader;
import java.io.OutputStreamWriter;
import java.nio.charset.StandardCharsets;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.Map;
import java.util.Objects;
import java.util.Properties;
import java.util.TreeMap;

/** Independent boundary checks; creates only an optional explicitly named output file. */
public final class PropertiesRoundTripProbe {
    private static int checks;
    private static void check(boolean condition, String reason) {
        checks++;
        if (!condition) throw new AssertionError(reason);
    }
    private static String visible(String value) {
        StringBuilder out = new StringBuilder();
        for (int i = 0; i < value.length(); i++) {
            char c = value.charAt(i);
            if (c == '\\') out.append("\\\\");
            else if (c == ' ') out.append("<SP>");
            else if (c < 32 || c > 126) out.append(String.format("U+%04X", (int) c));
            else out.append(c);
        }
        return out.toString();
    }
    public static void main(String[] args) throws Exception {
        String slash = Character.toString((char) 92);
        Map<String, String> expected = new TreeMap<>();
        expected.put("path", "C:" + slash + "temp" + slash + "logs");
        expected.put(" \u914d\u7f6e" + slash + "\u8def\u5f84:key #= ",
                "  C:" + slash + "\u4e2d\u6587\u76ee\u5f55" + slash + "file name:#=!.txt  ");
        expected.put("#leading:key", "#literal: value " + slash);
        expected.put("\u4e2d\u6587\u952e", "\u4e2d\u6587\u503c # : = " + slash);
        Properties original = new Properties();
        expected.forEach(original::setProperty);

        ByteArrayOutputStream bytes = new ByteArrayOutputStream();
        try (OutputStreamWriter writer = new OutputStreamWriter(bytes, StandardCharsets.UTF_8)) {
            // The API escapes the properties grammar; the Writer controls encoding.
            original.store(writer, null);
        }
        byte[] encoded = bytes.toByteArray();
        Properties reloaded = new Properties();
        try (InputStreamReader reader = new InputStreamReader(
                new ByteArrayInputStream(encoded), StandardCharsets.UTF_8)) {
            reloaded.load(reader);
        }
        check(reloaded.stringPropertyNames().equals(expected.keySet()),
                "All original keys, including spaces and syntax characters, must survive");
        for (Map.Entry<String, String> entry : expected.entrySet()) {
            check(Objects.equals(entry.getValue(), reloaded.getProperty(entry.getKey())),
                    "Value changed for " + visible(entry.getKey()));
            System.out.println("roundtrip[" + visible(entry.getKey()) + "]="
                    + visible(reloaded.getProperty(entry.getKey())));
        }

        // store(Writer) inserts a date comment. Keep the complete bytes for saving;
        // omit comments and sort data lines only for stable diagnostic output.
        String serialized = new String(encoded, StandardCharsets.UTF_8);
        serialized.lines().filter(line -> !line.startsWith("#") && !line.startsWith("!"))
                .sorted().forEach(line -> System.out.println("stored-data=" + visible(line)));
        if (args.length == 1) Files.write(Path.of(args[0]), encoded);
        System.out.println("full-stored-bytes-are-not-a-deterministic-format");
        System.out.println("PASS " + checks);
    }
}

怎样运行:保存为 PropertiesRoundTripProbe.java,在该目录先单独执行 javac -encoding UTF-8 --release 17 PropertiesRoundTripProbe.java;编译成功后另起一条命令执行 java -cp . PropertiesRoundTripProbe。2026-10-18 首次在 Windows、Microsoft OpenJDK 17.0.20.1 和 21.0.12 分别编译运行;2026-10-20 按本页最终源码和无参命令在两套 JDK 上复跑,每个版本仍通过 5 项检查,stdout 与下方输出按统一换行后相同。无参命令只在内存里往返;需要保存完整 store 字节时,额外传入一个自己新建的专用输出文件路径。

运行后对照结果
roundtrip[<SP>U+914DU+7F6E\\U+8DEFU+5F84:key<SP>#=<SP>]=<SP><SP>C:\\U+4E2DU+6587U+76EEU+5F55\\file<SP>name:#=!.txt<SP><SP>
roundtrip[#leading:key]=#literal:<SP>value<SP>\\
roundtrip[path]=C:\\temp\\logs
roundtrip[U+4E2DU+6587U+952E]=U+4E2DU+6587U+503C<SP>#<SP>:<SP>=<SP>\\
stored-data=\\<SP>U+914DU+7F6E\\\\U+8DEFU+5F84\\:key\\<SP>\\#\\=\\<SP>=\\<SP><SP>C\\:\\\\U+4E2DU+6587U+76EEU+5F55\\\\file<SP>name\\:\\#\\=\\!.txt<SP><SP>
stored-data=\\#leading\\:key=\\#literal\\:<SP>value<SP>\\\\
stored-data=path=C\\:\\\\temp\\\\logs
stored-data=U+4E2DU+6587U+952E=U+4E2DU+6587U+503C<SP>\\#<SP>\\:<SP>\\=<SP>\\\\
full-stored-bytes-are-not-a-deterministic-format
PASS 5

不是所有“看起来非法的转义”都采用同一种处理

普通未知转义和 Unicode 转义要分开判断。主程序把反斜线 q、反斜线 b 拼进属性值,实际得到 qb;properties 中的反斜线 b 并不表示退格。

如果写的是 Unicode 转义前缀,但后面的四位内容不合法,例如包含非十六进制字符,load 会抛 IllegalArgumentException。本例用 u12G4 的形式验证这一点。不能因为普通未知转义会吞掉反斜线,就认为所有错误输入都被默默接受。

反过来,使用 ASCII 字符写出的合法 Unicode 转义,在这两个 load 重载中都能还原中文。这个对照说明“源文件是否含中文原字符”和“最终属性值能否出现中文”也是两件事。

落到配置读写:成对指定编码,并明确文件格式边界

排查时先拿到真实输入字节与实际调用的重载。确认字符是否正确后,再检查 properties 对键和值的解析;不要把两层问题都归为“乱码”。

生成新配置时,使用 setProperty、显式 UTF-8 Writer 和 store;读取时使用匹配的 UTF-8 Reader。store 会刷新 Writer,但不会替调用者关闭它,资源仍由 try-with-resources 管理。

这个往返只保证示例键值的语义,不承诺保留旧文件的排版、注释和条目顺序。官方还说明 defaults 表的内容不会随 store 写出;本次没有使用 defaults,也未测试非 String 条目。

本文范围仅是 java.util.Properties 的普通行格式。不把结论直接套到 ResourceBundle、Spring Boot、YAML、XML 或 IDE 设置;BOM、重复键、续行、非法 UTF-8 字节策略与真实路径是否存在,都没有在本次验证。

记住这些要点

  • 字节解码与 properties 转义解析是两步;UTF-8 Reader 只明确前一步。
  • load(InputStream) 使用 Latin-1 约定,不能用它直接识别任意 UTF-8 中文。
  • 原始反斜线路径可能变成控制字符或丢反斜线;输出显示层又是另一层表示。
  • 程序生成复杂配置时,让 store(Writer) 处理格式转义,并用匹配编码的 Reader 做键值往返验证。

别把例子用过头

  • 修好中文后就认定整个属性值一定原样保留。
  • 把 Java 字符串字面量、实际 properties 文本与诊断输出中的斜线数量混在一起。
  • 把只替换反斜线的示范当成完整配置序列化器,或者先手工转义后再次交给 store。
  • 用整个文件字节是否一致判断 store/load 语义,忽略日期注释和条目顺序。

自己试一小步

如果配置里还有制表符、换行、空键或空值,怎样验证写回后仍得到最初的键和值?

  1. 自己选择至少四组新键值,覆盖上面提到的边界;先写下字符序列和长度,而不只看显示文字。
  2. 为每组数据分别预测直接拼 properties 文本与通过 store(Writer) 写出的差异,暂时不要运行。
  3. 使用独立 Properties 和内存字节流完成 UTF-8 写回、读取;比较键集合、每个值以及字符串长度。
  4. 为不可见字符设计诊断显示,再核对这个显示函数有没有改变被测试的数据。
  5. 提交预测、实际输出、差异解释和资源关闭方式;不要只提交一张终端截图。

做到这些,就算完成

  • 原始业务值、序列化后的文本和诊断显示分别保存,不重复手工转义。
  • Writer 与 Reader 的编码都明确指定。
  • 比较实际键值,不把日期注释或键顺序当成失败。
  • 只使用自己创建的内存输入或专用测试文件,不修改现有应用配置。

查原始资料

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

  • Java SE 17 Properties API (新标签页)↗Oracle Java 官方文档 · 英文

    选择 Reader 改变解码来源但不关闭 properties 转义,未知普通转义会丢弃反斜线。

    2026-10-18 已实际打开并核读:类说明中的字符/字节重载区别;load(Reader) 的转义差异与畸形 Unicode 异常;load(InputStream) 的 ISO 8859-1 契约。 补充阅读 store(Writer, String) 的完整方法说明:格式转义、defaults 不写出、日期注释和刷新但不关闭。 未声称通读整个文档或源码文件。

  • Java SE 17 InputStreamReader API (新标签页)↗Oracle Java 官方文档 · 英文

    显式传 StandardCharsets.UTF_8 固定字节解码步骤。

    2026-10-18 已实际打开并核读:类说明中从字节解码为字符的职责,以及接收 Charset 的构造器。 未声称通读整个文档或源码文件。

  • OpenJDK jdk-17+35 Properties.java (新标签页)↗OpenJDK 官方仓库 · 英文

    两个入口共享属性解析流程;字节分支逐字节取 Latin-1 字符,后续 loadConvert 才处理反斜线。

    2026-10-18 已实际打开并核读:load(Reader)/load(InputStream) 到 load0 的调用;LineReader 的 byte & 0xFF 与 Reader 字符分支;loadConvert 的 Unicode、t/r/n/f 和其他字符分支(源文件约 367–493 与 598–649 行)。 未声称通读整个文档或源码文件。