Java 字节流的系统化认知:抓住骨架,剩下的只是组装

Java 的 I/O 体系看起来有好几十个类,但它的设计模式极其简单——两个基类、几个节点流、一堆装饰器。这篇从四层认知出发,帮你把字节流体系彻底读薄。

之前帮人看一段代码,一个同学用 BufferedReader 去读图片文件,读完之后图片直接打不开。排查了半天,本质上就一个问题:字节流和字符流没分清楚,用了查编码的流去处理二进制数据,结构全被破坏了。

这件事让我意识到,很多人学 Java I/O 是靠死记硬背类名的。看到几十个以 Stream、Reader、Writer 结尾的类,第一反应是背,而不是理解它的设计逻辑。

但其实这套体系看透了就四个字:组装游戏

第一层:死死抓住两个基类就够了

不管操作的是文件、网络还是内存,字节流的底层永远只绕着两个抽象类转:

InputStream(输入流)——角色是搬运工。终极使命是把物理世界(磁盘、网络、内存)里的字节,读进 JVM 内存。

OutputStream(输出流)——角色是建筑工。终极使命是把 JVM 内存里的字节,写到物理世界里去。

怎么防止搞混方向?以 JVM 内存为中心。数据进内存叫 Input,数据出内存叫 Output。记牢了这件事,之后所有流的方向判断都不会错。

这两个基类给你最重要的方法各只有一个:read()write()

第二层:节点流是真干活的人

节点流直接连到数据源——你要从磁盘读,就用 FileInputStream;要从内存数组读,就用 ByteArrayInputStream。它们才是真正跟物理世界打交道的流。

操作介质 输入流 输出流 典型场景
磁盘文件 FileInputStream FileOutputStream 图片/视频复制、本地日志写入
内存数组 ByteArrayInputStream ByteArrayOutputStream 内存数据暂存、临时分片处理
网络通信 socket.getInputStream() socket.getOutputStream() TCP 数据包收发

掌握了节点流,你就能写出最经典的"文件搬运"代码。核心思路就是一块缓冲区分批次搬:

public static void copyFile(File src, File dest) throws IOException {
    try (InputStream fis = new FileInputStream(src);
         OutputStream fos = new FileOutputStream(dest)) {

        byte[] buffer = new byte[1024];
        int len;

        while ((len = fis.read(buffer)) != -1) {
            fos.write(buffer, 0, len);
        }
    }
}

这里用 try-with-resources 是必须养成的习惯——物理资源(文件句柄)用完了不释放,系统迟早要找你算账。

第三层:装饰器模式才是 Java I/O 的精髓

如果每次都用节点流一个字节一个字节地读写,磁盘 I/O 的频繁交互会让性能惨不忍睹。Java 没有通过疯狂堆子类来解决这个问题,而是用了装饰器模式(Decorator Pattern)——把一个流套在另一个流外面,给它充能加功能。

BufferedInputStream / BufferedOutputStream —— 性能充能

它们内部自带一个 8KB 的缓冲区。你调用 read() 时,它一次性从磁盘喂饱 8KB 到内存,后续的读取都在内存里完成,性能提升的差距是数量级的。

写法就跟套娃一样简单:

InputStream bis = new BufferedInputStream(new FileInputStream("video.mp4"));

DataInputStream / DataOutputStream —— 功能充能

普通流只能读 byte,它们让你直接读写 Java 基本类型:readInt()writeDouble()writeUTF()

ObjectInputStream / ObjectOutputStream —— 对象深拷贝充能

能把 JVM 里的 Java 对象直接打碎成字节流写到磁盘(序列化),或者从字节流里重组出一个全新对象(反序列化)。上篇提到的利用流做深拷贝,底层依赖的就是这个。

第四层:字节流 vs 字符流,什么时候选哪个?

你可能还听过 Java 有 ReaderWriter(字符流)。什么时候该用哪个?标准只有一个:

字节流(Stream 结尾)——面向 byte(8 bit),最纯粹的底层流,通吃一切文件:图片、视频、MP3、Zip、文本。计算机底层全是字节,字节流什么都能处理。

字符流(Reader/Writer 结尾)——面向 char(16 bit),专门为纯文本而生。它在字节流之上自动去查了编码表(UTF-8、GBK),把字节翻译成人类能直接看懂的字符。

一条防坑金律就够了:

复制图片、视频、音频、压缩包——必须用字节流。字符流查编码表翻译时会破坏二进制结构,文件直接损坏。

处理纯文本文档(.txt、.xml)且涉及复杂的文字修改——优先用字符流,防乱码。

两个问题检测你是不是真懂了

看完上面四层,你可能觉得自己懂了。我用两个面试里常出现的问题检验一下——没有新知识,全是上面讲过的东西,看你能不能联系得起来。

问题一:为什么 BufferedOutputStream 写完要调 .flush()

BufferedOutputStream 能在内存里建一个 8KB 的缓冲区(水库)。当你 write() 时,Java 并没有把数据实时写到磁盘或网络,而是高效地先丢进这 8KB 的水库里。

只有水库满了,才会触发一次真正的物理写入(把水库清空,水倒向磁盘)。

假设你导出文件,总共 10KB:
- 前 8KB 写入,水库满了,自动倒向磁盘 ✓
- 剩下 2KB 继续写入,水库没满,只装了 25%
- 此时不调 .flush() 也不关流——这 2KB 数据永远滞留在内存里

惨剧就是:用户下载的文件缺了结尾,或者网络对端永远在死等最后 2KB,导致超时挂起。

.flush() 的终极使命就是:管你水库满没满,立刻把当前缓冲区里的数据强行倒向物理世界。虽然 .close() 内部会自动调一次 .flush(),但在高并发长连接的网络编程里,手动及时调用是保证数据实时到达的铁律。

问题二:ByteArrayOutputStream 需要调 .close() 吗?

不用。调了也是空实现。

原因看透本质就觉得很简单:流的关闭是为了释放物理资源。操作系统允许同时打开的文件数量有限(文件句柄),FileInputStream 不关会耗尽系统资源。

ByteArrayOutputStream 底层就是一个会自动扩容的 byte[] 数组,全在 JVM 堆里,没碰任何操作系统资源。它就是个普通内存对象,失去引用后 GC 自然会回收它。

所以你去看它的 .close() 源码——什么都没写,标准的空实现。

该形成什么样的条件反射

通过这两个问题,你应该建立起两套本能的反应:

只要流的名字里带 Buffered,写完数据必须时刻紧跟 .flush()(或者用 try-with-resources 结束时自动 flush),防止数据卡在内存缓冲区里。

只有和磁盘、网络、系统设备打交道的流,才需要严谨关闭释放资源。纯内存流(ByteArray 系列),GC 自会超度它。

评论

此博客中的热门博文

我写了半年 prompt,最后发现最好的技巧就三个