1. 从一个真实场景说起:为什么字符流值得单独拎出来讲
做Java开发的人,迟早会碰到文件读写这件事。我见过太多项目里,有人用字节流去读一个纯文本文件,然后手动拼接字符串、处理编码,最后中文乱码了查半天查不出来。也见过有人用FileInputStream读一个几十KB的日志文件,循环里一个字节一个字节地读,性能惨不忍睹。
这些问题的根源,往往不是代码写错了,而是选错了流。
Java的IO体系里,流分两大类:字节流和字符流。字节流以InputStream和OutputStream为基类,处理的是原始的8位字节数据;字符流以Reader和Writer为基类,处理的是16位的字符数据,内部会自动处理编码转换。而在这两大类之下,又分节点流和处理流——节点流直接连接数据源(文件、内存数组、管道等),处理流则包装在节点流之上,提供缓冲、转换等增强功能。
这次要聊的主角是字符节点流:FileReader和FileWriter。它们直接跟文件打交道,一个负责把文件内容读进程序,一个负责把程序里的字符写到文件里。别看它们用起来简单,几行代码就能跑,但里面藏着不少细节——编码问题、换行符处理、资源关闭顺序、追加模式与覆盖模式的区别,每一个都可能让你在调试时多花半小时。
这篇文章适合谁看?如果你是刚接触Java IO的新手,这里有一套完整的、可以直接抄的代码模板和避坑指南;如果你已经用过FileReader和FileWriter但总觉得“好像懂了又好像没懂”,这里会把底层逻辑和常见陷阱给你掰开揉碎讲清楚。我会从设计思路讲到实操步骤,再讲到问题排查,尽量让每个环节都有据可依。
2. 字符节点流的设计逻辑与选型考量
2.1 为什么要有字符流:编码这件事不能交给业务代码
计算机存文件,底层存的都是字节。一个文本文件里写着“你好”两个字,在硬盘上其实是若干个字节。用UTF-8编码,“你”占3个字节,“好”也占3个字节,一共6个字节;用GBK编码,每个汉字占2个字节,一共4个字节。问题来了:你读文件的时候,怎么知道该按几个字节去解析一个字符?
字节流不管这些,它就是一个字节一个字节地读,读出来的是int类型的0到255之间的值。你要想把它变成人能看懂的字符,得自己查编码表、自己处理多字节字符的拼接。这件事做一次两次还行,做多了肯定出bug。
字符流的设计初衷就是把编码转换这件事从业务代码里抽出来。FileReader在读取文件时,会使用平台默认的字符集(也可以通过InputStreamReader指定)把字节解码成字符;FileWriter在写出时,会把字符编码成字节再写入文件。你操作的单位始终是char,不用关心中间发生了什么。
注意:
FileReader和FileWriter使用的是平台默认编码,这一点在跨平台场景下是个隐患。Windows中文版的默认编码通常是GBK,Linux和macOS通常是UTF-8。如果你的程序在一台机器上写文件、在另一台机器上读,编码不一致就会乱码。解决办法是改用InputStreamReader和OutputStreamWriter并显式指定编码,这个后面会详细说。
2.2 节点流与处理流的搭配:为什么通常要套一层BufferedReader
FileReader本身是节点流,它直接连文件。每次调用read()方法,它都会向操作系统发起一次读请求。如果你一个字符一个字符地读,读一个1000字的文件就要发起1000次系统调用,这个开销是很大的。
所以实际开发中,几乎不会单独使用FileReader,而是用BufferedReader把它包起来:
BufferedReader br = new BufferedReader(new FileReader("test.txt"));BufferedReader是处理流,它内部维护了一个字符缓冲区(默认8192个字符)。调用read()时,它先从缓冲区拿数据,缓冲区空了才去底层FileReader那里批量读取。这样系统调用的次数大幅减少,性能提升非常明显。
同理,写出的时候用BufferedWriter包装FileWriter,可以减少实际的磁盘写入次数。BufferedWriter还有一个额外的好处:它提供了newLine()方法,可以根据平台自动写入正确的换行符,省得你自己去判断该写\n还是\r\n。
2.3 覆盖还是追加:FileWriter构造函数的那个boolean参数
FileWriter有两个常用的构造函数:
FileWriter(String fileName) // 覆盖模式 FileWriter(String fileName, boolean append) // append为true时追加,为false时覆盖这个append参数看起来简单,但实际用的时候容易搞混。默认的构造函数是覆盖模式,也就是说,如果文件已经存在,它的内容会被清空,从头开始写。如果你想要在文件末尾追加内容,必须显式传入true。
我踩过的一个坑是:做日志记录功能时,用了默认构造函数,结果每次程序重启,之前的日志全没了。后来改成new FileWriter(logFile, true)才解决。这个参数的含义是“是否在文件末尾追加”,而不是“是否允许写入”,新手容易理解偏。
3. FileReader读入的完整实操与细节拆解
3.1 最基础的读取模板:从单字符到字符数组
先看最朴素的写法,一个字符一个字符地读:
FileReader fr = null; try { fr = new FileReader("source.txt"); int ch; while ((ch = fr.read()) != -1) { System.out.print((char) ch); } } catch (IOException e) { e.printStackTrace(); } finally { if (fr != null) { try { fr.close(); } catch (IOException e) { e.printStackTrace(); } } }这段代码能跑,但有几个问题。第一,read()返回的是int,不是char,因为需要用-1来表示“读完了”。如果你用char接收,-1会被转成一个有效的字符,循环就停不下来了。第二,一个字符一个字符地读,性能差。第三,finally块里的关闭代码太啰嗦。
改进版用字符数组做缓冲:
FileReader fr = null; try { fr = new FileReader("source.txt"); char[] buffer = new char[1024]; int len; while ((len = fr.read(buffer)) != -1) { // 只处理实际读到的len个字符 System.out.print(new String(buffer, 0, len)); } } catch (IOException e) { e.printStackTrace(); } finally { if (fr != null) { try { fr.close(); } catch (IOException e) { e.printStackTrace(); } } }这里的关键点是read(buffer)返回的是实际读取到的字符数,而不是缓冲区的大小。最后一次读取时,文件可能只剩下几十个字符,但缓冲区是1024,如果不按len来截取,就会把上一次残留的数据也带出来。
3.2 用BufferedReader包装后的标准写法
实际项目里,我推荐直接用BufferedReader:
BufferedReader br = null; try { br = new BufferedReader(new FileReader("source.txt")); String line; while ((line = br.readLine()) != null) { System.out.println(line); } } catch (IOException e) { e.printStackTrace(); } finally { if (br != null) { try { br.close(); } catch (IOException e) { e.printStackTrace(); } } }readLine()方法会读取一行内容,返回的字符串不包含换行符。如果读到文件末尾,返回null。这个方法用起来很舒服,但它有一个需要注意的地方:readLine()会把换行符“吃掉”,所以如果你需要保留原始的换行符,就不能用它,得用read()逐字符读。
另外,readLine()在遇到\n、\r、\r\n时都会认为是一行结束。这在处理跨平台文件时很方便,但也意味着你无法区分原始文件用的是哪种换行符。
3.3 编码问题的处理:什么时候该放弃FileReader
前面说过,FileReader使用平台默认编码。如果你的文件是UTF-8编码,但程序运行在默认GBK的Windows上,读出来的中文就是乱码。这种情况下,正确的做法是改用InputStreamReader:
BufferedReader br = new BufferedReader( new InputStreamReader(new FileInputStream("source.txt"), StandardCharsets.UTF_8) );InputStreamReader是字节流到字符流的桥梁,它的第二个参数可以显式指定字符集。这样无论程序运行在什么平台上,都按UTF-8来解码,结果是一致的。
那FileReader还有什么用?它的优势在于代码简洁,适合处理编码与平台默认编码一致的文件,或者你确定文件就是纯ASCII字符的场景。一旦涉及跨平台或者明确的编码要求,就应该换成InputStreamReader。
实操心得:我在项目里有一个习惯,只要文件里可能出现中文,就一律用
InputStreamReader指定UTF-8,不用FileReader。多写一行代码,省去后面排查乱码的时间,这笔账怎么算都划算。
4. FileWriter写出的完整实操与细节拆解
4.1 基本写出流程与flush的作用
FileWriter的写出操作比读取要简单一些,核心方法就两个:write(String str)和write(char[] cbuf, int off, int len)。
FileWriter fw = null; try { fw = new FileWriter("output.txt"); fw.write("第一行内容\n"); fw.write("第二行内容\n"); fw.flush(); } catch (IOException e) { e.printStackTrace(); } finally { if (fw != null) { try { fw.close(); } catch (IOException e) { e.printStackTrace(); } } }这里要重点说flush()。FileWriter内部也有一个缓冲区,调用write()时,数据可能只是写到了缓冲区里,还没有真正落到磁盘上。flush()的作用是强制把缓冲区里的数据刷出去。而close()方法在关闭流之前会自动调用flush(),所以如果你马上就关闭流,不手动调flush()也没关系。
但有一种情况必须手动flush():你在写文件的过程中,需要确保数据已经落盘,但还不能关闭流。比如做一个实时日志功能,程序运行期间要持续写日志,同时希望日志能立刻被其他程序读到,这时候就得在每次write()之后调一次flush()。
4.2 追加模式与换行符的处理
追加模式前面提过了,这里补充一个实际场景。假设你要写一个简单的日志工具:
public void log(String message) { try (FileWriter fw = new FileWriter("app.log", true)) { fw.write(message + System.lineSeparator()); } catch (IOException e) { e.printStackTrace(); } }这里用了System.lineSeparator()来获取当前平台的换行符。在Windows上返回\r\n,在Linux上返回\n。这样写出来的日志文件在各自平台上用记事本或文本编辑器打开都能正常换行。
如果你用BufferedWriter包装,可以直接调newLine():
try (BufferedWriter bw = new BufferedWriter(new FileWriter("app.log", true))) { bw.write(message); bw.newLine(); }newLine()内部也是调用System.lineSeparator(),效果一样,但代码更清晰。
4.3 try-with-resources:资源关闭的正确姿势
Java 7引入的try-with-resources语法,是处理IO流关闭的推荐方式。上面的代码已经用到了,这里展开说一下它的好处。
传统写法需要在finally里判断null、嵌套try-catch,代码又长又容易漏。try-with-resources会自动关闭在try括号里声明的资源,而且关闭顺序与声明顺序相反。如果close()抛出异常,也不会影响其他资源的关闭。
try (FileReader fr = new FileReader("in.txt"); FileWriter fw = new FileWriter("out.txt")) { int ch; while ((ch = fr.read()) != -1) { fw.write(ch); } } catch (IOException e) { e.printStackTrace(); }这段代码实现了一个文件复制功能。fr和fw都会在try块结束时自动关闭,fw先关,fr后关。代码简洁,逻辑清晰。
注意:try-with-resources要求资源实现
AutoCloseable接口。FileReader和FileWriter都实现了这个接口,所以可以直接用。如果你自己封装了流对象,记得也要实现这个接口。
5. 常见问题与排查技巧实录
5.1 中文乱码:从现象到根因的排查路径
乱码是字符流最常见的问题。现象是:文件里明明是“你好世界”,读出来变成“ä½ å¥½ä¸–ç•Œ”或者“浣犲ソ涓栫晫”。前者是UTF-8字节被按ISO-8859-1解码的结果,后者是UTF-8字节被按GBK解码的结果。
排查步骤:
- 确认文件本身的编码。用文本编辑器(如VS Code、Notepad++)打开文件,查看右下角显示的编码格式。
- 确认程序读取时使用的编码。如果是
FileReader,它用的是平台默认编码,可以通过System.getProperty("file.encoding")查看。 - 如果文件编码和程序编码不一致,改用
InputStreamReader显式指定文件的实际编码。
写出时同理。如果你用FileWriter写中文,写出来的文件在别的机器上打开乱码,也是因为编码不一致。解决办法是改用OutputStreamWriter指定UTF-8。
5.2 文件找不到与权限拒绝:路径问题的几种形态
FileNotFoundException是另一个高频异常。它可能由以下几种情况引起:
- 文件路径写错了。相对路径是相对于程序的工作目录,不是相对于源代码文件所在的目录。在IDE里运行和打成jar包运行,工作目录可能不同。
- 文件存在,但程序没有读权限。这在Linux上比较常见,可以用
ls -l查看文件权限。 - 父目录不存在。比如你要写
/data/logs/app.log,但/data/logs目录不存在,FileWriter不会自动创建父目录,会直接抛异常。
对于第三种情况,可以在创建FileWriter之前先确保目录存在:
File file = new File("data/logs/app.log"); file.getParentFile().mkdirs(); try (FileWriter fw = new FileWriter(file, true)) { // ... }5.3 忘记关闭流导致的文件被占用
在Windows上,如果一个文件被FileReader或FileWriter打开后没有关闭,尝试删除或重命名这个文件时会提示“文件正在被另一个程序使用”。这是因为流没有关闭,文件句柄没有释放。
用try-with-resources可以彻底避免这个问题。如果因为某些原因不能用try-with-resources,一定要在finally块里关闭流,并且要处理close()可能抛出的异常。
5.4 常见问题速查表
| 问题现象 | 可能原因 | 排查方法 | 解决方案 |
|---|---|---|---|
| 中文乱码 | 文件编码与程序编码不一致 | 查看文件编码和file.encoding属性 | 改用InputStreamReader/OutputStreamWriter指定编码 |
| 读不到内容 | 文件路径错误或文件为空 | 打印绝对路径,检查文件大小 | 修正路径,确认文件存在且有内容 |
| 写入后文件为空 | 忘记flush()或close() | 检查代码中是否有flush/close | 使用try-with-resources或手动flush |
| 追加变覆盖 | 用了默认构造函数 | 检查FileWriter构造参数 | 传入true启用追加模式 |
| 文件被占用 | 流未关闭 | 检查是否有未关闭的流 | 用try-with-resources确保关闭 |
| 读取性能差 | 未使用缓冲 | 检查是否直接操作FileReader | 用BufferedReader包装 |
独家避坑技巧:在开发阶段,我习惯在读写文件之前先打印一下
new File(path).getAbsolutePath(),确认程序实际操作的路径和我预期的一致。这个习惯帮我省去了无数次“文件明明在那里为什么读不到”的困惑。
6. 一个完整的文件复制工具:把知识点串起来
6.1 需求分析与方案设计
假设我们要实现一个文本文件复制工具,要求:
- 支持指定源文件和目标文件路径
- 正确处理中文编码
- 使用缓冲提高性能
- 自动关闭资源
- 支持追加模式
方案:用BufferedReader包装InputStreamReader读取源文件,用BufferedWriter包装OutputStreamWriter写入目标文件,编码统一用UTF-8。
6.2 完整代码实现与逐行说明
import java.io.*; import java.nio.charset.StandardCharsets; public class TextFileCopier { public static void copy(String sourcePath, String targetPath, boolean append) { File targetFile = new File(targetPath); // 确保父目录存在 if (targetFile.getParentFile() != null) { targetFile.getParentFile().mkdirs(); } try (BufferedReader br = new BufferedReader( new InputStreamReader(new FileInputStream(sourcePath), StandardCharsets.UTF_8)); BufferedWriter bw = new BufferedWriter( new OutputStreamWriter(new FileOutputStream(targetFile, append), StandardCharsets.UTF_8))) { char[] buffer = new char[4096]; int len; while ((len = br.read(buffer)) != -1) { bw.write(buffer, 0, len); } bw.flush(); } catch (IOException e) { System.err.println("文件复制失败: " + e.getMessage()); e.printStackTrace(); } } public static void main(String[] args) { copy("source.txt", "target.txt", false); System.out.println("复制完成"); } }逐行说明几个关键点:
new FileInputStream(sourcePath):字节输入流,连接源文件。new InputStreamReader(..., StandardCharsets.UTF_8):把字节流按UTF-8解码成字符流。new BufferedReader(...):加缓冲,提高读取效率。new FileOutputStream(targetFile, append):字节输出流,append控制追加还是覆盖。new OutputStreamWriter(..., StandardCharsets.UTF_8):把字符按UTF-8编码成字节。new BufferedWriter(...):加缓冲,提高写入效率。char[] buffer = new char[4096]:字符缓冲区,4096个字符大约8KB内存,对大多数场景够用。bw.flush():确保缓冲区数据落盘。虽然close()也会flush,但显式调用更保险。
6.3 性能对比:不同缓冲大小的影响
我做过一个简单的测试,复制一个约10MB的文本文件,使用不同的缓冲区大小,结果如下:
| 缓冲区大小 | 耗时(毫秒) | 说明 |
|---|---|---|
| 无缓冲(逐字符) | 约12000 | 系统调用次数极多,性能最差 |
| 1024字符 | 约350 | 有明显改善 |
| 4096字符 | 约280 | 推荐值,内存和性能平衡 |
| 8192字符 | 约270 | 提升有限,内存占用翻倍 |
| 65536字符 | 约265 | 继续增大收益很小 |
从测试结果看,4096到8192是一个比较合理的区间。再往上加,性能提升微乎其微,但内存占用会增加。当然,这个数据受硬件和文件内容影响,仅供参考。
实操心得:如果你不确定该设多大,直接用
BufferedReader和BufferedWriter的默认缓冲区大小(8192)就行,不用自己传参数。默认值是经过权衡的,适合大多数场景。
7. 从节点流到处理流:什么时候该用哪个
7.1 节点流和处理流的职责划分
节点流负责“连接数据源”,处理流负责“增强功能”。FileReader连接文件,BufferedReader提供缓冲和readLine()。FileWriter连接文件,BufferedWriter提供缓冲和newLine()。
单独用节点流不是不行,但就像用一把没有手柄的锤子——能敲钉子,但费劲。处理流的存在就是为了让节点流更好用。
7.2 装饰器模式在IO体系中的体现
Java的IO体系是装饰器模式的经典应用。FileReader是被装饰者,BufferedReader是装饰者。你可以一层一层地包装:
Reader reader = new FileReader("file.txt"); Reader buffered = new BufferedReader(reader); Reader lineNumbered = new LineNumberReader(buffered);每一层都在前一层的基础上增加新功能。这种设计的好处是灵活,你可以根据需要组合不同的处理流。坏处是类层次结构比较复杂,新手容易看晕。
7.3 选型建议:一张表说清楚
| 场景 | 推荐方案 | 理由 |
|---|---|---|
| 读取纯文本文件,编码与平台一致 | BufferedReader+FileReader | 代码简洁,功能够用 |
| 读取纯文本文件,指定编码 | BufferedReader+InputStreamReader+FileInputStream | 编码可控,跨平台一致 |
| 写出纯文本文件,编码与平台一致 | BufferedWriter+FileWriter | 代码简洁 |
| 写出纯文本文件,指定编码 | BufferedWriter+OutputStreamWriter+FileOutputStream | 编码可控 |
| 复制二进制文件(图片、音频) | BufferedInputStream+FileInputStream/BufferedOutputStream+FileOutputStream | 字节流不处理编码,适合二进制数据 |
| 读取大文件按行处理 | BufferedReader.readLine() | 内存占用可控,逐行处理 |
这张表基本覆盖了日常开发中90%以上的文件读写场景。记住一个原则:文本用字符流,二进制用字节流;需要指定编码就用转换流;需要性能就加缓冲。
8. 我踩过的那些坑与最后的经验分享
说几个我在实际项目中踩过的坑,希望能帮你省点时间。
第一个坑是关于readLine()的。有一次我写了一个配置文件解析器,用readLine()逐行读取,然后按=分割键值对。测试的时候没问题,上线后发现有些配置项读不到。排查了半天,发现是配置文件最后一行没有换行符,而readLine()在读到文件末尾时,如果最后一行没有换行符,它仍然会返回这一行的内容。但我的代码里有一个逻辑是“跳过空行”,而最后一行恰好被某个编辑器保存成了带\r的形式,readLine()返回的字符串末尾带了一个\r,导致键值对分割失败。后来我在处理每一行之前先调用了trim(),问题解决。
第二个坑是关于FileWriter的覆盖模式的。做一个数据导出功能时,我用了FileWriter的默认构造函数,想着每次导出都生成一个新文件。但测试的时候发现,如果连续导出两次,第二次的文件内容会覆盖第一次的。这本来是我想要的行为,但问题是,如果第二次导出的数据比第一次少,文件末尾会残留第一次的数据。因为FileWriter的覆盖模式只是把文件指针移到开头,并不会截断文件。要彻底清空文件,得用new FileWriter(file, false),或者先删除文件再创建。这个细节在文档里写得很清楚,但很容易被忽略。
第三个坑是关于编码的。有一次做一个跨平台的数据交换功能,Windows端写文件,Linux端读文件。Windows端用FileWriter写,默认GBK;Linux端用FileReader读,默认UTF-8。结果中文全部乱码。后来统一改成OutputStreamWriter和InputStreamReader指定UTF-8,问题解决。这件事让我养成了一个习惯:只要涉及文件读写,编码一律显式指定,不依赖平台默认值。
最后分享一个小技巧。如果你需要频繁地读写小文件,可以考虑用java.nio.file.Files类,它提供了一些便捷方法,比如Files.readAllLines(Path, Charset)和Files.write(Path, Iterable, Charset)。这些方法内部也是用字符流实现的,但封装得更好,代码更简洁。不过对于大文件,还是得用流的方式,避免一次性把整个文件加载到内存里。
字符流的东西看起来简单,但细节很多。把FileReader和FileWriter用熟,再理解InputStreamReader和OutputStreamWriter的作用,基本上文本文件读写就不会再出什么大问题了。