本节目标:能把一段网络字节正确解释成 int 和文本,并知道截断发生在哪里、该报错还是该等待。
适用版本:JDK 25(Temurin 25.0.5+8);ByteBuffer、ByteOrder、CharsetDecoder。
承接的问题
3.3 的 Echo 把字节原样回写,看起来已经「通了」。但 Echo 从不解释字节,它只是搬运。业务层第一次把字节读成数字和文本时,真正的问题才出现。
linkd 的自定义协议以 4 字节长度开头,4.2 的 FrameDecoder 要靠这个长度切帧。如果长度字段的字节序读反,300 会被读成 738263040,解码器会认为对端要发一个 700 MB 的帧;如果长度字段本身被截断,解码器会把半条消息当成合法输入。
反方向也一样。linkd-app 的 /books/{id} 返回书名,UTF-8 的「书」占 3 个字节。如果按 2 字节切,或者用平台默认字符集解码,得到的是 U+FFFD 或乱码,而日志里往往看不出来——因为替换是静默的。
本节先把「字节到数字」和「字节到字符」这两层拆开,4.2 再谈「字节到消息边界」。顺序不能反:帧边界建立在字符边界之上,而字符边界又建立在字节序和编码规则之上。
本机口径:Apple M1 Pro,10 核,32 GB,macOS 26.3 (25D125);Temurin 25.0.5+8。
核心机制一:网络字节序是大端,ByteBuffer 默认也是大端
ByteBuffer 出厂时是 BIG_ENDIAN。putInt/getInt 按 buffer 当前的 order() 解释 4 个字节。先看 300 在两种顺序下的内存布局:
import java.nio.ByteBuffer;
import java.nio.ByteOrder;
import java.util.HexFormat;
public class EndianProbe {
public static void main(String[] args) {
ByteBuffer be = ByteBuffer.allocate(4);
be.putInt(300).flip();
System.out.println("default order=" + be.order()
+ " hex=" + HexFormat.of().formatHex(be.array())
+ " getInt=" + be.getInt());
ByteBuffer le = ByteBuffer.allocate(4).order(ByteOrder.LITTLE_ENDIAN);
le.putInt(300).flip();
System.out.println("little order=" + le.order()
+ " hex=" + HexFormat.of().formatHex(le.array())
+ " getInt=" + le.getInt());
byte[] wire = {0x00, 0x00, 0x01, 0x2c};
ByteBuffer readBack = ByteBuffer.wrap(wire).order(ByteOrder.LITTLE_ENDIAN);
System.out.println("same bytes read as LITTLE_ENDIAN=" + readBack.getInt());
int manual = ((wire[0] & 0xff) << 24) | ((wire[1] & 0xff) << 16)
| ((wire[2] & 0xff) << 8) | (wire[3] & 0xff);
System.out.println("manual big-endian=" + manual);
}
}
本机输出:
default order=BIG_ENDIAN hex=0000012c getInt=300
little order=LITTLE_ENDIAN hex=2c010000 getInt=300
same bytes read as LITTLE_ENDIAN=738263040
manual big-endian=300
四行对应四个事实:
- 300 的十六进制是
0x0000012C。大端把最高有效字节00放在低地址,内存顺序是00 00 01 2c;小端把最低有效字节2c放在低地址,顺序是2c 01 00 00。 - 两种 buffer 的
getInt()都返回 300,因为写入和读取用了同一个order()。字节序本身不会「算错」,错在两端对同一串字节的解释不一致。 - 第三行是本节最重要的数字:同一组 wire 字节
00 00 01 2c,如果读端把 buffer 设成LITTLE_ENDIAN,得到 738263040。字节一个没变,解释变了。长度字段读反时,解码器看到的不是 300,而是一个接近 700 MB 的长度。 - 手工移位不依赖 buffer 的
order(),它把「大端」写死在代码里。4.2 的长度域解码器用这种方式在不移动position的前提下窥探长度。
Java 类文件常量池、TCP/IP 头、HTTP/2 帧头、WebSocket 的长度字段都是大端。BMP、WAV 等部分文件格式是小端。不要用 ByteOrder.nativeOrder() 推断协议字节序:本机是 ARM,native 是 little-endian,但 linkd 的线协议仍然固定大端。order() 是每个 ByteBuffer 实例的属性,duplicate() 和 slice() 会继承它,wrap() 默认大端。这意味着字节序错误会跟着 buffer 一起传播:把一个小端 buffer 放进 BufferPool 复用,下一个借到它的人读到的每个 int 都是反的。
核心机制二:UTF-8 变长,UTF-16 是码元不是码点
String.length() 返回的是 UTF-16 code unit 的数量,不是字符数,也不是字节数。看三个典型字符:
import java.nio.charset.StandardCharsets;
public class CharsetProbe {
public static void main(String[] args) {
String text = "书A📚";
System.out.println("length=" + text.length()
+ " codePoints=" + text.codePointCount(0, text.length()));
text.codePoints().forEach(cp -> {
String ch = new String(Character.toChars(cp));
System.out.printf("U+%04X chars=%d utf8=%d utf16be=%d%n",
cp, ch.length(),
ch.getBytes(StandardCharsets.UTF_8).length,
ch.getBytes(StandardCharsets.UTF_16BE).length);
});
}
}
本机输出:
length=4 codePoints=3
U+4E66 chars=1 utf8=3 utf16be=2
U+0041 chars=1 utf8=1 utf16be=2
U+1F4DA chars=2 utf8=4 utf16be=4
三行字符数据说明了所有差异:
- 「书」U+4E66 在 UTF-8 里占 3 字节,在 UTF-16BE 里占 2 字节;「A」U+0041 分别占 1 和 2 字节,ASCII 兼容是 UTF-8 最大的工程优势。
- 「📚」U+1F4DA 超出 BMP,UTF-16 需要一对代理(高代理 + 低代理),所以
chars=2;UTF-8 需要 4 字节。
length=4 而 codePoints=3,差的 1 就是代理对。用 substring(0, 2) 切「书A📚」会切在 emoji 的两个代理之间,得到一个孤立的高代理,打印出来是 ?,再拼回去也恢复不了。要按字符切,用 offsetByCodePoints 或 codePoints()。
UTF-8 的变长规则值得记住,因为它决定了「能不能按字节扫描」:
| 首字节 | 总长度 | 后续字节 |
|---|---|---|
0xxxxxxx | 1 | 无 |
110xxxxx | 2 | 1 个 10xxxxxx |
1110xxxx | 3 | 2 个 10xxxxxx |
11110xxx | 4 | 3 个 10xxxxxx |
所有续字节都以 10 开头,取值范围是 0x80–0xBF,不可能等于 ASCII 的 0x0A(换行)、0x0D(回车)或 0x3A(冒号)。这条性质叫自同步:在一个合法的 UTF-8 序列内部,不会出现 ASCII 字节。因此按 \n 扫描行、按 : 扫描头字段名,不会切在汉字中间;4.2 的分隔符帧和 8.1 的 HTTP 头解析都依赖这一点。
UTF-16 没有这个性质。它的每个 code unit 是两个字节,ASCII 字符的高字节是 0x00,低字节是字符本身。按单字节分隔符扫描 UTF-16 文本,随时可能切在 code unit 中间。所以线协议默认 UTF-8,Java 内部字符串用 UTF-16 是另一回事。
还有一个容易忽略的边界:UTF-8 BOM。EF BB BF 用 StandardCharsets.UTF_8 解码不会被去掉,而是变成一个 U+FEFF 字符。本机用 new String(new byte[]{(byte) 0xEF, (byte) 0xBB, (byte) 0xBF, 'A'}, UTF_8) 得到长度 2、首字符 U+FEFF,因为 BOM 占了 1 个字符。JSON 解析器看到 U+FEFF 会报 unexpected character。读文件或读第一帧时,如果协议允许 BOM,要显式跳过这三个字节,不要指望解码器。
核心机制三:截断字符与 CharsetDecoder 的动作
网络读到的字节经常停在字符中间。取「书」的 UTF-8 字节 e4 b9 a6,只给前两个:
import java.nio.ByteBuffer;
import java.nio.CharBuffer;
import java.nio.charset.CharacterCodingException;
import java.nio.charset.CharsetDecoder;
import java.nio.charset.CoderResult;
import java.nio.charset.CodingErrorAction;
import java.nio.charset.StandardCharsets;
import java.util.HexFormat;
public class TruncatedUtf8Probe {
public static void main(String[] args) throws Exception {
byte[] full = "书".getBytes(StandardCharsets.UTF_8);
byte[] firstTwo = {full[0], full[1]};
System.out.println("bytes=" + HexFormat.of().formatHex(full)
+ " firstTwo=" + HexFormat.of().formatHex(firstTwo));
String replaced = new String(firstTwo, StandardCharsets.UTF_8);
System.out.println("new String -> length=" + replaced.length()
+ " text=" + replaced
+ " cp=" + Integer.toHexString(replaced.codePointAt(0)));
CharsetDecoder strict = StandardCharsets.UTF_8.newDecoder()
.onMalformedInput(CodingErrorAction.REPORT);
try {
strict.decode(ByteBuffer.wrap(firstTwo));
} catch (CharacterCodingException e) {
System.out.println("REPORT at end -> " + e);
}
CharsetDecoder streaming = StandardCharsets.UTF_8.newDecoder()
.onMalformedInput(CodingErrorAction.REPORT);
ByteBuffer in = ByteBuffer.allocate(8);
in.put(firstTwo).flip();
CharBuffer out = CharBuffer.allocate(8);
CoderResult first = streaming.decode(in, out, false);
System.out.println("first pass result=" + first
+ " consumed=" + (2 - in.remaining()) + " left=" + in.remaining());
in.compact();
in.put(full[2]).flip();
CoderResult second = streaming.decode(in, out, true);
out.flip();
System.out.println("second pass result=" + second
+ " chars=" + out.remaining() + " text=" + out);
}
}
本机输出:
bytes=e4b9a6 firstTwo=e4b9
new String -> length=1 text=� cp=fffd
REPORT at end -> java.nio.charset.MalformedInputException: Input length = 2
first pass result=UNDERFLOW consumed=0 left=2
second pass result=UNDERFLOW chars=1 text=书
四行输出对应四种处理方式,差别很大:
new String(bytes, UTF_8)走的是 REPLACE,遇到不完整序列替换成 U+FFFD(打印为�),长度 1,不抛异常。调用方拿到的是一段「看起来正常」的字符串,数据已经坏了。CharsetDecoder默认就是REPORT(本机打印default malformed=REPORT)。显式写onMalformedInput(REPORT)是为了让代码里的意图可见。一次性decode(ByteBuffer)把输入当成完整流,遇到e4 b9直接抛MalformedInputException: Input length = 2,数字 2 是未消费的字节数。- 流式调用
decode(in, out, false)返回UNDERFLOW,consumed=0、left=2。这里的false不是「忽略错误」,而是「流还没结束」。此时还不能判定e4 b9非法,它可能只是缺第三个字节。 - 补上
a6并把endOfInput设为true,解码器输出 1 个字符「书」。注意第二次结果仍是UNDERFLOW:输入被消费完、输出没有溢出,正常结束就是 UNDERFLOW。
CoderResult 有三种状态,不要混:
| 结果 | 含义 | 调用方动作 |
|---|---|---|
UNDERFLOW | 输入读完或不够一个字符 | 保留剩余字节,等下一次 |
OVERFLOW | 输出缓冲区满了 | 扩大输出或先排空,再继续 |
isError() | malformed / unmappable | 按策略替换或抛异常 |
MALFORMED 和 UNMAPPABLE 是两类错误:
- MALFORMED 是字节序列不符合字符集。
e4 b9后面跟一个 ASCII 字节,或者e4后面跟e4,都是 malformed。 - UNMAPPABLE 是字符无法用目标字符集表示,发生在编码方向。本机把「书」用 ISO-8859-1 编码,得到
3f,也就是?,同样不报错。要发现它,CharsetEncoder也要设REPORT。
CharsetDecoder 和 CharsetEncoder 都是有状态的,不是线程安全的。StandardCharsets.UTF_8 这个 Charset 对象可以共享,但 newDecoder() 出来的实例不能跨线程、不能跨连接复用。一个连接一个实例,或者每次解码时新建。
在 linkd 里落地
linkd-core 需要两个小工具:一个能跨 read 保存半个字符的 UTF-8 解码器,一个强制大端的 int 读写。前者给文本帧和 HTTP 头用,后者给 4.2 的长度字段用。
先看流式解码器:
package com.plume.linkd.core;
import java.nio.ByteBuffer;
import java.nio.CharBuffer;
import java.nio.charset.CharacterCodingException;
import java.nio.charset.CharsetDecoder;
import java.nio.charset.CoderResult;
import java.nio.charset.CodingErrorAction;
import java.nio.charset.MalformedInputException;
import java.nio.charset.StandardCharsets;
/**
* 有状态的 UTF-8 增量解码器。每个连接独占一个实例,不可跨线程共享。
*/
public final class Utf8StreamDecoder {
private static final int MAX_PENDING = 4;
private final CharsetDecoder decoder = StandardCharsets.UTF_8.newDecoder()
.onMalformedInput(CodingErrorAction.REPORT)
.onUnmappableCharacter(CodingErrorAction.REPORT);
private final ByteBuffer pending = ByteBuffer.allocate(MAX_PENDING);
public String decode(ByteBuffer in) throws CharacterCodingException {
ByteBuffer source = in;
if (pending.position() > 0) {
pending.flip();
source = ByteBuffer.allocate(pending.remaining() + in.remaining());
source.put(pending).put(in).flip();
pending.clear();
}
CharBuffer out = CharBuffer.allocate(source.remaining());
CoderResult result = decoder.decode(source, out, false);
if (result.isError()) {
result.throwException();
}
if (source.hasRemaining()) {
pending.put(source);
}
out.flip();
return out.toString();
}
public String finish() throws CharacterCodingException {
pending.flip();
CharBuffer out = CharBuffer.allocate(MAX_PENDING);
CoderResult result = decoder.decode(pending, out, true);
if (result.isError()) {
result.throwException();
}
if (pending.hasRemaining()) {
throw new MalformedInputException(pending.remaining());
}
result = decoder.flush(out);
if (result.isError()) {
result.throwException();
}
out.flip();
return out.toString();
}
}
pending 保存跨 read 的不完整序列。UTF-8 一个字符最多 4 字节,所以残留最多 3 字节,MAX_PENDING = 4 留了 1 字节余量。decode 只返回本次能确定的字符;不完整的尾部留在 pending,不报错也不丢弃。finish 在帧结束或连接关闭时调用,把 endOfInput 设为 true;如果还有残留字节,说明对端在字符中间断了,抛 MalformedInputException。
decode 里有一个分配点:当 pending 非空时,会新建一个 pending.remaining() + in.remaining() 的缓冲区把两段拼起来。正常路径(字符没跨 read)不会走这里。5.1 和 5.2 会把这类临时缓冲区换成池化或直接在 carry 里预留空间,本节优先保证语义正确。
用一个字节一喂的极端方式验证它确实能跨 read 拼回字符(Utf8StreamDecoderDemo):
本机输出:
feed e4 -> 0 chars
feed b9 -> 0 chars
feed a6 -> 1 chars
feed e5 -> 0 chars
feed 90 -> 0 chars
feed 8d -> 1 chars
feed f0 -> 0 chars
feed 9f -> 0 chars
feed 93 -> 0 chars
feed 9a -> 2 chars
assembled=书名📚 equals=true
finish=[]
前 3 次喂入只在第 3 次产出 1 个字符;emoji 的 4 个字节在第 4 次才产出 2 个 UTF-16 码元。finish 返回空串,说明没有残留。如果日志里出现 chars=0 就以为丢数据,就会误判;chars=0 只表示「还差字节」。
第二个工具是固定大端的 int 读写:
package com.plume.linkd.core;
import java.nio.ByteBuffer;
import java.nio.ByteOrder;
public final class WireInt {
public static final int SIZE = Integer.BYTES;
private WireInt() {
}
public static void put(ByteBuffer out, int value) {
requireBigEndian(out);
out.putInt(value);
}
public static int get(ByteBuffer in) {
requireBigEndian(in);
return in.getInt();
}
private static void requireBigEndian(ByteBuffer buffer) {
if (buffer.order() != ByteOrder.BIG_ENDIAN) {
throw new IllegalStateException(
"wire int must be big-endian, got " + buffer.order());
}
}
}
requireBigEndian 把「用错字节序」从静默错误变成调用期异常。本机把一个 LITTLE_ENDIAN buffer 传进去:
本机输出:
hex=0000012c value=300
little-endian buffer -> wire int must be big-endian, got LITTLE_ENDIAN
linkd 的线协议固定大端,不跟随 ByteOrder.nativeOrder()。4.2 的 LengthFieldFrameDecoder 不直接调用 WireInt.get,因为它需要在不移动 position 的情况下窥探长度,用手工移位更直接;两者对字节的解释完全一致,可以互相校验。
落地时有三个具体位置用到这两件工具:HTTP 头解析先按字节找 \r\n,找到整行后再解码,因为 UTF-8 自同步,按字节找行不会切坏汉字;WebSocket 文本帧在整帧到达后解码,帧边界由 11.2 保证;日志记录非法输入时打印原始字节的 hex,不要打印解码后的字符串——U+FFFD 会把定位信息抹掉。
常见错误与边界
错误一:把 String.length() 当字符数。 本机「📚」的 length() 是 2,codePointCount 是 1。substring 按 code unit 切,切在代理对中间会得到孤立代理,再拼回去也恢复不了。按字符切用 offsetByCodePoints。
错误二:对半包调用 new String。 本机 e4 b9 解出 1 个 U+FFFD,长度 1,不抛异常。网络数据必须等帧完整,或者用带 pending 的 Utf8StreamDecoder。new String 适合已经确认完整的字节数组,不适合 socket 读到的任意片段。
错误三:让 buffer 的 order 泄漏。 order(ByteOrder.LITTLE_ENDIAN) 改的是这个 buffer 后续所有 getInt。如果 buffer 被放回池里,下一个使用者读到的每个 int 都是反的,而且不会报错。WireInt 选择直接抛 IllegalStateException,让错误在第一次调用时暴露。
错误四:依赖平台默认字符集。 JDK 18 起 file.encoding 默认 UTF-8(JEP 400),但 native.encoding 仍可能是别的值;容器、老启动脚本、-Dfile.encoding=COMPAT 都能改变它。所有 getBytes()/new String() 都显式传 StandardCharsets.UTF_8。
错误五:把 BOM 当内容。 UTF-8 BOM 解码后是 U+FEFF,长度多 1,JSON 解析器报 unexpected character。协议要么禁止 BOM,要么在解码前跳过 ef bb bf 三个字节。
边界:字符边界不等于消息边界。 Utf8StreamDecoder 只保证「一个字符跨 read 时不丢」,它不知道消息在哪里结束。帧边界仍由 4.2 的 FrameDecoder 负责。两层都做对,才能既不切坏汉字,也不把两条消息粘成一条。
边界:UTF-8 不是唯一编码。 对端用 GBK 时,new String(bytes, UTF_8) 可能不抛异常但全是乱码。协议必须写明字符集:HTTP 用 Content-Type 的 charset 参数,linkd 的自定义协议在握手时固定 UTF-8,并在版本升级时保持不变。
小结
ByteBuffer默认BIG_ENDIAN;300 的大端字节是00 00 01 2c,小端是2c 01 00 00。同一组 wire 字节按小端解释会得到 738263040;字节序错误不会抛异常,只会让长度、类型、校验和全部失真。String.length()数的是 UTF-16 code unit;「书」UTF-8 占 3 字节,emoji 占 4 字节且length()为 2。- UTF-8 自同步,多字节序列内不会出现 ASCII 字节,所以按
\n、\r、:扫描字节是安全的。 new String遇到截断序列静默替换为 U+FFFD;CharsetDecoder的REPORT抛MalformedInputException,endOfInput=false则返回UNDERFLOW等待后续字节。Utf8StreamDecoder每个连接一个实例,WireInt强制大端;两者分别守住字符边界和数字边界。
字节和字符都对齐了,但「一条消息到哪里结束」还没有答案。下一节把定长、分隔符、长度域三种帧写成可运行的 FrameDecoder,并用半包、粘包和超长帧把边界跑出来。
阅读导航:上一节:3.3 半关闭、RST 与优雅关闭 · 下一节:4.2 定长、分隔符与长度域帧 。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。