
如果你准备过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, ListFile 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_8try (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与Files5.1 NIO到底解决什么问题传统BIO是阻塞式的读数据时线程卡在那里等数据到位才往下走写数据时同理。并发一大每个连接一个线程线程数成了瓶颈。NIO引入了Channel通道、Buffer缓冲区、Selector三个概念。通道支持双向读写数据都会经过BufferSelector可以让一个线程同时管理多个通道有事件才响应这就是“非阻塞”的底气所在。不过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几行就能解决。读小型文本文件ListString lines Files.readAllLines(Path.of(data.txt), StandardCharsets.UTF_8);遍历目录树找.java文件try (StreamPath paths Files.walk(Path.of(project))) { ListPath 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和BufferedInputStreamtry-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[]再处理。文件小没事文件上了几百MBGC直接给你颜色看甚至读一半内存就崩了。正确姿势是大文本文件逐行处理用BufferedReader.readLine()循环大二进制文件用固定大小byte[]缓冲区分段读必须全量处理时考虑FileChannel.map做内存映射。我处理过几个GB的日志文件就是靠BufferedReader逐行读一边读一边按行解析内存占用始终稳定。如果换成一次读全部早就OOM了。6.4 常见问题速查表与面试追问清单最后整理一个排查速查表几乎覆盖了日常I/O操作80%的怪问题。现象可能原因解决方案中文乱码编码不一致InputStreamReader显式指定UTF-8/GBKdelete()返回false流未关闭、目录非空、文件被锁关流递归删子文件排查占用进程写入内容不落盘缓冲未flushclose或flush大文件OOM一次读入内存缓冲区分段读或用BufferedReader逐行读InvalidClassExceptionserialVersionUID不一致实体类显式声明serialVersionUIDreadLine()进入死循环判空条件写错写成while ((line br.readLine()) ! null)换启动方式后路径失效相对路径基于工作目录用绝对路径或Paths.get拼接面试追问清单也可以拿来自测Reader为什么是字符流BufferedOutputStream和FileOutputStream什么关系NIO和BIO的区别在哪File和Files的定位有何不同这些问题都能用本文里的内容组织出答案。我个人的体会是I/O这块学习的重点不是背API而是建立“数据流什么时候在内存、什么时候在磁盘、什么时候在缓冲区”的画面感。只要有了这个画面乱码、丢数据、删不掉、占内存这些问题看代码时就能一眼锁定方向。建议你学完这篇后自己去写三个小工具一个递归统计目录下各类型文件数量一个用缓冲流复制100MB文件并计时一个把User对象序列化到本地再读回来。跑通这三个Java基础里的I/O与File就算真正入门了。