qinghuan
← Java SE / 全部笔记

单元 104 / Java SE

P2 · 按需深入L3 · 深入

字符编码与 ByteBuffer:分块读取怎样避免乱码

用被分成两次读取的汉字理解乱码,再看懂 ByteBuffer 的读写范围和连续解码方法。

本篇目录 · 按需要跳转

读完这篇,你能做什么

能识别畸形文本和缓冲区状态错误,并选择有明确失败策略的解码方式。

学习理由、准备要求与知识点

为什么学它

协议与大文件处理会遇到跨块字符和缓冲状态问题,需要理解底层数据边界。

先读这些内容

知识点清单

  • 变长编码与跨块字符
  • CharsetDecoder 增量解码
  • REPORT 与 REPLACE
  • ByteBuffer position/limit/capacity
  • flip 读写切换
  • clear 不擦除数据
  • 字节序
  • Channel 部分读写与循环

运行前先确认Java 17;源码在本机 Microsoft OpenJDK 17.0.20.1 src.zip 核验,在线链接为上游 jdk-17.0.20-ga。深入模块可在掌握基础后回读。

01先弄懂它是什么

读取文件或网络数据时,拿到的是字节,要按约定的编码才能还原成文字。UTF-8 的一个码点可能占一到四个字节,但每次读取多少字节由缓冲大小和传输情况决定。例如一个汉字的字节可能分在两次读取中;若把每块都当成完整文本独立转换,就可能出现乱码。

ByteBuffer 通过三个值记录读写范围:capacity 是总容量,position 是下一次读写位置,limit 是当前不能越过的边界。写完后调用 flip,把已写入部分设为可读范围,并从开头读;clear 重置范围供再次写入,但不擦掉旧字节;compact 把没读完的字节移到前面。分块解码时,这样就能保留尚未凑齐的字符字节。

CharsetDecoder 把分块输入连接成同一条有状态的解码过程。REPORT、REPLACE 和 IGNORE 分别表示报错、替换和忽略问题数据,选择会影响数据完整性。Java 17 的默认字符集可能随环境变化,业务协议应明确指定 UTF-8 等编码。字节序用于解释多字节数值,ByteBuffer.order 不会把 UTF-8 自动变成另一种文本编码。

文中这几个词,先认一认

UNDERFLOW
现有输入无法继续解码:可能已读完,也可能还差几个字节才能组成完整字符。
OVERFLOW
输出区放不下更多字符了,需要先取走已解码的字符,再继续。
增量解码
多次读取共用同一个解码器,保留块与块之间的状态,并在最后明确告知输入结束。

还有陌生词?到术语小抄里看一个具体例子 ↗

02看看它怎么工作

一次循环先向处于写模式的输入缓冲追加字节,再 flip 进入读模式,调用 decode。输出缓冲写入字符后也要 flip,才能将实际生成部分交给下游,随后 clear 复用。若返回 UNDERFLOW,输入可能仍有一到三个 UTF-8 前缀字节,此时 compact 保留它们,下一批字节必须追加在其后。

OVERFLOW 表示输出区不够放,不等于输入数据错误,也不应直接结束循环。先取走已生成的字符,再清空输出缓冲,继续处理同一份输入。有些码点,例如某些表情对应的一个码点,在 Java 中需要两个 char 保存;输出区若连这两个 char 都放不下,就可能反复返回 OVERFLOW 而无法前进。教学程序使用两个 char 的输出区和四字节输入区,足以容纳一个合法 UTF-8 码点。

真正到达输入结束时,调用 decode(..., true),这使末尾缺字节从暂时等待转化为畸形输入。完成最终 decode 后还需 flush,适应具有额外状态的字符集解码器;复用到独立数据流前则要 reset。不能每一块都声明 endOfInput=true,也不能在最后永远保持 false,否则前者破坏状态,后者可能漏掉截断错误。

Channel 的部分读写同样要求循环检查实际进度:read 可能只填入部分容量,非阻塞模式可能返回零,write 可能只发送一部分剩余数据。非阻塞零进度应结合就绪通知和超时处理,不能无限忙循环。本文算法只在内存中模拟分块,专门隔离编码状态;文件与网络的超时、取消和资源生命周期属于接入层的额外职责。

正在加载流程图…

未消费字节留在输入缓冲,已生成字符交给输出;结束信号必须由真实数据流边界决定。
查看流程图源码(Mermaid)
flowchart TD
 A["追加字节"] --> B["flip 输入"]
 B --> C["decode 并取走字符"]
 C --> D{"解码结果"}
 D -->|"OVERFLOW"| C
 D -->|"UNDERFLOW"| E["compact 保留尾部"]
 E --> A
 D -->|"错误"| F["按策略终止"]
 E --> G["输入结束后最终 decode 与 flush"]

按顺序看,每一步做了什么

  1. 选择明确编码和错误处理策略。
  2. 保留 position 与 limit 表示的有效范围。
  3. OVERFLOW 时排空输出并继续。
  4. UNDERFLOW 时 compact 保存跨块前缀。
  5. 真正结束时 decode true,再 flush;新数据流前 reset。

03对照实现代码

CharsetDecoder.decode:状态与结果共同驱动循环(源码生成模板) (新标签页)↗

Microsoft OpenJDK 17.0.20.1;java.base/java/nio/charset/CharsetDecoder.java · java.nio.charset.CharsetDecoder#decode(ByteBuffer,CharBuffer,boolean)

以下为原创 Java 17 教学实现,不是原样 JDK 源码;只保留文中明确讨论的机制,省略优化、兼容及内部接口。

核心机制代码 · java
public final class SourceModel {
  enum State { RESET, CODING, END, FLUSHED }
  private State state=State.RESET;
  void begin(boolean end) {
    // 原创状态门模型:结束后允许再次处理末尾输出,但不能恢复普通输入。
    if(state!=State.RESET && state!=State.CODING && !(end && state==State.END))
      throw new IllegalStateException("invalid decode state");
    state=end?State.END:State.CODING;
  }
  void flushCompleted() {
    if(state!=State.END) throw new IllegalStateException("input not ended"); // 先结束输入再刷新。
    state=State.FLUSHED;
  }
  void reset() { state=State.RESET; } // 新的独立数据流必须重新开始。
}

本机 CharsetDecoder.decode 先根据 endOfInput 判断这次是否已经到达输入末尾,选择 CODING 或 END 状态,并检查此前的调用顺序是否合法,然后进入 decodeLoop。

具体字符集实现负责把字节按编码规则变成字符,基类负责管理状态和出错时怎么办。教学模型只检查哪些状态允许继续调用,不会真正转换字符,也没有完整模拟 API 允许的全部调用情况,例如再次调用 flush。

decodeLoop 返回 OVERFLOW 时,基类把结果交给调用方,要求调用方提供输出空间;UNDERFLOW 且输入已经结束但仍残留字节时,基类会将残留解释为畸形输入。相同的字节前缀在尚未结束时可以等待,在结束之后却必须有明确处理结果,这正是结束标志不可省略的原因。

发生错误时基类依据 malformedInputAction 或 unmappableCharacterAction 选择报告、替换或跳过。替换也需要输出容量,并不是不可能再出现 OVERFLOW。选择 REPORT 后,调用者应检查 CoderResult 并调用 throwException 抛出对应异常;仅检测 UNDERFLOW 和 OVERFLOW 会遗漏错误分支。

UTF-8 解码和 Java String 的 UTF-16 表示处于不同层次。一个表情码点可能生成两个 char,不能用输出 char 数量直接断言码点数量。源码阅读应连同具体字符集实现和 API 契约理解,本篇用固定缓冲与完整自测观察这一边界,而不把 CharBuffer 当作按用户可见字符计数的工具。

代码先调用谁,再调用谁

  1. 追加输入并 flip
  2. CharsetDecoder.decode 状态检查
  3. 具体字符集 decodeLoop
  4. 按 CoderResult 排空、等待或报错
  5. 最终 decode true 后 flush

04动手写一个例子

四字节缓冲完成严格 UTF-8 增量解码

将合法字节按指定步长喂给固定缓冲,输出原字符串;遇到截断或非法 UTF-8 必须报错。保存完整 Main.java,Java 17 编译运行,无文件或网络依赖。

输入 1
A中😀B 的 UTF-8,步长 1
输出 1
A中😀B
输入 2
E4 B8 两字节后结束
输出 2
CharacterCodingException

输入缓冲只保留尚未处理的前缀,索引指向下一个尚未喂入的字节。每轮最多追加步长、剩余输入和缓冲可用空间三者中的最小值;decode 消费完整码点,compact 留下尾部。四字节容量确保合法 UTF-8 的最长单码点不会因为缓冲太小而永远等待。

输出缓冲仅有两个 char,每次 decode 后都排空到 StringBuilder。OVERFLOW 继续循环,UNDERFLOW 返回喂入层,错误则立即抛出。输入耗尽后再调用最终解码,可以把未完成的字节序列识别为错误,而不是静默丢掉尾部或拼入替换字符。

正确性依赖两个守恒关系:每个已输入字节要么已消费,要么仍在缓冲未读区;每个已产生 char 恰好追加一次。自测让相同文本以一步、两步和三步输入,覆盖不同切断位置,并分别检查空文本、ASCII、孤立非法字节和缺失尾字节,避免只拿一种缓冲大小验证。

为什么这样写是对的字节不会因切块重复或丢失;未完成前缀留到下一轮,字符只在输出排空时追加一次。

时间 O(b+c),b 为字节数,c 为生成 char 数。额外空间 除返回结果 O(c) 外,解码缓冲为 O(1)。

UTF-8 单码点最多四字节,残留搬移上界为常数;每个字节和字符只参与有限次处理,StringBuilder 摊还追加线性。

保存为 Main.java,运行 javac --release 17 -encoding UTF-8 Main.java,再运行 java Main。程序包含固定输入自测,断言失败时会抛出错误。

展开收起代码 · Main.java · 完整可运行程序Java · 52 行

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

Main.java · 完整可运行程序 · Java
import java.nio.*;
import java.nio.charset.*;
import java.util.*;
public class Main {
  static void drain(CharBuffer out,StringBuilder text) {
    out.flip(); text.append(out); out.clear(); // 只追加已写入部分,再恢复写模式。
  }
  static void pump(CharsetDecoder d,ByteBuffer in,CharBuffer out,StringBuilder text,boolean end)
      throws CharacterCodingException {
    while(true) {
      CoderResult r=d.decode(in,out,end);
      drain(out,text);
      if(r.isError()) r.throwException(); // 严格报告畸形数据,不悄悄替换。
      if(r.isUnderflow()) return;
      // OVERFLOW 已排空输出,继续消费原输入。
    }
  }
  static String decode(byte[] bytes,int step) throws CharacterCodingException {
    Objects.requireNonNull(bytes);
    if(step<=0) throw new IllegalArgumentException("step");
    CharsetDecoder d=StandardCharsets.UTF_8.newDecoder()
      .onMalformedInput(CodingErrorAction.REPORT).onUnmappableCharacter(CodingErrorAction.REPORT);
    ByteBuffer in=ByteBuffer.allocate(4); CharBuffer out=CharBuffer.allocate(2);
    StringBuilder text=new StringBuilder(); int next=0;
    while(next<bytes.length) {
      int n=Math.min(Math.min(step,bytes.length-next),in.remaining());
      if(n==0) throw new IllegalStateException("no progress");
      in.put(bytes,next,n); next+=n;
      in.flip(); pump(d,in,out,text,false); in.compact(); // 保存跨块前缀,追加时不覆盖它。
    }
    in.flip(); pump(d,in,out,text,true); // 最终结束让截断前缀转为错误。
    while(true) {
      CoderResult r=d.flush(out); drain(out,text);
      if(r.isError()) r.throwException();
      if(r.isUnderflow()) break;
    }
    return text.toString();
  }
  static void eq(Object a,Object b) {if(!Objects.equals(a,b)) throw new AssertionError(a+" != "+b);}
  static void bad(byte[] bytes) throws Exception {
    try {decode(bytes,1);} catch(CharacterCodingException e) {return;}
    throw new AssertionError("expected malformed input");
  }
  public static void main(String[] args) throws Exception {
    eq(decode(new byte[0],1),""); eq(decode("ASCII".getBytes(StandardCharsets.UTF_8),2),"ASCII");
    String text="A中😀B";
    for(int step=1;step<=3;step++) eq(decode(text.getBytes(StandardCharsets.UTF_8),step),text);
    bad(new byte[]{(byte)0xe4,(byte)0xb8}); bad(new byte[]{(byte)0xff});
    try {decode(new byte[0],0); throw new AssertionError();} catch(IllegalArgumentException expected) {}
    System.out.println("PASS 8");
  }
}

这些特殊情况也要试一试

  • 空输入仍执行结束和刷新流程。
  • 补充平面码点需要两个输出 char。
  • 非法字节和截断结尾均报错。
  • 步长必须为正,零进度有保护。
进阶选读 · 带背压的网络文本解码器

场景下游处理字符慢于网络接收速度。

做法将输入就绪、解码与下游消费分离,设置有界缓冲,输出满时停止继续读取,并保留错误偏移与取消状态。

复杂度处理仍可线性,但内存受显式容量控制;调度成本取决于事件模型。

好处与代价状态数量增多,需测试部分读写、取消和慢消费者,不能仅靠扩大缓冲解决无限积压。

05项目里怎么用

配置、标识和结构化数据若把坏字节悄悄替换成文字,原本不同的输入可能变成同一文本,影响去重、签名或审计。严格报错通常更容易保留问题原因;面向人的尽量显示场景,也可以选择替换,但要明确告诉后续处理这不是完整原样内容,不能一边容错、一边按完全可信原文判断。

大文件可以逐段处理字符,避免全部积进 StringBuilder。但字符解码正确,只表示字节已正确变成文字,还不表示一行或一个 JSON 对象已经完整。上层仍需处理换行、转义和消息长度。报错时可保留字节位置和短摘要,不必输出整段敏感内容。

普通文件读写优先使用 Reader、Writer 与明确编码,让成熟 API 处理大部分状态。只有协议栈、固定缓冲或特殊吞吐需求确实需要时再直接操作 ByteBuffer。优化前先比较真实数据大小、字符分布与下游速度;单个小例子的吞吐不能证明直接缓冲或更大块大小在所有部署上更快。

容易踩的坑

  • 对每个读取块独立 new String 导致跨块字符损坏。
  • UNDERFLOW 后 clear 丢掉尚未消费前缀。
  • 每块都传 endOfInput=true。
  • 输出缓冲过小且不检测零进展,反复 OVERFLOW。

几种做法,怎么选

表格有四列,窄屏可左右滑动查看代价和适用条件。

方案优势代价与限制适用条件
Reader 与明确编码使用简单,状态管理成熟底层缓冲控制较少普通文本文件
手工 CharsetDecoder可控错误策略和分块行为状态与循环更复杂协议层和流式转换
一次性解码全部字节实现直观内存随文件规模增加有明确大小上限的小数据

06检验是否读懂了

先自己想、自己写,再展开答案核对。场景题是假设的练习情境;遇到还没学过的内容,可以做完基础后再回来。

先用自己的话解释

01clear 会擦除旧数据吗?

展开参考答案与推导

不会。它重设 position 和 limit,旧内容仍可能留在内存中,只是不再被当作有效读取范围。不能把 clear 当作敏感数据清零操作。

02UNDERFLOW 一定表示缓冲已经空了吗?

展开参考答案与推导

不一定。输入可能还留着一个不完整的多字节字符前缀,等待更多字节。需要检查剩余内容并通过 compact 保留,而不是无条件丢弃。

03ByteBuffer.order 能改变 UTF-8 编码吗?

展开参考答案与推导

不能。字节序控制 getInt 等多字节数值的解释方式,文本由 Charset 决定。编码、字节序和 Java char 表示需要分别理解。

换个情况,还会处理吗

01中文只有在某些网络包长度下乱码,如何排查?

展开参考答案与推导

首先确认是否逐包独立创建字符串,再用一步长输入复现。检查同一个解码器是否跨块保留状态,以及 UNDERFLOW 后是否保存剩余字节,不能只修改缓冲大小掩盖边界。

02末尾缺字节却没有报错可能是什么原因?

展开参考答案与推导

可能从未使用 endOfInput=true 完成最终解码,或错误策略选择了替换、忽略。核对结束调用、CoderResult 错误检查与后续 flush,而不仅看主循环是否结束。

03非阻塞 read 返回零,应继续死循环吗?

展开参考答案与推导

不应无条件忙等。它可能意味着当前无数据,应结合选择器就绪通知、取消和超时恢复读取。编码器不会替网络层提供等待策略。

写代码试一试

01一个汉字拆成两块如何处理?

输入 / 条件
第一块 E4 B8,第二块 AD
输出 / 预期
中

提示 保留未消费字节。

展开参考答案与推导

第一轮 decode false 返回不足时 compact 保存两个字节,追加第三字节后再次 flip 解码。不能分别创建两个字符串后再拼接。

02输出空间不足时如何推进?

输入 / 条件
decode 返回 OVERFLOW
输出 / 预期
排空输出后继续

提示 保持输入 position。

展开参考答案与推导

将输出 flip 并消费有效字符,然后 clear 输出并继续 decode 同一个输入;不能重置输入位置,否则会重复输出已处理内容。

03如何检测孤立 FF?

输入 / 条件
单个字节 FF
输出 / 预期
抛出字符编码异常

提示 使用 REPORT。

展开参考答案与推导

创建 UTF-8 decoder 并设置畸形输入 REPORT,检查 CoderResult.isError 并 throwException。用宽容的字符串构造方法不能验证严格报错契约。

读完之后,独立完成

课后作业:自己做一遍

本篇共 8 道作业,先完成 6 道基础与综合任务,再按前置知识挑战进阶题。其中包含 1 道思维拓展,试着换个条件,看看原来的办法是否还管用。不附参考答案。

这次练什么

能识别畸形文本和缓冲区状态错误,并选择有明确失败策略的解码方式。

本篇重点加练

重点核验缓冲区有效范围与部分传输,确保每轮推进都能解释而非依赖偶然一次读满。

  • ByteBuffer position/limit/capacity
  • Channel 部分读写与循环

重点内容会多练几次。先说清楚怎么做,再试试空数据、出错或条件改变时会发生什么。

先自己做,卡住时记下问题,再回来查。问答题写清“为什么、什么时候适用”;动手题留下输入、输出和运行步骤,方便下次重做。

基础必做

  1. 作业 01代码推演

    把 UTF-8 文本“甲A😀”按每次一至三字节分块输入解码器,画出每轮已消费字节、未完成字节与产出字符;先预测再与实际解码记录比较。

    提交内容
    分块表、状态图与最小运行记录。
    怎样算完成
    • 剩余字节可逐轮追踪
    • 解码状态跨块连续

    练到的知识点变长编码与跨块字符 · CharsetDecoder 增量解码

  2. 作业 02排错实验

    对完整 UTF-8、孤立续字节和末尾截断字符分别使用 REPORT 与 REPLACE,记录错误发生阶段及输出;明确这些政策适合哪种业务合同。

    提交内容
    Java 实验、自写非法编码边界测试和观察记录。
    怎样算完成
    • 真正结束输入与暂缺字节分开
    • 替换政策有业务依据

    练到的知识点REPORT 与 REPLACE

综合应用

  1. 作业 03代码推演

    创建容量为 8 的 ByteBuffer,执行写 3 字节、flip、读 2 字节、clear、再写 1 字节;每步标出 position、limit、capacity 和允许访问范围。

    提交内容
    缓冲区状态图、预测与实际值记录。
    怎样算完成
    • 游标状态与底层内容分开
    • 每次切换目的明确

    练到的知识点ByteBuffer position/limit/capacity · flip 读写切换 · clear 不擦除数据

  2. 作业 04编程实践

    实现一个只处理本地内存数据的整数记录编解码器,文件头指定字节序,每条为一个 32 位整数;模拟每次只处理一至三字节的 Channel,完成全部读写或报告截断。

    提交内容
    Java 实现、自写两种字节序/短读写/空输入测试及运行证据。
    怎样算完成
    • 不依赖一次操作传输完整记录
    • 字节序合同可验证
    • 无进展时有处理政策

    练到的知识点字节序 · Channel 部分读写与循环

  3. 作业 05面试场景

    网络接收程序每次 clear 后从位置零把整块缓冲转成字符串,偶尔出现旧内容。要求区分编码状态、有效字节范围和缓冲复用三个问题,提出独立验证步骤。

    提交内容
    三层排查表和修复验收样本。
    怎样算完成
    • 有效范围有实际读数支持
    • 不通过增大缓冲掩盖边界错误

    练到的知识点变长编码与跨块字符 · CharsetDecoder 增量解码 · ByteBuffer position/limit/capacity · clear 不擦除数据

  4. 作业 06编程实践

    实现限制每次只写两个字节的内存 Channel 适配实验,把两个不同长度 ByteBuffer 连续写出;允许某次返回零,但总步数受测试预算限制。

    提交内容
    Java 实验、自写部分写/空缓冲/无进展测试与运行证据。
    怎样算完成
    • position 与实际输出逐步对应
    • 未完成和失败由合同区分

    练到的知识点ByteBuffer position/limit/capacity · Channel 部分读写与循环

进阶挑战 · 掌握前置后选做

  1. 作业 07编程实践

    完成状态机后:实现有界 UTF-8 增量解码适配器,允许输出缓冲只能容纳一个 char,支持明确的 endOfInput;用所有分割位置与一次性严格解码作比较。

    提交内容
    Java 实现、自写 UNDERFLOW/OVERFLOW/结束状态测试及运行证据。
    怎样算完成
    • 每轮有进展或返回明确等待状态
    • 资源有上限
    • 错误输入政策一致

    练到的知识点CharsetDecoder 增量解码 · REPORT 与 REPLACE · ByteBuffer position/limit/capacity · Channel 部分读写与循环

思维拓展

先写出你的猜想,再用例子或实验验证。答案可以不同,但要说清依据、适用情况,以及还有什么没弄懂。

  1. 作业 08思维拓展 · 条件变化

    同一写入循环从阻塞 Channel 改为可返回零的非阻塞入口。审查原循环的进展、CPU 与取消预算,设计无需真实网络的事件序列检验。

    提交内容
    状态图、零返回序列和验收标准。
    怎样算完成
    • 等待与完成状态明确
    • 进展判断使用缓冲区可观察状态

    练到的知识点ByteBuffer position/limit/capacity · Channel 部分读写与循环

完成后,再检验一次

给每题标记“独立完成 / 查阅后完成 / 尚未完成”。查过资料或没做出的题,记下卡在哪里。隔一天换一组输入再试,能解释结果和出错原因,再把这个知识点标为掌握。

继续阅读

先看能帮你完成当前练习的部分。例子跑通后,再读更深入的解释。

打开资料库,找下一份学习材料 ↗