流、文件与通道:read 契约、缓冲与部分读写
结论基线:API 语义以 Java SE 17 为准,实现分析以 OpenJDK 17u
jdk-17.0.12+7为准。实验在本机 Oracle JDK 17.0.12(x86_64)实际运行。read返回值是"本次真实读到的数量",不是"请求的数量";缓冲与 mark 的失效条件、Files.copy的竞态窗口、Buffer 的 flip/clear 状态机,都是靠读源码和实测才能确认为什么——语言层的关闭责任(TWR 主异常与抑制异常)已在类型正文讲清,本文直接引用。
read 契约:短读、EOF 与被吞掉的异常
InputStream 的三个返回值语义容易被想当然:read() 返回 0–255 的单字节或 -1 表示 EOF(不是异常、不是 0);read(b, off, len) 返回本次读到的数量(1..len 之间的任意值)或 -1;len == 0 时直接返回 0 且不碰底层。实验里一个每次只吐 1 字节的流,read(buf, 0, 4) 返回 1——短读是正常行为,循环必须按返回值累计而不是假设"要么满要么 EOF"。available() 只是提示:实验流 available()==0 但 read() 照常工作,用 available 判流结束是错的。
基类 read(byte[], int, int) 的默认实现值得逐行看,因为它是"子类只实现单字节 read()"时的真实行为:
原码连续摘录(仅统一公共缩进,截取方法局部):InputStream.java,jdk-17.0.12+7,L278–L302。
public int read(byte b[], int off, int len) throws IOException {
Objects.checkFromIndexSize(off, len, b.length);
if (len == 0) {
return 0;
}
int c = read();
if (c == -1) {
return -1;
}
b[off] = (byte)c;
int i = 1;
try {
for (; i < len ; i++) {
c = read();
if (c == -1) {
break;
}
b[off + i] = (byte)c;
}
} catch (IOException ee) {
}
return i;
}最扎眼的是那个空的 catch (IOException ee) {}:循环中途底层抛 IOException,基类吞掉异常、返回已写数量 i。实验实测:底层第 4 次 read() 抛 IOException,read(b, 0, 10) 返回 3——与一次正常短读完全无法区分(本轮开发时我先按老版本实现猜 i-1=2,实测 3 才核对源码确认 JDK17 是 return i,这是一次真实的红转绿)。意味着:依赖此类流的应用"读完了 3 字节就当正常结束",错误被无声吞掉;子类(如 FileInputStream)大多重写了批量 read 而不走这段代码,但任何只实现单字节 read() 的第三方流都继承这个语义。要暴露中途错误,读侧必须有上层完整性校验(长度/校验和/终止符),这是协议设计问题不是 API 问题。
transferTo 是官方给的搬运循环:
原码连续摘录(仅统一公共缩进,截取方法局部):InputStream.java,jdk-17.0.12+7,L777–L787。
public long transferTo(OutputStream out) throws IOException {
Objects.requireNonNull(out, "out");
long transferred = 0;
byte[] buffer = new byte[DEFAULT_BUFFER_SIZE];
int read;
while ((read = this.read(buffer, 0, DEFAULT_BUFFER_SIZE)) >= 0) {
out.write(buffer, 0, read);
transferred += read;
}
return transferred;
}它就是标准的 8K 循环(DEFAULT_BUFFER_SIZE=8192),语义要点在 javadoc:出错时可能已搬运部分字节,两侧流都可能处于不一致状态,强烈建议立刻关闭两端——即失败恢复不是重试,是整段重来。
缓冲、mark/reset 与关闭传播
BufferedInputStream 在流外面包一层填充缓冲:单字节 read() 从缓冲取,缓冲空了才碰底层。mark(readLimit)/reset() 的失效条件是最常答错的点:直觉以为"超过 readLimit 就失效",但实验实测 mark(1) 后连续读 4 字节 reset() 仍成功——因为底层 ByteArrayInputStream 一次 fill 就把全部内容吸进了 8192 的内部缓冲,标记的字节物理上还在缓冲里。把输入换成 10000 字节、读过 8192 之后,fill 发现 pos >= marklimit 才丢弃标记,reset() 抛 IOException("Resetting to invalid mark")。结论写成可检验的形式:reset 失效不取决于"读了多少",取决于"标记位置是否已被缓冲丢弃"——缓冲容量、readLimit 与底层供给速度三者共同决定。markSupported() 为 false 的流(如 FileInputStream)包上 BufferedInputStream 才能用 mark。
BufferedReader.readLine() 返回的字符串不含换行符(\n、\r、\r\n 都剥),末行无换行也正常返回、再读得 null;写回时要自己补终止符。字符缓冲的 BufferedWriter.flush() 责任与字节侧对称。
关闭传播:包装流(BufferedOutputStream/InputStreamReader 等)的 close() 会 flush 并关闭底层。实验用计数流实测:close() 调两次,底层 close 恰好执行 1 次、flush 先于 close——close 幂等是过滤器流的实现约定(包装流资源用 try-with-resources 管最外层即可,关闭次序与主异常/抑制异常的语义见类型正文 TWR 章节)。
文件与目录:copy 竞态、move 原子性与遍历资源
Files.copy(InputStream, Path, options) 的实现把 REPLACE_EXISTING 翻译成"先删后建":
原码连续摘录(仅统一公共缩进,截取方法局部):Files.java,jdk-17.0.12+7,L3124–L3167。
public static long copy(InputStream in, Path target, CopyOption... options)
throws IOException
{
// ensure not null before opening file
Objects.requireNonNull(in);
// check for REPLACE_EXISTING
boolean replaceExisting = false;
for (CopyOption opt: options) {
if (opt == StandardCopyOption.REPLACE_EXISTING) {
replaceExisting = true;
} else {
if (opt == null) {
throw new NullPointerException("options contains 'null'");
} else {
throw new UnsupportedOperationException(opt + " not supported");
}
}
}
// attempt to delete an existing file
SecurityException se = null;
if (replaceExisting) {
try {
deleteIfExists(target);
} catch (SecurityException x) {
se = x;
}
}
// attempt to create target file. If it fails with
// FileAlreadyExistsException then it may be because the security
// manager prevented us from deleting the file, in which case we just
// throw the SecurityException.
OutputStream ostream;
try {
ostream = newOutputStream(target, StandardOpenOption.CREATE_NEW,
StandardOpenOption.WRITE);
} catch (FileAlreadyExistsException x) {
if (se != null)
throw se;
// someone else won the race and created the file
throw x;
}两个从源码读出的行为:REPLACE_EXISTING 是 deleteIfExists + CREATE_NEW 两步,中间有竞态窗口——注释自己写着 "someone else won the race";并发写同一目标时失败方收到 FileAlreadyExistsException,不静默覆盖。第二个反直觉点:不带 REPLACE_EXISTING 时目标已存在也抛 FileAlreadyExistsException(CREATE_NEW 语义),很多人预期"覆盖"——实验实测三种输入:首次成功、无选项重复抛异常、带 REPLACE_EXISTING 成功替换。
Files.move 的原子性边界:同文件系统内 rename(POSIX rename(2))是原子的,ATOMIC_MOVE 强制要求原子否则抛 AtomicMoveNotSupportedException;跨文件系统时 move 退化为 copy+delete(除非给了 ATOMIC_MOVE,那会失败)。所以"要么旧要么新、绝不半份"的发布语义要 ATOMIC_MOVE + 同文件系统;move 到已存在目标不带 REPLACE_EXISTING 同样抛 FileAlreadyExistsException(实验已测)。
目录遍历是惰性资源:Files.newDirectoryStream(dir, glob) 返回的迭代器持有目录句柄,glob 在目录流上过滤(实验 u06-*.tmp 只匹配两个文件、skip.c 被排除);Files.walk 递归版本必须放 try-with-resources(遍历中打开的目录在流关闭前不释放)。用完即关,异常路径也要关——TWR 是唯一不遗漏的写法。符号链接边界:walk 默认不跟随目录符号链接(防环),copy/move 的 NOFOLLOW_LINKS 控制 link 本身还是目标;属性类操作(exists/isDirectory)默认跟随链接,两个口径在脚本类代码里经常被混用。
Buffer 状态机与通道位置读写
NIO 的 Buffer 是 mark ≤ position ≤ limit ≤ capacity 四指针状态机,flip/clear/compact 只是移动指针:
原码连续摘录(仅统一公共缩进,截取方法局部):Buffer.java,jdk-17.0.12+7,L427–L449。
/**
* Clears this buffer. The position is set to zero, the limit is set to
* the capacity, and the mark is discarded.
*
* <p> Invoke this method before using a sequence of channel-read or
* <i>put</i> operations to fill this buffer. For example:
*
* <blockquote><pre>
* buf.clear(); // Prepare buffer for reading
* in.read(buf); // Read data</pre></blockquote>
*
* <p> This method does not actually erase the data in the buffer, but it
* is named as if it did because it will most often be used in situations
* in which that might as well be the case. </p>
*
* @return This buffer
*/
public Buffer clear() {
position = 0;
limit = capacity;
mark = -1;
return this;
}原码连续摘录(仅统一公共缩进,截取方法局部):Buffer.java,jdk-17.0.12+7,L451–L477。
/**
* Flips this buffer. The limit is set to the current position and then
* the position is set to zero. If the mark is defined then it is
* discarded.
*
* <p> After a sequence of channel-read or <i>put</i> operations, invoke
* this method to prepare for a sequence of channel-write or relative
* <i>get</i> operations. For example:
*
* <blockquote><pre>
* buf.put(magic); // Prepend header
* in.read(buf); // Read data into rest of buffer
* buf.flip(); // Flip buffer
* out.write(buf); // Write header + data to channel</pre></blockquote>
*
* <p> This method is often used in conjunction with the {@link
* java.nio.ByteBuffer#compact compact} method when transferring data from
* one place to another. </p>
*
* @return This buffer
*/
public Buffer flip() {
limit = position;
position = 0;
mark = -1;
return this;
}读满→flip()(limit 锁定已写量、position 归零)→ 读出/写出→clear()(整缓冲重置)或 compact()(未读部分挪到头部、进入继续填充模式)。实验把三步跑全:put 3 字节后 flip,remaining 从 8 变 3;get 两次后 compact,position=1、limit=8——compact 是"读写模式连续搬运"的钥匙,flip+clear 是"整批处理"的钥匙。mark 在 flip/clear 中都被丢弃,之后 reset() 抛 InvalidMarkException(实验实测);mark 有效时 reset 回到标记位置。
FileChannel 的位置语义与流不同:通道自带 position,但 read(ByteBuffer, position)/write(ByteBuffer, position) 的位置参数版不改变通道 position(实验:position 0 时 read(buf, 5) 读到字节 5–7、通道 position 仍 0)——多线程共享通道时用位置参数版各自读互不踩踏;不带位置版按通道 position 前进且并发下交错。scatter/gather(read(ByteBuffer[])/write(ByteBuffer[]))把一段连续字节按序散进/聚自多个缓冲,协议"定长头+变长体"用它一次系统调用完成。
ByteBuffer.allocateDirect 分配堆外内存:实验确认 hasArray() 为 false(无堆内后备数组可直取)。堆外内存的代价与释放(Cleaner 依赖 GC、RSS 观察口径)属 JVM 域——native/RSS 的证据链见JVM 诊断正文,U10/U11 深化;网络侧的就绪/水位/背压与事件循环见操作系统与网络的 NIO 事件循环正文,本文不重复。
设计案例:导入文件的读取管道(算例,非实测)
负载假设:商户每晚批量上传对账文件,单文件 200MB,格式为定长头 + 变长行 + 尾部校验和;应用容器内存预算 512MB(多任务共享),文件在 NFS 上。不变量:行数与金额和与尾部校验一致;处理失败整文件重跑不重复入账(幂等键在外层);进程不因单个坏文件 OOM。
选择 1:BufferedReader + Files.lines 流式逐行。 按行读、按行解析入账,内存占用与文件大小解耦(行级对象);文件关闭由 try-with-resources 保证。短版在头/校验失败时中止——但要注意 readLine 剥掉的换行符不影响定长行校验,而被基类吞掉的 IOException(若上游是自定义流)会把"读了一半"伪装成 EOF,所以尾部校验和行数断言是必须的闭环。恢复责任:外层幂等键保证重跑安全,坏文件退回商户。成本:实现最直白,吞吐够用(单机顺序读不是瓶颈)。
选择 2:FileChannel + 堆外 ByteBuffer 分块并行解析。 位置参数读让多个解析线程共享一个只读通道互不干扰(实验已证位置独立性);堆外缓冲避免大块数据在堆内进出的复制。成本:按字节切分要自己处理行跨界(块边界落在行中间要拼接,compact 正是为此存在)、堆外内存要显式生命周期管理且观察口径要靠 NMT/RSS(引用 JVM 域证据);恢复责任不变但调试复杂度显著上升。两条路的分界是负载与团队约束:200MB/晚选 1 就够,200MB×并发上传才值得付选 2 的复杂度。算例数字用于说明决策单位,不是实测吞吐。
条件变化与边界
| 条件变化 | 需要复核的点 |
|---|---|
| 底层是自定义 InputStream(只实现单字节 read) | 基类批量 read 逐字节转发(慢)且循环内 IOException 被吞、返回值伪装成短读;上层必须做完整性校验 |
| 缓冲流上用 mark/reset | 失效取决于缓冲内标记是否被丢弃(容量×readLimit×供给速度),不是简单"读了多少";10000 字节实测越过 8192 缓冲后失效 |
| 并发写同一目标文件 | copy 的 REPLACE 是 delete+CREATE_NEW 两步,竞态输家抛 FileAlreadyExistsException;要原子发布用 move+ATOMIC_MOVE+同文件系统 |
| 跨文件系统 move | 退化为 copy+delete;ATOMIC_MOVE 跨界抛 AtomicMoveNotSupportedException |
| 目录遍历中途失败 | newDirectoryStream/walk 是惰性资源,必须 TWR;walk 默认不跟随目录符号链接,属性操作默认跟随,两口径别混用 |
| 换用堆外缓冲 | hasArray=false、生命周期与 RSS 观察口径变化;引用 JVM 域正文,不在本文展开 |
| 网络 IO | 就绪/部分写/水位/事件循环在 OS 域 NIO 正文;本文只有文件通道的位置语义 |
实验:输入、步骤、预期与边界
实验入口:labs/java-senior/run.sh io(EncodingIoProbe.java)。
U06 关键实测(Oracle JDK 17.0.12,x86_64;完整命令、stdout/stderr、退出状态与红绿记录见本批证据):
PASS read contract: short read=1, len0=0, EOF=-1, available hint only, base impl swallows mid-loop IOException returning 3 (same as normal short read), transferTo=6
PASS buffering: mark within limit, reset beyond 8192 buffer+marklimit rejected, readLine strips terminators, wrapper close flush+close-once
PASS files: copy exists/reject/replace, move REPLACE+ATOMIC ok, directory glob filter, walk closed by TWR, entries=4
PASS buffer: flip/compact/clear states, mark vs InvalidMark, positional read/write independence, scatter read, direct vs heap场景与预期:单字节流 read(b,0,4) 返回 1;len==0 返回 0;EOF 返回 -1;available==0 仍可读;中途抛 IOException 的流批量 read 返回 3 且 3 字节已写入(异常被吞);transferTo 计数 6;mark(4)+读 2+reset 回 0;10000 字节流 mark(1) 全读后 reset 抛 IOException;readLine 剥换行且末行无换行返回文本;包装流 close 两次底层关闭恰好 1 次且先 flush;copy 首建/重复抛/REPLACE 三态;move REPLACE+ATOMIC 成功且源消失;目录 glob 只匹配 u06-*.tmp;walk entries=4;flip 后 remaining=3、compact 后 position=1 limit=8;flip 后 reset 抛 InvalidMarkException;read(buf,5) 通道 position 保持 0 且读到字节 5–7;scatter 读按序填两个缓冲;write(buf,8) 只覆盖区间 8–9;allocateDirect 无堆内数组。负例(未知模式/多余参数/坏 JAVA_HOME/JDK21)统一退出 2。
边界:本实验在 macOS APFS 上运行,ATOMIC_MOVE 行为是 POSIX rename 的实现语义,Windows/跨文件系统未实测;竞态窗口只从源码推证("someone else won the race"注释)并用三态输入间接验证,未起双进程压并发;网络通道、Selector、异步文件通道(AsynchronousFileChannel)不在本票范围。
追问链
第一层:为什么 read 契约是"1..len"而不是"要么满要么 EOF"?
底层资源(socket 缓冲、管道、终端)任意时刻可读的数量不可预测,阻塞模型下"有多少给多少"是唯一诚实语义。满读期待是调用方误区:循环累计到需求量或 EOF 才是正确写法,readNBytes/readAllBytes 就是把这个循环封装好了。
第二层:mark(1) 读 4 字节 reset 为什么没抛异常?
标记失效的判定在 fill 时:缓冲里还有数据就永远回得去。ByteArrayInputStream 一次 fill 全量供给,10 字节都在 8192 缓冲内,mark(1) 名义上限没被"物理执行"。真正触发失效的是缓冲被冲刷——10000 字节输入读到 8192 之后。所以"readLimit 参数"是给缓冲的保留承诺,不是读操作的计数器。
第三层:copy 的 REPLACE_EXISTING 有竞态,那正确姿势是什么?
看需求:目标"最新者胜"的临时文件场景,竞态失败方捕获 FileAlreadyExistsException 重读即可;发布/落盘要原子(读者永远看到完整旧版或完整新版),用 move + ATOMIC_MOVE:先写临时文件(CREATE_NEW 天然防并发覆盖)再原子改名,rename 同文件系统内是原子的。copy+REPLACE 的两步窗口里读者可能看到空文件。
第四层:评审一段文件处理代码,按什么顺序问?
- 流式还是全量读入?内存上界与文件大小什么关系?2. 读循环按返回值累计了吗?EOF=-1、短读、len=0 分支全了吗?3. 关闭责任:TWR 包最外层了吗?惰性资源(目录流/Channel)在异常路径也关了吗?4. 完整性靠什么闭环(长度/校验和/行数)?中途 IOException 被吞的场景有人兜底吗?5. 并发:同目标并发写的窗口在哪?要原子语义吗?6. 符号链接与路径遍历(用户拼的路径能逃出目录吗)——这个完整展开归安全工程票(U14/C14),本文只立锚点。
来源与版本
| 来源 | 版本 | 用途 |
|---|---|---|
| Java SE 17 InputStream | Java SE 17 | read/skip/available/transferTo 契约与"出错后关闭两端"建议 |
| Java SE 17 BufferedInputStream | Java SE 17 | mark/reset 与 marklimit 语义 |
| Java SE 17 BufferedReader | Java SE 17 | readLine 终止符语义 |
| Java SE 17 Files | Java SE 17 | copy/move/newDirectoryStream/walk 契约 |
| Java SE 17 Buffer、ByteBuffer | Java SE 17 | 四指针状态机与 flip/clear/compact |
| Java SE 17 FileChannel | Java SE 17 | 位置参数读写与共享通道语义 |
| OpenJDK 17u InputStream 源码 | jdk-17.0.12+7 | 基类 read 吞异常实现与 transferTo 循环(L278、L777) |
| OpenJDK 17u Files 源码 | jdk-17.0.12+7 | copy 的 delete+CREATE_NEW 与竞态注释(L3124) |
| OpenJDK 17u Buffer 源码 | jdk-17.0.12+7 | clear/flip 指针操作(L427、L451) |
本地 EncodingIoProbe 实验 | Oracle JDK 17.0.12 | 本轮全部行为实测 |
访问日期:2026-10-25。源码行号以 jdk-17.0.12+7 标签为准;实验运行版本为 Oracle 17.0.12(x86_64)。文件系统相关结论限于 macOS APFS + 本地卷;竞态窗口为源码推证加单进程三态验证,未做双进程并发实测。
闭卷验收
本页 10 道闭卷题收进了下方自测面板:先回忆,再展开参考答案与评分点,最后给三档自评。