Java 基础体系 · 第 8/100 篇。示例统一以 Java 25 LTS 为语言和 JVM 基线;框架示例使用与其兼容的现代稳定版本。

Java I/O 与 NIO:Stream、Channel、Buffer、文件和网络边界

Java 的 I/O API 同时包含两套容易混淆、但抽象层次不同的模型:

  • java.ioStream 为中心:程序从输入流读取字节,或向输出流写入字节。
  • java.nioChannel + Buffer 为中心:程序在缓冲区和通道之间搬运数据,并显式管理缓冲区状态。
  • java.nio.file 将文件系统操作建立在 PathFilesFileChannel 等 API 之上。
  • java.netjava.net.http 则把这些原语应用到网络连接、协议分帧、TLS 和 HTTP。

这些 API 都处理“字节如何移动”,但不会自动替应用程序决定:

  1. 一次 read 是否读完整个消息;
  2. 一个文件是否适合一次性加载到内存;
  3. 一段字节是否正好对应完整字符;
  4. TCP 的一次写入是否对应对端的一次读取;
  5. HTTP 响应体是否可以无限制地读入内存;
  6. 发生异常时,已经打开的资源和已经消费的数据如何处理。

因此,理解 Java I/O 的关键不是记住更多方法,而是区分 数据容器、数据通道、边界定义和失败状态


1. 先建立统一模型:字节、字符、消息和资源

1.1 字节是 I/O 的底层单位

文件和网络通常都以字节序列表示:

B=b0,b1,b2,,bn1B = b_0, b_1, b_2, \ldots, b_{n-1}

其中每个 bib_i 的取值范围是 0255。Java 的 byte 是有符号的,取值范围是 -128127,但它仍然可以承载一个原始字节;需要无符号值时可以使用:

int unsignedValue = oneByte & 0xff;

I/O API 中的 int 返回值通常有特殊含义。例如:

int value = input.read();
  • 0255:读取到一个字节;
  • -1:已经到达输入结束;
  • 其他负值不是合法的单字节数据。

InputStream.read(byte[]) 则返回实际读取的字节数:

  • > 0:本次读取了这些字节;
  • -1:输入结束;
  • 0:通常只会在请求长度为零,或特定实现允许的情况下出现,不能把它当成“没有数据但以后一定有数据”。

1.2 字符是字节经过编码后的解释

文本不是天然的字节。字符集编码定义了字符序列 CC 与字节序列 BB 之间的转换:

encodecharset(C)=B\text{encode}_{charset}(C) = B

例如,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.InputStreamOutputStreamReaderWriter,不要与 java.util.stream.Stream 混淆。后者是集合数据的惰性计算管道,不是文件或网络 I/O 的字节通道。

2.1 InputStreamOutputStream

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;
    }
}

这段代码的逻辑是:

  1. read(buffer) 尝试读取数据;
  2. 返回 n 表示本次只有 buffer[0..n) 有效;
  3. write(buffer, 0, n) 只写有效区域;
  4. 返回 -1 表示输入结束;
  5. 输入结束后方法返回累计字节数。

常见错误是写成:

int n = in.read(buffer);
out.write(buffer); // 错误:可能把上一次残留数据也写出去

如果本次只读到 3 字节,而上次读到 8192 字节,out.write(buffer) 会把本次读取后仍残留的旧数据一并写出。

2.2 read 不保证填满请求长度

InputStream.read(byte[], off, len) 的契约通常只保证:

0<nlen0 < n \le 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 端点

Channeljava.nio.channels.Channel 体系的核心抽象。与 Stream 相比,它更明确地表示一个可打开、可关闭的 I/O 端点,并通常与 Buffer 配合使用。

常见类型包括:

  • FileChannel:文件的随机访问、定位、锁、映射和高效传输;
  • SocketChannel:TCP 套接字;
  • ServerSocketChannel:TCP 监听套接字;
  • DatagramChannel:UDP;
  • AsynchronousFileChannel:异步文件 I/O;
  • ReadableByteChannelWritableByteChannel:读写能力接口。

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:可选标记。

始终满足:

0markpositionlimitcapacity0 \le mark \le position \le limit \le capacity

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 allocateallocateDirect

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:PathFilesFileChannel

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 处理字节。桥接类 InputStreamReaderOutputStreamWriter 负责字符集解码和编码:

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");
        }
    }
}

这个例子展示了三个独立的边界:

  1. readFully 处理底层通道的短读;
  2. 长度前缀处理消息边界;
  3. 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 返回 404500 通常不是 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. 背压:数据生产速度超过消费速度时会发生什么

假设生产者速率为 PP,消费者速率为 CC,缓冲区容量为 KK

当:

P>CP > C

积压量会增长,直到:

积压K\text{积压} \ge K

之后系统必须选择一种行为:

  • 阻塞生产者;
  • 暂停注册写事件;
  • 丢弃数据;
  • 限制单连接内存;
  • 关闭慢连接;
  • 将数据持久化到磁盘或队列。

网络写入中,SocketChannel.write 返回较少字节是背压信号。不能为了“尽快写完”在非阻塞事件循环中反复调用,应该保留未写出的 ByteBuffer,等待下一次 OP_WRITE

文件上传也有同样问题:如果读取速度大于远端发送速度,内存中的待发送数据会不断增加。流式复制的缓冲区不应无界增长。


14. 常见误解与失败表现

14.1 把一次 read 当成完整消息

失败表现:

  • JSON 被截成半段;
  • 长度字段读不完整;
  • 偶发 EOFException
  • 压测时出现协议解析错误,低负载时却正常。

**原因:**读取边界是系统调用结果,不是应用消息边界。

**修复:**根据固定长度、长度前缀或分隔符循环解析。

14.2 忘记 flip()

失败表现:

buffer.put(data);
channel.write(buffer);

写入结果为 0,或发送了错误区域。

原因:putposition 在数据末尾,通道从当前位置开始读,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 的 positionlimitremaining
  • 已接收的协议头和消息体长度;
  • 是否出现 -10 或短写;
  • 连接处于哪个协议状态;
  • 最后一次成功读写的时间。

日志中应避免直接记录敏感数据;必要时记录长度、哈希或有限的十六进制前缀。

15.2 再确认字符集

如果出现乱码或替换字符,应确认:

  1. 写入方使用的字符集;
  2. 读取方使用的字符集;
  3. 是否在字节块边界上错误地重复解码;
  4. 是否把压缩、加密或二进制数据当成文本;
  5. 是否存在 BOM、协议头或额外前缀。

15.3 最后区分对端和本地故障

网络故障可以分为:

  • DNS 解析失败;
  • TCP 连接失败;
  • TLS 握手失败;
  • HTTP 请求发送失败;
  • HTTP 响应超时;
  • HTTP 状态码表示的业务失败;
  • 响应体格式错误;
  • 本地消费响应体失败。

把所有错误都归类为“请求失败”会掩盖重试是否安全、指标应归属哪一层以及应该修复客户端还是服务端。


16. 如何选择 API

可以按数据规模、访问模式和边界要求选择:

需求 通常适合的 API
读取小型文本配置 Files.readString
读取小型二进制文件 Files.readAllBytes
按行处理大型文本 Files.newBufferedReaderFiles.lines
顺序复制字节 InputStream / OutputStream
文件随机访问 FileChannel
文件区域映射 FileChannel.map,需评估映射范围和生命周期
一个线程管理大量 TCP 连接 非阻塞 SocketChannel + Selector
连接级业务流程清晰 阻塞 I/O,必要时配合虚拟线程
HTTP、TLS、重定向和状态码 HttpClient
固定协议消息 ByteBuffer 加明确的分帧状态机

StreamChannelBuffer 不是简单的性能等级关系:

  • Stream 隐藏了部分位置和缓冲细节,适合顺序处理;
  • Channel 暴露了通道能力和 I/O 方向;
  • Buffer 保存尚未消费的数据,并让程序显式管理读写状态;
  • 文件和网络只是不同的通道来源,它们的完成、关闭、短读、持久化和边界语义并不相同。

真正可靠的 I/O 程序,必须同时明确四件事:

  1. 数据是什么:原始字节、字符还是协议消息;
  2. 边界在哪里:固定长度、长度前缀、分隔符、EOF 还是 HTTP 规则;
  3. 状态如何推进:短读、短写、半个字符和半条消息如何保存;
  4. 失败如何传播:超时、EOF、协议错误、资源关闭和重试分别意味着什么。

当这些问题被明确后,Stream 的顺序读取、Channel 的通道操作、Buffer 的状态转换,以及文件和网络的不同边界,就能在同一个可验证的模型中统一起来。


系列导航与关联阅读

官方资料

本文依据 Java、Spring 与相关项目官方文档重新梳理;正文、示例与生产清单由 WR BLOG 编写。