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、读商品导入文件、复制上传的附件。
②没有它会怎样:一次一个字节地搬
最直白的写法:while ((b = in.read()) != -1) out.write(b);。能用,结果也对。可每一次 read() 都要穿过 JVM、进内核拿 1 个字节,再回来。
- 贵的不是字节,是“进内核”这一下:一次系统调用要切换到内核态再回来,搬 1 个字节和搬 8192 个字节,这部分成本差不多。
- BufferedInputStream 读的时候还是一次一个字节,但它背后一次向内核要 8 KB,于是系统调用从 200 万次降到两三百次。它比自己用数组慢(64 ms 对 2.4 ms),是因为 Java 这边仍然调了 200 万次方法。
- 缺的那一块:成块地搬(缓冲);能让内核直接搬的,就别让数据进 JVM(Files.copy、transferTo 底下是 Linux 的
sendfile)。
不懂精华时的典型误用:给 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 替你写好了。
④原理:搬字节、翻译字符、三个指针
④-1流:一层层套起来的装饰器
Files.newBufferedReader(path, UTF_8) 一行就替你把这几层套好了。字节流(Stream)和字符流(Reader / Writer)的分界线就是中间那层编解码。④-2字符集:同样的字节,换个字典就是乱码
- 1“机械键盘”用 UTF-8 编码是 12 个字节(每个汉字 3 个)。实测 同一份对账文件的字节,用 UTF-8 读是
机械键盘,用 GBK 读是鏈烘閿洏,用 ISO-8859-1 读是一串带重音的拉丁字母。字节一个没变,变的是“查哪本字典”。 - 2按字节切断会切坏字符:实测 只拿前 4 个字节解码,得到
机�,第二个字只剩 1/3,变成替换字符 �。所以“按 1024 字节分块读,每块各自 new String”是错的,解码必须交给 Reader(它会把半个字符留到下一块)。 - 3没写字符集时用“平台默认字符集”。JDK 17 及以前,它取决于操作系统和 locale(本机是
C.UTF-8,所以是 UTF-8;中文 Windows 默认是 GBK);JDK 18 起默认一律 UTF-8(JEP 400)。靠默认值,代码换台机器就可能乱码。
④-3ByteBuffer:一块数组,加上三个指针
点按钮操作一个 16 字节的 ByteBuffer,看 position、limit、capacity 怎么动。先想好再点:flip 之后 limit 是几?
- 1写模式:position 是下一个要写的位置,limit = capacity。put 一次,position 往后挪。
- 2flip():
limit = position; position = 0,切到读模式:从头读,读到刚才写到的地方为止。忘了 flip,就会从写到的位置往后读,读到的是没写过的 0(实测 还剩 12 字节“可读”)。 - 3compact():把没读完的字节挪到最前面,切回写模式,接着往后写。网络读写里“一条消息没收完整”时就用它,Tomcat 解析 HTTP 请求头时也会遇到这种半截数据。clear() 不清数据,只是把指针复位,旧数据会被覆盖。
- 4为什么一个 Buffer 读写共用、要手动 flip?因为它要直接交给 Channel 和内核:Channel 从 position 读到 limit、或者从 position 写到 limit,指针本身就是和内核交接的“这次搬多少”。代价是使用者必须自己记得切模式。
模拟和 ⑤ 的 JDK 17 实测一致(allocate(16) → put 5 字节 → flip → get 4 → compact,指针依次是 0/16、5/16、0/5、4/5、1/16),逻辑在 node 里跑过。
④-4Channel 和零拷贝:让内核自己在两个文件之间搬
- 1堆内缓冲 vs 直接缓冲:
ByteBuffer.allocate在 Java 堆上,背后是一个 byte[](实测hasArray=true);allocateDirect在堆外,没有 array(hasArray=false)。做系统调用时,内核要一块地址不会被 GC 挪动的内存,堆内缓冲会先被 JDK 拷进一块临时的直接缓冲再交给内核,所以网络框架(Netty、Tomcat)都自己管理直接缓冲。代价是分配和回收慢,大小受-XX:MaxDirectMemorySize限制,泄漏了也不在堆转储里(⑥ 坑 4)。 - 2为什么这样设计:java.io 的流是一个字节一个字节的抽象,好用但离操作系统远;NIO 把“一块内存 + 一个通道”直接摆出来,和操作系统的 read / write / sendfile 一一对应。业务代码不需要这么底层,所以 JDK 7 又在上面加了
Files这一层“一行搞定”的工具,底下再按平台选最快的实现(比如 Linux 上用 sendfile)。
最少语法
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 →
// 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();
== 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 那行里有不可见字符,中间用 … 略去。耗时只跑一次,只看量级。
⑥细节·规范·优化
注意事项:不知道就会出事故
- 现象
new FileReader(f)、new String(bytes)、getBytes()在开发机正常,部署到 Windows 或 locale 不对的容器里,中文全成了“鏈烘閿洏”。⑤ 实测 GBK 读- 根源
- 不写字符集就用平台默认值,JDK 17 及以前它跟着操作系统走。
- 做法
- 所有字节和字符的转换都显式写
StandardCharsets.UTF_8;JDK 18 起默认就是 UTF-8,但写明仍然是好习惯(老代码可能跑在 8、17 上)。
- 现象
- 对账文件在代码里读、在 Mac 上看都正常,财务用 Windows 版 Excel 双击打开全是乱码。
- 根源
- Excel 打开 CSV 时,没有 BOM(文件开头的
EF BB BF三个字节)就按系统的本地编码读,中文 Windows 上是 GBK。这是 Excel 的约定,和 Java 无关。 - 做法
- 给财务的 CSV 开头先写一个
'\uFEFF';或者直接导出 xlsx。程序之间交换的 CSV 不要加 BOM。
- 现象
- 服务跑几天后报
Too many open files,新连接也接不进来(socket 也占句柄)。 - 根源
- 每个打开的流背后是一个操作系统文件描述符,数量有上限(常见 1024 或 65535);
Files.lines是惰性的,不关就一直开着;GC 回收对象时不保证及时关闭。 - 做法
- 一律 try-with-resources;排查用
ls /proc/<pid>/fd | wc -l和lsof -p。
- 现象
- 导入一个 2 GB 的商品文件,OOM;或者上传接口用
getInputStream().readAllBytes(),被人传了个大文件拖垮。 - 根源
- 这两个方法把整个文件读进一个数组 / List,文件多大就占多少堆。
- 做法
- 大文件用
Files.lines或 BufferedReader 一行行读,或者按块读;上传接口限制大小(Spring 的spring.servlet.multipart.max-file-size)。
- 现象
- 导出的对账文件少了最后几十行,或者是 0 字节。
- 根源
- BufferedWriter 攒满 8192 个字符才真正往下写,最后不满的那一截要等
flush()或close()。 - 做法
- try-with-resources 关闭时自动 flush;要在中途让别人看到,手动 flush。
- 现象
OutOfMemoryError: Direct buffer memory,可堆转储里看不到大对象;容器的内存用量比-Xmx大很多,被 OOM Killer 杀掉。- 根源
- 直接缓冲区占的是堆外内存,Java 堆里只有一个很小的 DirectByteBuffer 对象指着它;要等这个小对象被 GC 回收,堆外那块才释放。
- 做法
- 不要频繁 allocateDirect,复用;设
-XX:MaxDirectMemorySize;容器内存要给堆外留余量(J03、J14 会再讲)。
使用规范
readString / writeString(JDK 11)、readAllLines、lines、copy、move、walk。少自己 new FileInputStream。
样板代码、漏关、漏缓冲。
StandardCharsets.UTF_8,别用字符串 "utf-8"(还要处理异常)。
坑 1。
FileInputStream / socket 流外面套 Buffered,或者自己用 8 KB 以上的数组。
每字节一次系统调用(实测 2 秒对 2 毫秒)。
流、Reader、Channel、Files.lines、DirectoryStream。
坑 3、坑 5。
导入用 lines 边读边处理,导出边查边写,不要先全读进 List。
坑 4。
或者 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.4 | NIO:Buffer、Channel、Selector、Charset |
|---|---|
| JDK 7 | NIO.2:Path、Files、WatchService、异步 Channel;try-with-resources;StandardCharsets |
| JDK 9 | InputStream.transferTo(OutputStream)、readAllBytes() |
| JDK 11 | Files.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)。
- Netty 的 ByteBuf:读写两个指针不用 flip,池化的直接内存,引用计数
- MappedByteBuffer 实战:用内存映射做一个追加写日志文件(对照 Kafka、RocketMQ)
- 大文件导出到浏览器:流式响应(
StreamingResponseBody),边查库边写,不占内存 - 网络这一层继续往下:TCP 的发送缓冲区、Nagle、粘包拆包(J04)
↺回到⑤:改造你的导出和导入
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 也占句柄。