开始前,会这些就够了
- 能读懂字节数组、输入流和简单的 Java 线程。
- 知道 read 返回本次取得的字节数;尚不熟悉
CountDownLatch也可跟随本篇的放行步骤。 - 安装完整 JDK 17 或更新版本;仅用标准库,不需要网络服务或外部文件。
接收程序先问 available() 还有多少字节,得到零就退出循环。日志看上去干净,另一端随后写出的内容却消失了。问题可能出在退出条件:此刻没有可立即读取的字节,并没有回答这条流以后是否还会产生数据。
本篇把同一条真实 Java 管道停在六个可确认的阶段。你会看到零值多次出现,也会看到写入方已经关闭时仍有数据可读。我们保存每个阶段的计数、关闭状态和实际 read 结果,让几个容易混淆的状态分开呈现。
把例子带到自己的电脑上
下载完整练习包
需要 JDK 17 或更新版本,无第三方依赖;制作时实测 openjdk 17.0.20.1 2026-08-18 LTS。使用标准库,不需要安装第三方包。下载 ZIP 后选择“全部提取”,进入能看到 README.md 的文件夹。
下载 ZIP · 5 个文件包里有什么
- AvailableBoundary.java:完整示例
- README.md:环境、范围与独立练习
- observed.stdout.txt:制作时的实际输出
- verification.json:源码与产物验证记录
- SHA256SUMS.txt:文件内容摘要
在这个文件夹打开 PowerShell,逐行运行
java --version
java --source 17 AvailableBoundary.java解压到独立练习文件夹,在含源文件的目录打开终端运行。程序会在当前目录写入教学报告;先预测结果,再查看当次输出。真实 JDK 17 管道流、闩锁协调的两次写入与六个读取阶段;不连接网络、不测吞吐,也不把 available 当作通用剩余长度。
找不到命令或文件时,先按 README 检查环境和所在目录。文中标出的阅读时间只估算文章篇幅,安装环境和动手练习可按自己的节奏进行。
沿着六个时刻,看清“空了”和“结束了”
逐步回放正文 JDK 实验留下的记录。留意第 1、3、5、6 步:available 都是零,它们各自发生了什么?
第 1 步:还没放行第一次写入
写入方还在等第一道门。现在没有可读字节,但它还会继续写。
- available
- 0 字节
- 累计读到
- 0 字节
- 写端已关闭
- 否
- 观察到 EOF
- 否
管道里等待读取的内容
(空)读取方已经收到的内容
(还没有)最近一次 read:尚未调用。这是最近一次读取的记录;点击回放按钮没有额外执行 read。
页面展示的是已保存的六阶段教学记录,并未在浏览器启动 Java 或创建真实管道。正文源码使用闩锁安排先后;固定 ASCII 样本共 11 字节,不能把这里的 available 数值当作所有输入流的剩余总长度。
先分清“现在有多少”和“已经结束了吗”
InputStream.available 描述可以不阻塞地读取或跳过的字节数估计。这个估计可能为零;到达结尾时也可能是零。因此零值不能单独区分“暂时没有”与“已经结束”。API 文档还明确提醒,不应靠它分配容纳整条流的数组。
本篇调用的是 read(byte[]),缓冲区长度固定为八。正数表示本次真正取到的字节数,-1 才是这次读取观察到的结束信号。零长度数组另有返回零的规则,所以不能脱离调用参数,把所有零值放在一起讨论。
本站的 I/O 基础文章已经介绍这项规则;这里增加的是可重跑的时间顺序证据。读报告时先找阶段,再看 available,最后检查 read。不要先用一个零值判定结尾,再拿这个未经验证的结论解释后面的记录。
把写入方停在两道门前,不猜它睡了多久
程序连接 PipedOutputStream 和 PipedInputStream。写入方使用独立线程,读取交给单线程执行器;主线程只负责放行、等待和记录。管道缓冲为六十四字节,样本总共十一字节,因此这个实验没有刻意制造缓冲区写满。
第一道门允许写入 alpha|,第二道门允许写入 omega。CountDownLatch 的计数归零后,等待对应信号的线程才能继续。主线程还分别等待“第一块已写完”和“写端已关闭”,在明确的事件之后采样。
这里没有 Thread.sleep,也没有依赖“过一小会儿应该写好了”。等待信号有五秒上限,超时会让实验失败,不会被记录成 EOF。CountDownLatch 文档给出的内存可见性约定,也让信号前的操作能够由成功等待后的步骤观察到。
两个相同的零,中间已经收到六个字节
写入方还停在第一道门前,available 为零,累计读取也为零,写端没有关闭。打开第一道门并确认写入完成后,可立即读取的数量变为六。实际 read 返回六,收到的是 alpha|。
此时第二道门仍关着。第一块已经全部取走,available 又回到零,累计读取却是六,写端仍未关闭。随后放行第二块,剩余五字节便能被正常读取。前后两次零都没有说明“以后再无数据”。
如果把 available 大于零当作唯一循环条件,在第一阶段就会退出;即使碰巧赶上第一块,也可能在两块之间退出。这是从本次已记录状态得到的后果分析,脚本本身保留正常读取路径,没有用真实丢失数据冒充一次成功。
写入方关闭之后,接收方还没有读完
第二块写入完成后,生产线程调用 pipe.close,再设置 writerClosed。主线程等到 writerDone 才记录,因此 true 表示本实验的写端关闭已经返回,并非根据线程名字或时间推测。
这一刻 available 仍为五,累计读取仍为六。接收方随后读到 omega,read 返回五。关闭写端没有把已经进入管道、但尚未取走的字节抹掉;写入结束与接收结束可以发生在不同步骤。
取完十一字节后 available 为零,最后一次 read 仍是五,saw_eof 还是 false。必须再执行一次正长度读取,得到 -1,最后一行才把 saw_eof 记为 true。报告刻意保留这两个相邻阶段,防止把“内容已齐”写成“已经观察到结束”。
还要留意 last_read 是最近一次读取的结果,并不是记录这一行时又读了一次。第一行与第二行的“未调用”保留了这一点。观察日志若把旧返回值与新采样混在一起,却不注明各自含义,就会制造并不存在的操作顺序。
运行完整代码,按六行时间表核对
把下方代码保存为 AvailableBoundary.java,在该目录执行命令。源码启动需要完整 JDK;先运行 java --version 确认实际版本。程序不访问网络,不读取你的业务文件,默认结果放入 available-output。
目录中有 report.json、timeline.csv 和 received.txt。重复运行会更新同名教学结果,也可用唯一命令行参数指定另一个目录。received.txt 应恰好包含 alpha|omega,没有额外换行;CSV 则把每个观察阶段单独留一行。
2026 年 10 月 16 日重新使用 Microsoft OpenJDK 17.0.20.1 运行,六阶段断言通过,两个工作线程完成清理后才输出 PASS。版本写入 JSON。表中的六和五来自本例的字符、编码与同步安排,不是任何输入流都必须一次返回的数量。
展开收起代码 · 小实验
长行可在代码区域内左右滚动,按 Tab 可把焦点移入代码区。
import java.io.ByteArrayOutputStream;
import java.io.IOException;
import java.io.PipedInputStream;
import java.io.PipedOutputStream;
import java.nio.charset.StandardCharsets;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.List;
import java.util.Locale;
import java.util.concurrent.CountDownLatch;
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;
import java.util.concurrent.Future;
import java.util.concurrent.TimeUnit;
import java.util.concurrent.atomic.AtomicBoolean;
import java.util.concurrent.atomic.AtomicReference;
public class AvailableBoundary {
static final byte[] FIRST = "alpha|".getBytes(StandardCharsets.US_ASCII);
static final byte[] SECOND = "omega".getBytes(StandardCharsets.US_ASCII);
static final int WAIT_SECONDS = 5;
record Chunk(int count, byte[] bytes) {}
record Row(String stage, int available, boolean writerClosed,
int totalRead, String lastRead, boolean sawEof) {
String json() {
return String.format(Locale.ROOT,
"{\"stage\":\"%s\",\"available\":%d,\"writer_closed\":%s,"
+ "\"total_read\":%d,\"last_read\":\"%s\",\"saw_eof\":%s}",
stage, available, writerClosed, totalRead, lastRead, sawEof);
}
String csv() {
return String.join(",", stage, Integer.toString(available),
Boolean.toString(writerClosed), Integer.toString(totalRead), lastRead,
Boolean.toString(sawEof));
}
}
static void require(boolean condition, String message) {
if (!condition) throw new AssertionError(message);
}
static void await(CountDownLatch signal, String name) throws InterruptedException {
require(signal.await(WAIT_SECONDS, TimeUnit.SECONDS), "timed out: " + name);
}
static void producerOk(AtomicReference<Throwable> failure) {
if (failure.get() != null) throw new AssertionError("producer failed", failure.get());
}
static Chunk boundedRead(ExecutorService reader, PipedInputStream input) throws Exception {
Future<Chunk> future = reader.submit(() -> {
byte[] buffer = new byte[8];
int count = input.read(buffer); // Positive buffer size; actual read result matters.
return new Chunk(count, count > 0 ? Arrays.copyOf(buffer, count) : new byte[0]);
});
try { return future.get(WAIT_SECONDS, TimeUnit.SECONDS); }
finally { if (!future.isDone()) future.cancel(true); }
}
static void add(List<Row> rows, String stage, PipedInputStream input,
AtomicBoolean closed, ByteArrayOutputStream received,
String lastRead, boolean eof, int expectedAvailable) throws IOException {
int available = input.available();
require(available == expectedAvailable, stage + ": available=" + available);
rows.add(new Row(stage, available, closed.get(), received.size(), lastRead, eof));
}
public static void main(String[] args) throws Exception {
Path output = args.length == 0 ? Path.of("available-output") : Path.of(args[0]);
PipedInputStream input = new PipedInputStream(64);
PipedOutputStream pipe = new PipedOutputStream(input);
CountDownLatch allowFirst = new CountDownLatch(1);
CountDownLatch firstWritten = new CountDownLatch(1);
CountDownLatch allowSecond = new CountDownLatch(1);
CountDownLatch writerDone = new CountDownLatch(1);
AtomicBoolean writerClosed = new AtomicBoolean(false);
AtomicReference<Throwable> producerFailure = new AtomicReference<>();
ExecutorService reader = Executors.newSingleThreadExecutor();
Thread producer = new Thread(() -> {
try {
await(allowFirst, "first gate");
pipe.write(FIRST);
pipe.flush();
firstWritten.countDown();
await(allowSecond, "second gate");
pipe.write(SECOND);
pipe.flush();
pipe.close();
writerClosed.set(true); // Set only after this thread's close returned.
} catch (Throwable failure) {
producerFailure.set(failure);
} finally {
firstWritten.countDown();
writerDone.countDown();
}
}, "fixture-producer");
List<Row> rows = new ArrayList<>();
ByteArrayOutputStream received = new ByteArrayOutputStream();
producer.start();
try {
add(rows, "before_first_write", input, writerClosed, received, "not_called", false, 0);
require(!writerClosed.get(), "writer must be open at the first gate");
allowFirst.countDown();
await(firstWritten, "first data");
producerOk(producerFailure);
add(rows, "first_chunk_buffered", input, writerClosed, received, "not_called", false, 6);
Chunk first = boundedRead(reader, input);
require(first.count == 6 && Arrays.equals(first.bytes, FIRST), "first chunk");
received.write(first.bytes);
add(rows, "between_chunks", input, writerClosed, received, "6", false, 0);
require(!writerClosed.get(), "writer still waits at second gate");
allowSecond.countDown();
await(writerDone, "writer close");
producerOk(producerFailure);
require(writerClosed.get(), "producer close was not observed");
add(rows, "second_buffered_writer_closed", input, writerClosed, received, "6", false, 5);
Chunk second = boundedRead(reader, input);
require(second.count == 5 && Arrays.equals(second.bytes, SECOND), "second chunk");
received.write(second.bytes);
add(rows, "all_payload_read", input, writerClosed, received, "5", false, 0);
Chunk eof = boundedRead(reader, input);
require(eof.count == -1, "actual EOF signal required");
add(rows, "eof_observed", input, writerClosed, received, "-1", true, 0);
require(received.toString(StandardCharsets.US_ASCII).equals("alpha|omega"), "payload");
} finally {
// Unblock every controlled wait even if an assertion or Future times out.
allowFirst.countDown();
allowSecond.countDown();
input.close();
pipe.close();
producer.interrupt();
producer.join(TimeUnit.SECONDS.toMillis(WAIT_SECONDS));
reader.shutdownNow();
require(reader.awaitTermination(WAIT_SECONDS, TimeUnit.SECONDS), "reader not terminated");
require(!producer.isAlive(), "producer not terminated");
}
Files.createDirectories(output);
String version = System.getProperty("java.version");
String json = "{\n \"java_version\":\"" + version
+ "\",\n \"wait_limit_seconds\":5,\n \"coordination\":\"latches_no_sleep\",\n \"rows\":[\n "
+ String.join(",\n ", rows.stream().map(Row::json).toList()) + "\n ]\n}\n";
String csv = "stage,available,writer_closed,total_read,last_read,saw_eof\n"
+ String.join("\n", rows.stream().map(Row::csv).toList()) + "\n";
Files.writeString(output.resolve("report.json"), json, StandardCharsets.UTF_8);
Files.writeString(output.resolve("timeline.csv"), csv, StandardCharsets.UTF_8);
Files.write(output.resolve("received.txt"), received.toByteArray());
System.out.println("Java " + version + "; 6 stages; 11 bytes; workers terminated");
System.out.println("available=0 before writing, between chunks, and after EOF");
System.out.println("writer closed with 5 buffered bytes; actual final read=-1");
System.out.println("PASS; " + output);
}
}
怎样运行:java --source 17 AvailableBoundary.java(在源码所在目录执行;需要完整 JDK 17 或更新版本,仅标准库。) 2026-10-16 已用 openjdk 17.0.20.1 2026-08-18 LTS 执行这份代码并通过自带检查;下方是当次输出。真实 JDK 17 管道流、闩锁协调的两次写入与六个读取阶段;不连接网络、不测吞吐,也不把 available 当作通用剩余长度。
运行后对照结果
Java 17.0.20.1; 6 stages; 11 bytes; workers terminated
available=0 before writing, between chunks, and after EOF
writer closed with 5 buffered bytes; actual final read=-1
PASS; available-output读取循环应围绕实际结果继续推进
如果任务就是读取一条会正常结束的字节流,常见处理顺序是调用正长度 read、检查 -1、处理本次返回的有效区间,然后继续。缓冲区剩余部分可能留有旧数据,不能因为数组长度是八就每次处理八字节。
网络协议还有消息长度、分隔符和连接复用等额外约定。某条消息结束未必要求整条连接关闭,因此也不能把本例的“读到 -1”照搬成所有网络消息的边界。先明确你要读完一条流,还是读完流中的一个消息。
不要把错误循环简单改成不断查询 available,等它变成正数。那仍未回答结束和失败怎么处理,还可能让线程空转。阻塞读取、协议边界和超时各有职责,应按所用 API 的约定组合。
超时是实验保护,不能变成通用流的承诺
脚本给每次 Future.get 设置五秒上限,失败时取消未完成任务;finally 还释放两道门、关闭管道、中断生产线程并等待工作线程退出。这些清理步骤是为了避免断言失败后仍留下本实验的等待者。
本次没有在两块之间启动一个阻塞读取来测等待时长,也没有模拟真实网络延迟。它验证的是零值、数据到达、关闭和 EOF 的关系。Future 取消能否中断某个实际 I/O,要核对具体实现,不能从管道推广到所有流。
超时异常和 I/O 异常都应保留为各自的失败结果。把任何异常统一改成 -1,会把“没能继续读”伪装成“正常读完”。完整性验收应同时保留实际字节、正常结束证据和错误信息。
排查少读时,把日志改成一条可解释的顺序
遇到内容偶尔变少,先记录每次 read 返回多少、累计多少、为什么退出,以及谁负责关闭写端。再补 available 的采样值作为辅助信息。仅留下“剩余为零”一句日志,无法重建是哪一个阶段发生了退出。
这个管道样本只包含一个生产者、一个读取线程和两块 ASCII 数据。没有验证断管、读端提前关闭、多个消费者或任意网络封装。扩展时每次只改变一个条件,并给新条件安排能够区分成功与失败的结果字段。
记住这三点
- available 为零不能单独区分暂时无数据与正常结束。
- 写端关闭、有效字节全部取出、实际读到 -1,是三个可以分离的时刻。
- 使用明确事件控制实验顺序;超时与异常不得被伪装成 EOF。
别把例子用过头
- 用 available 大于零作为读取整条流的唯一循环条件。
- 知道写端已关闭,就跳过仍留在管道中的字节。
- 把本例的 Future 超时与取消行为当作所有 I
/O的通用超时方案。
自己试一小步
增加第三块数据后,怎样证明两次暂时空闲都没有被误判为结束?
- 自拟三块 ASCII 数据,增加第三道放行门与对应的完成信号,事先画出你准备记录的阶段。
- 在每次把当前块读空后记录 available、累计字节和写端状态;不允许用 sleep 安排先后。
- 另写一条明确标注为错误策略的读取路径,使用 available 决定是否继续,并保留其实际丢失的内容。
- 让两条路径各自使用独立管道,保存原始输出,并说明最后一次 -1 在哪一步观察到。
做到这些,就算完成
- 所有等待都有超时,异常和超时都没有被当作正常结束。
- 正常路径拼接内容与自拟原文一致,错误路径的结果来自实跑。
- 工作线程完成退出,重复运行不依靠固定等待时间。
- 提交源码、JDK 版本、逐阶段报告与自己的预测,并解释预测与实跑的差异。
查原始资料
想确认一个细节,先看这份资料对应的部分,不必从头读完所有文档。
- Java 17:InputStream 的 available 与 read (新标签页)↗
核读 available、
read(byte[])、read(byte[],int,int):估计值、-1 与零长度读取的区别。 - Java 17:PipedInputStream 的缓冲与读取 (新标签页)↗
核读类说明、带缓冲大小的构造器、read 与 available,确认实际管道及不同线程读写的使用边界。
- Java 17:PipedOutputStream 的写入和关闭 (新标签页)↗
核读 write、flush 和 close,区分交付字节、通知读取方与结束写入。
- Java 17:CountDownLatch 的等待与可见性 (新标签页)↗
核读
countDown、带超时 await 及 Memory consistency effects,用明确事件协调采样阶段。