学Java到第27天IO流和异常处理这块算是很多人第一次真正感受到“编程不是背语法”的分水岭。前面写数组、写集合逻辑上绕一绕总能通过但一碰到文件读写你就会发现文件可能不存在、可能被占用、可能编码不对、可能读到一半断掉……这些全是异常要管的。而File类呢又是所有文件操作的地基。今天这篇就把【异常、File、IO流】这三兄弟放在一起打通用一整套综合案例把它们串起来。不需要你有深厚的基础跟着一步步写写完基本就能应付日常开发里80%的文件操作需求。1. 异常处理写IO之前先把兜底逻辑练扎实1.1 编译期异常和运行时异常别搞混Java的异常体系是整个语言里最容易被忽视又最容易翻车的地方。很多新手写代码看到方法签名后面跟着throws就照抄出了问题就报红归根到底是没搞明白异常到底分几类。Java把异常分成两大类编译期异常checked exception和运行时异常unchecked exception / runtime exception。编译期异常的意思是编译器在编译阶段就强制你处理你不处理就编译不过去。典型代表就是IOException。你写FileInputStream打开一个文件编译器就会提醒这行代码可能抛IOException你得想办法兜住不然别想编译通过。这种设计的逻辑在于IO操作太不可控了文件系统不在你掌控之内磁盘可能满、权限可能不够、文件被别的进程占用这些都是“一定会发生”的客观风险编译器逼你做预案。运行时异常则相反编译器不强制你处理因为这类异常大多源于程序逻辑自身的错误。最典型的就是ArrayIndexOutOfBoundsException数组越界还有IllegalArgumentException非法参数、NullPointerException空指针。这些异常在写代码的时候就应该通过严谨的逻辑去避免而不是依赖try-catch去兜底。你可以捕获它们但如果你的程序到处靠捕获运行时异常来“续命”那说明逻辑本身就有问题。我之前给一个新人看代码他把所有的业务方法都包了一大层try-catch然后catch(Exception e)之后返回一个错误码。这样写表面上很稳实际上把所有问题的根因都吞掉了查起日志来满屏都是“操作失败”但具体哪儿失败、为什么失败一概不知。这是大忌。提示catch(Exception e)这种写法不是不能用但至少要在catch块里把e打印出来或者写入日志文件。空catch块等于什么都没做只会让问题越来越隐蔽。1.2 try-catch-finally 的过时写法与 try-with-resources传统IO操作的标准结构是try-catch-finally这个结构本身并没有错但有个非常折磨人的细节要在finally里关闭资源。以前写代码是这样的FileInputStream fis null; try { fis new FileInputStream(D:/readme.txt); int data; while ((data fis.read()) ! -1) { System.out.print((char) data); } } catch (FileNotFoundException e) { System.out.println(文件不存在请检查路径); e.printStackTrace(); } catch (IOException e) { System.out.println(读取过程中发生IO错误); e.printStackTrace(); } finally { if (fis ! null) { try { fis.close(); } catch (IOException e) { e.printStackTrace(); } } }看到finally里这个嵌套try-catch了吗这是以前每个Java开发者都写过无数遍的“经典样板”。麻烦在哪儿第一容易忘。第二关闭操作本身也可能抛IOException你不得不再包一层try-catch。第三如果有多个资源要关闭比如一个输入流一个输出流finally里的代码会膨胀到很恐怖的程度。所以Java 7之后引入了try-with-resources语法只要是实现了AutoCloseable接口的资源InputStream、OutputStream、Reader、Writer都是就能自动关闭try (FileInputStream fis new FileInputStream(D:/readme.txt); InputStreamReader isr new InputStreamReader(fis, StandardCharsets.UTF_8); BufferedReader br new BufferedReader(isr)) { String line; while ((line br.readLine()) ! null) { System.out.println(line); } } catch (IOException e) { System.out.println(读取失败 e.getMessage()); }这个写法有两个明显的好处资源会自动关闭而且是逆序关闭先开的后关代码量大幅减少finally块基本退出了日常写法的舞台。如果你现在还在写Java 8或更高的版本我强烈建议你把所有资源操作都改成这个写法。注意try-with-resources声明的变量在try块内是隐式final的你不能重新赋值这个限制对正常使用来说影响不大。1.3 三个实战准则何时捕获、何时抛出、何时记录异常处理的“分寸感”是最难教的部分代码里查得到语法但查不到这个。我给你三个我实际工作中一直遵守的准则。第一底层方法倾向于“抛出”高层方法才“捕获”。文件读取这种底层操作你不应该在每一层都try-catch。在工具方法里直接throws让上层业务人员去决定怎么处理——是重试、是提示用户、还是记录日志。层层捕获的结果就是每一层都只能打印日志最后根本分不清是哪儿出的问题。第二异常信息必须包含充分的上下文。比如FileNotFoundException如果你直接抛一条“文件不存在”上层拿到这个异常根本不知道是哪个文件。正确做法是带上路径。我见过线上日志里一堆“系统错误”“处理失败”这种毫无辨识度的异常信息排查起来真的是大海捞针。第三不要用异常控制业务流程。有些新手喜欢用异常来结束循环或者判断文件是否存在。这是非常糟糕的设计。异常的开销远高于普通条件判断而且会严重干扰调用方的正常逻辑。文件读完了read()返回-1就是结束信号不要用异常去表达“正常结束”这种业务状态。2. File类不读内容也能把文件管明白2.1 File的核心能力增删改查File类是Java里代表“文件和目录路径名”的抽象。注意它不代表文件内容它只负责文件系统的元信息操作。很多初学者误以为File能读取文件内容结果new完File就不知道下一步怎么走了。File类真正的能力可以概括为四件事查判断存在性、类型、大小、建创建文件/目录、删删除、列列出目录内容。给你一个快速入门的代码框架File file new File(D:/logs/app.log); // 查 System.out.println(是否存在: file.exists()); System.out.println(是否为文件: file.isFile()); System.out.println(是否为目录: file.isDirectory()); System.out.println(文件大小: file.length() bytes); System.out.println(最后修改时间: new Date(file.lastModified())); // 建 File dir new File(D:/logs/2025/06); if (!dir.exists()) { boolean ok dir.mkdirs(); // mkdirs会递归创建所有不存在的父目录 System.out.println(目录创建结果: ok); } // 列 File parent new File(D:/logs); String[] fileNames parent.list(); File[] fileObjects parent.listFiles();这里有个细节值得说mkdir()和mkdirs()的区别。mkdir()只能创建单层目录父目录不存在就直接失败mkdirs()会递归创建整条路径。日常开发里几乎可以只用mkdirs()因为目录层级往往不是一层能搞定的。另外file.length()这个方法返回的是long如果文件超过2GB你用int去接就会溢出这种坑很隐蔽。listFiles()返回一个File数组比list()返回字符串数组更常用因为你拿到File对象后可以直接继续递归或者判断类型不用再new一遍。这是个小技巧但对使用便利性的提升很明显。2.2 路径分隔符与相对路径的坑路径问题是File类第一个大坑。Java程序是跨平台的Windows的路径分隔符是反斜杠\Linux和macOS是正斜杠/。如果你在代码里写死D:\logs\app.log换到Linux服务器上必崩。正确的做法有几种第一用File.separator这个常量来表示系统相关的分隔符第二用正斜杠/——Java在Windows上也能识别正斜杠这是最偷懒但也最稳妥的写法第三不要自己拼路径用File提供的构造方法比如new File(parentDir, child.txt)。另一个容易翻车的是相对路径。很多新手以为相对路径是相对于当前代码文件所在的位置其实不是。相对路径是相对于“当前工作目录”也就是启动Java进程时所在的目录。你在IDE里运行和在命令行里运行工作目录往往不一样所以“为什么我用相对路径能读到文件部署到服务器就报FileNotFoundException”这种问题屡见不鲜。我的建议是日常开发、测试可以用相对路径但任何要上线的程序路径一律用绝对路径或者通过配置文件注入别在代码里写死。2.3 两个高频坑delete失败和listFiles空指针File的delete()方法是个“温吞水”如果文件正被某个进程占用或者目录不为空它并不会抛异常而是静默地返回false。很多新手写删除逻辑调了delete()就以为删掉了后面代码里继续用这个文件结果各种奇奇怪怪的错。正确姿势是删完检查返回值或者删除前先判断。if (file.delete()) { System.out.println(删除成功); } else { System.out.println(删除失败常见原因文件被占用或目录非空); }另一个高频坑是listFiles()返回null。原因是如果File对象本身不是一个存在的目录、或者没有权限读取目录内容listFiles()会返回null而不是空数组。很多人直接写for (File f : dir.listFiles())一旦拿到nullfor循环里立刻空指针。我见过不少线上崩溃源于此所以在递归遍历目录时务必要做null判断File[] files dir.listFiles(); if (files null) { return; // 说明这个目录不可读或者根本不存在 }这个判断看起来像是“防御式编程”的多余动作但它是无数血泪换来的规矩。3. IO流体系字节流、字符流、缓冲流怎么选3.1 字节流和字符流本质区别与应用场景IO流的底层逻辑其实不复杂计算机最终存储和传输的只有字节byte。字节流InputStream/OutputStream是直接跟字节打交道的流字符流Reader/Writer则是在字节流基础上增加了字符编码和解码的层次相当于做了一层翻译。所以选字节流还是字符流核心判断标准是你操作的数据是文本还是任意二进制数据如果是图片、视频、压缩包、class文件这类只能用字节流用字符流去读会毁掉数据。如果只是读写文本文件用字符流效率更高、代码也更直观因为你可以直接按行读处理字符串。典型场景代码// 字节流复制图片 try (FileInputStream in new FileInputStream(D:/a.jpg); FileOutputStream out new FileOutputStream(D:/copy.jpg)) { byte[] buffer new byte[1024]; int len; while ((len in.read(buffer)) ! -1) { out.write(buffer, 0, len); } }// 字符流按行读文本 try (BufferedReader reader new BufferedReader(new FileReader(D:/a.txt))) { String line; while ((line reader.readLine()) ! null) { System.out.println(line); } }中间那个byte[] buffer是干嘛的read()一次读一个字节效率极低所以在内存里开一个1024字节的缓冲区一次读一批再用循环把缓冲区里的数据写出去。这个思路跟后面要讲的缓冲流的原理是一样的只是在代码里手动实现而已。3.2 缓冲流为什么快缓冲区机制解析Java IO体系里有个性能关键角色BufferedInputStream和BufferedOutputStream字符流对应BufferedReader和BufferedWriter。很多人疑惑直接用FileInputStream读写很正常为什么套上一层缓冲流就快了原理是没有缓冲时每次read()都会触发一次系统级调用跟操作系统要一个字节。想象一下你去餐厅吃饭每吃一口饭就跑一趟厨房端这效率能高吗有了缓冲流之后它默认一次性从系统里读进8192个字节到内存缓冲区你的程序在缓冲区里取数据只有缓冲区空了才再次跟系统要。这就相当于厨房一次做好一大桌菜你坐在桌上慢慢吃不用来回跑。实操中我测试过用FileInputStream一个字节一个字节地读一个几百MB的大文件耗时是按秒算的套上BufferedInputStream之后几乎是瞬间读完。差别不在一个量级。所以读文件请无脑套缓冲流这是IO性能的第一优先级优化。需要留意的是如果你用Reader读文本一定要用BufferedReader因为readLine()这个方法只有BufferedReader提供FileReader本身没有按行读的方法。再往后进阶你还会接触到NIO里面的通道和选择器也就是常说的IO多路复用很多网络框架的底层都在用但那是下一步的事今天先把基础体系的账算清楚。3.3 编码问题乱码的根源与转换流乱码是IO里最磨人的问题没有之一。根源在于字符编码不一致写入时用了A编码读取时用了B编码。比如你用UTF-8写入一个“中”字占3字节读取时却按GBK去解码GBK一个汉字2字节字节错位出来的就是“锟斤拷”这种经典乱码。Java中FileReader和FileWriter默认使用JVM的平台默认编码这是个非常危险的行为。在Windows中文版上默认编码是GBK在Linux服务器上默认是UTF-8。你的代码在本地运行正常部署到Linux就乱码大概率就是这个问题。解决方案明确指定编码。用InputStreamReader和OutputStreamWriter手动指定编码try (InputStreamReader isr new InputStreamReader( new FileInputStream(D:/data.txt), StandardCharsets.UTF_8); BufferedReader br new BufferedReader(isr)) { String line; while ((line br.readLine()) ! null) { System.out.println(line); } }还有一点Windows下的文本文件换行是\r\nLinux和macOS是\n。如果你用readLine()方法BufferedReader会聪明地把行尾的换行符吃掉你拿到的字符串不带换行这个问题一般不会暴露。但如果你自己用read()逐个字节读然后再拼字符串就得自己处理\r\n和\n的差异这也就是为什么很多人第一次在Windows上写文件然后传到Linux上发现内容错乱。跨平台处理文本时最好统一用UTF-8和readLine()别自己在底层跟换行符较劲。4. 综合案例递归实现文件批量重命名与关键字扫描4.1 需求拆解与设计思路光讲知识点不算完得有个综合案例把异常、File、IO流全串起来用一遍。我设计了一个非常贴合日常工作的场景你的项目目录底下有几百个txt文件零散分布在多级子目录里里面有的笔记、有的日志、有的配置文件文件名七零八落。现在有两个需求第一把所有的txt文件统一重命名加上日期前缀比如20250610_report.txt 第二在所有txt文件里搜索一个关键字比如“ERROR”把命中的文件路径、行号和具体内容打印出来。这个需求看起来简单但背后覆盖了我们前面讲的所有知识点递归遍历目录要用File类的isDirectory()和listFiles()重命名要用renameTo()读取文件内容要找编码和缓冲流整个过程中文件可能不存在、被占用、权限不足所以异常处理要到位。设计思路分三步写一个递归方法遍历目录树遇到文件就判断扩展名是txt就执行重命名同时再写一个递归方法做关键字搜索搜索的逻辑是按行读、行号递增、用String.contains()判断最后在main里把两个方法串起来入口参数是扫描根目录、日期前缀、关键字。4.2 核心代码实现先写重命名部分public class Renamer { private static int counter 0; public static void renameTxtFiles(File dir, String prefix) { File[] files dir.listFiles(); if (files null) { System.out.println(目录不可读或不存在: dir.getAbsolutePath()); return; } for (File file : files) { if (file.isDirectory()) { // 递归进入子目录 renameTxtFiles(file, prefix); } else if (file.getName().toLowerCase().endsWith(.txt)) { String newName prefix _ (counter) .txt; File newFile new File(file.getParent(), newName); if (file.renameTo(newFile)) { System.out.println([重命名] file.getName() - newName); } else { System.out.println([失败] 无法重命名: file.getAbsolutePath()); } } } } public static void main(String[] args) { File root new File(D:/work/files); renameTxtFiles(root, 20250610); } }然后写关键字扫描部分public class Searcher { public static void searchTxt(File dir, String keyword) { File[] files dir.listFiles(); if (files null) { System.out.println(目录不可读或不存在: dir.getAbsolutePath()); return; } for (File file : files) { if (file.isDirectory()) { searchTxt(file, keyword); } else if (file.getName().toLowerCase().endsWith(.txt)) { int lineNum 0; try (BufferedReader reader new BufferedReader( new InputStreamReader(new FileInputStream(file), StandardCharsets.UTF_8))) { String line; while ((line reader.readLine()) ! null) { lineNum; if (line.contains(keyword)) { System.out.println(file.getAbsolutePath() : lineNum - line.trim()); } } } catch (IOException e) { System.out.println([读取失败] file.getAbsolutePath() 原因: e.getMessage()); } } } } }这两段代码应该都很容易看懂。有一点要说清楚重命名后的文件名用了一个静态计数器counter这保证同一次运行中不会重名但如果两次运行之间目录里已经有文件计数器会从1重新开始可能跟现有文件冲突。更健壮的做法是先扫描一遍目录找到当前最大的序号再继续。这个留作练习——这才是真正锻炼工程思维的地方。4.3 边界情况与异常兜底这个案例跑起来很容易但要跑好还得处理几个边界情况。文件被占用时renameTo()会静默失败你在代码中看到的结果是返回false但没有任何异常抛出。这很容易被忽略因为程序“没报错”但功能没生效。所以我特意在失败时打出了一行提示而不是直接忽略。编码问题也很关键。如果目录里有GBK编码的老旧txt文件你用UTF-8去读就会读出一堆乱码contains(keyword)自然永远匹配不上。我自己的处理习惯是项目内统一约定UTF-8对于历史遗留文件先用一个文本编辑器打开确认编码再指定对应编码读取。这一块做起来其实不复杂但很琐碎最能体现工程经验的积累。还有一个细节递归遍历如果目录层级特别深比如超过几千层会有栈溢出的风险。日常业务目录基本碰不到这个深度但如果你在写一个通用的文件操作工具可以考虑用队列加循环来替代递归。递归代码更直观非递归代码更稳这个取舍看使用场景。5. 高频问题排查与避坑清单5.1 常见异常速查表写IO代码到今天下面这些异常是我在带新人时反复看到的直接给你整理成速查表遇到问题对号入座。异常类型典型触发场景解决思路FileNotFoundException路径不存在、文件被移动、没有读权限检查路径、确认文件存在、检查权限IOException磁盘满、文件被锁、网络中断看详细message多半是系统级问题NullPointerExceptionlistFiles()返回null后直接遍历遍历前判空或改用Files.walkArrayIndexOutOfBoundsException读取固定大小缓冲区时下标越界循环变量检查注意边界值UnsupportedEncodingException指定了不存在的编码字符集用标准编码常量别手打编码名StackOverflowError递归遍历目录层级过深改成循环加队列实现这里特别说下UnsupportedEncodingException。我们平时指定UTF-8、GBK都没问题但如果你把编码名传成“UTF8”少个横杠在某些环境上就会抛异常。使用Java标准库里的StandardCharsets常量是更安全的做法。5.2 排查思路分享排查IO问题我一直主张“先看路径再看编码最后看权限”的顺序。路径问题最多也最容易排查把要向程序提供的路径先手动确认一下比如在资源管理器里自己走一遍基本能排除一半问题。编码问题次之用编辑器打开文件看看右下角显示的编码类型跟代码里的指定编码对一下就容易定位。权限问题最隐蔽尤其是Windows上偶尔会有只读属性、Linux上会有属主权限问题导致程序无法创建文件所以IO代码里的异常信息一定要把具体路径和操作类型打全不要只打一句“IOException”。另外如果你的程序可能会被反复运行要注意上一次运行留下的文件句柄。在Windows上文件被其他程序甚至自己程序上一次没关掉的流占用会导致删除、重命名都失败。这也是为什么我前面反复强调try-with-resources它的自动关闭逻辑能帮你规避掉很大一部分这一类问题。我个人在实际项目里的体会是异常处理和IO流看起来是“基础中的基础”但它们恰恰是区分“会写Java”和“写好Java”的分水岭。很多线上故障归根结底不是业务逻辑多复杂而是文件操作没做好兜底。把File、异常、IO流这三块看成一体去练习而不是分开学语法你会发现写起来顺很多。如果你把上面的综合案例自己动手敲一遍再试着把重命名逻辑改成“先扫描已有文件再生成不冲突的新文件名”这一关就算真正过了。
