《Java 网络编程基础》4.1 字节序、字符集与边界

字节序与字符集是 linkd 协议的第一道边界。本节用 ByteBuffer 对比大端与小端的真实字节,拆开 UTF-8 和 UTF-16 对汉字、ASCII、emoji 的编码差异,用 CharsetDecoder 的 REPORT 抓住被截断的 3 字节汉字,并给出每连接独占的 Utf8StreamDecoder 与固定大端的 WireInt。

本节目标:能把一段网络字节正确解释成 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 的变长规则值得记住,因为它决定了「能不能按字节扫描」:

首字节总长度后续字节
0xxxxxxx1无
110xxxxx21 个 10xxxxxx
1110xxxx32 个 10xxxxxx
11110xxx43 个 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 定长、分隔符与长度域帧 。

继续阅读

探索更多技术文章

浏览归档,发现更多关于系统设计、工具链和工程实践的内容。

全部文章 返回首页

「java」更多文章

  1. 《Netty 高性能网络编程》15.3 linkd-v2 验收与复盘
  2. 《Netty 高性能网络编程》15.2 限流、超时与降级
  3. 《Netty 高性能网络编程》15.1 网关路由与转发