qinghuan
← 回到知识探索

后端现场 / 动手实验

available() 等于零,为什么后面还能读到数据?

用真实 JDK 17 管道和明确的放行信号,记录写入前、两块数据之间、写端关闭后和真正读到结尾的六个阶段。

展开本文目录
  1. 下载完整练习包
  2. 先动手看看
  3. 先分清“现在有多少”和“已经结束了吗”
  4. 把写入方停在两道门前,不猜它睡了多久
  5. 两个相同的零,中间已经收到六个字节
  6. 写入方关闭之后,接收方还没有读完
  7. 运行完整代码,按六行时间表核对
  8. 读取循环应围绕实际结果继续推进
  9. 超时是实验保护,不能变成通用流的承诺
  10. 排查少读时,把日志改成一条可解释的顺序
  11. 记住这三点
  12. 别把例子用过头
  13. 自己试一小步
  14. 查原始资料
开始前,会这些就够了
  • 能读懂字节数组、输入流和简单的 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。表中的六和五来自本例的字符、编码与同步安排,不是任何输入流都必须一次返回的数量。

展开收起代码 · 小实验java · 151 行

长行可在代码区域内左右滚动,按 Tab 可把焦点移入代码区。

小实验 · java
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 的通用超时方案。

自己试一小步

增加第三块数据后,怎样证明两次暂时空闲都没有被误判为结束?

  1. 自拟三块 ASCII 数据,增加第三道放行门与对应的完成信号,事先画出你准备记录的阶段。
  2. 在每次把当前块读空后记录 available、累计字节和写端状态;不允许用 sleep 安排先后。
  3. 另写一条明确标注为错误策略的读取路径,使用 available 决定是否继续,并保留其实际丢失的内容。
  4. 让两条路径各自使用独立管道,保存原始输出,并说明最后一次 -1 在哪一步观察到。

做到这些,就算完成

  • 所有等待都有超时,异常和超时都没有被当作正常结束。
  • 正常路径拼接内容与自拟原文一致,错误路径的结果来自实跑。
  • 工作线程完成退出,重复运行不依靠固定等待时间。
  • 提交源码、JDK 版本、逐阶段报告与自己的预测,并解释预测与实跑的差异。

查原始资料

想确认一个细节,先看这份资料对应的部分,不必从头读完所有文档。