字符编码与解码:从字节到文本的边界
结论基线:API 语义以 Java SE 17 为准,实现分析以 OpenJDK 17u
jdk-17.0.12+7为准。实验在本机 Oracle JDK 17.0.12(x86_64)实际运行。编码错误动作分两层:String构造与getBytes按设计替换(REPLACE),CharsetDecoder/CharsetEncoder默认报告(REPORT)——两层混用是"乱码为什么没报错"和"为什么到处是 U+FFFD"的根因。
编码动作:String 层替换,Decoder 层报告
同一份非法 UTF-8 字节 {0x61, 0x80, 0x62}(0x80 是非法起始字节),走两条路结果完全不同:
new String(bytes, StandardCharsets.UTF_8)得到"a\uFFFDb"——错误字节被替换为 U+FFFD(REPLACEMENT CHARACTER),不抛任何异常;CharsetDecoder.decode(...)(默认动作 REPORT)在便利方法里抛MalformedInputException,在decode(in, out, endOfInput)形式里返回CoderResult.isMalformed()。
为什么 new String 永远不抛 CharacterCodingException?看源码——先走 ASCII 快路径:
原码连续摘录(仅统一公共缩进,截取方法局部):String.java,jdk-17.0.12+7,L612–L623。
if (cd instanceof ArrayDecoder ad) {
// ascii
if (ad.isASCIICompatible() && !StringCoding.hasNegatives(bytes, offset, length)) {
if (COMPACT_STRINGS) {
this.value = Arrays.copyOfRange(bytes, offset, offset + length);
this.coder = LATIN1;
return;
}
this.value = StringLatin1.inflate(bytes, offset, length);
this.coder = UTF16;
return;
}纯 ASCII 输入根本不进入解码循环——hasNegatives 检查没有字节 ≥ 0x80 就直接按 LATIN1 复制。所以"纯 ASCII 永远不乱码"是快路径保证;带高位字节的输入才落到解码器,而那里错误动作被显式设置成替换:
原码连续摘录(仅统一公共缩进,截取方法局部):String.java,jdk-17.0.12+7,L652–L668。
// decode using CharsetDecoder
int en = scale(length, cd.maxCharsPerByte());
cd.onMalformedInput(CodingErrorAction.REPLACE)
.onUnmappableCharacter(CodingErrorAction.REPLACE);
char[] ca = new char[en];
if (charset.getClass().getClassLoader0() != null &&
System.getSecurityManager() != null) {
bytes = Arrays.copyOfRange(bytes, offset, offset + length);
offset = 0;
}
int caLen;
try {
caLen = decodeWithDecoder(cd, ca, bytes, offset, length);
} catch (CharacterCodingException x) {
// Substitution is enabled, so this shouldn't happen
throw new Error(x);三行关键信息:onMalformedInput(REPLACE) + onUnmappableCharacter(REPLACE) 是硬编码的;即使替换已启用,仍保留对 CharacterCodingException 的防御,捕获后抛 Error——注释写明"替换已启用,理论上不会发生"。String.getBytes 对称地用 ? 替换不可映射字符:"汉".getBytes(ISO_8859_1) 实测得到 {0x3F}(?)。
要拒绝坏数据而不是默默替换,必须自己拿 decoder/encoder 并把动作设回 REPORT(这也是 JDK 里很多网络协议栈、配置解析器的做法):
CharsetDecoder strict = StandardCharsets.UTF_8.newDecoder(); // 默认动作就是 REPORT
CharBuffer out = strict.decode(ByteBuffer.wrap(bytes)); // 便利方法遇错抛 MalformedInputException
// 或非抛出形式:CoderResult r = decoder.decode(in, out, true); r.isMalformed() && r.length()==1恢复路径:捕获 MalformedInputException 后 getInputLength() 给出坏序列长度(本例 1),跳过或修复后继续解码;也可以对同一份数据跑一次 REPLACE 解码做"带损预览"。两类名称异常要分开:Charset.forName("!!!") 抛 IllegalCharsetNameException(名字本身不合法),Charset.forName("not_a_registered_charset") 抛 UnsupportedCharsetException(名字合法但没有注册)——前者改名字,后者换字符集或注册 provider,处置不同。
一个版本事实:jdk17u 仓库里没有独立的 CharsetDecoder.java/CharsetEncoder.java 源文件,两者由 Charset-X-Coder.java.template 在构建期生成(2026-10-25 查证)。所以本文引用的是 String.java/Charset.java 的真实源码与 API 文档,不给出指向生成物的行号。
默认字符集:file.encoding、平台与 JEP 400
所有不带 charset 参数的 API(new String(byte[])、getBytes()、InputStreamReader(InputStream)、FileReader(File)、System.out)都落在默认字符集上。它在 JDK 17 里这样决定:
原码连续摘录(仅统一公共缩进,截取方法局部):Charset.java,jdk-17.0.12+7,L592–L616。
/**
* Returns the default charset of this Java virtual machine.
*
* <p> The default charset is determined during virtual-machine startup and
* typically depends upon the locale and charset of the underlying
* operating system.
*
* @return A charset object for the default charset
*
* @since 1.5
*/
public static Charset defaultCharset() {
if (defaultCharset == null) {
synchronized (Charset.class) {
String csn = GetPropertyAction
.privilegedGetProperty("file.encoding");
Charset cs = lookup(csn);
if (cs != null)
defaultCharset = cs;
else
defaultCharset = sun.nio.cs.UTF_8.INSTANCE;
}
}
return defaultCharset;
}三个条件分支:读 file.encoding → 查不到(值非法/未知)时回退 UTF-8 → 结果缓存为 volatile 静态字段。所以"file.encoding 永远生效"是无条件断言的反例:显式 -Dfile.encoding=bogus 时得到的是 UTF-8,不是崩溃也不是 bogus。
版本断层是本节的核心边界:JDK 18 起(JEP 400)默认字符集无条件是 UTF-8,不再跟随平台 locale;JDK 17 及之前跟随操作系统。同一段 new FileReader(f) 代码:在中文 Windows(GBK 区域设置)+ JDK 17 读 UTF-8 文件会乱码(U+FFFD 或"锟斤拷"),同一台机器 + JDK 21 则正常。U12 已在本机(macOS)用 LC_ALL=C 把这一断层做成了三档可复现对照(labs/modern-java/run.sh jep400,固定源码 jdk-21.0.5+9):JDK 17 下 defaultCharset() 平台推导为 US-ASCII、无 charset 的 FileReader 把 UTF-8 文件解成乱码;JDK 21 下 defaultCharset() 恒为 UTF-8、FileReader 解码正确,而 native.encoding 仍如实报告 US-ASCII——平台字符集还在被报告,只是不再被用作默认;-Dfile.encoding=COMPAT 显式恢复 17 行为后乱码回归。JDK 21 的规范依据在 Charset.defaultCharset() 的 javadoc/implNote:
原码连续摘录(仅统一公共缩进,截取方法局部):Charset.java,jdk-21.0.5+9,L641–L668。
* <p> The default charset is {@code UTF-8}, unless changed in an
* implementation specific manner.
*
* @implNote An implementation may override the default charset with
* the system property {@code file.encoding} on the command line. If the
* value is {@code COMPAT}, the default charset is derived from
* the {@code native.encoding} system property, which typically depends
* upon the locale and charset of the underlying operating system.
*
* @return A charset object for the default charset
* @see <a href="../../lang/System.html#file.encoding">file.encoding</a>
* @see <a href="../../lang/System.html#native.encoding">native.encoding</a>
*
* @since 1.5
*/
public static Charset defaultCharset() {
if (defaultCharset == null) {
synchronized (Charset.class) {
// do not look for providers other than the standard one
Charset cs = standardProvider.charsetForName(StaticProperty.fileEncoding());
if (cs != null)
defaultCharset = cs;
else
defaultCharset = sun.nio.cs.UTF_8.INSTANCE;
}
}
return defaultCharset;
}javadoc 契约是"默认 UTF-8",file.encoding 覆盖属于 @implNote(实现备注,非规范承诺);JEP 400 明文规定只有 UTF-8 与 COMPAT 两个值有定义行为,其他值(如 GBK)"未指定"。规则只有一条:跨进程、跨机器、跨版本的文本读写永远显式传 charset(Files.newBufferedReader(path, UTF_8)、new String(bytes, UTF_8));JDK 11+ 的 Files.readString/writeString 默认 UTF-8,与其他无参 API 不一致,依赖它时要意识到这是 Files 的默认而不是全局默认。
长度单位:code unit、code point 与代理对
String.length() 数的是 UTF-16 code unit,不是"字符数"。"a汉😀":a=1、汉=1、😀=2(代理对),length()==4 而 codePointCount(0,4)==3。两类高频事故都从单位混淆来:
截断事故:substring(0, 1) 切在代理对中间得到孤立高代理项(lone surrogate)。实测 lone.getBytes(UTF_8) 解回来是 "?"——编码层把孤立代理替换成 ?,数据静默损坏且不可逆。正确截断用 offsetByCodePoints 定位边界;遍历用 codePoints() 而不是 charAt 循环。
BOM 残留:UTF-8 文件以 EF BB BF 开头时,解码后文本第一个字符是 \uFEFF(长度 1),不是空串。"abc" 文件读出来 length()==4、首字符是 U+FEFF——JSON 解析器报"unexpected token"、startsWith 判断失效都是它。Java 标准 API 不自动剥离 BOM,责任在调用方:读入后判断首字符是 \uFEFF 则 substring(1)。
反例都实测过:new String(withBom, UTF_8) 得到 "\uFEFFabc"(长度 4);孤立代理经 UTF-8 往返退化为 ?。字节不是字符、code unit 不是 code point——这三个单位在协议、存储和日志里经常被混写,评审时先问"这里数的是哪个单位"。
设计案例:多渠道文案下发的编码契约(算例,非实测)
负载假设:内容中心每天 2 万条文案下发到 App、短信网关(GBK)和邮件(UTF-8)三个渠道;短信单条 70 字上限按 code point 计费;App 端展示 UTF-8。不变量:入库文本是规范化的 UTF-16(无孤立代理项);短信截断处不落在代理对中间;各渠道出口字节可无损往返(除显式降级)。
选择 1:入库即校验 + 渠道各自编码。 入库时用 CharsetDecoder(REPORT) 全量校验拒绝坏字节与孤立代理(Character.isSurrogate 扫描),短信出口按 code point 截断并统计(codePointCount/offsetByCodePoints),GBK 渠道遇不可映射字符走人工降级清单而不是静默 ?。成本:入库多一次解码扫描(2 万条/天远不是瓶颈);恢复责任:坏数据在源头被拒绝、作者改稿重传,出口端永不面对无法编码的输入。
选择 2:信任上游 + 出口尽力替换。 不做入库校验,出口用 String.getBytes(GBK)(REPLACE)静默替换。成本最低,但 ? 混入短信无法计费对账,孤立代理静默变 ? 无法追溯哪条文案损坏;恢复责任漂移到客服与对账环节。两条路都能跑,差别在坏数据在哪里被发现、由谁修。算例不是实测吞吐,容量数字仅用于说明校验成本可忽略。
条件变化与边界
| 条件变化 | 需要复核的点 |
|---|---|
| 升级 JDK 18+ | JEP 400 后默认字符集恒为 UTF-8;曾经"恰好能用"的无参 API 行为改变,跨版本混部时入出口必须统一显式 charset |
-Dfile.encoding 显式设置 | 非法值回退 UTF-8(源码分支),不抛异常;不能假设"设置了就一定生效" |
| 换操作系统/locale | JDK 17 及以前默认字符集跟随平台(Windows GBK 区域 ≠ macOS UTF-8);本机观察值不外推 |
| 数据源含 BOM | \uFEFF 残留在文本首部;剥离是调用方责任,标准 API 不代劳 |
| 截断/按位处理文本 | code unit 截断可产生孤立代理项,经编码往返退化为 ?;用 code point API |
| 需要拒绝坏数据 | String 层做不到(REPLACE 硬编码);自己持 decoder/encoder 设 REPORT |
实验:输入、步骤、预期与边界
实验入口:labs/java-senior/run.sh io(EncodingIoProbe.java)。
cd <仓库根>
JAVA_HOME=/Library/Java/JavaVirtualMachines/jdk-17.jdk/Contents/Home \
bash labs/java-senior/run.sh ioU06 关键实测(Oracle JDK 17.0.12,x86_64;完整命令、stdout/stderr、退出状态与红绿记录见本批证据):
PASS encoding: String replace U+FFFD, decoder REPORT/REPLACE split, malformed len=1, ISO-8859-1 ? vs REPORT, illegal vs unsupported name
OBSERVE defaultCharset=UTF-8 file.encoding=UTF-8(JDK17 平台推导;JDK18 起 UTF-8 默认,本行是观察不是契约)
PASS text units: length=4 codePoints=3 for a汉😀, lone surrogate degrades to ?, BOM \uFEFF survives decode and must be stripped by caller场景与预期:非法 0x80 经 new String 得 a\uFFFDb 不抛异常;默认 decoder 的三参 decode 返回 isMalformed() 且 length()==1;REPORT 便利方法抛 MalformedInputException(1);"汉"→ISO-8859-1 得 ? 而 REPORT encoder 抛 UnmappableCharacterException;!!! 与 not_a_registered_charset 分别抛 IllegalCharsetNameException/UnsupportedCharsetException;"a汉😀" 的 length/codePointCount/codePoints().count() 为 4/3/3;孤立代理 UTF-8 往返得 ?;带 BOM 字节解码首字符 \uFEFF 长度 4。负例(未知模式/多余参数/坏 JAVA_HOME/JDK21)统一退出 2。
边界:实验只覆盖所列输入;"本机 defaultCharset=UTF-8"是单机观察,Windows/其他 locale 未实跑;JDK 18+ 行为按 JEP 400 规范推导,未在 21 上重复本实验(run.sh 版本守卫固定 17)。JEP 400 断层的 JDK 17/21 双机对照实测已由 U12 承接完成:labs/modern-java/run.sh jep400 三档对照(17 平台推导乱码 / 21 无条件 UTF-8 正确 / 21+COMPAT 乱码回归)见现代 Java 实验入口与本节上方 U12 增补段。
追问链
第一层:为什么 new String 不直接抛异常?
String 的定位是"尽力给出可显示文本":大多数调用方(日志、拼接、展示)宁要替换字符也不要异常,所以 REPLACE 被硬编码进构造与 getBytes;需要严格性的场景(协议、配置、校验)自己持 decoder 设 REPORT。设计取舍的代价是"坏数据静默存活"——它把报错责任从解码层推给了下游。
第二层:乱码出现 U+FFFD 后还能还原吗?
不能。U+FFFD 产生时原字节已丢弃,替换不可逆;连续坏字节还可能合并成不同数量的 U+FFFD。能做的是保留原始字节副本用于取证,或在入口用 REPORT 拒绝。找回数据的唯一路径是回到仍存有原始字节的层(文件、消息、数据库 blob)。
第三层:file.encoding 和 Charset.defaultCharset() 是一回事吗?
17 及以前:defaultCharset() 惰性读 file.encoding,查得到就用、查不到回退 UTF-8;file.encoding 本身还影响 System.out 等原生控制台编码,两者覆盖面不完全相同。18+:JEP 400 把默认字符集定为 UTF-8,file.encoding 退化为兼容属性,file.encoding=UTF-8 之外的效果不再被规范承诺。所以"改 file.encoding 修乱码"在 18+ 上是失效偏方。
第四层:评审一个跨系统文本接口,按什么顺序问?
- 字节在边界上用什么字符集进出——有没有显式声明(协议头/配置),还是依赖双方默认字符集巧合一致?2. 坏字节是拒绝(REPORT+错误响应)还是替换(REPLACE+审计)?谁负责对账?3. 长度上限按什么单位计(字节/code unit/code point),截断是否可能落在代理对中间?4. BOM、换行符(CRLF/LF)、规范化形式(NFC/NFD)有没有约定?5. 升级 JDK18+ 或换平台时,哪些"恰好能用"会坏?
来源与版本
| 来源 | 版本 | 用途 |
|---|---|---|
| Java SE 17 String | Java SE 17 | 构造/getBytes/codePointCount 契约 |
| Java SE 17 CharsetDecoder | Java SE 17 | 默认 REPORT、onMalformedInput、decode 两种形式 |
| Java SE 17 Charset | Java SE 17 | defaultCharset/forName 异常分层 |
| JEP 400 UTF-8 by Default | JDK 18 | 默认字符集断层的规范依据(未实跑,推导引用) |
| OpenJDK 17u String 源码 | jdk-17.0.12+7 | ASCII 快路径与 REPLACE 硬编码(L612、L652) |
| OpenJDK 17u Charset 源码 | jdk-17.0.12+7 | defaultCharset 回退分支(L592) |
| OpenJDK 21u Charset 源码 | jdk-21.0.5+9 | JEP 400 后 defaultCharset 的 UTF-8 契约与 COMPAT implNote(L641,U12 增补) |
本地 labs/modern-java/run.sh jep400 | Oracle JDK 17.0.12 + 21.0.5 | JEP 400 断层三档对照实测(U12,证据) |
jdk17u Charset-X-Coder.java.template | 仓库事实 | CharsetDecoder/Encoder 由模板构建期生成(2026-10-25 查证) |
本地 EncodingIoProbe 实验 | Oracle JDK 17.0.12 | 本轮全部行为实测 |
访问日期:2026-10-25。源码行号以 jdk-17.0.12+7 标签为准;实验运行版本为 Oracle 17.0.12(x86_64)。defaultCharset 的本机观察值不外推到其他平台;JEP 400 行为是规范推导。
闭卷验收
本页 10 道闭卷题收进了下方自测面板:先回忆,再展开参考答案与评分点,最后给三档自评。