Java 基础体系 · 第 8/100 篇。示例统一以 Java 25 LTS 为语言和 JVM 基线;框架示例使用与其兼容的现代稳定版本。
Java I/O 与 NIO:Stream、Channel、Buffer、文件和网络边界
Java 的 I/O API 同时包含两套容易混淆、但抽象层次不同的模型:
java.io以 Stream 为中心:程序从输入流读取字节,或向输出流写入字节。java.nio以 Channel + Buffer 为中心:程序在缓冲区和通道之间搬运数据,并显式管理缓冲区状态。java.nio.file将文件系统操作建立在Path、Files、FileChannel等 API 之上。java.net与java.net.http则把这些原语应用到网络连接、协议分帧、TLS 和 HTTP。
这些 API 都处理“字节如何移动”,但不会自动替应用程序决定:
- 一次
read是否读完整个消息; - 一个文件是否适合一次性加载到内存;
- 一段字节是否正好对应完整字符;
- TCP 的一次写入是否对应对端的一次读取;
- HTTP 响应体是否可以无限制地读入内存;
- 发生异常时,已经打开的资源和已经消费的数据如何处理。
因此,理解 Java I/O 的关键不是记住更多方法,而是区分 数据容器、数据通道、边界定义和失败状态。
1. 先建立统一模型:字节、字符、消息和资源
1.1 字节是 I/O 的底层单位
文件和网络通常都以字节序列表示:
其中每个 的取值范围是 0 到 255。Java 的 byte 是有符号的,取值范围是 -128 到 127,但它仍然可以承载一个原始字节;需要无符号值时可以使用:
int unsignedValue = oneByte & 0xff;
I/O API 中的 int 返回值通常有特殊含义。例如:
int value = input.read();
0到255:读取到一个字节;-1:已经到达输入结束;- 其他负值不是合法的单字节数据。
InputStream.read(byte[]) 则返回实际读取的字节数:
> 0:本次读取了这些字节;-1:输入结束;0:通常只会在请求长度为零,或特定实现允许的情况下出现,不能把它当成“没有数据但以后一定有数据”。
1.2 字符是字节经过编码后的解释
文本不是天然的字节。字符集编码定义了字符序列 与字节序列 之间的转换:
例如,UTF-8 中一个字符可能占 1 到 4 个字节。因此,下面的操作不是等价的:
byte[] bytes = text.getBytes(StandardCharsets.UTF_8);
String decoded = new String(bytes, StandardCharsets.UTF_8);
如果字节数组在一个多字节字符中间被截断,直接对每一块调用 new String(...) 可能产生替换字符或解码错误。跨块文本读取应使用 Reader,或使用可保存解码状态的 CharsetDecoder。
1.3 消息边界不是 I/O API 自动提供的边界
应用协议中的一条消息通常需要额外的 framing,即“分帧”规则。常见方式有:
- 固定长度:每条消息恰好 32 字节;
- 长度前缀:先发送 4 字节长度,再发送消息体;
- 分隔符:以
\n或\r\n结束; - 自描述格式:例如 HTTP 根据响应头确定消息体长度或传输方式。
TCP 只提供有序、可靠的字节流,不保留发送方的写入边界。发送方执行两次:
write("abc")
write("def")
接收方可能看到:
"abcdef"
也可能分两次看到:
"ab"
"cdef"
甚至:
"a"
"bcde"
"f"
这不是异常,而是 TCP 流的正常行为。应用程序必须自行定义并解析消息边界。
2. Stream:顺序访问数据的抽象
这里的 Stream 主要指 java.io.InputStream、OutputStream、Reader 和 Writer,不要与 java.util.stream.Stream 混淆。后者是集合数据的惰性计算管道,不是文件或网络 I/O 的字节通道。
2.1 InputStream 和 OutputStream
InputStream 表示从某个来源顺序读取字节,OutputStream 表示向某个目标顺序写入字节。
一个可靠的复制循环必须根据返回值推进,而不是假定一次 read 填满缓冲区:
import java.io.IOException;
import java.io.InputStream;
import java.io.OutputStream;
public final class Copying {
private Copying() {}
public static long copy(InputStream in, OutputStream out)
throws IOException {
byte[] buffer = new byte[8192];
long total = 0;
for (int n; (n = in.read(buffer)) != -1; ) {
out.write(buffer, 0, n);
total += n;
}
return total;
}
}
这段代码的逻辑是:
read(buffer)尝试读取数据;- 返回
n表示本次只有buffer[0..n)有效; write(buffer, 0, n)只写有效区域;- 返回
-1表示输入结束; - 输入结束后方法返回累计字节数。
常见错误是写成:
int n = in.read(buffer);
out.write(buffer); // 错误:可能把上一次残留数据也写出去
如果本次只读到 3 字节,而上次读到 8192 字节,out.write(buffer) 会把本次读取后仍残留的旧数据一并写出。
2.2 read 不保证填满请求长度
InputStream.read(byte[], off, len) 的契约通常只保证:
它不保证 n == len。文件输入在普通本地实现上经常一次返回较多数据,但网络输入尤其容易返回较少数据。需要读取固定长度时,应显式循环:
import java.io.EOFException;
import java.io.IOException;
import java.io.InputStream;
public static byte[] readExactly(InputStream in, int length)
throws IOException {
if (length < 0) {
throw new IllegalArgumentException("negative length");
}
byte[] result = new byte[length];
int offset = 0;
while (offset < length) {
int n = in.read(result, offset, length - offset);
if (n == -1) {
throw new EOFException(
"expected " + length + " bytes, got " + offset);
}
offset += n;
}
return result;
}
这里的 EOF 是协议层错误:如果消息声明有 100 字节,但连接在 60 字节后关闭,则不是“正常读完”,而是消息截断。
2.3 输出流的 write 也不应被误解为“对端已经处理”
OutputStream.write 通常表示数据已经交给该流的实现。对于文件,这可能意味着数据进入操作系统缓存,并不等同于物理介质已经持久化;对于网络,这通常也不等于对端应用程序已经读取。
文件需要更强的持久化语义时,可以使用 FileChannel.force(boolean),但它仍受操作系统、文件系统和存储设备语义影响:
channel.force(true);
网络协议如果需要确认对端收到并处理数据,必须设计应用层响应,例如 ACK 或 HTTP 响应,不能仅凭本地 write 成功推断。
3. 资源生命周期:关闭、异常和错误链
I/O 对象通常占用文件描述符、套接字、内核缓冲区或其他外部资源。资源关闭必须与异常路径一起设计。
3.1 try-with-resources 的关闭顺序
import java.io.BufferedInputStream;
import java.io.InputStream;
import java.nio.file.Files;
import java.nio.file.Path;
public static long countBytes(Path path) throws Exception {
try (InputStream in =
new BufferedInputStream(Files.newInputStream(path))) {
long count = 0;
byte[] buffer = new byte[4096];
for (int n; (n = in.read(buffer)) != -1; ) {
count += n;
}
return count;
}
}
资源在 try 结束时自动关闭,关闭顺序与声明顺序相反。这里先关闭 BufferedInputStream,再关闭底层文件流。关闭外层流通常会连带关闭底层流。
如果主体抛出异常,关闭阶段也抛出异常,主体异常作为主异常,关闭异常会被加入:
Throwable[] suppressed = exception.getSuppressed();
这比手写 finally 更不容易丢失原始错误。
3.2 关闭不等于成功完成业务操作
例如,写文件时主体没有抛异常,但 close() 失败,整个操作仍应视为失败。相反,网络连接关闭可能是:
- 对端正常以 EOF 结束;
- 对端异常断开;
- 本地超时后主动关闭;
- 当前请求已完成后的正常释放。
“资源已关闭”描述的是生命周期状态,不描述业务是否成功。
3.3 异常类型是 API 契约的一部分
常见边界包括:
IOException:外部 I/O 操作失败;NoSuchFileException:路径不存在;AccessDeniedException:权限不足;EOFException:期望更多数据但输入结束;InterruptedIOException:I/O 期间线程被中断或发生相关中断;UncheckedIOException:某些不允许直接声明受检异常的回调中包装了IOException;HttpTimeoutException:HTTP 操作发生超时;ConnectException:连接建立失败。
异常消息和 getCause()、getSuppressed() 共同构成错误链。重新抛出异常时不应无故丢弃原因:
throw new IOException("failed to load configuration: " + path, cause);
恢复策略必须根据阶段判断。连接尚未建立时可以重试;请求体已经部分发送时,重试可能造成服务端执行两次操作,除非请求具有幂等性或带有去重标识。
4. Channel:可读写、可定位或可复用的 I/O 端点
Channel 是 java.nio.channels.Channel 体系的核心抽象。与 Stream 相比,它更明确地表示一个可打开、可关闭的 I/O 端点,并通常与 Buffer 配合使用。
常见类型包括:
FileChannel:文件的随机访问、定位、锁、映射和高效传输;SocketChannel:TCP 套接字;ServerSocketChannel:TCP 监听套接字;DatagramChannel:UDP;AsynchronousFileChannel:异步文件 I/O;ReadableByteChannel、WritableByteChannel:读写能力接口。
Channel 不一定都支持所有操作。例如,某个通道可能只读、只写,或不支持位置定位。具体能力由实现和打开选项决定。
4.1 Stream 与 Channel 的关系
可以通过适配器转换:
import java.io.InputStream;
import java.nio.channels.Channels;
import java.nio.channels.ReadableByteChannel;
ReadableByteChannel channel = ...;
InputStream in = Channels.newInputStream(channel);
适配并不会改变底层事实:
- 网络通道仍然可能短读;
- 文件通道仍然受位置和并发访问影响;
- 流的关闭通常会关闭底层通道;
- 非阻塞通道适配成传统流后,调用语义需要特别谨慎。
Stream 更适合顺序消费;Channel 更适合显式缓冲、文件定位、非阻塞事件循环和零拷贝式传输。
5. Buffer:状态机,而不是普通数组
ByteBuffer 是 NIO 最容易因状态误用而出错的组件。它至少包含四个概念:
capacity:底层存储容量,不随普通操作改变;position:下一次读或写的位置;limit:当前操作边界;mark:可选标记。
始终满足:
5.1 写入模式与读取模式
新建缓冲区:
ByteBuffer buffer = ByteBuffer.allocate(8);
初始状态:
capacity = 8
position = 0
limit = 8
向缓冲区写入 3 个字节后:
position = 3
limit = 8
此时前 3 个字节是刚写入的数据。若想让通道读取这 3 个字节,必须调用:
buffer.flip();
flip() 将状态变为:
position = 0
limit = 3
于是,接收方只能读取 [0, 3) 范围。
import java.nio.ByteBuffer;
import java.nio.charset.StandardCharsets;
ByteBuffer buffer = ByteBuffer.allocate(8);
buffer.put("cat".getBytes(StandardCharsets.US_ASCII));
System.out.println(buffer.position()); // 3
buffer.flip();
System.out.println(buffer.remaining()); // 3
flip() 的含义不是“翻转字节”,而是把“已经写入的数据范围”转换为“接下来可读取的数据范围”。
读取完后:
buffer.clear();
状态回到:
position = 0
limit = capacity
clear() 不会擦除字节,只是允许下一轮覆盖整个缓冲区。
5.2 compact() 用于保留未处理数据
考虑一个协议解析器。缓冲区中有:
[已经解析的部分][尚未组成完整消息的部分]
若 flip() 后只消费了前一部分,剩余部分必须保留。此时调用:
buffer.compact();
它会把未消费的数据移动到开头,并进入写入状态:
position = 未消费字节数
limit = capacity
完整循环通常如下:
ByteBuffer buffer = ByteBuffer.allocate(4096);
while (true) {
int n = channel.read(buffer);
if (n == -1) {
// 对端结束;还要检查 buffer 中是否残留不完整协议数据
break;
}
buffer.flip();
while (buffer.hasRemaining()) {
// 解析器消费完整消息;如果不足以形成完整消息,应停止
// parse(buffer);
break;
}
buffer.compact();
}
如果解析器在 flip() 后直接调用 clear(),尚未完成的半条消息会被丢弃。
5.3 allocate 与 allocateDirect
ByteBuffer heap = ByteBuffer.allocate(8192);
ByteBuffer direct = ByteBuffer.allocateDirect(8192);
堆缓冲区由 Java 堆管理,访问和调试通常更直接;直接缓冲区位于堆外,常用于需要与本地 I/O 交互的场景。直接缓冲区的分配成本、回收时机和内存占用都需要考虑,不能因为名称中有 direct 就假定一定更快。
性能结论必须基于实际工作负载测量。缓冲区大小也不是越大越好:过小会增加系统调用和状态切换,过大会增加内存占用和缓存压力。
5.4 字节序是协议的一部分
ByteBuffer 默认使用大端序:
buffer.order(ByteOrder.BIG_ENDIAN);
若协议规定小端序:
buffer.order(ByteOrder.LITTLE_ENDIAN);
例如,长度前缀协议规定 4 字节大端无符号长度。解析前必须确认:
int length = buffer.getInt();
这里得到的是有符号 int。如果协议长度允许达到大于 Integer.MAX_VALUE 的范围,不能直接用 int 表示;实际协议通常会先限制最大消息长度,防止整数溢出和内存耗尽。
6. 文件 I/O:Path、Files 和 FileChannel
6.1 Path 表示路径,Files 执行操作
Path 只是路径对象,不代表目标一定存在:
Path path = Path.of("data", "input.txt");
文件操作由 Files 执行:
String text = Files.readString(path, StandardCharsets.UTF_8);
这会把整个文件加载到内存。它适合配置文件、小型文本或明确受大小限制的内容,不适合直接用于不受信任的大文件。
文件属性和实际读取之间也可能有竞态:
if (Files.exists(path)) {
// 这里到下一行之间文件可能被删除或替换
String text = Files.readString(path);
}
更可靠的方式通常是直接执行目标操作,并根据 NoSuchFileException 等异常处理结果。
6.2 小文件、流式文件和随机访问
三种典型方式的语义不同:
byte[] all = Files.readAllBytes(path);
适合小文件,内存占用大致随文件大小增长。
try (var lines = Files.lines(path, StandardCharsets.UTF_8)) {
lines.filter(line -> !line.isBlank())
.forEach(System.out::println);
}
Files.lines 返回惰性流,底层文件在流关闭时释放,因此必须使用 try-with-resources。终止操作执行前,文件不会被完整读取。
try (FileChannel channel = FileChannel.open(path)) {
ByteBuffer buffer = ByteBuffer.allocate(4096);
while (channel.read(buffer) != -1) {
buffer.flip();
// 处理 buffer 中的内容
buffer.clear();
}
}
FileChannel 允许显式位置访问:
channel.position(1024);
int n = channel.read(buffer);
多个线程共享同一个 FileChannel 时,是否共享和推进同一个当前 position 是重要的并发设计问题。需要独立位置时,可使用带显式位置参数的 read(buffer, position) 与 write(buffer, position),避免依赖共享 position。
6.3 文件读写也可能短读和短写
FileChannel.read(ByteBuffer) 返回实际读入的字节数,不保证填满缓冲区。FileChannel.write(ByteBuffer) 也可能只写出部分字节:
while (buffer.hasRemaining()) {
channel.write(buffer);
}
复制文件时,若使用 transferTo,也必须处理返回值可能小于请求范围甚至为零的情况:
long position = 0;
long size = source.size();
while (position < size) {
long transferred = source.transferTo(
position, size - position, target);
if (transferred == 0) {
// 具体原因取决于通道和平台;不能无条件死循环
throw new IOException("no progress while transferring");
}
position += transferred;
}
transferTo 是通道级传输能力,可能让实现使用更高效的内核路径,但是否真正零拷贝、单次能传输多少,取决于实现和平台,不能把方法名当成性能保证。
6.4 原子替换与持久化是不同问题
常见的安全写文件流程是先写临时文件,再替换目标:
Path target = Path.of("config.json");
Path temp = target.resolveSibling("config.json.tmp");
Files.writeString(
temp,
"{\"enabled\":true}\n",
StandardCharsets.UTF_8);
Files.move(
temp,
target,
StandardCopyOption.REPLACE_EXISTING,
StandardCopyOption.ATOMIC_MOVE);
这里的 ATOMIC_MOVE 能否实现取决于文件系统和提供者;不支持时可能抛出异常,不能假定所有文件系统都具备原子替换能力。
即使替换是原子的,也不自动等价于断电后数据一定持久化。若应用需要更强的持久化保证,通常还要结合 FileChannel.force,并理解目标文件系统的实际语义。
7. 字符流、编码边界和半个字符
Reader/Writer 处理字符,InputStream/OutputStream 处理字节。桥接类 InputStreamReader 和 OutputStreamWriter 负责字符集解码和编码:
import java.io.BufferedReader;
import java.io.BufferedWriter;
import java.nio.charset.StandardCharsets;
import java.nio.file.Files;
import java.nio.file.Path;
Path path = Path.of("message.txt");
try (BufferedWriter writer = Files.newBufferedWriter(
path, StandardCharsets.UTF_8)) {
writer.write("你好,Java");
writer.newLine();
}
try (BufferedReader reader = Files.newBufferedReader(
path, StandardCharsets.UTF_8)) {
for (String line; (line = reader.readLine()) != null; ) {
System.out.println(line);
}
}
必须明确指定字符集。使用平台默认字符集会使同一程序在不同机器上产生不同结果。StandardCharsets.UTF_8 是稳定的协议和文件格式选择,但不是所有外部系统都使用 UTF-8,编码仍应由接口契约确定。
BufferedReader.readLine() 去掉行结束符,因此如果程序需要保留原始换行格式,不能直接用它重建原文件。
更底层地说,CharsetDecoder 可能处于以下状态:
- 输入字节构成完整字符;
- 输入末尾停在多字节字符中间;
- 字节序列非法;
- 还有足够字节但输出字符缓冲区已满。
这说明“读取到一块字节”与“读取到完整字符”是两个不同边界;“读取到一行”则是第三个更高层边界。
8. 网络 Channel:阻塞、非阻塞和 Selector
8.1 阻塞模式
SocketChannel 默认可以以阻塞方式工作:
try (SocketChannel channel =
SocketChannel.open(new InetSocketAddress("example.com", 80))) {
// read/write 可能等待
}
阻塞并不表示一定读满,也不表示一定在有限时间内返回。连接建立、读取和写入都可能等待,除非设置了超时或通过其他方式取消。
8.2 非阻塞模式
非阻塞通道的基本行为是:当前操作无法立即完成时返回,而不是等待。
SocketChannel channel = SocketChannel.open();
channel.configureBlocking(false);
非阻塞读取可能返回:
> 0:读到了字节;0:当前没有可立即读取的数据;-1:对端已经有序关闭输出方向。
0 不是 EOF。若把 0 当成结束,协议消息会被错误截断;若在没有事件时无限循环调用 read,则会产生忙等并消耗 CPU。
Selector 允许一个线程等待多个非阻塞通道的事件。基本结构如下:
try (Selector selector = Selector.open();
ServerSocketChannel server = ServerSocketChannel.open()) {
server.configureBlocking(false);
server.bind(new InetSocketAddress(8080));
server.register(selector, SelectionKey.OP_ACCEPT);
while (!Thread.currentThread().isInterrupted()) {
int ready = selector.select();
if (ready == 0) {
continue;
}
var iterator = selector.selectedKeys().iterator();
while (iterator.hasNext()) {
SelectionKey key = iterator.next();
iterator.remove();
if (!key.isValid()) {
continue;
}
if (key.isAcceptable()) {
SocketChannel client = server.accept();
client.configureBlocking(false);
client.register(selector, SelectionKey.OP_READ,
ByteBuffer.allocate(8192));
} else if (key.isReadable()) {
SocketChannel client = (SocketChannel) key.channel();
ByteBuffer input = (ByteBuffer) key.attachment();
int n = client.read(input);
if (n == -1) {
key.cancel();
client.close();
} else if (n > 0) {
input.flip();
// 解析完整协议消息;不完整部分必须保留
input.compact();
}
}
}
}
}
关键路径是:
flowchart LR
A[监听 SocketChannel] -->|OP_ACCEPT| B[accept 新连接]
B --> C[非阻塞 SocketChannel]
C -->|OP_READ| D[读取到 ByteBuffer]
D --> E[flip]
E --> F[解析协议消息]
F -->|消息不完整| G[compact 保留剩余字节]
F -->|消息完整| H[执行业务并准备响应]
H --> I[注册 OP_WRITE]
I --> J[write 直到 Buffer 没有 remaining]
OP_READ 表示读取操作有机会进行,不表示一个完整请求已经到达。OP_WRITE 通常长期就绪,因此不能一直注册写事件,否则事件循环可能持续被写就绪唤醒。通常只有在确实有待发送数据时才注册 OP_WRITE,写完后取消。
8.3 非阻塞事件循环的状态
每个连接至少需要维护:
- 接收缓冲区;
- 已解析但尚未处理的消息;
- 待发送缓冲区;
- 协议阶段,例如“等待长度”“等待消息体”“已关闭”;
- 超时和最后活动时间;
- 异常关闭原因。
连接不能只用一个 ByteBuffer 和一段 read 调用表示。协议解析必须能够在任意字节边界暂停并继续。
9. 一个完整的长度前缀协议例子
假设协议规定:
[4 字节大端长度][长度字节的 UTF-8 消息体]
例如消息 OK:
00 00 00 02 4f 4b
发送端必须循环写完:
import java.io.IOException;
import java.nio.ByteBuffer;
import java.nio.channels.WritableByteChannel;
import java.nio.charset.StandardCharsets;
public static void writeFrame(
WritableByteChannel channel, String message)
throws IOException {
byte[] body = message.getBytes(StandardCharsets.UTF_8);
if (body.length > 1024 * 1024) {
throw new IOException("message too large");
}
ByteBuffer frame = ByteBuffer.allocate(4 + body.length);
frame.putInt(body.length);
frame.put(body);
frame.flip();
while (frame.hasRemaining()) {
channel.write(frame);
}
}
接收端不能只调用一次 read。可以维护一个接收状态:
import java.io.EOFException;
import java.io.IOException;
import java.nio.ByteBuffer;
import java.nio.channels.ReadableByteChannel;
import java.nio.charset.CharacterCodingException;
import java.nio.charset.CodingErrorAction;
import java.nio.charset.StandardCharsets;
public static String readFrame(ReadableByteChannel channel)
throws IOException {
ByteBuffer header = ByteBuffer.allocate(4);
readFully(channel, header);
header.flip();
int length = header.getInt();
if (length < 0 || length > 1024 * 1024) {
throw new IOException("invalid frame length: " + length);
}
ByteBuffer body = ByteBuffer.allocate(length);
readFully(channel, body);
body.flip();
try {
return StandardCharsets.UTF_8
.newDecoder()
.onMalformedInput(CodingErrorAction.REPORT)
.onUnmappableCharacter(CodingErrorAction.REPORT)
.decode(body)
.toString();
} catch (CharacterCodingException e) {
throw new IOException("invalid UTF-8 frame", e);
}
}
private static void readFully(
ReadableByteChannel channel, ByteBuffer buffer)
throws IOException {
while (buffer.hasRemaining()) {
int n = channel.read(buffer);
if (n == -1) {
throw new EOFException("truncated frame");
}
}
}
这个例子展示了三个独立的边界:
readFully处理底层通道的短读;- 长度前缀处理消息边界;
- UTF-8 解码处理字节到字符的边界。
如果长度字段来自网络,必须先做上限检查,再分配 ByteBuffer。否则攻击者可以发送 0x7fffffff,导致内存分配异常或服务不可用。
在非阻塞模式下,readFully 不能直接使用,因为它会等待直到完成。非阻塞版本必须将“已收到的头部字节数”和“已收到的消息体字节数”保存到连接状态中,每次事件循环只推进当前阶段。
10. TCP、UDP 和 HTTP 的边界差异
10.1 TCP 是字节流
TCP 提供:
- 有序;
- 可靠;
- 无重复;
- 面向连接的字节流。
TCP 不提供:
- 消息边界;
- 一次发送对应一次接收;
- 应用层请求自动超时;
- 对端业务已经完成的确认。
连接关闭后,读取返回 -1,这表示字节流结束。但若协议没有规定 EOF 是消息边界,EOF 也可能意味着截断。
10.2 UDP 保留数据报边界,但有不同风险
UDP 每次接收的是一个数据报,数据报边界由协议栈保留;但它不保证:
- 到达;
- 顺序;
- 不重复;
- 数据报一定不被丢弃或重排。
因此 TCP 需要解决“如何分帧”,UDP 则通常需要解决“如何确认、排序、去重和重传”。
10.3 HTTP 在 TCP 之上定义更高层边界
HTTP 请求和响应有方法、目标、头部、状态码和消息体。消息体的边界由 HTTP 规则确定,例如:
Content-Length;Transfer-Encoding: chunked;- HTTP/2 的帧和流语义;
- 连接关闭,在某些响应场景中作为结束方式。
应用代码不应自行把一次 TCP read 当作一个 HTTP 响应。应使用 HTTP 客户端解析协议。
11. Java HttpClient:连接、TLS、超时和响应体
Java 11 引入的 java.net.http.HttpClient 在 Java 25 中仍是标准 HTTP API。它管理连接建立、HTTP 版本协商、响应头解析、响应体处理和 TLS 集成。
一个完整的请求示例:
import java.net.URI;
import java.net.http.HttpClient;
import java.net.http.HttpRequest;
import java.net.http.HttpResponse;
import java.time.Duration;
public class HttpGetExample {
public static void main(String[] args) throws Exception {
HttpClient client = HttpClient.newBuilder()
.connectTimeout(Duration.ofSeconds(3))
.followRedirects(HttpClient.Redirect.NORMAL)
.build();
HttpRequest request = HttpRequest.newBuilder()
.uri(URI.create("https://example.com/"))
.timeout(Duration.ofSeconds(10))
.header("Accept", "text/html")
.GET()
.build();
HttpResponse<String> response = client.send(
request,
HttpResponse.BodyHandlers.ofString());
System.out.println("status = " + response.statusCode());
System.out.println(response.body());
}
}
这里有两个不同层次的超时:
connectTimeout:建立连接阶段的超时;HttpRequest.timeout:请求级超时,覆盖请求生命周期中的更广泛阶段。
具体异常可能包括 HttpTimeoutException、连接异常、TLS 握手异常或底层 IOException。HTTP 返回 404 或 500 通常不是 send 本身抛异常,而是正常获得了一个带错误状态码的响应。因此必须同时检查:
if (response.statusCode() / 100 != 2) {
throw new IOException(
"unexpected HTTP status: " + response.statusCode());
}
11.1 响应体处理器决定内存和生命周期
HttpResponse.BodyHandlers.ofString()
会把响应体读入内存并按默认或指定字符集解码。大响应应使用文件或流式处理:
HttpResponse<Path> response = client.send(
request,
HttpResponse.BodyHandlers.ofFile(
Path.of("download.bin")));
if (response.statusCode() / 100 != 2) {
Files.deleteIfExists(response.body());
throw new IOException("download failed");
}
下载到文件后仍需检查状态码。某些服务可能在 HTTP 失败状态下返回一个错误页面,若不检查状态码,程序可能把错误响应当作有效文件保存。
处理器为 ofInputStream() 时,响应体的关闭责任由调用方承担:
HttpResponse<InputStream> response = client.send(
request,
HttpResponse.BodyHandlers.ofInputStream());
try (InputStream body = response.body()) {
// 读取并关闭响应体
}
响应体未关闭或未消费完,可能影响连接复用和资源回收。连接池是否复用、何时创建物理连接,属于客户端实现和配置行为;程序不应把每次请求都等同于一次新 TCP 连接。
11.2 TLS 不是“普通字节流直接加密”
HTTPS 的数据路径大致是:
sequenceDiagram
participant App as 应用程序
participant HTTP as HttpClient
participant TLS as TLS 层
participant TCP as TCP Socket
participant Server as 服务器
App->>HTTP: HttpRequest
HTTP->>TCP: 建立连接
TCP->>Server: TCP 建连
HTTP->>TLS: TLS 握手
TLS->>Server: 协商版本、密码套件、证书
Server-->>TLS: 握手结果
App->>HTTP: send()
HTTP->>TLS: HTTP 字节
TLS->>TCP: 加密记录
TCP->>Server: 字节流
Server-->>TCP: 响应字节流
TCP-->>TLS: TLS 记录
TLS-->>HTTP: 明文 HTTP 数据
HTTP-->>App: status、headers、body
证书校验、主机名校验、TLS 版本和密码套件属于 TLS 层,不应通过“关闭校验”来掩盖配置问题。生产环境中若出现证书链、过期、主机名不匹配或信任库错误,应修复信任配置,而不是安装接受任意证书的 SSLContext。
12. 阻塞 I/O、异步 I/O 和虚拟线程的取舍
12.1 阻塞模型的优点
阻塞代码通常更容易表达完整流程:
连接
→ 读取固定头部
→ 解析长度
→ 读取完整消息体
→ 处理
→ 写回响应
→ 关闭
每个请求可以拥有局部变量和清晰的异常路径。对于连接数有限、业务逻辑复杂、需要调用多个阻塞 API 的服务,这种模型可读性较好。
12.2 非阻塞模型的代价
Selector 模型通过少量线程管理大量连接,但必须显式维护每个连接的状态。业务代码会从“调用直到完成”变成“每次事件推进一小步”。
它适合:
- 大量长连接;
- 协议状态机明确;
- 每次事件处理必须短小;
- 可以接受更复杂的缓冲区和背压管理。
12.3 异步文件通道不是普通线程池的语法糖
AsynchronousFileChannel 以异步操作和完成处理器或 Future 返回结果。它适合将文件操作与其他任务组合,但底层执行方式受平台和实现影响,不能仅凭 API 名称断言一定由硬件异步完成。
Java 25 中,虚拟线程可以降低大量阻塞式任务的线程资源成本,使“每连接一个任务”的代码在许多场景下更有吸引力。但虚拟线程不会消除:
- 文件系统本身的吞吐限制;
- 网络带宽限制;
- 连接和请求超时;
- 缓冲区内存占用;
- 下游服务背压;
- 不可中断的本地操作或同步锁竞争。
选择模型的核心仍是状态管理、并发规模、阻塞依赖和可观测性,而不是“Channel 一定比 Stream 快”。
13. 背压:数据生产速度超过消费速度时会发生什么
假设生产者速率为 ,消费者速率为 ,缓冲区容量为 。
当:
积压量会增长,直到:
之后系统必须选择一种行为:
- 阻塞生产者;
- 暂停注册写事件;
- 丢弃数据;
- 限制单连接内存;
- 关闭慢连接;
- 将数据持久化到磁盘或队列。
网络写入中,SocketChannel.write 返回较少字节是背压信号。不能为了“尽快写完”在非阻塞事件循环中反复调用,应该保留未写出的 ByteBuffer,等待下一次 OP_WRITE。
文件上传也有同样问题:如果读取速度大于远端发送速度,内存中的待发送数据会不断增加。流式复制的缓冲区不应无界增长。
14. 常见误解与失败表现
14.1 把一次 read 当成完整消息
失败表现:
- JSON 被截成半段;
- 长度字段读不完整;
- 偶发
EOFException; - 压测时出现协议解析错误,低负载时却正常。
**原因:**读取边界是系统调用结果,不是应用消息边界。
**修复:**根据固定长度、长度前缀或分隔符循环解析。
14.2 忘记 flip()
失败表现:
buffer.put(data);
channel.write(buffer);
写入结果为 0,或发送了错误区域。
原因:put 后 position 在数据末尾,通道从当前位置开始读,remaining() 可能为 0。
修复:
buffer.flip();
while (buffer.hasRemaining()) {
channel.write(buffer);
}
14.3 把 clear() 当成“清空未处理数据”以外的操作
clear() 只重置状态,不擦除内存。更严重的是,它会让未消费数据变得不可见,导致半条消息丢失。需要保留剩余数据时应使用 compact()。
14.4 把 EOF、超时和异常关闭混为一谈
- EOF:对端正常结束输入方向,读取返回
-1; - 超时:在规定时间内没有完成目标操作;
IOException:底层 I/O 失败;- 协议错误:字节流仍然可读,但内容不符合协议。
不同原因对应不同恢复策略。EOF 可能是正常响应结束,也可能是截断;必须结合协议状态判断。
14.5 把 flush() 当成持久化或网络确认
flush() 通常只推动 Java 缓冲层向下层写出。它不保证:
- 文件已落盘;
- 对端已读取;
- 对端业务已提交;
- HTTP 服务已成功处理。
这些都需要更高层的确认机制。
14.6 把 Files.lines 当成普通集合
var lines = Files.lines(path);
这是一个持有底层资源的惰性流。若不关闭,可能泄漏文件描述符。正确方式是:
try (var lines = Files.lines(path)) {
lines.forEach(System.out::println);
}
14.7 只捕获异常消息,不保留原因
catch (IOException e) {
throw new RuntimeException("read failed");
}
这样会丢失原始堆栈和错误类型。应保留 cause:
catch (IOException e) {
throw new RuntimeException("read failed", e);
}
15. 诊断 I/O 问题的顺序
遇到文件或网络数据错误时,可以按数据路径定位:
来源
→ 原始字节
→ Buffer/Stream 读取
→ 分帧
→ 字符集解码
→ 协议解析
→ 业务处理
→ 输出编码
→ Channel/Stream 写出
→ 对端或文件系统
15.1 先确认边界
记录以下信息通常比打印完整内容更有效:
- 每次
read返回的字节数; - 当前 buffer 的
position、limit、remaining; - 已接收的协议头和消息体长度;
- 是否出现
-1、0或短写; - 连接处于哪个协议状态;
- 最后一次成功读写的时间。
日志中应避免直接记录敏感数据;必要时记录长度、哈希或有限的十六进制前缀。
15.2 再确认字符集
如果出现乱码或替换字符,应确认:
- 写入方使用的字符集;
- 读取方使用的字符集;
- 是否在字节块边界上错误地重复解码;
- 是否把压缩、加密或二进制数据当成文本;
- 是否存在 BOM、协议头或额外前缀。
15.3 最后区分对端和本地故障
网络故障可以分为:
- DNS 解析失败;
- TCP 连接失败;
- TLS 握手失败;
- HTTP 请求发送失败;
- HTTP 响应超时;
- HTTP 状态码表示的业务失败;
- 响应体格式错误;
- 本地消费响应体失败。
把所有错误都归类为“请求失败”会掩盖重试是否安全、指标应归属哪一层以及应该修复客户端还是服务端。
16. 如何选择 API
可以按数据规模、访问模式和边界要求选择:
| 需求 | 通常适合的 API |
|---|---|
| 读取小型文本配置 | Files.readString |
| 读取小型二进制文件 | Files.readAllBytes |
| 按行处理大型文本 | Files.newBufferedReader 或 Files.lines |
| 顺序复制字节 | InputStream / OutputStream |
| 文件随机访问 | FileChannel |
| 文件区域映射 | FileChannel.map,需评估映射范围和生命周期 |
| 一个线程管理大量 TCP 连接 | 非阻塞 SocketChannel + Selector |
| 连接级业务流程清晰 | 阻塞 I/O,必要时配合虚拟线程 |
| HTTP、TLS、重定向和状态码 | HttpClient |
| 固定协议消息 | ByteBuffer 加明确的分帧状态机 |
Stream、Channel 和 Buffer 不是简单的性能等级关系:
- Stream 隐藏了部分位置和缓冲细节,适合顺序处理;
- Channel 暴露了通道能力和 I/O 方向;
- Buffer 保存尚未消费的数据,并让程序显式管理读写状态;
- 文件和网络只是不同的通道来源,它们的完成、关闭、短读、持久化和边界语义并不相同。
真正可靠的 I/O 程序,必须同时明确四件事:
- 数据是什么:原始字节、字符还是协议消息;
- 边界在哪里:固定长度、长度前缀、分隔符、EOF 还是 HTTP 规则;
- 状态如何推进:短读、短写、半个字符和半条消息如何保存;
- 失败如何传播:超时、EOF、协议错误、资源关闭和重试分别意味着什么。
当这些问题被明确后,Stream 的顺序读取、Channel 的通道操作、Buffer 的状态转换,以及文件和网络的不同边界,就能在同一个可验证的模型中统一起来。
系列导航与关联阅读
- 系列入口:Java 完整学习路线:从 Java 25 语言与 JVM 到 Spring、微服务和生产交付
- 上一篇:Java 异常处理:受检异常、错误链、资源关闭和 API 契约
- 下一篇:Java 日期时间:Instant、LocalDateTime、ZoneId、Duration 和序列化
- 延伸:Java 网络与 HTTP Client:连接、TLS、超时、流和错误恢复
官方资料
本文依据 Java、Spring 与相关项目官方文档重新梳理;正文、示例与生产清单由 WR BLOG 编写。

评论
0 条讨论