我记得刚学Java那会儿最头疼的就是IO流。文件读写、控制台输入、网络数据传输全要跟流打交道。后来在项目里做日志拆分、文件上传、乱码修复踩的坑多了才慢慢把这套体系串起来。这篇文章就按照“字节流 - 字符流 - 桥接流 - 性能优化 - 踩坑排查”这条主线把Java IO流讲清楚。如果你正准备Java面试或者刚入门想搞懂流机制又或者写文件读写时经常碰上乱码、卡死、文件删不掉这类问题这篇笔记应该对你有用。我尽量不背API而是讲清楚底层逻辑和实际场景。1. 先从日常场景看IO流文件读写到底做了什么1.1 文件在计算机里到底是什么在聊Java的InputStream和OutputStream之前必须先确认一个最基础的事实磁盘上的文件不管后缀是什么本质都是一串二进制字节。.txt只是告诉系统“你可以用文本编辑器打开我”但真正存储的是由0和1组成的字节序列。Java的File类还有后来NIO里的Path本质上只是路径信息相当于一个指路牌指到某个文件的存储位置但它们本身不负责搬数据。所以你会发现File类里面有exists()、isDirectory()、createNewFile()这些元数据操作方法但你就是找不到read()或write()方法。要想真正读文件内容必须新建流对象用流去搬运字节。这个组合设计一开始会让人不习惯但理顺之后反而清晰文件系统只管“文件和目录长什么样”流负责“数据怎么流动”。1.2 把流想象成一根单行道水管“流”这个名字非常形象。假如你把数据想成水那流就是一根水管。这根水管有明确方向InputStream和Reader负责把外部数据流进程序OutputStream和Writer负责把程序数据送出去。请一定记住“输入”和“输出”永远是站在程序角度来说的不是站在文件角度。我第一次做文件复制时一直搞不懂为什么读文件要叫InputStream后来想明白文件里的数据要流到程序里对程序来说当然是输入。方向一旦搞反编译就直接给你报错。流还有一个特点它是单向连续的水流。数据一个接一个过来你只能按顺序读不能像访问数组那样直接跳到第1000个字节去取。这种设计比较原始因为一个字节一个字节地走太慢。所以Java后面才提供了缓冲流相当于在水管中间加了一个蓄水池一车一车地拉水而不是一趟一趟地倒一杯。1.3 字节与字符的第一次碰撞既然文件底层都是字节那为什么Java还要单独设计一套字符流为了处理文本。人类阅读文本时理解的是字符比如“你好Java”底层在UTF-8编码下却是E4 BD A0 E5 A5 BD一堆字节在GBK编码下又不一样。如果你抱着字节流去读文本文件输出的可能是一堆数字或者乱码字符因为字节和字符之间的翻译工作没人做。字符流就是这个翻译员。它内部依然依赖字节流去读文件但多了一个编码解码器把字节翻译成人类认识的字符。所以我的学习顺序建议是先把字节流玩透再学字符流。这样你就能清楚“字符流 字节流 编码器”而不是把两者割裂成两个无关体系。2. 字节流实操FileInputStream与FileOutputStream的硬核细节2.1 为什么read()返回int而不是byte很多新手第一次读文件时会发现FileInputStream.read()的返回值是int而不是byte。这让人很疑惑明明读的是字节为什么返回一个四字节的int原因其实很严谨字节的取值范围是-128到127而read()要用-1来表示“流结束”。如果返回byte读到真正的字节值-1时就无法区分它到底是一个真实的字节数据还是文件已经读完了。所以Java把返回值的范围扩大到0到255并用-1专门表示EOFEnd Of File。类似地BufferedReader.read()返回char或int读完时也返回-1。你还会发现如果拿返回值直接强转byte可能得到负数。因为Java里的byte是有符号的比如一个字节0xFF转换成byte是-1。如果后续要把字节拼成字符串经常要写b 0xFF来还原成无符号整数。这个细节在调试文件头时特别有用。2.2 用byte数组批量读写性能直接翻倍单字节循环读虽然写法最简单但性能极差。每次read()都可能触发一次系统级IO操作读一个50MB的文件相当于调用几千万次底层函数慢得不可想象。所以工程上最常见的方式是准备一个字节数组一次读一批try (FileInputStream fis new FileInputStream(source.bin); FileOutputStream fos new FileOutputStream(target.bin)) { byte[] buffer new byte[8192]; int len; while ((len fis.read(buffer)) ! -1) { fos.write(buffer, 0, len); } }这里有一个非常隐蔽的坑read(byte[] buffer)返回的是实际读到的字节数len最后一次读不一定把buffer填满。如果你直接fos.write(buffer)就会把buffer里上一次残留的旧数据也写进去导致目标文件比源文件多出一截内容。必须用write(buffer, 0, len)只写出本次实际读到的部分。这个问题在小文件上可能不暴露因为文件一次就读完了但大文件循环读几次之后最后一轮很容易多写。缓冲区大小一般选1024的整数倍比如8KB、16KB。也许你会觉得越大越好但超过64KB后性能提升并不明显反而白白占用内存。在JVM堆内存有限的情况下8KB到32KB是比较稳妥的选择。2.3 文件复制完整示例与流的关闭哲学给你一个可以直接抄的文件复制方法public static void copyFile(File src, File dest) throws IOException { if (!src.exists() || !src.isFile()) { throw new IllegalArgumentException(源文件不存在或不是文件); } if (dest.getParentFile() ! null !dest.getParentFile().exists()) { dest.getParentFile().mkdirs(); } try (InputStream in new FileInputStream(src); OutputStream out new FileOutputStream(dest)) { byte[] buffer new byte[8192]; int len; while ((len in.read(buffer)) ! -1) { out.write(buffer, 0, len); } out.flush(); } }这段代码里我故意在两个流外面套了try-with-resources。这是Java 7之后最推荐的写法好处是编译器会自动调用close()不用再手写finally块。而且如果in和out都有问题关闭顺序也是语言规范处理好的不会出现关外层时内层还没关的尴尬局面。顺便说一句out.flush()在这里有没有必要其实FileOutputStream关闭时会自动flush但如果你在flush之后还要检查写入大小或者程序还要继续跑一段时间那flush一次是保险操作。后面讲缓冲流时flush的意义会重要得多。3. 字符流从字节流到字符流的真实跳跃3.1 字符流解决什么问题如果你只跟图片、压缩包这类二进制文件打交道字节流就行。但处理文本文件时字节流太反人类。比如你想统计一个英文文件的行数用字节流得自己识别\n但用BufferedReader直接就有readLine()方法想读一行拿一行。字符流的核心价值在于两点第一负责把底层字节按照指定字符集解码成char第二提供面向文本的便捷方法。它并不是什么神奇的魔法而是内部做了编码转换。3.2 FileReader与FileWriter的便利和致命短板最简单的字符流就是FileReader和FileWriter用法和字节流相似try (Reader reader new FileReader(test.txt); Writer writer new FileWriter(out.txt)) { int c; while ((c reader.read()) ! -1) { writer.write(c); } }这里不需要手动处理字节和字符的转换底层全封装好了。但请记住FileReader继承自InputStreamReader它内部其实创建了一个FileInputStream再按默认字符集翻译成字符。它把细节隐藏得很彻底好处是好用坏处是你失去了指定字符集的机会。Java 8及之前的FileReader、FileWriter完全不支持在构造参数里指定字符集只能跟随系统默认值。Windows中文版默认GBKLinux默认UTF-8。如果你在Windows上写了一个GBK编码的文件放到Linux上用FileReader读乱码几乎是必然的。所以我在正规项目中很少直接用FileReader读外部文件除非100%确定文件是平台默认编码。3.3 编码与解码乱码的本质是“不匹配”乱码看起来很玄学但本质只有一句话写入时的字符集和读取时的字符集对不上。举个例子汉字“中”用UTF-8编码是三个字节E4 B8 AD用GBK编码是两个字节D6 D0。如果你用UTF-8写入文件再用GBK去解码那么E4 B8 AD就会被错误地拼成其他汉字屏幕上出现一堆“涓”之类的怪字。如果进一步分析即使字符集匹配还会遇到BOM的问题。UTF-8文件有时带BOM头EF BB BFFileReader读第一行时行首会多出一个\uFEFF不可见字符如果直接做字符串比较会莫名失败。所以读取文本文件时我习惯先读几个字节判断有没有BOM有就去掉再交给字符流解码。下面这个工具方法很实用可以用指定字符集读取一个文件的全部文本public static String readText(File file, Charset charset) throws IOException { StringBuilder sb new StringBuilder(); try (FileInputStream fis new FileInputStream(file); InputStreamReader isr new InputStreamReader(fis, charset); BufferedReader br new BufferedReader(isr)) { String line; while ((line br.readLine()) ! null) { sb.append(line).append(System.lineSeparator()); } } return sb.toString(); }这里我用了三层包装字节流负责读取转换流负责解码缓冲流负责按行读取。注意字符集参数推荐用StandardCharsets.UTF_8这种常量不要用字符串UTF-8手写容易拼错而且部分JDK版本对utf8这种缩写支持不一致。4. 桥接流InputStreamReader/OutputStreamWriter字节到字符的正式通道4.1 桥接流内部到底做了什么理解字符流的钥匙是搞清楚InputStreamReader和OutputStreamWriter。它们是字节流世界和字符流世界之间的桥。InputStreamReader构造时需要传入一个InputStream内部持有这个字节流和一个CharsetDecoder解码器。你调用read()时它先从底层字节流里读若干个字节然后按字符集规则解码成一个或多个char返回。也就是说它把“字节”翻译成了“字符”。OutputStreamWriter则相反它持有OutputStream和一个CharsetEncoder编码器当你write(char[])时先把字符编码成字节再交给底层字节流写出去。用代码还原拆解一下FileInputStream fileInputStream new FileInputStream(data.txt); InputStreamReader reader new InputStreamReader(fileInputStream, StandardCharsets.UTF_8);这样写你就能清楚地看到两个角色FileInputStream只负责把文件字节读进来InputStreamReader负责规定“这些字节应该按什么规则翻译成字符”。如果你直接用FileReader这个分工就被隐藏了。4.2 默认字符集陷阱别让平台替你做决定桥接流构造函数中Charset参数是可以省略的。如果省略就会用Charset.defaultCharset()这个值由JVM启动时所在的操作系统语言环境决定。同一套代码在Windows和Linux上跑出来的结果可能完全不同。定位乱码问题的时候第一件事就是确认系统默认字符集。我处理跨平台项目时凡是涉及文本文件的读写都会显式传入StandardCharsets.UTF_8。虽然多写了一行参数但换来的是一致性。尤其在开发Web接口时前端传过来的表单数据已经按UTF-8编码了服务端如果还按默认字符集解码中文很容易变成问号。4.3 一条完整管线从文件到处理再到写出桥接流的组合方式很灵活你可以根据场景自由嵌套。下面是完整的“读UTF-8文件 - 写UTF-8文件”的管线public static void processTextFile(File src, File dest) throws IOException { try (Reader reader new BufferedReader( new InputStreamReader( new FileInputStream(src), StandardCharsets.UTF_8)); Writer writer new BufferedWriter( new OutputStreamWriter( new FileOutputStream(dest), StandardCharsets.UTF_8))) { char[] buffer new char[4096]; int len; while ((len reader.read(buffer)) ! -1) { writer.write(buffer, 0, len); } } }注意最外层是BufferedReader/BufferedWriter中间是转换流最里层是文件字节流一层套一层。这种结构可以类比成三明治最里层接触“真实物理设备”中间层做“格式翻译”最外层给你“高级API”。排查问题时你可以从最外层往内层想是缓冲问题还是编码问题还是文件本身打不开逐层定位非常高效。5. 提升IO性能缓冲流、flush与实战优化笔记5.1 缓冲流为什么快那么多内部有个8KB水池不带缓冲的逐字节read()每次都会触发一次系统调用这和从内存读取的耗时差距是数量级的。BufferedInputStream默认维护一个8KB的缓冲区第一次读时一次性从底层InputStream填充尽可能多的数据之后你的每次read()都直接从内存缓冲区里取等缓冲区空了再触发新一轮底层读取。这样就把几十万次系统调用压缩成了几百次。BufferedReader也是类似机制默认缓冲区大小是8192个字符。因为它的单位是字符而不是字节所以处理文本时速度同样可观。有一点容易被忽略如果底层流本身不支持mark/reset那么BufferedInputStream的mark与reset功能是唯一能帮你在流里“回头”的方式。比如你想先探测文件头再重新读整个文件就可以先mark(1024)读完几个字节后reset()回开头。5.2 flush()的时机藏着日志丢失的隐患write()操作不一定会立即把数据写到磁盘或网络。缓冲流内部有缓冲区可能这么久都只是囤积在内存里。flush()就是强制把缓冲区内数据全部交付出去了。如果你写完一批数据后程序崩溃缓冲区内未flush的数据会直接丢失。典型场景就是日志文件。有些人自己用BufferedWriter写日志每一行写完不flush然后程序跑了一会儿发现日志文件是空的或者只写了前面几条。原因很简单缓冲区没满数据还在内存里。如果程序正常关闭close()会触发flush但如果中途异常退出未flush数据就丢了。所以我写日志这类需要实时落盘的代码时会每写完一条日志调用一次flush。当然频繁flush会牺牲一部分批量写性能具体取舍取决于业务对实时性的要求。5.3 四种读取方式的实测对比找一台普通虚拟机用一个约50MB的文本文件做测试结果大致如下读取方式相对耗时特点单字节read()约100倍基准代码简单速度最慢byte[8192]数组循环接近基准需要自己维护缓冲区BufferedInputStream逐字节比byte数组稍快内部有缓冲代码简洁BufferedReader.readLine()文本场景最快直接按行处理最方便这个对比不是精确基准但方向很明显大文件处理千万别用单字节循环。二进制处理就用byte[]数组文本处理就用BufferedReader按行读需要合并多次读取时加一个缓冲流。缓冲区并非越大越好8KB到64KB是大多数场景的舒适区。6. 那些年踩过的IO坑排查思路与避坑清单6.1 用available()判断文件大小为什么会翻车有人读文件前喜欢先查一下还有多少字节可读于是写fis.available()然后new一个同长度的byte[]一次read读完。这个方法是错误的。available()的官方含义是“当前流中无阻塞可读取的字节数”它不等于文件总长度。对普通FileInputStream它通常返回剩余文件大小但对Socket流、管道流它往往只返回当前缓冲区里的字节数可能为0。如果我拿这个值分配数组极有可能提前截断内容。正确的做法是直接循环read()直到返回-1或者用File.length()获取文件逻辑大小。但File.length()只对文件流有意义对网络流无用。设计读逻辑时最稳妥的方式就是不依赖预设长度用循环读取拼接。6.2 排查乱码先把字节打回原形有一次线上CSV文件乱码团队里争论文件到底是不是UTF-8。我做的第一件事不是改代码而是把文件头几个字节的十六进制打出来FileInputStream fis new FileInputStream(data.csv); byte[] head new byte[8]; int len fis.read(head); for (int i 0; i len; i) { System.out.printf(%02X , head[i]); }看到EF BB BF基本能确定是UTF-8带BOM看到D6 D0多半是GBK。之后再选择对应的Charset解码问题就清楚了。这个方法比盲试各种编码快很多。我强烈建议在处理任何可疑文本时都先做一步“字节视图”观察再谈解码。6.3 流不关闭导致文件被占用还是try-with-resources靠谱Windows服务器上最常见的事故之一就是日志文件删不掉、改不了名被Java进程占用。原因极可能是代码里new了FileOutputStream但没close。Java流持有底层文件句柄释放不及时文件就会一直处于被锁状态。Linux下不及时关闭还可能报“Too many open files”。处理这类问题的标准答案就是try-with-resourcestry (FileOutputStream fos new FileOutputStream(log.txt)) { // 写入 }这样无论正常执行还是抛异常close都会在作用域结束时自动调用。如果你的项目里还有老式的finally手动关闭代码建议逐步改成这个写法能省掉很多低概率但高影响的线上故障。最后说一点个人体会学IO流我最大的心得是不要背类继承图而是亲手写一遍“文件复制”和“文本读取”。先单字节再字节数组再缓冲流再字符流每一步都感受性能差异和代码简洁度。等你把一条完整链路跑通再回头看InputStream、OutputStream、Reader、Writer的关系会特别清晰。一个小技巧调试IO问题时在关键节点打印文件大小、实际读取长度、第一段字节的十六进制比看任何报错都直观。IO流就像一根水管顺着数据流的方向一段一段检查问题总能定位到具体那一节。