开头直接说个我经历的小事。前阵子部门面实习生,问了一圈java基础,大部分人都能背出“IO流有字节流字符流,InputStream、OutputStream、Reader、Writer”,但当我让他们现场写一段代码:拿到一个网页文本,存到本地文件,再生成一批假数据写进CSV,很多人当场卡住。不是不会写,而是压根没意识到这两件事本质上就是同一件事——IO流。
这个认知一旦建立起来,java基础里的IO流就再也不是八股文了。这篇文章我会用网络爬虫和假数据工具包这两个场景,把IO流从头到尾拆一遍:字节流和字符流怎么选、缓冲流为什么非用不可、网络响应怎么读、造数工具怎么落盘,最后再讲几个我实际踩过、面试也爱问的坑。适合刚学完Java语法想动手做东西的人,也适合准备面试想把这部分彻底讲清楚的人。
1. 先聊清楚:写完文件、收到响应,为什么都在和流打交道
1.1 从一次面试开场想起的
那次面试我出的题很朴素:用Java读取一个网络上公开页面的HTML内容,保存成本地index.html。题干里我故意没提IO流,就是想看对方能不能自己绕到这条路上。
结果有候选人直接写了HttpClient拿到响应,然后卡在“接下来怎么存文件”;还有人问我“要引入什么jar包”;最让我哭笑不得的是,有个同学回答“下载网页应该用浏览器”。这其实暴露了一个很普遍的问题:很多人把IO流当成“文件读写”的专属API,没意识到网络、内存、标准输入输出,全部都是通过“流”来交互的。
IO流抽象出来的那个模型特别简单:一切数据都可以看成字节序列,你从一个源去读它,或者往一个目的地去写它。至于这个源是硬盘上的文件、还是远端服务器返回的响应体,在流的世界里,它们长得一样——都给一个InputStream。学会IO流,本质上是学会了“怎么跟所有数据源打交道”,而不是学会几个File相关类就完事。
1.2 IO流管的到底是哪几层事
我用一个水管模型来理解IO流。程序是个水箱,文件、网络响应、键盘输入都是水源,流就是连接它们的水管。数据从水管进到程序里,这个过程叫输入流;程序把数据通过水管送出去,落在文件、发送到远端、打在屏幕上,这个过程叫输出流。
Java里具体到类上,分了两条主线。一条以字节为最小单位,InputStream和OutputStream是老大,凡是读图片、音频、视频、压缩包,走这条线;另一条以字符为最小单位,Reader和Writer是老大,凡是处理文本、HTML、JSON、日志,走这条线。
这两条线之间的关系不是割裂的,字符流底层也是靠字节流在跑,只是中间多了一层“编码解码器”。后面第2章我会专门展开这个换算过程,先记住一个结论:你最终操作的粒度和数据本身的格式,决定了你选哪条线。二进制文件硬要用字符流去读,轻则乱码,重则数据损坏。
1.3 网络爬虫和假数据工具包的共同点
回到标题的两个场景。网络爬虫的第一步,本质上就是用输入流把网页数据读进程序;第二步,用输出流把处理好的内容写入文件或者数据库。假数据工具包正好反过来,核心是用输出流把程序生成的一堆记录写进CSV、JSON、SQL文件。一进一出,全是IO的活。
很多教程喜欢把爬虫讲得很玄,又是正则匹配又是XPath解析,搞得新手觉得门槛很高。其实你可以把爬虫的骨架简化成三步:拿流、读数据、落盘。解析只是中间处理的一环,IO才是从头贯穿到尾的地基。工具包造数也一样,生成数据只是内存里的几行对象,真正难的不是生成,而是把千万条数据稳定、高效、不卡内存地写出去。
所以我把这篇文章的重心放在IO流本身。转码、模拟数据、写文件这些只是载体,目的是让你在一个真实场景里彻底理解流的用法。
2. 字节流与字符流的选型逻辑:读文件写数据的底层路径
2.1 四个基类的现实对应物
先把IO世界里最重要的四个抽象类摆出来:
| 抽象类 | 作用 | 常见实现 |
|---|---|---|
InputStream | 按字节读入 | FileInputStream、ByteArrayInputStream、FilterInputStream |
OutputStream | 按字节写出 | FileOutputStream、ByteArrayOutputStream |
Reader | 按字符读入 | FileReader、InputStreamReader、BufferedReader |
Writer | 按字符写出 | FileWriter、OutputStreamWriter、BufferedWriter |
记住这四个名字以后,你会发现Java IO类的命名规则极其直白:前缀表示数据源类型,后缀表示流类型。FileInputStream就是“从文件来的字节输入流”,ByteArrayOutputStream就是“写到字节数组的输出流”。
刚入门阶段,直接在try块里new FileInputStream和new FileOutputStream,是最快建立体感的写法。比如读取同一目录下的图片并复制为bak文件:
try (FileInputStream in = new FileInputStream("avatar.png"); FileOutputStream out = new FileOutputStream("avatar_bak.png")) { byte[] buffer = new byte[1024]; int len; while ((len = in.read(buffer)) != -1) { out.write(buffer, 0, len); } } catch (IOException e) { e.printStackTrace(); }read(buffer)这个方法会批量读取最多buffer.length个字节,返回实际读到的数量;返回-1说明到底了。之所以要写out.write(buffer, 0, len)而不是out.write(buffer),是因为最后一批数据往往凑不满缓冲区,多写了会把空白字节也写进文件,文件还能用但体积不对,严重点的二进制文件直接损坏。
2.2 为什么普遍要套一层缓冲流
很多教程会告诉你“用BufferedInputStream性能更好”,但不说为什么。这里我展开讲一下。
不缓冲的FileInputStream每次调用read(),都相当于直接向操作系统发起一次读取请求。而系统调用是有成本的,就好比你每天要去银行办业务,一次只取1块钱,来回折腾几十次,时间和手续费全浪费在路上了。缓冲区相当于你办了一个蓄水池,一次性往里倒100万,你从池子里取,取完了再让系统往池子里补一次水。
BufferedInputStream内部默认的缓冲区大小是8192字节,也就是8KB。它做的事情很简单:当你调用read()从它这里要一个字节时,它一次性从底层FileInputStream里读走8KB,存在内存里,然后慢慢给你。这样系统调用的次数一下子缩小了几千倍,IO性能自然就上去了。
这个性能差异在读大文件时特别明显。我自己测过,用一个几百MB的日志文件做纯读取累加字节数的操作,FileInputStream裸读比BufferedInputStream慢好几倍,数据越读系统调用越多,差距越离谱。另一个容易感知的场景是BufferedReader.readLine(),读文本时它内部按行缓存,几乎是你写文本处理代码的标配。顺带说一句,Scanner虽然也能读文件,但内部也是包装了流对象,而且类名一听就适合“解析”而非“搬运”,性能差BufferedReader一截。
2.3 编码问题:字节和字符之间的换算规则
字符流是建立在字节流之上的。InputStreamReader接收一个字节流和一个字符集,负责把字节按规则翻译成字符;OutputStreamWriter负责把字符反向翻译回字节。翻译规则不统一,就会出现最常见的乱码事故。
“UTF-8”现在基本是事实标准,但Windows老机器上的中文文件,很多还是GBK编码。你读取时如果用了UTF-8解码GBK文件,中文就会化成一堆锟斤拷。极端情况是文件编码和程序指定编码一致,但平台默认编码不一致,比如在Linux上跑new String(bytes),它默认用UTF-8,在Windows上跑,默认可能是GBK——同一段代码,换个环境就乱码。
正确的做法是永远显式指定字符集。读文件:
try (BufferedReader reader = new BufferedReader( new InputStreamReader(new FileInputStream("data.txt"), StandardCharsets.UTF_8))) { String line; while ((line = reader.readLine()) != null) { System.out.println(line); } }写文件同理,OutputStreamWriter的构造方法里第二个参数传StandardCharsets.UTF_8。你是从哪个源读到字节的,在哪个环节解码,写出去时又以什么编码编码,三处必须口径一致。很多人只改读取端不改写出端,照样乱码,就是这个链条中间断了一节。
3. 网络爬虫场景里的IO:从URL读到InputStream再到落盘
3.1 抓网页在IO层面到底是什么
先说清楚边界:本文涉及的“网络爬虫”只指对公开页面做少量、低频的读取,用于学习IO流和网络编程。真实做爬虫业务,还必须遵守目标网站的Robots协议、服务条款以及个人信息保护相关法律,不去绕过访问控制,不做高并发轮询,不采集个人敏感信息。
原理层面看,爬虫的第一步就是IO流。Java里最朴素的网络请求方式不是HttpClient,而是java.net.URL和URLConnection。创建一个URL对象,调用openConnection()拿到一个连接,再调用getInputStream(),服务器返回的数据就以输入流的形式出现在你面前。
这和我们读本地文件没有任何区别,甚至代码结构都是一模一样的。本地文件的FileInputStream换成了网络连接的输入流,数据源从硬盘换成了远程服务器,剩下的读取逻辑完全复用。对使用者来说,流把“距离”这个变量抹平了。
3.2 一个最小可运行的抓取示例
下面这个例子,用HttpURLConnection请求一个公开页面,读取HTML正文并保存到本地文件。我选了一个稳定、公开、内容简单的示例页面,并把读网络流和写文件流拆得很清晰:
import java.io.*; import java.net.*; import java.nio.charset.StandardCharsets; public class SimplePageFetcher { public static void main(String[] args) { String urlStr = "https://www.example.com"; String outputPath = "example.html"; HttpURLConnection conn = null; try { URL url = new URL(urlStr); conn = (HttpURLConnection) url.openConnection(); conn.setRequestMethod("GET"); conn.setConnectTimeout(5000); conn.setReadTimeout(5000); conn.setRequestProperty("User-Agent", "Mozilla/5.0 (Java IO learning demo)"); int status = conn.getResponseCode(); if (status != 200) { System.err.println("请求失败,HTTP状态码:" + status); return; } try (InputStream in = conn.getInputStream(); BufferedReader reader = new BufferedReader( new InputStreamReader(in, StandardCharsets.UTF_8)); BufferedWriter writer = new BufferedWriter( new OutputStreamWriter( new FileOutputStream(outputPath), StandardCharsets.UTF_8))) { String line; while ((line = reader.readLine()) != null) { writer.write(line); writer.newLine(); } } System.out.println("页面已保存到:" + outputPath); } catch (Exception e) { e.printStackTrace(); } finally { if (conn != null) { conn.disconnect(); } } } }这段代码有几个细节值得解释。setConnectTimeout和setReadTimeout必须设置,否则网络挂了之后整个程序会一直卡在读流上,生产环境里这就是线程泄漏。User-Agent设置成浏览器标识,是因为不少服务器会拒绝无标识的请求,这是网络服务端的正常防护,不代表我们可以干套壳伪装的活。最后finally里disconnect,很多人不写,短时间跑一次没感觉,但如果你的程序要连续抓取几十个页面,不主动断开连接会导致本地端口耗尽。
3.3 HTTP响应处理里容易忽略的IO问题
第一个坑是输入流必须用完就关。HttpURLConnection默认是keep-alive的,你关了输入流,连接才能回到连接池里复用。你要是偷懒不关,连接池被占满,后续请求全部排队,表现出来就是“程序跑了一会儿越跑越慢”。
第二个坑是响应编码可能不是UTF-8。很多老网站用的是GBK或GB2312,还不在响应头里声明。我的经验是,先尝试按Content-Type里的charset参数去读,如果没有这个参数,就先用UTF-8读几行,如果出现大量替换字符,换GBK重试。当然这是在对方允许的前提下调试,实际做数据采集前一定要先看网站的声明。
第三个坑是网络流和文件流的关闭顺序。像上面代码里我用了try-with-resources多资源自动关闭,多个资源会按声明顺序的逆序关闭,也就是writer先关、reader接着关、in最后关。这符合逻辑:必须先保证数据写完落盘成功,再关闭输入源。如果你在一个try块里手动关,顺序反了,文件流写一半网络流断了,可能连异常信息都拿不到。
3.4 除了文本,抓图片等二进制资源的IO处理
爬虫业务里除了HTML,最常碰的就是图片。图片和文本的区别,就是底层不需要字符转换这一步。之前第2章那个文件复制示例,直接把FileInputStream换成网络输入流,就能把网络图片存到本地:
try (InputStream in = conn.getInputStream(); FileOutputStream out = new FileOutputStream("logo.png")) { byte[] buffer = new byte[4096]; int len; while ((len = in.read(buffer)) != -1) { out.write(buffer, 0, len); } }二进制读写不需要BufferedReader和BufferedWriter,因为它们提供的是行级文本语义,对图片来说没有意义。这里有个容易犯的错:图片之类的大流量文件,缓冲区大小适当放大,比如改成8192甚至16384,吞吐效率会明显提升。原因和缓冲流的原理一样,单位时间内系统调用次数更少,流水线搬运量更大。
4. 工具包生成假数据:把“造数逻辑”变成可复用的IO解决方案
4.1 为什么需要一个造数工具包
开发联调时最烦的一件事,是数据库里没数据。你写了个分页查询的接口,总得先造个几百条数据验证一下吧;你写了个导出报表的定时任务,总得有一个几十MB的输入文件测一下性能吧。每次现写循环插入SQL,代码是能跑,但一点不可复用,下次想生成一万条不同格式的数据又得重写。
工具包的价值,是把“生成规则”和“输出通道”解耦。生成规则管的是“这行数据长什么样”,比如姓名、年龄、手机号、地址、时间戳;输出通道管的是“数据写到哪、用什么格式、以什么编码”,比如UTF-8编码的CSV文件。两边各自独立,以后想改成输出JSON,只换输出通道;想加一个字段,只改生成规则。
4.2 用成熟的工具包还是自己写
Java生态里有现成的假数据生成库,比如Faker,底层很庞大,覆盖人名、地址、公司、银行、互联网、文案等各国本地化内容。实际项目里用它最省时间。先加依赖:
<dependency> <groupId>com.github.javafaker</groupId> <artifactId>javafaker</artifactId> <version>1.0.2</version> </dependency>生成用户对象并输出到CSV的完整逻辑:
import com.github.javafaker.Faker; import java.io.*; import java.nio.charset.StandardCharsets; import java.util.Locale; public class FakeUserCsvGenerator { public static void main(String[] args) { Faker faker = new Faker(Locale.CHINA); int count = 10000; String path = "fake_users.csv"; try (BufferedWriter writer = new BufferedWriter( new OutputStreamWriter( new FileOutputStream(path), StandardCharsets.UTF_8))) { writer.write("id,name,phone,address,email"); writer.newLine(); for (int i = 1; i <= count; i++) { String line = String.join(",", String.valueOf(i), faker.name().fullName(), faker.phoneNumber().cellPhone(), faker.address().fullAddress().replace(",", " "), faker.internet().emailAddress()); writer.write(line); writer.newLine(); } System.out.println("生成完成:" + count + " 条,文件大小 " + new File(path).length() + " 字节"); } catch (IOException e) { e.printStackTrace(); } } }这里有个我在真实项目里栽过的跟头:地址字段里有逗号,直接拼进CSV会把一列撕成两列。所以我写了个.replace(",", " "),把字段里的逗号替换成空格。CSV是没有严格转义标准的,最稳妥的方案是字段一律清洗掉分隔符、换行符。
4.3 写文件时编码与追加模式的选择
造数工具包输出文件,编码选择直接决定目标系统的体验。绝大多数场景选UTF-8,但如果你的数据要给Excel用,且用户用的是Windows中文环境,Excel打开UTF-8的CSV会乱码。两个解法:一是输出GBK编码的文件,二是输出UTF-8文件但写入BOM头。我一般选后者,因为UTF-8更通用,加BOM反而会让老牌编辑器兼容性变好。写入BOM只需在文件最开头写三个字节EF BB BF。
追加模式是另一个容易绕晕的点。new FileOutputStream(path)默认覆盖,new FileOutputStream(path, true)才是追加。批量生成时如果要分段写,后续打开一定记得带上第二个参数true。Java 7之后还提供了Files.newBufferedWriter(path, charset, StandardOpenOption.APPEND),语义更清晰,不用自己包OutputStreamWriter。
4.4 造大文件时的IO细节
测试导出功能时,我经常需要生成几百MB的文本文件。这时候光会BufferedWriter还不够,有几个细节是必须注意的。
一是不能每条记录都flush。flush是把缓冲区数据强制推到磁盘,频繁触发等于把缓冲优势全部放弃。正确做法是大量写入后统一flush一次,或者靠close时的自动刷新。
二是不要逐条拼接大字符串。每个writer.write方法都会做一次字符转字节,在循环里尽量把一行拼好再一次性写入。拼的时候别用+循环拼接,用StringBuilder或String.join。虽然BufferedWriter自己有缓冲,但Java层字符串拼接的开销依然存在,数据量过千万时差距很明显。
三是预留可中断/可续跑的入口。造数工具跑一半挂了,重跑一遍是灾难。我的做法是每次写完N条就flush一次,并打印当前进度,配合追加模式,挂了可以从上次的记录数接着跑。这不算IO流的语法问题,但如果没有IO刷新的概念,你根本不知道什么时候数据真正落盘了。
4.5 生成二进制假数据(比如占位图片)的思路
有些项目需要一堆假图片来测上传、压缩、缩略图逻辑。用IO流生成纯色占位图,可以直接用BufferedImage配合ImageIO写出来:
BufferedImage img = new BufferedImage(800, 600, BufferedImage.TYPE_INT_RGB); Graphics2D g = img.createGraphics(); g.setColor(new Color(135, 206, 235)); g.fillRect(0, 0, 800, 600); g.dispose(); ImageIO.write(img, "png", new File("placeholder.png"));ImageIO.write内部已经把OutputStream封装好了,你只需要给它一个文件路径。这类场景要记住的IO要点是:图片是二进制数据,任何环节都不要用字符流去处理;ImageIO默认支持png、jpg、gif等格式,写之前确认目标格式对颜色模式的支持。
5. 高频考点与真实踩坑经验:flush、编码读取与资源异常
5.1 不关流的代价与try-with-resources的正确姿势
不关闭输入输出流,下面这几个问题迟早找上门:文件句柄耗光——运行一段时间后报Too many open files;Windows下文件被占锁——文件明明不在了,但删除时提示被另一个程序使用;网络连接无法释放——端口被占满。
解决这个问题最干净的方式,就是用try-with-resources语法。它的好处是不用写finally,资源在try块结束会自动关闭,而且是逆序关闭,天然符合释放顺序。注意try-with-resources里的资源类必须实现AutoCloseable接口,Java里所有的IO流类都实现了。
有人习惯在里面写catch然后继续往下跑,我建议catch里至少打日志,不然文件写失败了程序还假装成功,后面查问题会疯。一个更隐蔽的点是:关闭资源时的异常,比如磁盘满了,close()可能抛异常。try-with-resources会把关闭异常一并捕获,你可以在catch里区分到底是业务读写异常还是关闭异常,虽然多数场景不需要区分,但心里要有这个认知。
5.2 flush和close别搞混:数据不落盘的两类原因
这两个词是新手问得最多的问题之一。我先说结论:close()一定包含flush(),但flush()不关闭流。
为什么需要flush?因为BufferedWriter把数据攒在8KB缓冲区里,没满之前不会真正写进磁盘。你以为写完了,其实数据还在内存里,程序一崩全丢。比如造数工具里,如果不做任何flush直接close,数据也会落盘,close会补一次刷新。但如果你的程序是常驻进程,写一批数据后要立刻让另一个进程来读这个文件,那必须flush(),否则另一个进程读到的可能是空文件或者旧文件。
两段代码做个对照:
// 写法1:不flush,数据留在缓冲区,另一个进程立刻读不到 writer.write("hello"); // 没有flush就 Thread.sleep(1000),另一个进程读文件可能是空的 // 写法2:显式flush,数据真正落盘,其他进程可以立刻看到 writer.write("hello"); writer.flush();注意FileWriter和FileOutputStream这种没有内置缓冲区的流,write就是直接走系统调用,数据会立即写出去,不存在flush的烦恼。所以这个问题基本只出现在BufferedWriter/Writer这类带缓冲的流上。
5.3 编码错乱排查的完整思路
乱码问题在社区里永远有热度。我把完整的排查链路列出来,你按顺序走,基本都能定位。
第一步,确认文件真实编码,而不是“你希望它是UTF-8”。Linux下用file -i命令看类型和编码,Windows下可以用编辑器或一些工具查看文件字节,比如打开十六进制视图,看UTF-8的中文通常是以三个字节一组出现,且首字节的二进制高位特征不同。
第二步,检查读取端代码是否显式指定了与文件真实编码一致的字符集。最常出事的代码是new InputStreamReader(new FileInputStream(path)),这行代码没指定字符集,会用平台默认值。服务器Linux上默认UTF-8,本地Windows默认可能是GBK,同一行代码两个环境两套行为。
第三步,检查写出端编码。读取端解码正确,写出端如果以错误编码重新编码,也会乱码。这个错误在“文件复制后再打开”的场景特别隐蔽,因为是两份文件,乱码可能只出现在其中一份。
第四步,检查数据在内存里的流转过程。比如读进来是String类型,中间经过数据库、消息队列、HTTP传输,每一跳都有编码转换的可能。IO流只负责文件链路两端,中间环节的编码问题要在整个数据链路上统一。
5.4 从基础IO到更上层的写法:Files工具类与NIO
最后聊一个面试里加分、实践里也常用的东西:Java 7推出的java.nio.file.Files工具类。它把最常见的IO操作压缩成了几个静态方法,比如:
// 整个文件一次读成List<String> List<String> lines = Files.readAllLines(Paths.get("data.csv"), StandardCharsets.UTF_8); // 一次性写入多行文本 Files.write(Paths.get("out.csv"), lines, StandardCharsets.UTF_8); // 复制文件 Files.copy(Paths.get("a.txt"), Paths.get("b.txt"));readAllLines和write的底层还是包装了流,本质上只是把样板代码替你写好了。我建议:小文件、低频操作,直接用Files掌握核心方法即可;大文件、高频操作,回到BufferedReader/BufferedWriter流式处理,不要让readAllLines一次性把所有行加载进内存。面试时如果被问到这部分,能把“高层API本质是包装IO流”这个点说出来,说明你对底层是真理解,而不是只背了一堆方法名。
NIO的FileChannel、ByteBuffer属于另一套体系,适合追求更高性能和手动控制缓冲区的场景。学习顺序上,先把本文的字节流、字符流、缓冲流、网络流玩熟,再去看NIO会顺畅得多。直接跳学NIO的人,经常被Buffer的flip、clear、compact搞懵,原因就是没理解流式的数据搬运模型。
我个人在实际项目里的习惯是,涉及IO的代码一定把“资源关闭”“编码明确”“缓冲合理”三条底线先立好,再优化性能和写法。很多年前我第一次写爬虫,文件流没关,Windows机器上反复跑了几次后,日志文件全部锁定,最后只能重启电脑,从那以后我写IO代码就再也不敢邋遢了。你自己写工具包的时候,也建议在一开始就把try-with-resources和显式字符集养成分秒不忘的习惯,这些基础动作积累多了,IO流才能真正变成你工具箱里的顺手机械,而不是面试时的背诵素材。