如果你准备过Java面试,或者刚学完Java SE的基础语法,大概率会在资料里反复看到这么一组词:I/O与File。和它一起出现的,还有java面试题、java八股文、java基础知识总结这类搜索词。说它是基础,它却经常被面试官拿出来深挖到让人冒汗;说它难,它其实就是那几张类图和一些读写套路。这篇文章就把I/O和File这摊事讲透:File怎么用、字节流和字符流到底怎么选、对象流和序列化是怎么回事、NIO和Files到底比老API强在哪,最后还有一堆我踩过和帮别人排查过的坑。
1. 为什么I/O和File值得认真学一学
1.1 面试题里的“常青树”
最近刷热搜词的时候,“java面试题”“java八股文”“java基础知识总结 超详细”里,I/O和File几乎从不缺席。这个模块简直是“看着基础、问着就深”的典型案例。面试官常问:字节流和字符流有什么区别?BufferedInputStream到底做了什么?File类和Files类怎么选?深挖下去,背后牵扯到操作系统调用、内存缓冲区、字符编码、线程阻塞,这些东西每一个都能把问题问得很大。想靠背书应付是不行的。
我见过不少候选人,能背出InputStream、OutputStream、Reader、Writer四个抽象类的名字,但问到“为什么FileReader读UTF-8文件会乱码”就彻底卡住。这说明他只记了类名,没有建立起“数据流从磁盘到内存再到字符串”的完整画面。面试官问I/O,表面是考API熟练度,实际是在考你对底层数据路径的理解。
1.2 业务里无处不在的I/O
日志要写文件、上传要存磁盘、配置要读入内存、数据备份要复制文件、导出Excel要生成临时文件——几乎没有一个业务系统能绕过I/O。性能瓶颈往往也发生在这里。一条SQL慢可以加索引,一个接口慢很可能就是I/O在那里干等。理解I/O,很多线上问题的定位速度会快很多。
我之前帮同事排查一个线上日志文件不落盘的问题,第一反应就是查文件句柄和流是否被正确关闭,而不是去看业务代码逻辑。查了半天,果然是某处用FileWriter写日志后没调用flush,数据全积在缓冲区里。这种问题如果你脑子里没有“缓冲流”的概念,光看业务代码根本找不到方向。
2. File类的正确打开方式
2.1 路径:第一个暗坑
构造new File("data/config.ini")这类相对路径时,相对的是“当前工作目录”。在IDE里点击运行时,工作目录通常是项目的根目录;打包成jar用java -jar启动时,工作目录又变成了运行命令所在的目录。同一个代码,换了启动方式路径就失效,这类问题平时最容易忽略。
更别提Windows和Linux的分隔符差异了。Windows用反斜杠\,Linux用正斜杠/,虽然Java在Windows上也能识别正斜杠,但拼接路径时如果自己写死分隔符,代码就没法跨平台了。我的习惯是:能用绝对路径就用绝对路径;实在要相对路径,用Paths.get("data", "config.ini")拼接,或者用File.separator。再或者干脆把配置文件放classpath,用getResourceAsStream读,直接绕开路径问题。
2.2 File的方法有“隐藏语义”
File类的API看着简单,用起来容易踩语义上的坑。
exists()返回false不代表文件一定不存在,也可能是当前用户没有权限访问父目录。isFile()只有在路径是一个真实存在的普通文件时返回true,目录返回false。length()对于空文件返回0,但目录也会返回一个无效值。listFiles()更讲究:当File对象不是目录时返回null;当目录下没有文件时返回空数组,不是null。写代码时必须先判null再遍历,否则直接for循环会抛NullPointerException。
还有一个我在项目里见到过的真实Bug:某同事判断文件是否存在时用了file.isFile(),结果那个路径是目录,程序一直走不进存在性判断的逻辑分支。后来改成file.exists()才正常。出现这种问题的根源,是没搞清楚“存在”和“是普通文件”是两回事。
2.3 一个查找所有.java文件的递归实现
业务上常常要遍历目录结构,比如在项目根目录下找出所有.java文件。用File实现大致是这样:
public void listJavaFiles(File dir, List<File> result) { if (dir == null || !dir.isDirectory()) { return; } File[] files = dir.listFiles(); if (files == null) { return; } for (File file : files) { if (file.isDirectory()) { listJavaFiles(file, result); } else if (file.getName().endsWith(".java")) { result.add(file); } } }关键点有三个。第一,递归前必须先判断是目录还是文件,避免把二进制文件也塞进过滤逻辑。第二,listFiles()前一定要判空,目录没有权限或者路径本身有问题时会返回null。第三,如果目录层级特别深,递归可能栈溢出,一般业务目录不至于深到那个程度,但如果确实遇到,可以用显式栈或者队列来做迭代式遍历。
这段代码虽然能用,但你会发现它写起来挺啰嗦。后面讲到NIO的Files类时,会有更简洁的替代方案。
3. 流:字节流与字符流怎么选
3.1 流家族的层级
为什么会有两套体系?核心原因是“数据形态”不同。字节流处理的是二进制数据,一切文件在底层都是字节,所以InputStream/OutputStream是通吃的;字符流处理的是文本数据,底层依然读写字节,但中间封装了“字节到字符”的解码过程。
从类图上看,字节流的根是InputStream和OutputStream,字符流的根是Reader和Writer。FileInputStream和FileOutputStream是文件字节流,FileReader和FileWriter是文件字符流。
面试里说“Reader按字符读取,Stream按字节读取”只是基本分,真正的细节在于字符流内部要按编码表把多个字节拼成一个字符。UTF-8的中文一个字符占3个字节,GBK占2个字节,这就是为什么字符流更适合读中文,而字节流更适合读图片、音频、压缩包。如果你让字符流去读一张图片,解码过程大概率会报错,因为二进制文件的字节序列根本不构成合法的文本。
3.2 缓冲流带来的性能变化
BufferedInputStream默认有一个8KB的内部缓冲区。读数据时,它会尽量把底层数据先读到缓冲区,后面程序再读就直接从内存取。写数据同理,先攒着,攒满8KB一次性写到底层。
为什么要这么做?因为底层系统调用的成本比内存操作高很多。这就像你去超市买菜,一次买够一周的量,比天天为了一根葱跑一趟划算得多。每次调用read()或write()都可能触发一次系统调用,而系统调用涉及用户态到内核态的切换,这个开销在频繁小数据读写时会被无限放大。
注意:用BufferedWriter或BufferedOutputStream时,如果不调用flush()或close(),数据可能一直养在缓冲区没落盘。程序正常退出时会自动关闭,但进程被强杀时,这部分数据就丢了。所以涉及重要数据的写入,写完一定显式调flush()。
3.3 乱码的根源与转换流
新手最常见的“读中文变乱码”,根因99%是编码不匹配。FileReader有一个不太好的设计:它只能使用平台默认字符集去解码,没法在构造时指定编码。以前Windows上默认GBK,现在大多数环境是UTF-8。一旦编码不对,读出来的字符串看起来就是天书。
正确做法是绕开FileReader,用InputStreamReader包一个FileInputStream,并显式传StandardCharsets.UTF_8:
try (InputStreamReader reader = new InputStreamReader( new FileInputStream("data.txt"), StandardCharsets.UTF_8); BufferedReader br = new BufferedReader(reader)) { String line; while ((line = br.readLine()) != null) { System.out.println(line); } }写文件同理,用OutputStreamWriter指定编码:
try (OutputStreamWriter writer = new OutputStreamWriter( new FileOutputStream("data.txt"), StandardCharsets.UTF_8); BufferedWriter bw = new BufferedWriter(writer)) { bw.write("中文内容"); }转换流的核心作用就是把字节流“翻译”成字符流,并在这个翻译过程中指定编码表。你只要记住:读文本靠InputStreamReader,写文本靠OutputStreamWriter,编码别用默认,显式指定UTF-8,乱码问题能少一大半。
3.4 一个干净的文件复制实现
文件复制是I/O里的经典场景。用字节流加缓冲的常规写法:
try (FileInputStream in = new FileInputStream("source.zip"); FileOutputStream out = new FileOutputStream("target.zip"); BufferedInputStream bin = new BufferedInputStream(in); BufferedOutputStream bout = new BufferedOutputStream(out)) { byte[] buffer = new byte[8192]; int len; while ((len = bin.read(buffer)) != -1) { bout.write(buffer, 0, len); } }几个细节值得说。read(byte[])返回的是实际读到的字节数,最后一次读取很可能不足8192字节,所以write时必须传0, len而不是直接写整个数组。缓冲数组大小取8192是性能经验值,太小会频繁系统调用,太大又会浪费内存。try-with-resources的关闭顺序是自动反序的,先关缓冲流再关文件流,这个后面会细讲。
4. 对象流与序列化
4.1 把对象存进文件再读回来
把对象保存成二进制文件,听起来很高端,其实就是序列化。ObjectOutputStream把对象状态变成字节流写进文件,ObjectInputStream再把字节流还原成对象。
class User implements Serializable { private static final long serialVersionUID = 1L; private String name; private transient String password; private int age; }写入:
User user = new User(); user.setName("张三"); user.setPassword("123456"); try (ObjectOutputStream oos = new ObjectOutputStream( new FileOutputStream("user.obj"))) { oos.writeObject(user); }读取:
try (ObjectInputStream ois = new ObjectInputStream( new FileInputStream("user.obj"))) { User user = (User) ois.readObject(); System.out.println(user.getName()); System.out.println(user.getPassword()); }运行这段代码你会看到,password字段没有被还原。因为它在定义时加了transient关键字,标记为不参与序列化。这个设计很实用,像密码、密钥这类敏感字段就不该被持久化。
一个容易忽视的细节:序列化并不会调用构造函数,还原对象是通过JVM底层直接分配内存重建的。所以就算你在构造函数里做了各种初始化逻辑(设置默认值、校验字段),反序列化时这些逻辑统统不会执行。读出来的对象,字段值完全来自文件里保存的状态。
4.2 serialVersionUID、transient和那些坑
serialVersionUID是序列化时的“版本指纹”。如果没有手动声明,Java会根据类结构自动算一个。一旦改了类的字段(增删字段、变更类型),自动算出来的UID就会变,反序列化时JVM发现版本对不上,直接抛InvalidClassException。
这个问题在公司里极其常见:上线前改了实体类,缓存里存着旧版本的序列化数据,一启动就炸。所以实体类只要你确定会被序列化,就一定要显式声明private static final long serialVersionUID = 1L;。声明成固定值后,反序列化时即使类结构稍微变了一下,只要字段名对得上,Java还会尝试兼容恢复。
还有几个和序列化有关的冷知识点:
- 静态字段不会被序列化,因为它是类级别的,不属于某个对象的实例状态。
- 父类如果也实现了Serializable,父类的字段会被序列化;父类没实现Serializable,父类字段不会被保存,反序列化时会用父类的无参构造函数初始化。
- 序列化的对象图里如果包含无法序列化的成员,会直接抛
NotSerializableException。
实际开发里,序列化还大量用于缓存、RPC、消息队列的对象传递。本地调试时,把一个复杂对象序列化到文件再读回来,比录一堆日志直观多了。
5. 从传统I/O走向NIO与Files
5.1 NIO到底解决什么问题
传统BIO是阻塞式的:读数据时线程卡在那里,等数据到位才往下走;写数据时同理。并发一大,每个连接一个线程,线程数成了瓶颈。
NIO引入了Channel(通道)、Buffer(缓冲区)、Selector三个概念。通道支持双向读写,数据都会经过Buffer;Selector可以让一个线程同时管理多个通道,有事件才响应,这就是“非阻塞”的底气所在。
不过NIO的API写起来比较细腻,缓冲区里position、limit、capacity三个指针翻转两下,初学者很容易晕。我看过不少项目,嘴上说用NIO,实际代码里就是Files.readAllBytes一下读完,根本没有发挥非阻塞的优势。对于大多数业务场景,BIO加缓冲流已经够用。真要上NIO,建议先把Buffer的flip、clear、compact这几个操作理清楚,否则写出来的代码连自己都看不懂。
5.2 用Files类优雅地替代90%的File操作
JDK 7之后,官方推荐用Path和Files来处理文件元信息和文件内容。很多原本要写十几行File操作才能搞定的活,用Files几行就能解决。
读小型文本文件:
List<String> lines = Files.readAllLines(Path.of("data.txt"), StandardCharsets.UTF_8);遍历目录树找.java文件:
try (Stream<Path> paths = Files.walk(Path.of("project"))) { List<Path> javaFiles = paths .filter(p -> p.toString().endsWith(".java")) .collect(Collectors.toList()); }复制和移动文件:
Files.copy(Path.of("a.txt"), Path.of("b.txt"), StandardCopyOption.REPLACE_EXISTING); Files.move(Path.of("a.txt"), Path.of("b.txt"), StandardCopyOption.REPLACE_EXISTING); Files.deleteIfExists(Path.of("temp.txt"));注意,Files.walk返回的是Stream,用完之后要关掉,否则文件句柄也会泄漏。这正是try-with-resources派上用场的地方。
Files类比File强在几个地方:方法命名更直观;几乎每个操作都有对应的deleteIfExists这类“不炸版”方法;抛异常时给的信息更准确;还能配合NIO的Channel做内存映射。面试如果问到File和Files的区别,可以从JDK版本、API命名、异常处理、对NIO的配合几个角度展开。
6. 常见问题排查与面试避坑
6.1 流关闭:try-with-resources是底线
最经典的I/O坑就是“打开了流忘记关”。轻则文件被占用,让别的程序删不掉;重则文件句柄耗尽,整个应用打不开文件。
老代码里无脑加finally然后close()是常见做法,但自己一旦忘写finally,代码就会泄漏。JDK 7之后有try-with-resources,声明在try括号里的资源,代码块一结束就会自动close,而且关闭顺序和资源声明顺序相反。
这里有一个容易忽略的细节:如果同时开了FileInputStream和BufferedInputStream,try-with-resources会先关BufferedInputStream,再关FileInputStream。因为外层缓冲流关闭时会把缓冲区里的数据flush到底层流,如果先关底层流,外层流的flush就会失败。所以千万别为了省事只声明一个缓冲流,底层文件流不写进try的括号里。
6.2 文件删不掉的“悬案”
File.delete()返回false,或者Windows系统提示文件被占用,这种情况我排查过很多次。常规排查顺序是:
- 先确认这个文件没有别的地方还开着它的流。
- 再确认是不是自己代码里读完后没有close。
- 然后考虑程序往文件写入后没flush,导致缓冲区里还占着文件。
- 最后看Windows下文件是否被某个进程锁住。
还有一个冷门的:如果File对象指向的是目录,目录里有文件,delete()也会返回false,必须先清空目录里的内容。至于用deleteOnExit()做延迟删除,除非有特殊需要,否则别用它,程序异常退出时它不一定会执行。
6.3 大文件读进内存的陷阱
经常看到有人用Files.readAllBytes或者FileInputStream.readAllBytes把整个文件读进byte[]再处理。文件小没事,文件上了几百MB,GC直接给你颜色看,甚至读一半内存就崩了。
正确姿势是:大文本文件逐行处理用BufferedReader.readLine()循环;大二进制文件用固定大小byte[]缓冲区分段读;必须全量处理时,考虑FileChannel.map做内存映射。
我处理过几个GB的日志文件,就是靠BufferedReader逐行读,一边读一边按行解析,内存占用始终稳定。如果换成一次读全部,早就OOM了。
6.4 常见问题速查表与面试追问清单
最后整理一个排查速查表,几乎覆盖了日常I/O操作80%的怪问题。
| 现象 | 可能原因 | 解决方案 |
|---|---|---|
| 中文乱码 | 编码不一致 | InputStreamReader显式指定UTF-8/GBK |
| delete()返回false | 流未关闭、目录非空、文件被锁 | 关流,递归删子文件,排查占用进程 |
| 写入内容不落盘 | 缓冲未flush | close或flush |
| 大文件OOM | 一次读入内存 | 缓冲区分段读或用BufferedReader逐行读 |
| InvalidClassException | serialVersionUID不一致 | 实体类显式声明serialVersionUID |
| readLine()进入死循环 | 判空条件写错 | 写成while ((line = br.readLine()) != null) |
| 换启动方式后路径失效 | 相对路径基于工作目录 | 用绝对路径或Paths.get拼接 |
面试追问清单也可以拿来自测:Reader为什么是字符流?BufferedOutputStream和FileOutputStream什么关系?NIO和BIO的区别在哪?File和Files的定位有何不同?这些问题都能用本文里的内容组织出答案。
我个人的体会是,I/O这块学习的重点不是背API,而是建立“数据流什么时候在内存、什么时候在磁盘、什么时候在缓冲区”的画面感。只要有了这个画面,乱码、丢数据、删不掉、占内存这些问题看代码时就能一眼锁定方向。建议你学完这篇后,自己去写三个小工具:一个递归统计目录下各类型文件数量,一个用缓冲流复制100MB文件并计时,一个把User对象序列化到本地再读回来。跑通这三个,Java基础里的I/O与File就算真正入门了。