今天是Java学习打卡系列的第27天主题是IO、异常和File的综合实战。走到这一步说明你已经把基础语法、面向对象、集合这些东西都过了一遍终于来到Java里最实用、也最容易翻车的一组内容了。很多人学到这里会觉得知识点太散——异常一堆概念、File一堆方法、IO流更是五花八门但真正写代码的时候又不知道从哪下手。这篇文章我就把这三大块揉碎了讲清楚最后用一个“文件分类整理工具”把它们全部串起来保证你看完能直接照着敲一遍顺手还能沉淀出一套属于自己的IO工具箱。整个内容既适合刚学完集合、准备进入IO阶段的初学者也适合回头复习异常处理和文件操作的开发者。1. 为什么异常、File、IO要放在同一课讲1.1 三者的血缘关系没有异常处理IO代码寸步难行很多初学者觉得异常处理是语法课File是工具类课IO是数据流课三个东西好像没关系。但等你真正写过一次文件读写的代码就明白了IO操作是整个Java体系里最容易抛异常的地方。文件不存在、路径没有权限、磁盘空间不足、文件被另一个程序占用、读取过程中网络中断如果是网络流——任何一个环节出问题程序直接崩溃或者更糟糕资源没释放导致文件一直被锁住。异常处理就是给IO上的一份“保险”。比如你写一个FileInputStream去读文件构造方法本身就会抛FileNotFoundException这是受检异常编译器强制你处理读数据的时候还可能抛IOException关流的时候又可能抛一次IOException。如果你不懂异常机制连编译都过不去。反过来你学会了异常处理再看IO代码就明白每个try-catch都是在堵哪些风险点。File类则是连接两者的桥梁——它是你要操作的那个“文件/目录”的抽象描述但真正读写数据靠的是IO流。File负责“指路”IO负责“干活”异常负责“兜底”这三者天然就是一套组合拳。1.2 学习顺序的良苦用心从理论到实战的闭环课程设计把IO放在这个阶段是有讲究的。前面的语法、数组、面向对象、集合学的都是“内存中的数据操作”而IO是第一次打开“外部世界”的大门——文件系统。从内存到磁盘数据形态变了操作难度也上来了。安排异常和File在前面本质上是在给你铺路先用一小部分时间搞清楚“出错了怎么办”和“文件在哪”然后才敢放手去写IO。更重要的是IO是综合演练的最佳题材。一个稍微完整点的IO程序必然会用到主方法传参、判断条件、循环遍历、集合存储、自定义异常、递归调用这些前面积累的知识。你可以在一个案例里同时复习面向对象和集合框架这就是为什么“综合案例”这三个字经常出现在IO章节里——它不是额外加餐而是把一堆零散语法点焊成完整技能树的焊接点。2. 核心知识点拆解异常、File、IO的必会细节2.1 异常体系受检异常与非受检异常的分水岭Java的异常体系以Throwable为根下面分Error和Exception两大分支。Error代表JVM层面的严重问题比如OutOfMemoryError、StackOverflowError这类问题程序本身处理不了别去catch它让程序尽快终止才是正经。Exception才是我们平时说的“异常”它又分成两类继承自RuntimeException的运行时异常和除此之外的受检异常。受检异常是编译器强制你处理的典型代表就是IOException、FileNotFoundException、SQLException。你写代码的时候要么用try-catch包住要么在方法签名上throws声明出去否则编译不通过。它的设计意图是这种异常通常发生在“外部环境不可控”的场景——比如文件可能不存在、网络可能断掉编译器逼你把这种风险显式地写出来别假装没事发生。运行时异常则相反NullPointerException、ArrayIndexOutOfBoundsException、IllegalArgumentException这类通常是你代码逻辑自身的问题编译器不强制处理但运行时一旦触发就很容易让整个程序挂掉。实操中我的习惯是受检异常就地catch并处理记录日志、返回友好提示、或者向上抛给调用方决策运行时异常则以“防御性编程”为主先判空、校验参数、检查下标尽量从源头避免。try-catch-finally是老三样但Java 7开始推荐用try-with-resources自动关流这个后面案例会用到。另外自定义异常也很重要——你可以继承Exception或RuntimeException定义自己的业务异常让代码的错误语义更清晰比如这次案例里的FileCopyException。2.2 File类别把它当文件它是路径的抽象初学者最容易误解的一个点就是File对象并不代表一个真实存在的文件它只是一个路径的抽象表示。你new File(D:/readme.txt)的时候磁盘上可能根本没有这个文件这个对象只是把路径字符串包了一层提供了各种操作路径和文件属性的方法。真正在磁盘上创建文件要调用createNewFile()创建目录要调用mkdirs()删除文件要调用delete()。File类的核心方法分几类判断类exists、isFile、isDirectory、canRead、canWrite、isHidden、获取信息类getName、getPath、getAbsolutePath、getParent、length、lastModified、操作类createNewFile、mkdir/mkdirs、delete、renameTo、listFiles。我平时用得最多的就是listFiles()它返回一个File数组配合递归就能遍历整个目录树。注意listFiles()的返回结果可能为null——当目录不存在、路径不是目录、或者没有读取权限的时候它返回的是null而不是空数组这个坑非常经典案例里我会特别处理。另外要提醒一个Windows下的细节File的delete()是永久删除不会进回收站而且如果文件正在被某个流占用删除会失败返回false。所以实操中删除文件之前要确保所有相关流都已关闭。renameTo()在Windows下也容易出问题——目标已存在时会失败跨盘符移动也不可靠这两个方法算是File类的“历史遗留短板”能用就凑合用不能用就要考虑Files类java.nio.file的方案了。2.3 IO流选择字节流、字符流、缓冲流到底怎么选IO流分类方式有好几种按数据单位分字节流和字符流按方向分输入流和输出流按功能分节点流和处理流。对新手来说最重要的就是分清什么时候用哪个。先说字节流FileInputStream和FileOutputStream以字节为单位读写适合所有类型的文件——图片、视频、压缩包、可执行文件包括文本文件在底层也是字节。处理二进制的场景字节流是唯一选择。字符流FileReader和FileWriter以字符为单位读写本质是字节流加了编码转换适合读写纯文本文件尤其处理中文时不会读到半个乱码。选型口诀我总结了一句文本用字符其他全字节不确定就用字节。这个口诀在绝大多数场景都不会错。但直接拿FileStream和FileReader去读写大文件性能会很差因为每次只读一个字节/字符系统调用次数太多。这时候就要上缓冲流BufferedInputStream、BufferedOutputStream、BufferedReader、BufferedWriter。它们内部维护一个缓冲区默认8192字节可以大幅减少底层IO次数把散装读写改成“攒一批再读写”。我测试过一个几十MB的文件不加缓冲和加缓冲的时间差距通常在5到10倍以上。另外有个进阶知识转换流InputStreamReader和OutputStreamWriter可以在字节流和字符流之间搭桥用来指定编码格式比如读GBK编码的旧文件时可以new InputStreamReader(new FileInputStream(file), Charset.forName(GBK))。真正的高端玩法是用Java NIO的Files.copy一行搞定复制文件但作为基础能力的积累手工实现IO流还是必须过一遍的。3. 综合案例实战文件分类整理工具3.1 需求分析这个工具到底要解决什么问题先描述一个非常常见的现实场景你的下载目录或者某个共享盘里日积月累堆了一堆乱七八糟的文件——pdf文档、jpg图片、mp4视频、zip压缩包、java源码全部混在一起。想找一个文件得翻半天。手动整理太累所以这个综合案例就做一个“文件分类整理工具”扫描指定的源目录把里面的文件按扩展名分类自动创建对应的子目录然后把文件移动进去同时生成一份操作日志来记录每个文件从哪里移到了哪里。这个案例能覆盖的知识点非常全判断文件类型需要异常思维跳过不可读的、遍历目录需要File的递归操作、移动文件需要字节流我这里用Files.move演示同时也会带一遍手动字节流复制、记录日志需要字符流、批量处理需要集合和循环、归类逻辑需要switch或Map分组。整体代码量不大但每一行都有实际意义完全不是那种“为了演示而写”的玩具代码。3.2 代码实现逐步拆解完整流程先搭主类框架我把它叫做FileSorter。核心思路是传一个源目录路径进来递归遍历所有子目录和文件针对每一个文件根据扩展名决定目标子目录名如果子目录不存在就创建再把文件移动到目标位置每成功处理一个文件就写一行日志到log.txt如果某个文件处理失败权限不足、文件被占用等捕获异常并记录不影响整体流程继续。import java.io.*; import java.nio.file.*; import java.time.LocalDateTime; import java.time.format.DateTimeFormatter; import java.util.*; import java.util.stream.Collectors; public class FileSorter { // 扩展名 - 目标子目录名 的映射表 private static final MapString, String RULES new HashMap(); static { RULES.put(jpg, 图片); RULES.put(png, 图片); RULES.put(gif, 图片); RULES.put(bmp, 图片); RULES.put(jpeg, 图片); RULES.put(doc, 文档); RULES.put(docx, 文档); RULES.put(pdf, 文档); RULES.put(txt, 文档); RULES.put(ppt, 文档); RULES.put(pptx, 文档); RULES.put(xls, 文档); RULES.put(xlsx, 文档); RULES.put(mp4, 视频); RULES.put(avi, 视频); RULES.put(mkv, 视频); RULES.put(zip, 压缩包); RULES.put(rar, 压缩包); RULES.put(7z, 压缩包); RULES.put(java, 代码); RULES.put(py, 代码); RULES.put(js, 代码); RULES.put(html, 代码); RULES.put(css, 代码); RULES.put(xml, 代码); RULES.put(json, 代码); } // 其他没匹配到的文件归类到这里 private static final String DEFAULT_CATEGORY 其他; public static void main(String[] args) { if (args.length 1) { System.err.println(用法: java FileSorter 源目录路径); System.exit(1); } File sourceDir new File(args[0]); FileSorter sorter new FileSorter(); sorter.sort(sourceDir); } private int successCount 0; private int failCount 0; private final ListString logLines new ArrayList(); public void sort(File dir) { if (!dir.exists()) { System.err.println(目录不存在: dir.getAbsolutePath()); return; } if (!dir.isDirectory()) { System.err.println(路径不是目录: dir.getAbsolutePath()); return; } walk(dir); // 全部处理完之后统一写日志减少IO次数 writeLog(dir); System.out.println(整理完成: 成功 successCount 个, 失败 failCount 个); } private void walk(File dir) { File[] files dir.listFiles(); if (files null) { System.err.println(无法读取目录: dir.getAbsolutePath()); failCount; logLines.add(ERROR 无法读取目录: dir.getAbsolutePath()); return; } for (File f : files) { if (f.isDirectory()) { walk(f); // 递归处理子目录 } else { handleFile(f); } } } private void handleFile(File file) { if (file.isHidden()) { System.out.println(跳过隐藏文件: file.getName()); return; } String fileName file.getName(); int dotIndex fileName.lastIndexOf(.); String ext (dotIndex -1) ? : fileName.substring(dotIndex 1).toLowerCase(); String folderName RULES.getOrDefault(ext, DEFAULT_CATEGORY); // 目标目录源目录/分类子目录 File targetDir new File(file.getParent(), folderName); if (!targetDir.exists() !targetDir.mkdirs()) { String errMsg 创建目录失败: targetDir.getAbsolutePath(); System.err.println(errMsg); failCount; logLines.add(ERROR errMsg); return; } // 目标路径若目标文件已存在则加时间戳避免覆盖 File targetFile new File(targetDir, fileName); if (targetFile.exists()) { String baseName fileName.substring(0, dotIndex -1 ? fileName.length() : dotIndex); String newName baseName _ System.currentTimeMillis() (dotIndex -1 ? : . ext); targetFile new File(targetDir, newName); } // 用java.nio.file.Files.move做移动跨目录重命名 try { Path moved Files.move(file.toPath(), targetFile.toPath(), StandardCopyOption.REPLACE_EXISTING); successCount; logLines.add(MOVE file.getAbsolutePath() - moved); System.out.println(已整理: fileName - folderName /); } catch (IOException e) { String errMsg 移动失败 file.getAbsolutePath() 原因: e.getMessage(); System.err.println(errMsg); failCount; logLines.add(ERROR errMsg); } } private void writeLog(File dir) { // 这里刻意演示字符流 try-with-resources File logFile new File(dir, sort_log_ LocalDateTime.now().format(DateTimeFormatter.ofPattern(yyyyMMdd_HHmmss)) .txt); try (BufferedWriter writer new BufferedWriter(new FileWriter(logFile, true))) { writer.write( 整理时间: LocalDateTime.now() ); writer.newLine(); for (String line : logLines) { writer.write(line); writer.newLine(); } writer.write( 成功: successCount 失败: failCount ); writer.newLine(); System.out.println(日志已写入: logFile.getAbsolutePath()); } catch (IOException e) { System.err.println(日志写入失败: e.getMessage()); } } }代码里有两个细节值得单独说。第一个是Files.move(targetFile.toPath(), targetFile.toPath(), StandardCopyOption.REPLACE_EXISTING)这行代码本质上是“跨目录重命名”效率比先复制再删除高很多因为操作系统层面可以直接走rename。第二个是日志文件的命名带时间戳避免多次运行互相覆盖——这也算一个小经验实际工具类项目里百分之百会用到。如果你还没有完全理解字节流的用法我把手动复制的经典代码也放出来这是IO面试里最常考的写法public static void copyFile(File src, File dest) throws IOException { try (InputStream in new BufferedInputStream(new FileInputStream(src)); OutputStream out new BufferedOutputStream(new FileOutputStream(dest))) { byte[] buffer new byte[8192]; int len; while ((len in.read(buffer)) ! -1) { out.write(buffer, 0, len); } } }这个写法有几个门道用byte数组做缓冲8192字节8KB是兼顾性能与内存的常用值不是越大越好read返回值是int等于-1表示读完了write的时候必须用带长度参数的out.write(buffer, 0, len)防止最后一段缓冲区只有部分有效数据时把垃圾字节也写进去。3.3 运行实测看看效果和日志长什么样假设我在D盘建了一个test_input目录里面放了一些测试文件readme.txt、photo1.jpg、demo.java、music.mp3、report.pdf还有一个嵌套子目录backup里面放着old.doc。运行java FileSorter D:/test_input控制台会输出类似这样的结果已整理: readme.txt - 文档/ 已整理: photo1.jpg - 图片/ 已整理: demo.java - 代码/ 已整理: music.mp3 - 视频/ 已整理: report.pdf - 文档/ 已整理: old.doc - 文档/ 整理完成: 成功 6 个, 失败 0 个 日志已写入: D:/test_input/sort_log_20250608_153022.txt虽然mp3配到“视频”分类有点粗糙但分类规则本身就是可配置的你完全可以把mp3单独分出来。日志文件内容是纯文本逐行记录每一条MOVE和ERROR方便后续排查。4. 常见问题与排查技巧实录4.1 FileNotFoundException三大典型原因这大概是IO阶段遇到最多的异常。我统计了一下学员和同事踩过的坑无外乎三种路径写错、文件不存在、文件名大小写不对。路径写错要特别注意相对路径的基准问题——你用new File(config/app.properties)这种相对路径时基准目录是JVM的启动目录一般是项目根目录或者IDE配置的工作目录并不是你以为的类的所在目录。排查的时候先打印new File(.).getAbsolutePath()看看JVM当前的工作目录到底在哪儿。还有一个很容易被忽略的点Windows下路径分隔符是反斜杠\在Java字符串里要写成\或者直接用正斜杠/。File类内部会自动处理分隔符所以统一用/最省心。大小写问题主要在Linux/Mac上Windows不区分但不代表你就能随便乱写跨平台项目一律按区分大小写的方式来写路径。4.2 数组越界和非法参数异常都是防御性编程没做到位ArrayIndexOutOfBoundsException和IllegalArgumentException看起来不像IO相关但实际经常出现在File相关的代码里。比如你用split(.)去拆文件名——注意split接收的是正则表达式.匹配任意字符结果你会得到一个空数组访问下标就直接越界。正确的写法是split(\.)或者用lastIndexOf(.)来截取扩展名我上面案例就是用的后者稳。IllegalArgumentException常见于调用Files.move等NIO方法时传入了不受支持的参数组合或者你自定义的工具方法没做参数校验。比如用户传了一个null路径进来你用new File(null)就会抛NPE但这其实应该在入口处就判断掉给出友好提示而不是让调用栈飞到用户眼前。防御性编程的精髓就是“在错误发生之前拦住它”。4.3 NullPointerExceptionlistFiles()的隐藏大坑我在课程里反复强调一句话listFiles()返回null不是设计缺陷而是设计者对“无法读取目录”的诚实表达。当你遍历一个目录而这个目录恰好没有读取权限或者它的绝对路径其实指向一个文件而不是目录listFiles()就会返回null。如果你直接for (File f : files)瞬间NPE。对策也很简单每次调用listFiles()后马上判空如果为null要么跳过这个目录要么记录日志。上面的实战案例已经演示了这种写法。另外递归遍历目录树时还可能遇到循环引用比如Unix的符号链接指回上级目录这种属于高阶问题但在Linux服务器上处理文件时确实会遇到对策是记录已访问的目录路径或者限制递归深度。4.4 文件被占用的IOExceptionWindows特供版这个异常的描述经常是The process cannot access the file because it is being used by another processWindows系统最常见。比如你用Excel打开了某个xlsx然后再用Java程序尝试覆盖它或者你上一个流的close()没执行JVM进程内还握着句柄。特别注意有时候不是你的代码没关流而是异常在关流之前就抛出来了导致finally没执行到——这也是为什么我强烈推荐try-with-resources它在字节码层面帮你实现了“即使写数据过程中抛异常也会自动关流”的保证。顺带分享一个排查小工具Windows的Resource Monitor资源监视器里可以按进程查看文件句柄Linux下可以用lsof命令。遇到“文件被占用”类问题时先用工具确认到底是谁占着它再决定是改代码还是跟别的进程协商。4.5 异常排查速查表异常信息触发场景排查方向FileNotFoundException文件路径不存在、权限不足、大小写错误打印绝对路径、检查文件是否存在NullPointerExceptionlistFiles()返回null、入参为null判空、校验目录权限ArrayIndexOutOfBoundsException文件名拆解逻辑错误用lastIndexOf替代split(.)IllegalArgumentExceptionNIO方法参数非法检查路径格式、Charset名称IOException: 文件被占用Windows下文件被其他程序或流占用资源监视器查句柄、确保自动关流EOFException读DataInputStream时越过文件尾部检查读写数据的顺序和数量是否匹配StackOverflowError递归遍历超深层目录改用迭代式遍历或限制深度这张表我建议直接收藏以后写文件处理程序遇到相关异常先对照这张表做初步定位能省不少时间。最后分享一点个人经验我在实际教过无数遍IO之后最大的体会就是学IO最有效的方式不是看教程而是逼自己写一个“真实需要”的工具。文件分类整理、批量重命名、日志切割、配置文件解析——随便挑一个写完就发现自己对异常、File和流的理解都上了一个台阶。这个案例里的FileSorter虽然简单但它具备了真实工具的骨架参数校验、规则可配置、异常隔离、日志记录。你完全可以把它改造成一个命令行工具加入递归深度限制、文件大小过滤、按日期归档等新特性。踩过几次坑之后你会越来越认同一个道理JAVA里的IO不是语法题而是工程题——你的代码写得怎么样从你如何处理一个消失的文件就能看出来。