
刚接触 Java 的人十有八九都会被 IO 流绕晕InputStream、OutputStream、Reader、Writer、File、Files、Channel……一堆名词堆在一起不知道项目里到底该用哪个。到了 JavaEE 的阶段情况更麻烦——你要读配置文件、上传文件、导出文件每一样都绕不开 IO 流和文件操作。这篇指南就是给你把这些东西捋顺的。我不讲教科书式的概念只讲实战里怎么用、为什么这么用、以及在 JavaEE 开发中哪些地方最容易翻车。读完你会发现IO 流没那么可怕文件操作也就那几板斧。1. 先搞懂 Java IO 流的家族体系1.1 字节流与字符流的根本区别Java 的 IO 家族表面上类很多其实骨架就两条分支字节流和字符流。字节流的顶层是 InputStream 和 OutputStream处理的是以 byte 为单位的原始数据字符流的顶层是 Reader 和 Writer处理的是以 char 为单位的文本数据。为什么非得分两套因为内存里的字符和磁盘/网络上的字节不是一一对应的。你在代码里写一个字符串 中文Java 的 char 用的是 UTF-16 编码一个中文字符占 2 个字节但写进文件的时候如果按 UTF-8 编码一个中文字符可能占 3 个字节。如果你用字节流直接读文本读到一半发现字符被拆成两半就乱码了。字符流做的事就是在内部帮你把字节解码成字符或者把字符编码成字节。这层转换关系理解透了后面遇到乱码问题才不会一头雾水。实际开发中我习惯先问自己一个问题我要处理的是数据还是文本图片、视频、压缩包、序列化对象全是数据必须用字节流配置文件、日志文件、HTML、JSON全是文本用字符流更省心。硬要用字节流去读文本也不是不行但你要自己处理编码很容易出问题。1.2 节点流与处理流以及装饰器模式再往深一层看IO 类里还分节点流和处理流。节点流是直接接在数据源上的比如 FileInputStream 直接接文件FileReader 直接接文件。处理流则是包在别的流外面给流加功能比如 BufferedInputStream 给字节流加缓冲BufferedReader 给字符流加缓冲和 readLine 方法。这里的设计思想就是装饰器模式。你别被这个名词吓到它本质就是套娃你有一个最底层的文件流然后外面包一个缓冲流还能再包一个数据流。每一层只负责自己的事组合起来就变成一个功能强大的流。在 Java 里最常见的写法BufferedReader reader new BufferedReader(new InputStreamReader(new FileInputStream(a.txt), StandardCharsets.UTF_8));这个嵌套看起来繁琐但每一层都有它的意义FileInputStream 负责从文件读原始字节InputStreamReader 负责把字节按 UTF-8 解码成字符BufferedReader 负责提供缓冲和按行读取的能力。缺了任何一层写起来都不顺手。理解了装饰器模式你看到一长串 new 嵌套就不会觉得是灾难了。2. 文件操作前的必备功课File 类的正确用法2.1 路径、分隔符与文件检查操作文件之前你得先知道怎么描述一个文件。Java 里 File 类就是文件路径的抽象但它不读文件内容只负责路径、目录、属性这些元信息。很多人刚上手时会在 Windows 上写硬编码路径比如C:\\Users\\xiaoming\\test\\a.txt这时候有个细节特别容易踩坑Windows 路径分隔符是反斜杠\而 Linux/Mac 是正斜杠/。Java 代码里反斜杠要转义写俩\\但代码一换到 Linux 上就跪了。正确的做法是尽量用相对路径或者用 Java 提供的跨平台分隔符// 不推荐 File f1 new File(C:\\Users\\xiaoming\\test\\a.txt); // 推荐使用 File.separator 或直接写正斜杠 File f2 new File(data File.separator test File.separator a.txt); File f3 new File(data/test/a.txt); // Java 会帮你处理注意正斜杠在 Windows 和 Linux 上都是能被识别的所以data/test/a.txt这种写法最省事也能跨平台。在你动手读写之前先养成检查文件的习惯文件存不存在、是不是目录、能不能读、能不能写。这几个检查能帮你省下一堆莫名其妙的异常。File file new File(config/application.properties); if (!file.exists()) { System.out.println(文件不存在); return; } if (file.isDirectory()) { System.out.println(这是一个目录不是文件); return; } if (!file.canRead()) { System.out.println(文件不可读); return; }很多新手一上来就new FileInputStream(file)结果文件路径少写了一个层级直接抛 FileNotFoundException。提前用 exists() 判断一下要么跳过要么打日志排查问题会快很多。2.2 目录遍历与递归删除File 类除了判断文件是否存在还能遍历目录。listFiles()可以拿到目录下的所有文件配合递归就能实现目录树遍历。这个需求在实际项目里太常见了扫描某个目录下的所有日志文件、批量处理图片、按目录结构打包下载……写递归遍历时我最烦的是隐藏了一个空目录问题如果一个目录下只有子目录而没有文件递归遍历可能会漏掉或者写删除方法时删不掉。下面这段递归删除的代码我用了很多次比较稳public static void deleteRecursively(File file) throws IOException { if (file.isDirectory()) { File[] children file.listFiles(); if (children ! null) { for (File child : children) { deleteRecursively(child); } } } if (!file.delete()) { throw new IOException(删除失败: file.getAbsolutePath()); } }listFiles()返回 null 要处理否则会空指针。先递归删子文件和子目录最后删根目录这样能把整个目录连锅端。注意Java 8 之后可以用Files.walk(Paths) sorted(reverseOrder()) delete实现同样的效果但那个写法对新手不太友好而且如果文件正在被占用照样会报异常。逻辑上掌握递归理解原理再使用工具类扩展这是正路。3. 读写文件的几种主流姿势与实战选择3.1 基于 FileInputStream / FileOutputStream 的原始读写如果你要复制的文件是图片、视频、压缩包这类二进制数据最底层的读写姿势是直接用字节流。先把文件读成字节数组再写出去。最简单的版本长这样FileInputStream in new FileInputStream(source.jpg); FileOutputStream out new FileOutputStream(target.jpg); byte[] buffer new byte[1024]; int len; while ((len in.read(buffer)) ! -1) { out.write(buffer, 0, len); } in.close(); out.close();这个代码虽然能跑但有个隐患如果 read 或 write 过程中抛了异常后面的 close 就执行不到文件流会一直占用资源。所以老项目里到处是 try-catch-finally 包着关闭逻辑。我建议你现在就用后面要讲的 try-with-resources别再写这种裸奔的代码了。不过理解这段循环很关键read(buffer)返回的是本次读到的字节数不是缓冲数组的长度所以 write 的时候必须写write(buffer, 0, len)。你要是写成了out.write(buffer)会把缓冲区里上次残留的数据一起写进去文件最后会多出一截垃圾数据。3.2 基于 BufferedReader / BufferedWriter 的文本读写处理文本文件时字符流是首选。特别是行文文本文档、CSV、日志按行读写非常自然。BufferedReader.readLine()这个方法是性能利器它内部做了缓冲不必要挨个字符读。标准的按行读文件try (BufferedReader reader new BufferedReader(new InputStreamReader( new FileInputStream(data.log), StandardCharsets.UTF_8))) { String line; while ((line reader.readLine()) ! null) { System.out.println(line); } }写文件对应地使用 BufferedWritertry (BufferedWriter writer new BufferedWriter(new OutputStreamWriter( new FileOutputStream(out.txt), StandardCharsets.UTF_8))) { writer.write(第一行); writer.newLine(); writer.write(第二行); writer.newLine(); }很多人会问为什么不用 FileReaderFileReader 确实可以直接读文件但它的构造方法默认使用平台字符集换句话说同样的代码在中文 Windows 上和 Linux 上读出来的编码可能不一样。这是个不稳定的隐患。所以我几乎不用 FileReader/FileWriter一律用 InputStreamReader/OutputStreamWriter 显式指定 UTF-8。你别嫌多写几行参数后面换服务器的时候就知道省了多少事。3.3 基于 Files 工具类的极简读写如果你只是想把一个小文件一次性全部读进来或者一次性写出去Java 7 引入的java.nio.file.Files类提供了极其方便的方法。无需手动创建流不用担心 close一行搞定// 读小文件 ListString lines Files.readAllLines(Paths.get(data.txt), StandardCharsets.UTF_8); // 读所有内容为字符串 String content Files.readString(Paths.get(data.txt), StandardCharsets.UTF_8); // 写文件 Files.writeString(Paths.get(out.txt), hello world, StandardCharsets.UTF_8); // 复制文件 Files.copy(Paths.get(a.txt), Paths.get(b.txt), StandardCopyOption.REPLACE_EXISTING);这里必须强调readAllBytes/readAllLines/readString都是把文件整个加载进内存。文件一大内存直接爆掉。生产环境里几百 MB 的日志想都不要想用它来读。它的正确使用场景是配置文件、SQL 脚本、小模板、测试数据这类几 KB 到几百 KB 的文件。还有更多高级操作比如移动文件、删除文件、设置权限都在 Files 工具类里它比 File 类的老 API 清爽不少也建议优先使用。4. 性能与资源管理try-with-resources 与缓冲区的正确使用4.1 try-with-resources 为什么是默认选择以前写 IO 代码最繁琐的是关闭资源。Java 7 引入的 try-with-resources 语法让任何实现了 AutoCloseable 接口的资源都能自动关闭。在 try 后面用括号声明资源无论是否抛异常结束时都会按逆序调用 close()。这是现在写 IO 的唯一标准姿势。try (FileInputStream in new FileInputStream(a.bin); FileOutputStream out new FileOutputStream(b.bin)) { byte[] buffer new byte[8192]; int len; while ((len in.read(buffer)) ! -1) { out.write(buffer, 0, len); } }两个流都在括号里try 块执行完in 和 out 都自动关。你不用写 finally也不用担心漏关。还有一个容易被忽视的细节如果一个流在关闭时抛异常包括了 try 块内抛出的异常try-with-resources 会在异常堆栈里把关闭失败那个异常添加为被抑制的异常仍然能拿到主要的错误信息。这在排查问题时非常友好。记住一条规则凡是实现了 Closeable/AutoCloseable 的能用 try-with-resources 就用。流、通道、连接、IO 对象都适用。你自己写的工具类如果需要持有资源也可以实现 AutoCloseable让调用方用同样的方式处理。4.2 缓冲区大小对性能的影响初学者经常忽略缓冲区大小的设置。默认的 FileInputStream 每次 read 是一次系统调用频繁访问磁盘性能很差。加缓冲的意义在于一次从磁盘读一大批字节放到内存然后程序在内存里慢慢取减少系统调用次数。Java 的 BufferedInputStream 默认缓冲区是 8KB大多数场景已经够用。但如果你手工创建一个 byte 数组来读数组大小也会影响性能太小循环次数多太大占用内存多。我实测过1KB、4KB、8KB、16KB 在普通文件复制上差距不明显但是用 1KB 复制一个 500MB 文件比用 8KB 慢 30% 以上。下面这个是我常用的标准复制思路try (InputStream in new BufferedInputStream(new FileInputStream(big.iso)); OutputStream out new BufferedOutputStream(new FileOutputStream(copy.iso))) { int len; byte[] buffer new byte[8192]; while ((len in.read(buffer)) ! -1) { out.write(buffer, 0, len); } }加上 Buffered 包装之后你底层的数组稍微小一点问题不大因为缓冲流自己会再缓冲。但别再加不必要的套娃了比如外面再包一个 DataOutputStream 去复制字节流浪费性能且意义不大。缓冲区还有一个注意点BufferedWriter 写文本时如果程序突然崩溃缓冲区里的数据可能还没 flush 到磁盘导致文件内容不完整。所以写重要文件时写完要调用writer.flush()或者直接在 try-with-resources 结束后自动关闭关闭时会先 flush。要在写的过程中确认数据落盘就主动 flush。5. 编码问题读文件乱码的根源与解决5.1 为什么你读到的中文是乱码乱码问题可以说是我见过 Java 新手踩坑最多的问题。根本原因就三个字编解码不一致。文件写入时用的是 A 字符集读取时却用 B 字符集两者对同一个字节序列的解释不同就出现了黑块、问号、火星文。例如你用 Windows 记事本另存为选择ANSI实际是 GBK文件里的中文你好对应的字节序列是C4 E3 BA C3。然后你用一段默认 UTF-8 的代码去读它UTF-8 解码器看到C4 E3不是合法的 UTF-8 连续字节可能显示为乱码强行配对的解读又会错位。反过来UTF-8 写的文件你用 GBK 去读也会乱码。这是纯字节层面的问题。另一个原因是 Java 内部字符串其实是 UTF-16所以你在系统里看到的中文在内存中始终是 2 字节的 UTF-16 表示。当你把一个 String 写到文件时必须指定一种字符集进行编码当你从文件读字节时必须用同一字符集解码。只要这个链路中某个环节默认走了系统的平台字符集而平台字符集在不同操作系统上不一样你的程序就可能换个环境就乱码。5.2 指定字符集的标准写法我个人的铁律是所有读写文本的地方显式指定字符集统一使用 UTF-8。不要依赖平台默认值。读文件标准写法// 方法一InputStreamReader 显式指定 try (BufferedReader reader new BufferedReader( new InputStreamReader(new FileInputStream(a.txt), StandardCharsets.UTF_8))) { String line; while ((line reader.readLine()) ! null) { // 处理 line } } // 方法二Files 工具类 ListString lines Files.readAllLines(Paths.get(a.txt), StandardCharsets.UTF_8);写文件标准写法// 方法一OutputStreamWriter 显式指定 try (BufferedWriter writer new BufferedWriter( new OutputStreamWriter(new FileOutputStream(a.txt), StandardCharsets.UTF_8))) { writer.write(中文内容); } // 方法二Files 工具类 Files.writeString(Paths.get(a.txt), 中文内容, StandardCharsets.UTF_8);注意StandardCharsets.UTF_8是 Java 7 提供的常量不需要抛 UnsupportedEncodingException比写字符串UTF-8更安全。有人会想在代码里写utf-8小写这个也能被识别但我还是建议用常量。如果非要动态指定字符集比如从请求参数里传进来那必须做白名单校验否则可能被传入不支持的编码名抛异常。还有一个常见坑读取网页内容或网络流时返回的 Content-Type 头里通常有 charset比如text/html; charsetgbk这种不要硬按 UTF-8 解码先解析响应头再用对应的字符集创建 InputStreamReader。HTTP 工具的 Response 对象一般都有charset()方法。如果你直接把字节流交给 JSON 解析库最好在解析前确认字符集保证数据不乱。6. JavaEE 场景下的文件操作实战6.1 读取 classpath 下的配置文件进入 JavaEE 后你写的代码常常要打成 jar/war 包部署到服务器。此时文件路径不再是当前工作目录这种简单逻辑。比如你想读application.properties它在 src/main/resources 下打包之后在 classpath 根路径。如果还是用new File(application.properties)十有八九找不到因为 jar 里的文件不是一个普通磁盘文件不能直接用 File 打开。正确做法是用ClassLoader.getResourceAsStream()或者ClassPathResourceSpring 环境下。这能把 classpath 中的内容当成流读进来不需要关心它是在源码目录还是 jar 包里// 纯 Java 方式 try (InputStream in Test.class.getClassLoader().getResourceAsStream(application.properties)) { Properties props new Properties(); props.load(in); String url props.getProperty(db.url); System.out.println(url); }Properties 类的load()方法接收一个 InputStream默认用 ISO-8859-1 读取属性文件如果属性文件里有中文需要额外转码处理。一个更稳妥的办法是用 UTF-8 从头读文件再包装成 Propertiestry (BufferedReader reader new BufferedReader(new InputStreamReader( Test.class.getClassLoader().getResourceAsStream(application.properties), StandardCharsets.UTF_8))) { Properties props new Properties(); props.load(reader); }如果项目用了 Spring 框架注解方式更简单直接注入Value(${db.url}) private String dbUrl; // 或者 PropertySource(classpath:custom.properties)但底层还是通过 classpath 资源流读取的。记住这个原则部署环境里凡是内部资源都用 getResourceAsStream不要拼绝对路径。6.2 模拟 Servlet 上传文件的保存JavaEE 中最常见的文件操作场景就是文件上传。Servlet 3.0 之后原生 API 就支持Part接口不需要额外引入 commons-fileupload。假设你的 web 应用要接收用户上传的头像图片处理逻辑一般是获取上传的 Part校验文件大小和类型生成唯一文件名保存到服务器指定目录下面是核心保存代码WebServlet(/upload) public class UploadServlet extends HttpServlet { Override protected void doPost(HttpServletRequest request, HttpServletResponse response) throws ServletException, IOException { Part part request.getPart(file); String submittedFileName part.getSubmittedFileName(); // 基本校验 if (part.getSize() 1024 * 1024 * 10) { response.getWriter().write(文件不能超过10MB); return; } // 生成唯一文件名防止覆盖 String ext ; int dotIndex submittedFileName.lastIndexOf(.); if (dotIndex 0) { ext submittedFileName.substring(dotIndex); } String fileName System.currentTimeMillis() _ UUID.randomUUID() ext; // 保存到指定目录 String uploadDir getServletContext().getRealPath(/uploads); File dir new File(uploadDir); if (!dir.exists()) { dir.mkdirs(); } try (InputStream in part.getInputStream(); OutputStream out new FileOutputStream(new File(dir, fileName))) { byte[] buffer new byte[8192]; int len; while ((len in.read(buffer)) ! -1) { out.write(buffer, 0, len); } } response.getWriter().write(上传成功: fileName); } }注意几个细节getServletContext().getRealPath(/uploads)返回的是服务器部署目录下的物理路径如果打成 war 包运行这个目录可被安全写入但服务器重启后可能被清空所以不要把永久资源存在这里。更合理的做法是把上传目录配置到外部绝对路径比如/var/www/uploads再在代码里通过配置项读取。part.getSubmittedFileName()可能包含恶意路径比如../../evil.jsp因此不要直接拼到路径里只截取文件名部分并强制重命名。6.3 大文件高效复制的实现JavaEE 项目中经常需要把上传的临时文件转移到存储目录或者把一个文件从一个目录复制到另一个目录。这时不能像读小文件那样readAllBytes要流式读写。我用 Files.copy 一行能搞定但它本质上也是流式复制且效率已经不低Files.copy(Paths.get(uploadPath), Paths.get(targetPath), StandardCopyOption.REPLACE_EXISTING);如果你要自己实现复制并且希望中途可以统计进度可以这样写public static long copyWithProgress(Path source, Path target) throws IOException { try (InputStream in Files.newInputStream(source); OutputStream out Files.newOutputStream(target)) { byte[] buffer new byte[8192]; long total 0; int len; while ((len in.read(buffer)) ! -1) { out.write(buffer, 0, len); total len; } return total; } }这里用Files.newInputStream/newOutputStream获得流写完后自动关闭且支持 Path 对象。如果你在 Servlet 里想给用户返回文件下载还需要设置响应头response.setContentType(application/octet-stream); response.setHeader(Content-Disposition, attachment; filename\ URLEncoder.encode(fileName, UTF-8) \);然后从文件流写到response.getOutputStream()。下载完成后建议删除临时文件避免服务器磁盘被撑爆。可以用 finally 块或者 try-with-resources 后调用 Files.deleteIfExists。7. 常见问题与避坑指南7.1 常见异常FileNotFoundException、IOException、NullPointerException我总结了一线最常见的问题和排查方向做成速查表遇到异常时先对号入座。异常或问题可能原因排查方向FileNotFoundException路径写错、文件不存在、目录不是文件、权限不足检查绝对路径 vs 相对路径确认文件确实存在于目标目录检查运行账号是否有读取权限文件被占用Windows 上常见其他进程打开文件未关闭杀毒软件扫描定位占用文件的进程确保程序所有流都正确关闭上传到再测试NullPointerExceptionfile.listFiles() 返回 nullpart.getSubmittedFileName() 为 null目录不存在或无权限访问时 listFiles 返回 null先判断是否存在再遍历中文乱码编码不一致容器默认字符集问题统一 UTF-8检查写入字符集和读取字符集检查响应头 charset文件内容不完整没有 flush断电或进程被杀缓冲区未刷新写完后 flush/close对关键文件改用同步写或额外做完整性校验路径带空格或中文处理失败URL 编码问题特殊字符未转义使用 Path/Files API对文件名进行规范化处理上面提到文件被占用在 Linux 下通常不会这么明显因为 Linux 允许多个进程同时读写但在 Windows 环境你测试上传功能时经常遇到FileNotFoundException或者AccessDeniedException实际上是文件被 Excel、Notepad 或其他程序锁了。遇到过明明路径没错但流就是打不开的情况多半是编码问题导致路径里的中文和实际目录不一致控制台打一下绝对路径、用资源管理器打开看一眼问题立刻暴露。7.2 心得那些年我踩过的文件操作坑第一个坑也是最常见的读取后忘记关闭流。新手时期我经常写完文件不关流导致服务器上堆积了几百个临时文件句柄最后连接数爆掉。现在根本没有这个烦恼因为 try-with-resources 已经强制解决了。第二个坑相对路径的基准目录不是你以为的目录。在 IDE 里运行 main 方法时相对路径相对于项目根目录在 web 应用里它的基准是服务器的启动目录通常不是你的项目目录。所以 web 应用里读文件优先用 classpath 资源不要用相对路径。有一次我帮同事排查他死活找不到report.csv后来发现文件写到了 Tomcat 的 bin 目录下而非他以为的项目目录。把System.out.println(System.getProperty(user.dir))打出来看基准目录是排查这类问题最快的办法。第三个坑Windows 和 Linux 的文件名大小写敏感差异。Windows 不区分大小写但 Linux 区分。你在 Windows 上写readme.txt能访问README.TXT部署到 Linux 上直接 FileNotFoundException。所以文件名、路径、资源名在代码里尽量统一大小写别依赖系统特性。第四个坑getRealPath在 war 包部署时不可靠。如今很多项目用 Spring Boot 打成 fat jar根本没有真实路径这一说。读取内部文件用 classpath写入运行时产生的文件就用外部目录不要把依赖环境的绝对路径写死在代码里。第五个坑删除文件需要在流关闭之后。如果你刚写完文件还没关闭流就调用 delete 方法Windows 上会失败因为文件句柄还没释放Linux 上可能成功但数据未落盘。一定要先结束 try-with-resources再执行删除。我个人的习惯是在任何涉及 IO 或者文件路径的工具类里写一个since和简单的使用示例这样半年后我自己回来看也能一眼想起怎么用。对于团队项目还可以封装一个统一的存储服务接口把上传、下载、删除、复制都包一层这样底层无论用本地磁盘还是对象存储都方便切换。说到最后其实 IO 流和文件操作一点都不神秘。你只要把字节流处理数据、字符流处理文本、流用完必关、读写必须指定字符集这几条刻在脑子里再多写几个小工具自然就熟练了。我在带团队时总说算法考得再好文件读写写不清楚一样没法干活因为几乎所有 JavaEE 项目都有导入导出、日志归档、配置文件加载的需求。把这篇指南里的代码自己手敲一遍再对照着实际项目改一改你就能从看别人代码都懂、自己写就报错跨越到写文件操作像喝水一样自然。最后再分享一个小技巧调试文件操作时在关键节点把文件的绝对路径打印出来用系统文件管理器打开那个目录直接看文件有没有生成、大小对不对。这比只看堆栈信息直观十倍。等你处理过几个文件读写正常但就是跟预期不符的诡异问题你就知道这个习惯能救你多少次。