IO 流这题有点尴尬:它是 Java 里最基础的一块,也是被面试官用来区分"背过 API"和"理解设计"的一块。答对"字节流操作二进制、字符流处理文本"是入门,能把装饰器模式、缓冲区的作用、字符编码的坑讲出来,才算过了这关。这几处恰好是真实项目里最容易出事的地方。
先把结论放在前面:Java 的 IO 流按处理单元分两族——字节流(InputStream/OutputStream)处理二进制,字符流(Reader/Writer)处理文本,字符流内部最终也是转成字节流再走底层。 两族之下各有一层装饰器包装:缓冲流(Buffered*)加装缓冲区减少系统调用,数据流(Data*)按基本类型读写,对象流(Object*)用序列化机制存取对象。这套结构本质是装饰器模式——new BufferedInputStream(new FileInputStream(f)),每一层包装都增加一种能力而不改变原接口,所以可以层层叠加。
机制拆解
讲清缓冲区为什么是关键优化。一次 FileInputStream.read() 若不包缓冲,每个字节都会触发一次系统调用;包上 BufferedInputStream 后,一次读入一整块到内存数组,后续 read 从数组里取,系统调用次数下降几个数量级。同理写出用 BufferedOutputStream 可以减少写调用,并且能在真正关闭前统一 flush。 这就是为什么"用不用缓冲"往往是 IO 性能差异的主要来源。进阶一点是 ByteBuffer 与 NIO:它把缓冲与通道分离,一个缓冲区可以在多个通道之间复用,还支持直接内存(省一次拷贝)用于提升吞吐。
这些坑的正确绕法
最常见的坑是字符流读写二进制文件导致数据损坏,字节与字符的边界没搞清。 表现是文件能写出去、读回来却乱码或直接抛异常,图片、APK、序列化数据都中招。根因是字符流会按编码做解码与编码转换,而二进制数据里的字节序列可能构成非法编码,或在转换时被改写。规矩很简单:文本用字符流,其余一律用字节流;判断标准是"这个内容是不是给人看的文本",而不是"平时习惯用哪个流"。
其次是缓冲流包裹后又在循环里逐字节读,缓冲优势完全没发挥。 常见写法是 for (int i = in.read(); i != -1; i = in.read()) 配上 BufferedInputStream,以为已经有缓冲了;实际上如果每次只读一个字节,缓冲区虽被填充但只消费一个,收益被浪费。 正确做法是直接调 read(byte[]) 一次读一批,或者用 readLine() / transferTo() 这类已经把批量语义封好的方法。
还有一个更隐蔽的坑:流没有关闭,长期运行后句柄耗尽。 手动 open 的流必须用 try-with-resources 关闭,异常的字节流通常实现里 close() 还要连带关闭它包装的底层流。"运行几天后报 too many open files"这类线上问题,多数能在代码里找到裸 new FileInputStream 或没关的连接。 Android 上读写文件与数据库、Socket 都要注意,泄漏会累积到进程级。
IO 流体系的最佳实践是把边界写进注释与检查清单,并用单测覆盖边界与异常路径。 落地成几条:统一用 try-with-resources;所有 IO 调用显式指定字符集(new InputStreamReader(in, StandardCharsets.UTF_8)),不依赖平台默认;大数据量分块流式处理、不整块读进内存;IO 操作放子线程并设置超时。
代码里见真章
看一段能直接跑的代码,把上面的机制落到具体写法上:
// IO 流:字节 vs 字符,装饰器层层包装
try (BufferedInputStream in = // 缓冲层:减少系统调用
new BufferedInputStream(new FileInputStream(binFile))) {
// 底层:字节流
byte[] buf = new byte[8192];
int n;
while ((n = in.read(buf)) != -1) {
// 批量读,别逐字节
out.write(buf, 0, n);
}
}
try (InputStreamReader r = // 字符流:仅用于文本
new InputStreamReader(new FileInputStream(txt), StandardCharsets.UTF_8)) {
String line = r.readLine();
}
// 文本用字符流,二进制一律字节流;charset 显式指定不依赖平台默认
这段代码值得盯三处:第一处,try-with-resources 保证流都会被关,异常路径同样会关;第二处,read(byte[]) 批量读取让缓冲层的优势真正发挥;第三处,字符流显式指定 StandardCharsets.UTF_8,不依赖平台默认编码。面试讲到这一层,基本就稳了。
这题在面试里怎么问、怎么答
"请简单介绍一下 IO 流体系,它在 Android 开发中起什么作用?"按"分两族 → 装饰器包装 → 各适用场景"三段讲。Android 上常见的用途是读写配置文件、缓存文件、解析 APK 资源、导出日志与数据。再落一个细节:文本必须显式指定字符集,这是跨平台协作里最常踩的坑。
"IO 流体系的底层原理是什么?能不能详细说一下?"从三层答:字节流基于 read/write 的抽象,每个方法最终落到一次系统调用;缓冲流预分配数组,把多次调用合并为一次;字符流用 StreamDecoder 做字节到字符的解码,字符流最终仍委托给字节流。 再补 NIO 的差异:Channel 与 Buffer 分离,缓冲区可复用,还支持零拷贝(FileChannel.transferTo)与直接内存。讲完这三层,这题可以给高分。
"在使用 IO 流体系时遇到过什么问题?"拿真实案例。一个典型案例:从服务端下载的配置文件在本机解析正常、用户设备上却乱码;定位到读取时用了平台默认字符集,设备默认编码与服务器不一致;修复为显式指定 UTF-8;验证是各机型解析一致。另一个案例是导出文件时句柄耗尽,定位到流在循环里没关。
"和相关的替代方案相比,有什么优劣?"对比传统 IO、NIO、以及 Android 上的 SharedPreferences 与数据库。结论落在场景:小文件与低频读写用传统 IO 足够;大文件与高并发传输用 NIO 的缓冲与通道更合适;结构化的小配置用 SharedPreferences、需要查询与事务用数据库,别拿文件硬扛。 选型时把"文本与二进制用错流"这类代价摆到台面上,再决定是否引入。
再补一个工程上值得讲清的点:字符编码为什么会成为事故高发区。字节流读到的只是一串字节,字符流要按某个编码把它解释成字符——同一个字节序列在不同编码下会得到不同字符,这就是乱码的根源;而编码转换过程中如果遇到源编码里不存在的字符,转换器可能用替换字符替代,原信息就永久丢失了。 中文场景里还有 GBK 与 UTF-8 的历史包袱:GBK 是变长编码、部分字节序列在 UTF-8 下是合法的但含义完全不同,混用时不会报错,只会静默产出错数据。工程上的硬规矩是三条:读写都显式指定字符集、统一用 UTF-8、跨端协议不用文本格式(传二进制或传明确的结构化数据)。把这三条讲清楚,比背十个流类有用。
顺带说一个容易被问到的高频陷阱:available() 的返回值不是剩余数据量。它返回的是"当前不阻塞能读到的字节数",受底层缓冲与网络状态影响,可能为 0 也可能少于实际剩余量。依赖它来控制读取循环是典型错误,正确做法是循环条件用 read 的返回值(返回 -1 表示结束),而不是用 available。这个点几乎每次 IO 追问都会被问到。
给正在准备面试的你
把 IO 流画成一张层次图:最底是字节流(InputStream/OutputStream),往上是缓冲流、字符流、数据流、对象流四条装饰器分支,右侧标出每一层"加了什么能力",再在字符流与字节流之间画一条箭头标注"内部最终仍转成字节流"。面试中关于 IO 的问题,关键在于能够从原理、应用、踩坑三个层面给出有深度的回答。
再补工程案例与踩坑——应用落点是把项目里裸 new FileInputStream 与依赖默认字符集的读写统一收敛到工具类(try-with-resources + 显式 UTF-8),并补一个"读写中文内容往返一致"的单测。
复习时别孤立刷题:垃圾回收基础——字节流的底层 read 在无缓冲时每次都是一次系统调用,开销与调用次数直接相关。
划两句重点:字节流处理二进制、字符流处理文本(内部仍转字节);缓冲流要配合批量读才有效,文本必须显式指定字符集,流一律用 try-with-resources 关闭。
下一篇聊 Serializable 与 Parcelable:Android 为什么偏爱后者——沿着今天这条主线继续往前走。
如果这篇文章对你有帮助,欢迎点赞、在看、转发三连。你的支持就是这个系列持续更新的动力。
「Android软件开发面试·从入门到精通」连载系列
上一篇:JVM-调优入门:从-OOM-日志倒推问题
下一篇预告:Serializable-与-Parcelable:Android-为什么偏爱后者
有任何问题欢迎在评论区留言交流。