UTF-8 坏字节怎样处理:替换、忽略与严格解码的不同后果
- 发布时间:2026-10-23 21:16:59
- 本文热度:浏览 3 赞 0 评论 0
- 文章标签: Java UTF-8 CharsetDecoder
- 全文共1字,阅读约需1分钟
new String(bytes, UTF_8) 返回了一个字符串,只能说明这批字节被转换成了可用的 Java 对象,不能说明输入原本就是合法 UTF-8。错误字节可能已经被替换成替代字符,原始信息也就丢了。
展示日志时,尽量显示后续文字有时合理;导入账号、文件标识、签名原文时,静默替换就可能改变身份。尤其不能先宽松解码,再检查字符串里有没有乱码符号,以此代替严格字节校验:替代字符也可能本来就是输入中的合法内容。
Java 17 的 CharsetDecoder 可以明确选择坏输入策略。下面把三种策略、分块时半个字符的保留、文件结尾的截断序列放进同一个程序。代码不读取外部文件,所有字节都直接列出,结果可以逐项核对。
一段坏输入会变成三种不同结果
输入是 ASCII 字节 a、非法 UTF-8 字节 FF、ASCII 字节 b。选择 REPLACE 会保留两边文字并插入替代字符;选择 IGNORE 会直接丢掉错误输入;选择 REPORT 则把错误交给调用者。
丢掉坏字节不是“修复为正确编码”。例如两个原始标识分别包含不同非法字节,忽略后都可能变成 ab。替换同样可能把不同输入合成相同字符串。这对展示未必致命,对唯一键和身份判断却是实质改变。输入协议要求合法 UTF-8 时,应先拒绝坏字节,再讨论业务格式。
CharsetDecoder 默认的错误策略是报告错误,但建议在关键入口显式写出,方便审查。它与带 Charset 参数的 String 构造器采取的行为不能混为一谈。把一种入口换成另一种入口,正是需要重新检查失败协议的地方。
完整实验:坏字节和跨块字符一起核对
保存为 DecodePolicy.java,使用 JDK 17 运行,不需要任何依赖。
import java.nio.*;
import java.nio.charset.*;
import java.util.*;
public class DecodePolicy {
static String decode(byte[] bytes, CodingErrorAction action)
throws CharacterCodingException {
return StandardCharsets.UTF_8.newDecoder()
.onMalformedInput(action).onUnmappableCharacter(action)
.decode(ByteBuffer.wrap(bytes)).toString();
}
static String chunks(List<byte[]> parts) throws CharacterCodingException {
CharsetDecoder decoder = StandardCharsets.UTF_8.newDecoder()
.onMalformedInput(CodingErrorAction.REPORT)
.onUnmappableCharacter(CodingErrorAction.REPORT);
ByteBuffer input = ByteBuffer.allocate(8);
CharBuffer output = CharBuffer.allocate(4);
StringBuilder text = new StringBuilder();
for (byte[] part : parts) {
if (part.length > input.remaining()) throw new IllegalArgumentException("chunk too large");
input.put(part).flip();
for (;;) {
CoderResult result = decoder.decode(input, output, false);
output.flip(); text.append(output); output.clear();
if (result.isError()) result.throwException();
if (result.isUnderflow()) break;
}
input.compact(); // Preserve a multibyte sequence that has not finished.
}
input.flip();
for (;;) {
CoderResult result = decoder.decode(input, output, true);
output.flip(); text.append(output); output.clear();
if (result.isError()) result.throwException();
if (result.isUnderflow()) break;
}
for (;;) {
CoderResult result = decoder.flush(output);
output.flip(); text.append(output); output.clear();
if (result.isError()) result.throwException();
if (result.isUnderflow()) break;
}
return text.toString();
}
public static void main(String[] args) throws Exception {
byte[] broken = {'a', (byte) 0xff, 'b'};
System.out.println("String=" + new String(broken, StandardCharsets.UTF_8));
System.out.println("REPLACE=" + decode(broken, CodingErrorAction.REPLACE));
System.out.println("IGNORE=" + decode(broken, CodingErrorAction.IGNORE));
try {
decode(broken, CodingErrorAction.REPORT);
throw new AssertionError("expected malformed input");
} catch (MalformedInputException expected) {
System.out.println("REPORT=" + expected.getClass().getSimpleName());
}
System.out.println("chunks=" + chunks(List.of(
new byte[]{(byte) 0xe4}, new byte[]{(byte) 0xb8, (byte) 0xad})));
try {
chunks(List.of(new byte[]{(byte) 0xe4}));
throw new AssertionError("expected truncated sequence");
} catch (MalformedInputException expected) {
System.out.println("truncated=" + expected.getClass().getSimpleName());
}
}
}
执行:
java DecodePolicy.java
核对结果为:
String=a�b
REPLACE=a�b
IGNORE=ab
REPORT=MalformedInputException
chunks=中
truncated=MalformedInputException
终端需要能够显示 UTF-8;终端字体不支持替代字符时,它可能看起来像空方框,这不改变字符串中的字符值。真正要做自动断言时,可以比较码点或转义值,不应依赖截图里这个符号画得像不像问号。
REPORT 的错误也不要原样变成面向用户的英文堆栈。导入任务可以将其转换成明确的“输入不符合 UTF-8 编码”错误,并按任务协议决定整批失败还是记录该输入失败。只要业务没有允许部分接收,就不能捕获异常以后悄悄继续使用前半段内容。
一个字符拆到两块,不是输入损坏
汉字中的 UTF-8 字节是 E4 B8 AD。程序把第一块设成 E4,第二块设成 B8 AD。如果对两块分别调用 new String,每块都不是独立完整的字符序列;宽松入口会产生替代字符,严格入口则会误以为输入坏了。
网络和文件块边界没有义务对齐字符边界。正确做法是保留上一块末尾尚未完成的字节,拼上下一块,再让同一个解码器继续处理。示例中负责保留这些字节的是 ByteBuffer.compact(),不是把每个块先变成字符串再拼接。
每一轮先 flip,让解码器读取已写入的部分;解码结束后 compact,把未消费字节移到缓冲区前部,后面的空间继续接收新数据。输出缓冲区满时返回 OVERFLOW,先取走已有字符,再继续解码同一批剩余字节。输入暂时不足则得到 UNDERFLOW,它在非结尾阶段可以表示“等下一块”,并不自动意味着失败。
这份实现为教学使用,输入缓冲区只有八字节,而且明确拒绝放不下的输入块。若改成读取真实流,需要让读操作直接利用缓冲区剩余空间,或拆分大块继续送入,不能删掉检查后期待 ByteBuffer 自动增长。输出则汇总到 StringBuilder,只适合有界文本;大文件应把已解码字符写给下游 Writer,并单独设置字节数、字符数和行长限额。
到了 EOF,必须告诉解码器这就是结尾
只收到 E4 还没拿到后两个字节,在流中途可以等待;到了 EOF,它就已经是一段截断输入。程序通过最后一次 decode(..., true) 传达这个事实,所以 truncated 得到 MalformedInputException。
如果每次都传 false,或者读完输入后直接取走已有输出,这类截断可能没有在预期位置被拒绝。结束解码以后还按状态机调用 flush,让有内部状态的解码器完成收尾。即便当前 UTF-8 样本不需要额外输出,通用解码流程仍应表达完整生命周期,而不是靠这个字符集的偶然简单省略收尾。
同一个 decoder 不适合被多个线程并发使用,也不能把上一份独立文件的未完成状态带到下一份文件里。最简单的边界是每次独立输入创建一个 decoder;需要复用时,必须在新的解码操作前 reset,并确保没有并发调用。线程安全的 Charset 对象与有状态的 CharsetDecoder 对象,不能因为名字相近就按同一种方式缓存。
严格 UTF-8 也不是全部文本校验
解码通过以后,文本仍可能包含控制字符、不可见字符、合法但业务禁止的符号,也可能存在 Unicode 归一化差异。那是字符层和业务层的规则,应在成功解码后继续检查。严格解码只回答字节序列是否符合指定编码,不替业务选择允许的账号字符集。
反过来,遇到错误也不能自动断言文件一定是另一种编码,更不能依次尝试多个字符集,挑一个没有报错的就当正确结果。有些字符集能为很多字节给出映射,成功解码不构成格式识别证据。输入应该带有明确编码约定;历史数据确需迁移时,保留原始字节和判定依据,单独处理歧义记录。
选择错误策略的依据是输入的用途。用于机器判断的标识、结构化导入和协议字段,通常更需要保留“无效”这个状态;面向人的不完整日志可以容忍替代字符,但也应留下解码失败的信号。不要让一段方便的字符串构造调用,在没有业务决定的情况下替你丢掉输入信息。