ARTICLE DETAIL

资讯详情

深耕郑州网站建设与运营推广的一线实战洞察。

Java I/O从入门到实战:流、序列化、NIO与高频异常排查

Java I/O从入门到实战:流、序列化、NIO与高频异常排查 1. 别急着背API先把I/O到底在干什么搞清楚JavaSE学到I/O这一章很多人的第一反应是类好多、名字好像、不知道选哪个。就算是跟着黑马的JavaSE教程走了一遍画过图、抄过代码回头自己写文件读写时还是会卡壳。这不是你笨而是I/O本身太抽象——它不像是if else、for循环那样有明确的逻辑推演过程它面对的是程序之外的世界是磁盘、网络、控制台这些外部设备。理解I/O本质上是在理解程序怎么和外部世界打交道。1.1 用水管运水理解I/O的本质I/O的全称是Input/Output也就是输入输出。我教新手的时候最喜欢打一个比方程序是一个蓄水池外部数据是另一个蓄水池I/O就是连接两个池子的水管。往程序里读数据就是水从外部流进程序把程序的数据写出去就是水从程序流向外部。Java里所有I/O相关的类干的都是搬运水这件事。区别只在于水管粗细不同、运送的水是清水还是污水、是一次性灌一桶还是一滴一滴地送。FileInputStream是细管子BufferedInputStream是粗管子ObjectInputStream运送的是序列化后的对象。想明白这一层后面碰到再多的流类都不会慌。1.2 输入/输出方向是最容易搞反的概念这里有个很多人一开始都会绕晕的点Input到底是从哪里到哪里记住一个口诀——以程序内存为参照物。数据从外部进入内存叫输入流InputStream/Reader数据从内存写到外部叫输出流OutputStream/Writer。打个比方你想把D盘里的a.txt读进Java程序里操作那就要用输入流因为这个文件的字节正从磁盘流向内存反之你想把程序里的一段字符串保存到b.txt就要用输出流因为数据正从内存流向磁盘。方向搞反了代码写得再漂亮也跑不出想要的结果。1.3 字节流和字符流一堵绕不过去的墙Java的I/O有两种基本流派字节流和字符流。字节流以InputStream和OutputStream为父类读写的单位是字节byte字符流以Reader和Writer为父类读写的单位是字符char。为什么要把事情搞得这么复杂因为Java的内存里用的是Unicode字符集char是16位的而磁盘、网络传输的时候全是字节。中间有一个编码和解码的过程。你直接拿字节流读文本文件如果文件是UTF-8编码一个中文字符占3个字节你得自己拼装这3个字节才能还原出那个汉字太痛苦了。字符流就是帮你把这个拼接解码的过程封装好让你能按字符来读写省心很多。但反过来说字符流也不是万能的。你拿字符流去读一张图片、一个压缩包中途必然出幺蛾子因为那些二进制数据根本不能按字符来解码。所以记住一条经验处理文本用字符流处理图片、音频、视频、压缩包等二进制用字节流。这个选择不用犹豫。2. Java I/O体系的整体设计四个爹和一群儿子2.1 流家族的家谱怎么看只要写过Java I/O你迟早会遇到一张让人头大的类图。其实这张图有规律可循顶层是四个抽象基类——InputStream、OutputStream、Reader、Writer。它们就是四个爹后面所有流类要么姓字节要么姓字符要么负责读要么负责写。抽象基类处理单位负责方向典型子类InputStream字节读FileInputStream、BufferedInputStream、ObjectInputStreamOutputStream字节写FileOutputStream、BufferedOutputStream、ObjectOutputStreamReader字符读FileReader、BufferedReader、InputStreamReaderWriter字符写FileWriter、BufferedWriter、OutputStreamWriter这四个基类各自只有少数几个核心抽象方法比如InputStream的read()OutputStream的write(int b)。JavaSE的I/O框架本质上就是对这4个方法的不断扩展。你把这四个爹认清了子类再多也只是换数据源、加缓冲、加功能而已。2.2 装饰器模式为什么BufferedInputStream能包FileInputStreamBufferedInputStream bis new BufferedInputStream(new FileInputStream(a.txt));——这种一层包一层的写法刚接触时很容易让人迷惑为什么不能直接new一个BufferedInputStream再传文件路径因为Java的I/O类大量使用了装饰器模式。FileInputStream负责最基础的事——从文件读取原始字节BufferedInputStream负责锦上添花——在读取的时候加一个缓冲区减少底层系统的I/O调用次数。两者各司其职然后通过层层包装组合出更强大的功能。这个设计的好处是灵活性极高。你想从文件读加缓冲那就包一层BufferedInputStream你想从文件读加缓冲还能读基本类型那就再包一层DataInputStream。组合的自由度全在你手里这也是Java I/O体系常被夸赞设计优秀的原因之一。理解装饰器模式之后你会发现自己能徒手拆掉任何JDK自带的I/O工具类也就能理解各种教程里那些层层嵌套的写法到底在干什么。2.3 从C流I/O转过来的同学注意思维切换热词里有c流i/o说明有不少人是学了C再来碰Java的。C的iostream和Java的I/O有一个很大的不同C里和运算符重载让你写起来特别顺手而Java不搞运算符重载这套一切都是方法调用。read()、write()、readLine()方法名就是全部。另外C的流对象大多数可以直接管理内存缓冲区而Java的传统I/O释放资源的责任完全在程序员身上——不关流文件就一直被占着Windows上直接删不掉。这也是很多C转Java的人刚开始极度不适应的点。换个角度想Java的这套设计其实把底层细节藏得更深你只需要记住用完关流这一个习惯就够了。3. 实操文件读写的正确打开方式这一部分是全文的重头戏。我不打算把API文档搬过来念而是从实际场景出发带你走一遍最常见的文件读写姿势。3.1 字节流复制文件最朴素但最通用的方案先来一个所有教程都会写的例子用FileInputStream和FileOutputStream复制一张图片。核心逻辑就三步开流、搬运、关流。// 不推荐的生产级写法仅用于展示最原始的逻辑 FileInputStream fis new FileInputStream(D:\\source.jpg); FileOutputStream fos new FileOutputStream(D:\\copy.jpg); int len; byte[] buffer new byte[8192]; while ((len fis.read(buffer)) ! -1) { fos.write(buffer, 0, len); } fis.close(); fos.close();重点解释两个容易被忽略的细节。一是read(buffer)返回的len不是每次都能装满整个buffer数组所以write时必须写0, len写少了漏数据写多了会把上次残留的脏数据也写进去。二是缓冲区大小选81928KB是个经验值太小了频繁发起底层读取性能差太大了又浪费内存8KB在绝大多数场景下都非常均衡这也是很多开源框架默认缓冲区大小的原因。接下来看一下如何用ByteArrayOutputStream把文件一次性读进内存常用于小文件上传场景FileInputStream fis new FileInputStream(D:\\small.txt); ByteArrayOutputStream baos new ByteArrayOutputStream(); byte[] buffer new byte[1024]; int len; while ((len fis.read(buffer)) ! -1) { baos.write(buffer, 0, len); } byte[] fileBytes baos.toByteArray(); fis.close(); baos.close();注意ByteArrayOutputStream是不用关的它操作的是内存数组不占用文件句柄。3.2 字符流读写文本编码问题从这里开始处理文本文件直接用FileReader和FileWriter很方便但它们有一个隐藏的坑它们会使用JDK默认的字符集。如果你的环境是UTF-8一切正常如果对方给你的文件是GBK编码或者你的服务器环境变了乱码就找上门了。我之前接过一个老系统的需求对方导出的数据文件是GBK格式我用FileReader一读中文全是锟斤拷。后来改成InputStreamReader并且显式指定编码就解决了BufferedReader reader new BufferedReader( new InputStreamReader(new FileInputStream(D:\\data.txt), GBK) ); String line; while ((line reader.readLine()) ! null) { // 处理每一行数据 System.out.println(line); } reader.close();这里顺便说一句前段时间的Java生态变化JDK 18之前很多环境的默认编码是平台相关的Windows上可能是GBKLinux上通常是UTF-8跨平台就很容易踩坑。所以写代码时永远不要依赖默认编码必须显式指定字符集。这是专业和业余的分水岭。3.3 缓冲流性能提升到底提升在哪很多人用BufferedInputStream只是因为教程说这样更快但不知道它快在哪。其实原理特别朴素它内部维护了一个默认8KB的字节数组。当你调用read()时它会一次性向底层文件读取尽量多的字节填满缓冲区然后每次read()都先从缓冲区里取缓冲区空了再触发一次底层读取。这样就避免了每读一个字节就做一次系统调用。系统调用的开销远比想象中大减少系统调用次数就是缓冲流最大的价值。同样的道理也适用于BufferedWriter。它会把写入的内容暂时囤在缓冲区攒够一批再真正刷到磁盘。手动调用flush()可以强制把缓冲内容输出。所以如果看到数据写进去了但文件里没有先想想是不是没关流、没flush。3.4 转换流字节到字符的桥InputStreamReader和OutputStreamWriter是字节流和字符流之间的桥梁。它们做的事情简单说就是在字节进来的时候按指定字符集解码成字符在字节出去的时候按指定字符集编码成字节。什么时候必须用它们两个典型场景一是文件编码和默认编码不一致就像上面GBK文件的例子二是从网络Socket拿数据时SocketInputStream返回的是字节流你要按文本处理就必须用InputStreamReader包一层。如果哪一天你发现网络协议里收到的中文全是乱码90%的可能是这里没指定字符集。3.5 try-with-resources关流这件事别再手动写了我早期写代码时经常漏关流Windows下文件被占用删不掉程序里头文件句柄泄露直到报Too many open files才知道出了大事。后来养成一个习惯只要实现了AutoCloseable接口的资源一律放进try后面括号里。JDK 7之后这样写不仅简洁还会自动帮你在try块结束时关闭资源连异常栈都处理得比手动关闭更好。try (BufferedReader reader new BufferedReader( new InputStreamReader(new FileInputStream(D:\\data.txt), StandardCharsets.UTF_8) )) { String line; while ((line reader.readLine()) ! null) { System.out.println(line); } } catch (IOException e) { e.printStackTrace(); }注意catch块还是得写自动关闭只保证关闭动作发生不代表不抛IOException。文件不存在、权限不够、磁盘满这些都得靠catch来处理。4. 序列化与NIOI/O的进阶方向基础的文件读写熟练之后有两块进阶内容值得深入学习面试和实际项目里出现的频率都很高。4.1 Serializable把对象变成字节保存在哪对象序列化Serialization是指把Java对象变成字节序列这样就能存储到磁盘或者通过网络传输。反序列化则是反向操作。Java里让一个类支持序列化非常简单实现Serializable接口就行——它是个标记接口内部没有任何方法。class User implements Serializable { private static final long serialVersionUID 1L; private String name; private int age; // 省略getter/setter和构造方法 }把对象写到文件的代码长这样try (ObjectOutputStream oos new ObjectOutputStream(new FileOutputStream(D:\\user.dat))) { oos.writeObject(new User(张三, 25)); } catch (IOException e) { e.printStackTrace(); }读取时用ObjectInputStream.readObject()。注意反序列化出来的类型要强转而且原类不能动序列化相关的东西。serialVersionUID这个字段非常重要它相当于序列化版本的指纹。如果序列化时是1L后来你改了类结构比如把name字段类型从String改成StringBuilder反序列化时就可能报InvalidClassException。所以建议每个可序列化类都显式声明serialVersionUID不要依赖编译器自动生成。4.2 NIO面向缓冲区和通道的新一代I/O传统I/O读数据时数据要从磁盘搬到内核缓冲区再从内核缓冲区搬到用户空间中间有两次拷贝。NIONew I/O引入了Channel通道和Buffer缓冲区的概念配合Selector还能实现单线程管理大量连接的非阻塞I/O。Netty这类高性能网络框架底层就构建在NIO之上。不过说句实在话对于JavaSE阶段的学习者NIO不需要像四大流那样深入每一个API。优先掌握三件事FileChannel能做什么、ByteBuffer怎么分配和翻转flip()方法、为什么NIO适合高并发网络场景。等真正做网络编程或者阅读框架源码时再来啃Selector也不迟。另外JDK 7之后有个Files工具类处理简单文件操作比I/O流舒服太多// 一行代码读进列表 ListString lines Files.readAllLines(Paths.get(D:\\data.txt), StandardCharsets.UTF_8); // 一行代码复制文件 Files.copy(Paths.get(D:\\source.jpg), Paths.get(D:\\copy.jpg));工具类虽方便但业界对大文件使用readAllLines持谨慎态度——整个文件会被读进内存文件一大就OOM。我自己处理日志文件时超过50MB就会老老实实回到BufferedReader逐行读。4.3 这块内容面试时怎么答才不翻车面试官只要聊到I/O基本绕不开这几个问题字节流和字符流的区别、缓冲流的工作原理、序列化时serialVersionUID的作用、NIO和传统I/O的差异。回答的时候不要光背概念最好顺手举一个自己踩过的坑比如我之前用FileReader读GBK文件乱码后来改成InputStreamReader指定编码解决了。这种带实践细节的回答远比字符流是处理字符的字节流是处理字节的这种教科书式答案有说服力。5. 实际开发高频I/O报错从热词看真实坑点这部分内容从搜索引擎的热词来反而比教程目录更能反映真实需求。大家真正在排查的问题往往不是哪个流怎么用而是程序报错了怎么办。5.1 SocketException请求第三方接口时最常见的I/O异常热词里有一条i/o exception (java.net.socketexception) caught when processing request to {...这个报错几乎每个做过HTTP接口调用的人都会碰到。它的含义是在HTTP请求处理过程中底层Socket通信出现了异常。常见的原因有几个网络抖动或对方服务器主动断开了连接请求超时时间设置太短服务端处理慢客户端等不及先关了连接服务器端的连接池已经满了没有可用连接给当前请求发送或接收数据量与服务器端Expect相关配置不匹配。排查时我通常按三步走。第一步看异常栈里Server name后面的地址确认请求打到哪台机器第二步看是否高频出现如果是偶发大概率是网络抖动或连接被服务器回收第三步在代码里给客户端加上连接超时和读超时并捕获该异常做重试或降级处理。5.2 用HttpClient调微信接口报I/O error怎么排查热词里还有一条关于微信API token请求的I/O error。这种错误通常出现在用HttpClient、OkHttp或RestTemplate调用对方接口时底层URL连接失败或数据读写失败。排查思路其实可以通用化先确认基础连通性在服务器上直接curl一下目标URL看能不能通。再确认请求是否被拦截很多内网服务器访问外网接口需要经过代理或防火墙如果代理配置有问题代码里也会报I/O error。检查HTTPS证书开发环境里很多人直接跳过证书校验生产环境不校验证书就会报SSLHandshakeException虽然不叫I/O error但排查路径是类似的。看连接池配置如果用的是HttpClient连接池池被耗尽时新请求会一直等等超时就报连接相关的异常。我见过最隐蔽的一次是服务器时间不正确导致HTTPS证书校验失败报出来的就是I/O相关异常排查了整整一个下午。5.3 no new i/o devices found不是Java的报错再解释一个热词no new i/o devices found和twincat3 no new i/o devices found。这两个报错来自倍福Twincat3这个PLC编程环境意思是没有找到新的I/O设备跟JavaSE的I/O八竿子打不着。但如果你在搜索引擎里搜java i/o很容易被这类结果干扰。这里也借机提醒大家技术搜索时如果关键词太宽泛经常会混入其他领域的噪音。搜Java I/O问题建议加上Java或具体的类名比如BufferedReader readLine 乱码、FileInputStream 中文路径 读取失败准确率会高很多。踩过这个坑的人一定懂我在说什么。6. 一张速查表解决80%的I/O困惑6.1 常见问题速查表为了方便以后查阅我把平时项目里积累下来的问题整理成了一个表格基本都是高频问题建议收藏。场景推荐方案注意点复制图片/视频/压缩包FileInputStream FileOutputStream不要转成字符流处理读文本文件UTF-8BufferedReader InputStreamReader显式指定StandardCharsets.UTF_8读文本文件GBKBufferedReader InputStreamReader编码名传GBK不要传错写日志/文本文件BufferedWriter OutputStreamWriter记得flush或close大文件逐行处理BufferedReader.readLine()不要用Files.readAllLines对象持久化ObjectOutputStream ObjectInputStream实现Serializable并声明serialVersionUID网络数据读取SocketInputStream InputStreamReader按协议指定字符集简单文件复制Files.copy()适合小文件注意替换策略6.2 自己动手写一个文件复制工具最后分享一个我特别推荐的学习方法别光看教程自己动手写一个支持进度显示、可选是否覆盖目标文件、能处理异常路径的命令行文件复制工具。这个练习看起来很基础但实际上涵盖了字节流读写、缓冲区的使用、异常处理、资源关闭、路径合法性校验等一整套知识点。写完基本功能之后再试着把源路径改成不存在的文件目标路径改成目录或非法字符看看程序会抛出什么异常然后再给这些异常写catch块。这一套流程走下来你对JavaSE I/O的理解深度绝对超过看十遍教程。我当时学完这个练习后面再看NIO和网络编程的代码明显感觉轻松了一大截。动手写代码时我还会顺便验证一个很多人忽略的点在Windows上文件路径分隔符怎么处理。直接用反斜杠写死在代码里是很坏的习惯用File.separator或者干脆用正斜杠/才能在跨平台时不出问题。这也是为什么更推荐用Paths.get()来拼接路径的原因它能自动处理当前操作系统的路径语法。
返回列表