InputStream · Reader · 装饰器 · BufferedInputStream · 字符集 · UTF-8 · 系统调用 · ByteBuffer · position / limit / capacity · flip · compact · 直接缓冲区 · FileChannel · transferTo · sendfile · Files · Path

IO 与 NIO 基础:字节怎么进来,怎么出去

Java 的 IO 只在做两件事:搬字节,以及把字节和字符互相翻译。搬字节最贵的不是搬,而是每次找操作系统要数据的那一下(系统调用),所以永远要成块地搬:一次一个字节复制 2 MB 要 200 多万次系统调用、2 秒多,加一层 8 KB 缓冲就只剩两三百次。翻译这一步唯一要做对的是:写和读用同一个字符集,否则就是乱码。NIO 的 ByteBuffer 和 Channel 把“成块”这件事摆到了台面上,还能让内核直接在文件之间搬(transferTo),数据都不进 JVM。

全景:一个订单对象的一生 · 第 1、11 站地图:J01 · IO 与 NIO 基础(下一圈)上一圈(连接和线程):网络 IO 模型实验:JDK 17.0.20 + strace 实测

①为什么需要:65 个字节进来,172 个字节出去

在全景第 1 站,这一步要解决的是:Tomcat 的 NIO 线程从 SocketChannel 把字节读进一个 ByteBuffer,交给业务的是一个 CoyoteInputStream;Jackson 再把这 65 个字节按 UTF-8 翻译成字符、解析成 record。第 11 站反过来,172 个字节的 JSON 写回连接。上一圈的网络 IO 模型讲的是“一万个连接要几个线程”;这一圈讲手里那几个字节:用什么搬、怎么翻译、怎么不出错。交易靶场里自己写 IO 的地方也不少:导出对账 CSV、读商品导入文件、复制上传的附件。

内核 socket65 个字节到了 SocketChannel→ ByteBuffer InputStreamCoyoteInputStream UTF-8 解码字节 → 字符 PlaceOrderCmd全景第 2 站 第 11 站,反过来: Order → UTF-8 编码成 172 个字节 → OutputStream(先攒在缓冲里)→ Channel → 内核 → 网卡 本页的三个主角:搬字节的“流”和“通道”、装字节的“缓冲”、字节和字符之间的“字符集”。
需求工作要用:导出、导入、上传、下载、读配置、调外部接口,每一个都在写 IO;最常见的线上问题是乱码、慢、文件句柄泄漏。⑤要能直接抄,⑥先讲这三个坑。

②没有它会怎样:一次一个字节地搬

最直白的写法:while ((b = in.read()) != -1) out.write(b);。能用,结果也对。可每一次 read() 都要穿过 JVM、进内核拿 1 个字节,再回来。

实测 复制同一个 2 MB 文件。系统调用次数用 strace -c 数,已减去 JVM 启动本身的那几十次;耗时是不带 strace 时单次运行。条形按对数刻度画。

一次一个字节read 约 210 万 · 2183 ms
BufferedInputStreamread 263 · 64 ms
自己用 8 KB 数组read 263 · 2.4 ms
Files.copysendfile 2 · 2.8 ms
FileChannel.transferTosendfile 1 · 4.1 ms

不懂精华时的典型误用:给 FileReader 套了 BufferedReader,却没指定字符集。开发机是 UTF-8,部署到一台默认 GBK 的 Windows 服务器上,导出的对账文件全成了乱码(⑥ 坑 1)。

③相邻方案:java.io、java.nio、Files

java.io(JDK 1.0 起)java.nio(JDK 1.4)java.nio.file(NIO.2,JDK 7)
核心概念流:InputStream / OutputStream(字节),Reader / Writer(字符),一个方向Channel + ByteBuffer,双向;Selector 多路复用Path + Files 工具类
阻塞吗阻塞文件 Channel 阻塞,Socket Channel 可以非阻塞阻塞
擅长按顺序读写、和各种库对接(几乎所有 API 都收流)网络服务器(Tomcat、Netty)、零拷贝、内存映射、随机读写日常文件操作:一行读写、遍历目录、复制、移动、属性
本页怎么选和库对接、处理请求体大文件复制、要直接控制缓冲时只要是文件,先找 Files 有没有现成的

选择规则:操作文件先用 Files(读写全部、按行流式读、复制);对接库就用流,记得包缓冲、写明字符集;网络服务器和大文件拷贝交给 NIO 的 Channel,这一层多半由 Tomcat、Netty、Kafka 替你写好了。

④原理:搬字节、翻译字符、三个指针

先知道这些
  • 系统调用:程序要读文件、读网络,只能请内核代劳(read、write),每次都要从用户态切到内核态再切回来。详见网络 IO 模型④的“一次读分两段”。
  • 字符集:同一个字“机”,在 UTF-8 里是 3 个字节,在 GBK 里是 2 个字节。字节本身不带“我是什么编码”的标记。
  • try-with-resources:见异常那页④-1。

④-1流:一层层套起来的装饰器

BufferedReader:字符缓冲,readLine() InputStreamReader(…, UTF_8):字节 → 字符(解码),字符集在这一层定 BufferedInputStream:一次向下要 8192 字节,存着慢慢给 FileInputStream:每次 read 都是一次系统调用
每一层只加一样本事,可以随意组合:这就是装饰器模式。Files.newBufferedReader(path, UTF_8) 一行就替你把这几层套好了。字节流(Stream)和字符流(Reader / Writer)的分界线就是中间那层编解码。

④-2字符集:同样的字节,换个字典就是乱码

④-3ByteBuffer:一块数组,加上三个指针

点按钮操作一个 16 字节的 ByteBuffer,看 position、limit、capacity 怎么动。先想好再点:flip 之后 limit 是几?

模拟和 ⑤ 的 JDK 17 实测一致(allocate(16) → put 5 字节 → flip → get 4 → compact,指针依次是 0/16、5/16、0/5、4/5、1/16),逻辑在 node 里跑过。

④-4Channel 和零拷贝:让内核自己在两个文件之间搬

read + write:数据进出 JVM 两次 用户态(JVM)内核态 byte[8192] 源文件页缓存 目标文件页缓存 read:拷进来 write:拷出去 每 8 KB:2 次系统调用、2 次拷贝。实测 2 MB:read 263 次、write 约 270 次。 transferTo:Linux 上就是一次 sendfile in.transferTo(0, n, out) 源文件页缓存 目标文件页缓存 内核里直接搬 数据不进 JVM。实测 2 MB:sendfile 1 次;Files.copy 底下也是 sendfile(2 次)。 Kafka 把磁盘上的消息发给消费者走的也是这条路(文件 → socket),见 Kafka 存储与零拷贝 那页。

最少语法

Path p = Path.of("orders-20261109.csv");
try (BufferedWriter w = Files.newBufferedWriter(p, StandardCharsets.UTF_8)) { w.write(…); w.newLine(); }
List<String> all = Files.readAllLines(p, UTF_8);                    // 小文件
try (Stream<String> lines = Files.lines(p, UTF_8)) { … }             // 大文件:惰性,必须关
Files.copy(src, dst, StandardCopyOption.REPLACE_EXISTING);
try (InputStream in = new BufferedInputStream(req.getInputStream())) { … }   // 对接库
ByteBuffer b = ByteBuffer.allocate(8192);  ch.read(b);  b.flip();  … b.compact();
try (FileChannel in = FileChannel.open(src, READ); FileChannel out = FileChannel.open(dst, CREATE, WRITE)) {
    for (long pos = 0, n = in.size(); pos < n; ) pos += in.transferTo(pos, n - pos, out);
}

⑤亲眼看到:交易靶场的导出、导入和附件复制

Demo:导出对账 CSV、用三种字符集读同一份字节、读 10 万行的商品导入文件、ByteBuffer 的指针。Copy:同一个 2 MB 附件五种复制法,配 strace 数系统调用。完整代码:study-labs/java/javase/io-nio-basics →

1Demo.java 节选,JDK 17
// 1. 导出对账 CSV:字符集写明
try (BufferedWriter w =
       Files.newBufferedWriter(csv, StandardCharsets.UTF_8)) {
  w.write("订单号,用户,商品,应付"); w.newLine();
  for (OrderRow r : rows) { w.write(r.id() + "," + …); w.newLine(); }
}

// 2. 同一份字节,三种字符集
byte[] bytes = Files.readAllBytes(csv);
new String(bytes, UTF_8);
new String(bytes, Charset.forName("GBK"));
new String(kb, 0, 4, UTF_8);       // 只取 4 个字节

// 3. 10 万行导入文件:惰性读,必须关
try (Stream<String> lines = Files.lines(imp)) {
  lines.map(l -> l.split(","))
       .filter(f -> Integer.parseInt(f[2]) > 900).count();
}

// 4. ByteBuffer
ByteBuffer buf = ByteBuffer.allocate(16);
buf.put("9527,".getBytes(US_ASCII));
buf.flip();  buf.get(new byte[4]);  buf.compact();
2运行 JDK 17.0.20,2 核 ARM,默认字符集 UTF-8
== 1. 导出对账 CSV ==
  写了 94 字节,3 行
== 2. 同一份字节,用错字符集 ==
  UTF-8 读:9527,1024,机械键盘,578.00
  GBK   读:9527,1024,鏈烘閿洏,578.00
  ISO-8859-1 读:9527,1024,溅ç,578.00
  “机械键盘” UTF-8 是 12 字节;
  只取前 4 个字节解码:机�
== 3. 读商品导入文件 ==
  2117 KB、10 万行,单价 > 900 的有 9999 个
== 4. ByteBuffer ==
  allocate(16)  position= 0 limit=16 capacity=16
  put 5 字节    position= 5 limit=16 capacity=16
  flip()        position= 0 limit= 5 capacity=16
  get 4 字节    position= 4 limit= 5 capacity=16
  compact()     position= 1 limit=16 capacity=16
  忘了 flip 就去读:还剩 12 字节可读,读到的是 0
  allocate:hasArray=true;allocateDirect:hasArray=false

$ strace -f -c -e trace=read,write,sendfile java … Copy <模式> src.bin dst.bin
  模式       read        write       sendfile   耗时(不带 strace)
  byte       2,097,159   2,097,165   -          2183.4 ms
  buffered   263         268         -            63.6 ms
  array      263         271         -             2.4 ms
  files      6           16          2             2.8 ms
  transfer   9           13          1             4.1 ms

系统调用次数已减去只启动 JVM 时的基线(read 55、write 10)。ISO-8859-1 那行里有不可见字符,中间用 … 略去。耗时只跑一次,只看量级。

到这里已经能交差了:对账文件能导出、不乱码,导入文件能流式读完、句柄会关,附件复制不经过 JVM。
已知还没处理的细节:Excel 打开 UTF-8 CSV 乱码(⑥-1)、readAllBytes 读大文件(⑥-1)、写完没 flush(⑥-1)、直接缓冲区泄漏(⑥-1)。

⑥细节·规范·优化

第一圈

注意事项:不知道就会出事故

坑 1没写字符集,换台机器就乱码← ④-2 默认字符集看平台
现象
new FileReader(f)、new String(bytes)、getBytes() 在开发机正常,部署到 Windows 或 locale 不对的容器里,中文全成了“鏈烘閿洏”。⑤ 实测 GBK 读
根源
不写字符集就用平台默认值,JDK 17 及以前它跟着操作系统走。
做法
所有字节和字符的转换都显式写 StandardCharsets.UTF_8;JDK 18 起默认就是 UTF-8,但写明仍然是好习惯(老代码可能跑在 8、17 上)。
坑 2UTF-8 的 CSV,Excel 打开是乱码历史原因
现象
对账文件在代码里读、在 Mac 上看都正常,财务用 Windows 版 Excel 双击打开全是乱码。
根源
Excel 打开 CSV 时,没有 BOM(文件开头的 EF BB BF 三个字节)就按系统的本地编码读,中文 Windows 上是 GBK。这是 Excel 的约定,和 Java 无关。
做法
给财务的 CSV 开头先写一个 '\uFEFF';或者直接导出 xlsx。程序之间交换的 CSV 不要加 BOM。
坑 3Files.lines、流、Channel 没关:文件句柄耗尽← ④-1 流背后是系统资源
现象
服务跑几天后报 Too many open files,新连接也接不进来(socket 也占句柄)。
根源
每个打开的流背后是一个操作系统文件描述符,数量有上限(常见 1024 或 65535);Files.lines 是惰性的,不关就一直开着;GC 回收对象时不保证及时关闭。
做法
一律 try-with-resources;排查用 ls /proc/<pid>/fd | wc -l 和 lsof -p。
坑 4readAllBytes / readAllLines 读大文件← ④-1 一次读进内存
现象
导入一个 2 GB 的商品文件,OOM;或者上传接口用 getInputStream().readAllBytes(),被人传了个大文件拖垮。
根源
这两个方法把整个文件读进一个数组 / List,文件多大就占多少堆。
做法
大文件用 Files.lines 或 BufferedReader 一行行读,或者按块读;上传接口限制大小(Spring 的 spring.servlet.multipart.max-file-size)。
坑 5写完没关、没 flush:文件尾巴丢了← ④-1 缓冲攒着还没写
现象
导出的对账文件少了最后几十行,或者是 0 字节。
根源
BufferedWriter 攒满 8192 个字符才真正往下写,最后不满的那一截要等 flush() 或 close()。
做法
try-with-resources 关闭时自动 flush;要在中途让别人看到,手动 flush。
坑 6直接缓冲区用满或泄漏:堆没满却 OOM← ④-4 直接缓冲在堆外
现象
OutOfMemoryError: Direct buffer memory,可堆转储里看不到大对象;容器的内存用量比 -Xmx 大很多,被 OOM Killer 杀掉。
根源
直接缓冲区占的是堆外内存,Java 堆里只有一个很小的 DirectByteBuffer 对象指着它;要等这个小对象被 GC 回收,堆外那块才释放。
做法
不要频繁 allocateDirect,复用;设 -XX:MaxDirectMemorySize;容器内存要给堆外留余量(J03、J14 会再讲)。
第二圈

使用规范

文件操作先找 Files

readString / writeString(JDK 11)、readAllLines、lines、copy、move、walk。少自己 new FileInputStream。

样板代码、漏关、漏缓冲。

字节与字符转换,永远写字符集

StandardCharsets.UTF_8,别用字符串 "utf-8"(还要处理异常)。

坑 1。

流一定包缓冲,或者按块读

FileInputStream / socket 流外面套 Buffered,或者自己用 8 KB 以上的数组。

每字节一次系统调用(实测 2 秒对 2 毫秒)。

所有资源 try-with-resources

流、Reader、Channel、Files.lines、DirectoryStream。

坑 3、坑 5。

大文件流式处理

导入用 lines 边读边处理,导出边查边写,不要先全读进 List。

坑 4。

大文件复制、文件发到网络,用 transferTo

或者 Files.copy;不要自己读进堆再写出去。

两次拷贝和大量系统调用。

第三圈

深处

为什么 BufferedInputStream 比自己用数组慢 25 倍

系统调用一样多(都是两三百次),差别在 Java 这一侧:buffered 模式仍然是 read() 一次一个字节,200 万次方法调用,每次都要进 BufferedInputStream.read,它是 synchronized 的(JDK 17 源码;JDK 21 源码里改成了内部锁,为的是不卡住虚拟线程),还要检查指针、边界。自己用数组则是 263 次 read(byte[]),每次一块。所以“包了 Buffered 就够了”只对了一半:能按块读的地方,就按块读。

内存映射文件:MappedByteBuffer

FileChannel.map 把文件映射进进程的地址空间,读写这块内存就是读写文件,由操作系统按页加载、回写,适合大文件的随机读写(数据库、搜索引擎的索引文件常用)。代价:映射期间文件删不掉(尤其 Windows),JDK 里没有正式的 unmap API(要等 GC),映射大小受地址空间限制。Kafka 的索引文件、RocketMQ 的 CommitLog 都用它。

版本差异

JDK 1.4NIO:Buffer、Channel、Selector、Charset
JDK 7NIO.2:Path、Files、WatchService、异步 Channel;try-with-resources;StandardCharsets
JDK 9InputStream.transferTo(OutputStream)、readAllBytes()
JDK 11Files.readString / writeString,Path.of
JDK 18默认字符集统一为 UTF-8(JEP 400)

按文档写;JDK 17 的默认字符集行为在本机实测为 UTF-8,因为 locale 是 C.UTF-8。

Tomcat 里的这一层(接全景第 1、11 站)

Tomcat 的 NIO 连接器用 SocketChannel 把字节读进它自己管理的 ByteBuffer(读请求头时会遇到“半个请求”,就是④-3 的 compact 场景);给业务的 request.getInputStream() 是一层 InputStream 包装(CoyoteInputStream),读的是同一块缓冲。写响应时,response.getOutputStream() 也先写进缓冲(默认 8 KB 左右),满了或请求结束才真正写回 socket。所以第 11 站那 172 字节是在请求结束、缓冲被 flush 时一次写出去的。

面试官会怎么问
字节流和字符流有什么区别?

字节流搬原始字节;字符流在字节流外面加一层按字符集的编解码。读写文本用字符流并写明字符集,读写图片、压缩包等二进制用字节流(④-1)。

为什么要用缓冲流?

每次 read / write 都是一次系统调用,成本主要在进出内核;缓冲把很多次小读写合并成一次 8 KB 的大读写(实测 200 万次 → 两三百次)(②、④-1)。

ByteBuffer 的 flip、clear、compact 有什么区别?

flip:limit = position,position = 0,写完转读;clear:position = 0,limit = capacity,不清数据,转写;compact:把没读完的挪到最前面,再转写(④-3)。

什么是零拷贝?Java 里怎么用?

数据在内核里直接从一个文件(或页缓存)搬到另一个文件或 socket,不拷进用户态。Java 用 FileChannel.transferTo / transferFrom,Linux 上底层是 sendfile;Files.copy 在 Linux 上也走 sendfile(实测)(④-4)。

堆内缓冲和直接缓冲怎么选?

短命的、小的用堆内;长期复用、频繁做 IO 的用直接缓冲(省掉一次拷贝),但分配回收慢、在堆外,要设上限、要复用(④-4、坑 6)。

下一圈

↺回到⑤:改造你的导出和导入

1. 让财务的 Excel 不乱码

给对账 CSV 开头写一个 '\uFEFF',用 xxd orders-*.csv | head -1 看文件开头是不是 efbb bf。

用到:坑 2

2. 复现句柄泄漏

写个循环调 2000 次 Files.lines(imp).count(),不关;运行时看 ls /proc/<pid>/fd | wc -l,再用 ulimit -n 256 跑一次看报错;改成 try-with-resources 再看。

用到:坑 3

3. 用 ByteBuffer 拆“半条消息”

把“9527,1024,88\n9528,20”分两次 put 进 16 字节的缓冲,每次 flip 后按换行切出完整的一行,剩下的 compact,第二次补上“48,66\n”后切出第二行。

用到:④-3 compact

自测

1. 用一句话说出这页的精华。

IO 就是成块地搬字节、用对字符集翻译字符;贵的是系统调用,所以要缓冲,能让内核直接搬就别让数据进 JVM。

2. 一次一个字节地 read,疼在哪?

每个字节一次系统调用,2 MB 就是 200 万次,2 秒多(②)。

3. 换台服务器导出文件就乱码,根源是哪条原理?

没写字符集时用平台默认,JDK 17 及以前它跟操作系统走;字节本身不带编码信息(④-2)。

4. 预测:⑤的 ByteBuffer 在 flip 之后又调一次 flip,再读,能读到几个字节?

第二次 flip 时 position 是 0(如果还没读),于是 limit 变成 0、position 变成 0,一个字节都读不到;如果已经读了 4 个,limit 变成 4,只能再读 0~3 这 4 个(④-3:flip 就是“limit = position,position = 0”)。

5. 预测:把⑤的 Copy 换成复制一个 2 GB 的文件,五种写法哪两种还能在几秒内完成?

byte 模式大约是 2 MB 的 1000 倍,半个多小时;buffered 大约一分钟(系统调用不多,但 Java 这边要 40 亿次方法调用);array、Files.copy、transferTo 都只做几十万次以内的系统调用或 sendfile,受磁盘速度限制,几秒到十几秒。注意 transferTo 单次可能搬不完,要像⑤那样循环(②、④-4)。

6. 放回全景:第 1 站前后各发生了什么?这一页讲的东西坏了,整条链路会怎样?

第 1 站前面是客户端发出 65 字节,后面是第 2 站 Jackson 解码、反序列化。如果客户端用 GBK 发了 JSON,而服务端按 UTF-8 解,商品名就成了乱码,严重时 JSON 解析直接失败,返回 400;如果第 11 站的响应流没 flush 就被提前断开,客户端拿到半截 JSON。服务端自己读写文件时漏关流,句柄耗尽后连第 1 站的新连接都 accept 不进来,因为 socket 也占句柄。