新闻详情

新闻详情

首页 / 资讯中心 / 详情

Java IO流从入门到实践:字节流、字符流、编码与性能陷阱全解析

发布时间:2026/9/9 10:40:37来源:尧图网络
Java IO流从入门到实践:字节流、字符流、编码与性能陷阱全解析
我见过不少Java学习者第一次接触IO流的反应类名一大堆——InputStream、FileInputStream、BufferedInputStream、InputStreamReader、Reader、FileReader……看着就头大学完一遍教程还是不知道读写文件该用谁。更常见的是一上手就翻车比如拿IO流把一串“11111111”写进文件写的过程没报错打开文件看也是对的结果用代码读出来却变成了乱码或者明明写了8个字符读出来只有2个。这篇文章就围绕IO流这件事把它的底层逻辑、类体系、选型思路、性能陷阱和那些教科书里不讲但实际开发必踩的坑一次讲透。适合刚学完Java语法正被IO困住的新手也适合写了好几年业务代码、但处理文件一直靠复制粘贴的老同学。1. 一次“写11111111读乱码”的排查IO流到底在解决什么问题1.1 最朴素的文件读写先看看问题长什么样先说一个很典型的场景。假设你用下面这段代码写入文件FileWriter writer new FileWriter(demo.txt); writer.write(11111111); writer.close();再读出来FileReader reader new FileReader(demo.txt); char[] buf new char[1024]; int len reader.read(buf); System.out.println(new String(buf, 0, len)); reader.close();这段代码在大多数环境下能正常打印出“11111111”但在某些环境下会打印出“11111111”以外的奇怪字符甚至直接读成“?”。问题不在于“11111111”本身而在于FileWriter和FileReader这对组合各用了一套字符编码规则。更有意思的现象是你往文件里写“abc”用字节流去读读出来可能是一堆数字你用字符流去读二进制图片读出来直接废掉。这背后牵出的是IO流最核心的一个设计——字节流与字符流的分工。但在讲那个之前我们先搞清楚更基础的问题IO流本身是干嘛的。1.2 IO流的本质程序与外部世界的数据通道如果把程序比作一间工厂内存就是工厂的车间CPU是工头。但工厂要运转必须从仓库硬盘取原料把产品送出去还要跟其他工厂通信网络这都需要管道。Java里的IO流就是这跟管道。它解决的是一个非常朴素的需求让Java程序能够与外部世界交换数据。外部世界包括文件系统、网络端口、另一个进程的输出、甚至内存里的一块缓冲区。Java把所有数据源和目标统一抽象成“流”上游是数据源头下游是数据去向代码只跟流打交道不关心对面是硬盘还是网络。这里有个“宽口径”的理解方式流是单向的。输入流负责把数据从源头拿进程序输出流负责把数据从程序送出去。你不能用同一个流既读又写。这正是新手最容易搞混的地方——想在一个文件里既读又写却拿着一个FileInputStream和一个FileOutputStream各开一遍结果文件被覆盖了。1.3 为什么流必须关闭先给这个“管道”立规矩“流用完要关”这句话谁都会背但很多人不理解为什么于是关了又忘忘了又被警告。类比一下就通了你跟某个仓库建立了管道连接这个连接会占用操作系统的资源——文件描述符也叫文件句柄。一个进程能打开的句柄数量是有限的你每new一个流不关就相当于占着管道不放。开得多了OS拒绝再给你分配文件描述符程序就会抛Too many open files。管道的另一头是操作系统。文件描述符是内核里的资源Java程序自己不会无限增长但如果你一直不释放最终整个进程的文件操作都会瘫痪。这条规矩后面第五章会详细讲这里先立个flagIO流不是一次性餐具是租的充电宝用完必须还。2. 字节流与字符流两套家族谁为什么存在2.1 字节流电脑最底层的搬运工Java的IO体系分两大阵营以InputStream和OutputStream为代表的字节流以Reader和Writer为代表的字符流。字节流的单位是byte也就是8位二进制数它不考虑编码不关心你写的是字母、汉字还是图片它眼里只有字节。try (FileInputStream in new FileInputStream(demo.txt)) { int b; while ((b in.read()) ! -1) { System.out.printf(%d , b); } }如果你拿上面的代码去读一个内容是“11111111”的文件你会看到49 49 49 49 49 49 49 49因为字符1的ASCII码就是49。这个过程不涉及任何编码转换读进来的是什么字节输出的就是什么数字。字节流的好处是“忠实”坏处是“原始”——程序拿到一堆字节还得自己负责解码成人能看懂的文本。底层文件操作本质全是字节操作。也不光是Java任何一门语言的磁盘读写最终都是字节级操作。字符流之所以存在纯粹是为了“帮程序员少操心编码这件事”但它并没有魔法字符流的底层还是要落到字节流上。2.2 字符流为了让人看懂必须处理编码字符流就不同了它的单位是char也就是字符。字符流在读的时候会把字节按某种字符集解码成字符写的时候会把字符按某种字符集编码成字节。try (BufferedReader reader new BufferedReader(new FileReader(demo.txt))) { String line; while ((line reader.readLine()) ! null) { System.out.println(line); } }这段代码是Java老鸟最熟悉的范本。FileReader是字符流把一个文件里的内容按“默认字符集”解码成Java内部的char。问题就出在这个“默认字符集”上。Java内部字符串是UTF-16编码而文件在磁盘上可能是UTF-8、GBK、ISO-8859-1等任何编码。如果FileReader在读取时使用了跟文件本身不同的编码解码结果就会变成乱码。换句话说字符流不是不需要编码而是把编码规则悄悄藏在了默认配置里。藏起来的东西最危险这也是为什么后来Java官方推荐用Files.newBufferedReader(path, StandardCharsets.UTF_8)明确指定编码而不是裸用FileReader。2.3 桥接InputStreamReader与OutputStreamWriter的粘合角色字节流和字符流之间有一座桥InputStreamReader和OutputStreamWriter。InputStreamReader负责把字节流包装成字符流并用指定的字符集解码OutputStreamWriter负责把字符流包装成字节流并用指定的字符集编码。try (InputStreamReader reader new InputStreamReader( new FileInputStream(demo.txt), StandardCharsets.UTF_8)) { char[] buf new char[1024]; int len; while ((len reader.read(buf)) ! -1) { System.out.print(new String(buf, 0, len)); } }为什么要存在这种桥因为现实世界太混乱你拿到的数据源可能是网络Socket的字节流可能是压缩包的字节流可能是ZipInputStream解出来的一层字节流它们一律只给你字节。如果你想直接按字符读就得用桥接类手动指定编码而不是依赖环境默认值。FileReader本质上就是new InputStreamReader(new FileInputStream(file))的快捷版快捷到让你误以为编码不存在。理解了这两大体系的区别和桥接逻辑你已经超过了大多数只会FileReader一把梭的人。接下来要解决的是更实际的选型问题面对具体的读写需求到底拿哪个类3. 实战选型读文件、写文件到底该选哪个类3.1 文本文件的标准组合BufferedReader/BufferedWriter很多新手抄了一个FileReader就到处用但专业的Java开发者处理文本文件首选组合是BufferedReader加BufferedWriter而不是裸用FileReader和FileWriter。Path path Paths.get(demo.txt); try (BufferedReader reader Files.newBufferedReader(path, StandardCharsets.UTF_8); BufferedWriter writer Files.newBufferedWriter( Paths.get(demo_copy.txt), StandardCharsets.UTF_8)) { String line; while ((line reader.readLine()) ! null) { writer.write(line); writer.newLine(); } }这里的关键是BufferedReader它内部维护了一个默认8192字节的缓冲区读文件时会一次性从底层读取一大批数据放进缓冲区然后readLine()、read()都在内存里进行不会每次调用都触发真正的磁盘IO。BufferedWriter同理写的内容先进缓冲区攒够一批再一次写到磁盘。裸用FileReader不是不能跑而是每次read()都直接触发系统调用性能差距在文件大时非常明显。第三个代码里readLine()也是BufferedReader独有的高效操作裸字符流可没有这么方便的方法。3.2 二进制数据怎么办DataStream与ByteArrayStream的使用场景处理图片、压缩包、序列化文件这类二进制数据时字符流完全出局必须回到字节流。纯字节搬运用FileInputStream和FileOutputStream就够了但如果还要直接读写Java的基本类型就得请出DataInputStream和DataOutputStream。try (DataOutputStream out new DataOutputStream(new BufferedOutputStream( new FileOutputStream(data.bin)))) { out.writeInt(42); out.writeDouble(3.14); out.writeUTF(hello); } try (DataInputStream in new DataInputStream(new BufferedInputStream( new FileInputStream(data.bin)))) { int i in.readInt(); double d in.readDouble(); String s in.readUTF(); }writeInt是固定写4个字节writeDouble固定写8个字节顺序写、顺序读。注意读写顺序必须一致不然数据全乱。ByteArrayInputStream和ByteArrayOutputStream则适合跟字节数组打交道它们不触碰文件系统纯内存操作常用于测试和内存中的临时转换。这里有一个我自己踩过的教训DataOutputStream写完后一定要flush()或close()否则末尾数据可能还堵在缓冲区里文件看起来不完整。如果你先关了底层FileOutputStream再关DataOutputStream顺序反了也可能直接丢数据。3.3 对象流与序列化ObjectOutputStream看起来很爽坑也很大ObjectOutputStream能直接把一个Java对象序列化到文件里读的时候一个readObject()就把整个对象还原出来乍一看简直太方便了。try (ObjectOutputStream out new ObjectOutputStream(new FileOutputStream(user.bin))) { out.writeObject(user); } try (ObjectInputStream in new ObjectInputStream(new FileInputStream(user.bin))) { User restored (User) in.readObject(); }前提是这个类必须实现Serializable接口并且强烈建议声明serialVersionUID。不声明的后果就是哪天你给类加了一个字段旧文件就反序列化不了抛InvalidClassException。这个错误在线上很常见而且特别隐蔽你会以为是文件坏了其实是对应类的版本号变了。对象流适合存配置快照、小规模缓存数据但不适合当数据库用。因为它是一整个对象树写进去的文件里哪怕改一个字段都可能要读入整个对象。对大对象而言既占内存又不灵活比JSON这种文本格式差远了。所以我的建议是跨系统、跨语言的数据交换用JSON/XML纯Java内部且对象结构稳定的场景才考虑ObjectOutputStream。4. 性能陷阱与缓冲区一个字节一个字节为什么慢4.1 一次read()的代价系统调用没那么便宜初学者最容易写出来的“经典慢代码”长这样try (FileInputStream in new FileInputStream(big.bin); FileOutputStream out new FileOutputStream(copy.bin)) { int b; while ((b in.read()) ! -1) { out.write(b); } }语法没问题逻辑没问题就是慢得离谱。如果文件是1GB而read()每次只读1个字节意味着要做大约10亿次循环和10亿次系统调用。每次系统调用都要从用户态切换到内核态相当于你每次去仓库取一颗螺丝还要专门走一遍保安验证、进门、出门的流程。就算你腿脚飞快这一来一回的固定开销也被放大了无数倍。正确的做法是缓冲。BufferedInputStream内部默认有个8192字节的缓冲区调用一次底层read()一次性拿回8192个字节到内存之后你的while ((b in.read()) ! -1)实际上是从内存缓冲区里拿而不再触发系统调用。4.2 缓冲区的正确打开方式大小、flush与close顺序关于缓冲区大小默认8192字节是经验值适合大多数场景。你想优化可以改成64KB或1MB但不要天真地以为越大越好。缓冲区越大单次系统调用搬运的数据越多但内存占用也水涨船高对千兆网络的I/O场景还可能因为一次搬太多数据导致延迟上升。一般的文件读写用默认值就够了。写方向有个绕不开的概念flush()。默认情况下BufferedWriter会攒够一批数据才真正写盘如果你写了几行就希望立刻落盘比如日志系统必须手动调用flush()。而close()在关闭之前总会帮你执行一次flush()所以很多人只调close()也能正常收尾。但顺序是讲究的如果存在多层包装比如BufferedWriter包OutputStreamWriter包FileOutputStream关闭最外层即可它会递归关闭里面所有层。反过来的错误示范是先关FileOutputStream再关BufferedWriter这时Writer试图把未写完的数据flush到已经关闭的流上直接抛异常或丢数据。记住一条只关最外层。4.3 NIO到底要不要上通道、直接缓冲区与真实收益Java NIO提供了FileChannel和ByteBuffer一度被传成“性能神器”很多人一上来就转型用NIO。实际上对普通文件I/ONIO在吞吐量上的优势并不像传说中那么大它的优势主要在非阻塞网络通信和更灵活的内存控制。try (FileChannel channel FileChannel.open( Paths.get(demo.txt), StandardOpenOption.READ)) { ByteBuffer buffer ByteBuffer.allocate(8192); int bytesRead channel.read(buffer); // flip()之后才能读到刚才写入buffer的数据 buffer.flip(); }有一个NIO特性倒是真的值得关注MappedByteBuffer内存映射文件。它把文件的一部分直接映射到内存地址空间读大文件时表现很好。但它也有隐藏的坑比如映射后的文件在Windows上被占用导致无法删除还有平台差异性。如果你只是普通读写别被“NIO就是快”的论调带着跑先用BufferedInputStream/BufferedOutputStream或Files工具类做对再考虑Channel。5. IO流最常见的两类坑乱码与资源泄漏5.1 中文乱码排查链路从字符编码到工具查看乱码是IO流领域出场率最高的bug。假设你写了一段代码把“你好”写入文件读出来是“浣犲ソ”。原因几乎一定是编码不一致。排查路径分三步第一步确认写入时使用的编码。如果你用的是Writer的快捷方式且没有指定字符集那写入编码取决于运行环境和平台默认值可能是UTF-8也可能是GBK。第二步确认读取时使用的编码。如果读取端用了另一个默认编码去解码写入的字节序列字符就会错位。比如写入用UTF-8你好 - 6字节读取用GBK每2字节解码成1个字符一个“你”被解读成两个没意义的字符。第三步用十六进制编辑器或od -x查看文件字节比对编码表。这一招基本能定位一切乱码。举个真实例子某个存档功能在Linux上正常部署到Windows后全乱原因是Windows文件系统默认用GBK而Linux默认UTF-8。后来我们把所有读写统一成StandardCharsets.UTF_8问题消失。我的原则任何IO操作里编码必须显式指定。永远不要依赖“默认”因为“默认”是环境说了算不是你的代码说了算。5.2 资源泄漏从finally到try-with-resources到流式API泄漏资源泄漏是老生常谈但实际项目里还是屡见不鲜。Java 7以前的写法是在finally块里关FileInputStream in null; try { in new FileInputStream(demo.txt); // ... } finally { if (in ! null) { try { in.close(); } catch (IOException e) { // 记录日志 } } }啰嗦、容易漏写、嵌套多。Java 7之后用try-with-resources代码干净多了try (FileInputStream in new FileInputStream(demo.txt)) { // ... }try块结束时自动调用所有声明在括号里资源的close()。这基本解决了显式关闭的痛点。但玩过流式API的同学要小心一个隐蔽的泄漏点。Files.lines()返回的是一个Stream它内部持有资源如果只调用了.filter().count()却没用try-with-resources包住底层文件句柄可能不释放。正确写法try (StreamString lines Files.lines(path, StandardCharsets.UTF_8)) { long count lines.filter(s - !s.isBlank()).count(); }5.3 并发与IO多线程读写同一个文件的IOException来源多线程同时写同一个文件是线上环境的另一个高频问题。多个线程各持一个FileOutputStream往同一路径写数据后打开的文件句柄会覆盖先打开的造成数据互相覆盖、内容缺失还可能因为写位置冲突直接抛异常。解决思路有三个一是加进程内锁比如用synchronized或ReentrantLock保证同一时刻只有一个线程写文件。这适合单进程多线程的场景。二是用FileChannel.lock()做进程级文件锁适合多进程互斥写的场景。注意锁的是文件区间不是整个文件要谨慎设计。三是换个思路不要并发写同一个文件而是每个线程各自写独立文件最后再做合并。一个真实案例某个结算系统在凌晨批量跑任务多个线程同时往日志文件里写结果日志行被截断得乱七八糟。改成队列加单线程消费者后问题消失。IO并发输出看似简单实际上比大多数业务逻辑更容易出错因为它直接和操作系统打交道。6. Files与Stream API现代Java处理IO的更短路径6.1 Files工具类一次覆盖绝大多数常用文件操作老式IO代码通常要“打开流 - 读/写 - 关流”三板斧而Java 7的java.nio.file.Files提供了一批直接可用的静态方法让常用操作极度简化。byte[] data Files.readAllBytes(Paths.get(demo.txt)); Files.write(Paths.get(copy.txt), data); ListString lines Files.readAllLines(Paths.get(demo.txt), StandardCharsets.UTF_8); Files.write(Paths.get(copy.txt), lines, StandardCharsets.UTF_8);readAllBytes适合中小文件一次性读入内存写法短但大文件慎用。readAllLines会把整个文件按行拆成一个List文件一大就内存爆表。这些方法是给“小文件快操作”设计的不是万能的。如果你要判断文件存在否、创建目录、移动、复制Files也全是现成的if (Files.exists(path)) { ... } Files.createDirectories(Paths.get(a/b/c)); Files.move(src, dest, StandardCopyOption.REPLACE_EXISTING);这套API比File类优雅得多强烈建议新项目直接使用。6.2 Files.lines()处理大文件流式操作的天然搭档对于大文件几个GB的日志、数据导出不能一口吞进内存更合适的思路是逐行流式处理。Files.lines()返回一个StreamString你可以像操作集合一样处理每一行但它是懒加载的不会一次性把所有行加载到内存。try (StreamString lines Files.lines(Paths.get(access.log), StandardCharsets.UTF_8)) { long errorCount lines.filter(line - line.contains(ERROR)).count(); }这里最重要的提醒是用完Stream后必须关闭道理跟关闭IO流一样。很多人把Files.lines()当纯集合操作来用丢了关闭逻辑文件句柄就被一直占着。线上出现过因为这种流式处理没关导致“Too many open files”的报错排查好久才找到是这里漏了。6.3 内存映射文件的适用边界别拿大锤敲钉子内存映射文件通过FileChannel.map()获得MappedByteBuffer文件内容表现得像内存数组一样读取很快适合超大文件的只读场景比如加载一个GB级别的磁盘索引。try (FileChannel channel FileChannel.open(path, StandardOpenOption.READ)) { MappedByteBuffer buffer channel.map(FileChannel.MapMode.READ_ONLY, 0, channel.size()); // 像操作ByteBuffer一样读取 buffer.getInt()、buffer.get()... }它的代价是映射后文件不能被自由删除Windows下尤为明显而且一旦映射了文件文件内容在内存中的修改不一定实时落盘需要手动force()。对一个普通业务系统来说绝大多数文件IO根本用不到内存映射。我在实际项目里只在处理上GB的地理数据文件时用过一次其余场景全是BufferedReader加Files搞定。技术选型不是越高级越好而是越匹配越好。就我自己而言IO流这门课老生常谈但常谈常新。刚学的时候总觉得类太多记不住等摸清了字节流与字符流的底层逻辑、缓冲区的性能机制、编码问题的排查链路之后再看其他语言的文件操作都一通百通。如果你正在学别急着追求NIO、零拷贝之类的进阶概念先把BufferedReader、Files、try-with-resources这些基础组合用熟再去翻那些龙飞凤舞的高级API心里就有底多了。记得有一次我把一个批量脚本从单字节循环改成带缓冲区的BufferedReader逐行处理耗时从三分钟降到十几秒那比用什么炫技框架都来得踏实。IO流真正的复杂度不在Java代码本身而在操作系统、文件系统和字符编码这些幕后机制——想明白这一点才算真入门。
网站建设高端定制企业官网
RELATED

相关资讯

更多精彩内容,欢迎继续阅读

较早相关资讯

最新相关资讯

Jetson Orin Nano 2:重新定义边缘AI机器人计算机 2026/9/9 11:13:56

Jetson Orin Nano 2:重新定义边缘AI机器人计算机

1. 这不是又一块开发板——Jetson Orin Nano 2 的真实定位与破局逻辑 你刷到“NVIDIA发布Jetson Orin Nano 2”这条新闻时,第一反应可能是:又一款带GPU的嵌入式板子?等一下——先别急着划走。我用它跑了整整三个月的真实机器人项目&#xff0…

阅读更多 →
STM32F407通过USB Host驱动EC20 4G模块:从枚举到AT命令的完整实现 2026/9/9 11:13:56

STM32F407通过USB Host驱动EC20 4G模块:从枚举到AT命令的完整实现

简介:面向嵌入式工程师与物联网开发者的一套基于意法半导体STM32F407芯片和移远EC20 4G模块的USB驱动工程代码。方案通过USB接口与AT指令协同,控制EC20完成模块初始化、网络注册及高速数据收发,解决了嵌入式设备接入4G网络的核心问题&#xf…

阅读更多 →
基于FFmpeg与Qt的播放器崩溃修复实战:线程安全、音画同步与打包坑 2026/9/9 11:13:56

基于FFmpeg与Qt的播放器崩溃修复实战:线程安全、音画同步与打包坑

简介:一套 FFmpeg Qt 开发的视频播放器完整工程,源自《从零开始学习音视频编程技术》系列的 Bug 修复最终完善版,面向音视频编程初学者与播放器模块化设计开发者。基于 Qt 5.6.2(VS2013)、FFmpeg 2.5.2 与 SDL 2.04 构…

阅读更多 →
边缘计算设备选型指南:AI SoC、边缘盒子与推理卡的实际应用选择 2026/9/9 11:13:56

边缘计算设备选型指南:AI SoC、边缘盒子与推理卡的实际应用选择

一提到边缘计算设备选型,我就想起去年帮朋友做校园物联网设备数据上云传输项目时的那次折腾。客户的需求听起来非常朴素——把分布在几十栋楼的摄像头、门禁、环境传感器数据汇聚起来,先做一层智能分析再上云,预算卡得死死的。结果我一打开电…

阅读更多 →
基于STM32的四路超重检测系统设计:从传感器到现场调试完整指南 2026/9/9 11:13:56

基于STM32的四路超重检测系统设计:从传感器到现场调试完整指南

简介:一套基于STM32的公路四路超重检测系统完整工程源码,面向嵌入式开发者、物联网学习者及交通监测方向技术人员,用于解决公路超重车辆实时检测与联网报警问题。资源总计306个文件,压缩包约10.31MB,包含Keil工程核心文…

阅读更多 →
光伏功率概率预测实战:Copula与MBLS结合的Matlab实现 2026/9/9 11:10:56

光伏功率概率预测实战:Copula与MBLS结合的Matlab实现

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views …

阅读更多 →

今日资讯

本周资讯

本月资讯

看完文章仍有疑问?

联系尧图顾问,获取一对一建站咨询

立即免费咨询 📞 400-888-8888
📞