做了几年Java后端,见到的线上事故里,有相当一部分最后都能查到同一个根上——IO。文件读取慢、网络消息粘包、序列化失败、磁盘缓存把内存打爆,这些现象在入门教程里每一个看起来都有标准答案,可真到了生产环境叠加起来,就会变成“Java的IO怎么这么多弯弯绕绕”的困惑。
这篇文章不想再给你把API文档复述一遍。我花了大把时间把Java IO的体系从传统IO一路摸到NIO、NIO.2,也在真实项目里排查过不少读写性能问题,这里把我的理解、踩过的坑、以及现在处理IO问题时固定的判断顺序都整理出来。适合正在从业务代码往底层技术走的Java开发者,也适合那些写过几年代码、但一聊到IO就只敢说“用BufferedReader”的工程师。
1. 先理清Java IO的三条技术脉络
第一次翻开Java IO,很多人都会懵:明明已经学了InputStream,为什么后面又冒出Channel、Buffer;以为换个API就完事,结果还有Path、Files;正准备把整个体系背下来,又发现还有异步IO在角落里等你。
Java官方的IO演进,基本可以拆成三条线:
- BIO(Blocking IO):也就是java.io包,从JDK 1.0就存在,以InputStream/OutputStream、Reader/Writer为代表,统统是阻塞式流。
- NIO(New IO):JDK 1.4引入,包名java.nio,核心是Buffer、Channel,以及用于多路复用的Selector。
- NIO.2:JDK 7出现,主要改进了文件系统API,引入了Path、Files、WatchService,还增加了异步文件通道。
很多人分不清NIO和NIO.2,以为NIO.2就是加了异步。真用起来你会发现,NIO.2带来的最实际变化是文件系统操作的API彻底换血,File类时代的很多别扭写法消失了。
我习惯用一张表格把它们放在一起看:
| 维度 | BIO | NIO | NIO.2 |
|---|---|---|---|
| 出现版本 | JDK 1.0 | JDK 1.4 | JDK 7 |
| 核心包 | java.io | java.nio | java.nio.file |
| 数据模型 | 单向流 | 双向Channel + Buffer | 文件路径、目录监控 |
| 网络模型 | 阻塞式 | 非阻塞多路复用 | 异步文件/网络通道 |
| 典型场景 | 简单文件读写、低并发连接 | 高并发网络通信 | 目录遍历、文件监听、大文件处理 |
这三条脉络不是替代关系。它们更像一个工具箱里的不同工具:你处理一个几百KB的配置文件,用BIO没什么问题;要支撑上万条并发连接,BIO的线程模型会先撑不住,这时候就得NIO;做配置热加载、批量目录导入,NIO.2的WatchService和Files才是正解。
这里必须先说清楚两对经常被混淆的概念:同步/异步、阻塞/非阻塞。同步和异步,说的是调用方拿到结果的方式——调用后一直等着结果,是同步;调用后先返回,结果通过回调或轮询Future拿到,是异步。阻塞和非阻塞,说的是等待结果期间能不能干别的事——线程纹丝不动等在这里,是阻塞;线程先去处理别的任务,稍后回来看结果,是非阻塞。
BIO是典型同步阻塞,NIO的Selector是同步非阻塞——select()返回时事件已经就绪,你只需要读数据,但整个查询过程还是同步的。AIO才算是异步非阻塞。很多同学刚接触NIO时以为非阻塞就是异步,这是第一个需要纠正的认知。
用生活里的场景类比:BIO就像你去营业厅排队,号码一直叫不到你,你就一直等在窗口前;NIO的Selector像在饭店拿了一个叫号器,等菜期间你可以刷手机,叫到你了再去端菜;AIO更像菜做好之后服务员直接送到你桌上,你压根不用关心菜什么时候出锅。
这一章是后面所有内容的地基。下面我们先进最重要的传统IO,因为虽然它老,但它留下的很多设计思路会直接映射到NIO里。
2. 传统IO的设计哲学与易踩的坑
2.1 装饰器模式为什么把流包得这么厚
很多人的第一个疑问来自一段看起来“多此一举”的代码:
FileInputStream fileInput = new FileInputStream(file); BufferedInputStream bufferedInput = new BufferedInputStream(fileInput, 64 * 1024); Reader reader = new InputStreamReader(bufferedInput, StandardCharsets.UTF_8); BufferedReader bufferedReader = new BufferedReader(reader);为什么一个读取要套四层?因为Java传统IO把“能力”拆成了细粒度组件,通过装饰器模式组合出各种读取策略。FileInputStream只负责从文件拿字节,BufferedInputStream负责减少底层系统调用次数,InputStreamReader负责把字节解码成字符,BufferedReader负责按行缓冲。每个类只干一件事,组合起来才够用。
这看起来繁琐,但换来的是极强的灵活性。你写网络程序时可以把FileInputStream换成SocketInputStream,其余装饰层完全不用改;你需要按行读换成按块读,只需要替换最外层。理解了这个设计,你在看一段业务代码时就不会被那一长串构造函数吓到。
实际使用里,缓冲区大小最好根据文件场景调整。默认缓冲区一般是8KB,我在做批量处理文本文件的工具时习惯改成64KB,多次实测下来,小文件批量场景里的吞吐提升明显;但如果文件本来就不大,改缓冲区收益可以忽略,白占内存。
我说一个常见的坑:装饰链的关闭顺序。如果你用传统写法手动关流,一定要从最外层开始关,也就是BufferedReader先关,FileInputStream最后关。Java的close调用会向下传播,外层关闭时内层也会被关掉。但很多人喜欢用两个try分别关,或者干脆只关了一个,结果就是底层文件句柄泄露,Windows上表现为文件被占用删不掉,Linux上可能要到句柄数耗尽才爆出来。
2.2 字节流与字符流之间的那道桥
字节流和字符流的边界,几乎是新手必踩的雷。字节流处理的是十六进制数组,字符流处理的是已经解码的文本。中间那座桥,就是InputStreamReader和OutputStreamWriter。
我记得有一次排查一个在本地好好的、部署到Linux服务器上就乱码的导出功能,问题出在代码里直接用了FileReader,而FileReader默认使用平台字符集。本地Windows开发机默认可能是GBK,生产机默认是UTF-8,同一份代码两个地方行为不一致——这就是最典型的“依赖默认编码”的下场。
我的规则很简单:凡是文本读写,一律显式指定字符集。改写成这样:
InputStream in = Files.newInputStream(path); Reader reader = new InputStreamReader(in, StandardCharsets.UTF_8); BufferedReader bufferedReader = new BufferedReader(reader);或者更省事一点,用Files.newBufferedReader(path, StandardCharsets.UTF_8),它内部已经帮你把桥搭好了。
另一个边界问题是二进制内容。很多人读图片、压缩包时,习惯把整个文件先转成String再处理,这是极其危险的。二进制数据里可能包含任何字节序列,一旦被字符集解码再重新编码,数据就悄悄变了。记住一句话:文本内容才走字符流,二进制内容永远走字节流。
2.3 try-with-resources不只是语法糖
Java 7开始,任何实现了AutoCloseable的资源都可以放进try的花括号里,编译时会自动补上finally并关闭资源:
try (BufferedReader reader = Files.newBufferedReader(path, StandardCharsets.UTF_8)) { String line; while ((line = reader.readLine()) != null) { // do something } } catch (IOException e) { // handle }很多人觉得这只是一个简化语法的糖,其实它更关键的保护是:当try块里同时抛出了业务异常和资源关闭异常时,关闭异常会被抑制并附加到业务异常上,而不是把业务异常吞掉。如果你手动写finally,很容易丢掉原始异常,排查问题时会莫名其妙。
我在做代码审查时,看到手动close的IO代码都会多看几眼。如果你维护的项目还停留在Java 7以下,至少也要在finally里判空后统一关闭。能一直用try-with-resources的代码,句柄泄漏的概率会低一个数量级。
3. NIO核心三件套:Buffer、Channel与Selector的实际配合
3.1 别再用错Buffer的position、limit和capacity
NIO里第一个让人犯晕的就是Buffer。它的本质就是一块连续内存,内部维护了四个关键索引,其中三个你几乎每天都会碰到:
- capacity:缓冲区总容量,创建时定死,不能修改。
- position:下一个要读或写的位置。
- limit:实际可读或可写的边界。
写模式下,position不断增长,limit等于capacity;需要把数据切到读模式时,必须调用flip(),它会把limit设置成当前的position,然后把position归零。很多人一开始就死在忘了flip这一步上——不调用flip直接get,读出来全是0或者越界。
下面这段是Buffer状态切换的最小示例:
ByteBuffer buffer = ByteBuffer.allocate(1024); buffer.put((byte) 1); buffer.put((byte) 2); buffer.flip(); byte a = buffer.get(); // 1 byte b = buffer.get(); // 2理解这三件事就够日常用了:写数据前用allocate或allocateDirect准备好空间;写完切读用flip;读完后想再写,要么compact()把剩余数据压到头部,要么clear()整个重置。永远不要一边写一边读,Buffer不是双向车道的容器。
踩过坑之后我总结了一个调试手段:遇到读出奇怪数据时,第一时间打印position、limit、capacity三个值。八成问题都是状态没切对。另外要清楚,flip和clear只改变索引,并不会清空底层数据,所以读到“脏数据”也别意外。
DirectBuffer是另一个常见问题点。allocateDirect直接在堆外分配内存,读写频繁时省去JVM堆与内核之间的一次拷贝,吞吐更好;但它的创建和释放成本明显更高。我的经验是:长生命周期、复用次数多的Buffer用direct,短生命周期的一次性读写用普通heap Buffer。还要注意直接内存是有上限的,需要通过-XX:MaxDirectMemorySize控制,否则频繁分配不回收会先把宿主机内存吃光。
3.2 Channel:双向车道的数据搬运动作
Channel和InputStream最大的区别是——它是双向的。FileChannel既能读也能写,SocketChannel既能收也能发,而不像传统IO那样读取流和写入流分别是两个对象。
FileChannel最惊艳的能力是transferTo/transferFrom。来看一个标准文件复制:
Path srcPath = Paths.get("/tmp/source.dat"); Path dstPath = Paths.get("/tmp/dest.dat"); try (FileChannel input = FileChannel.open(srcPath, StandardOpenOption.READ); FileChannel output = FileChannel.open(dstPath, StandardOpenOption.WRITE, StandardOpenOption.CREATE, StandardOpenOption.TRUNCATE_EXISTING)) { input.transferTo(0, input.size(), output); } catch (IOException e) { e.printStackTrace(); }这行transferTo在底层走的是操作系统提供的sendfile或类似机制,数据从磁盘到内核缓冲区再到对端Socket或文件,不经过用户态内存。这就是常说的零拷贝。大批量日志归档、文件服务下载等场景,用FileChannel输出比普通流式拷贝能省下一个数量级的CPU开销。
实际使用要注意:transferTo返回的long值表示实际传输的字节数,不要把返回值当摆设。有些操作系统或文件系统并不保证一次传完,尤其是网络文件系统和大文件,正确写法是循环传输,记录剩余字节数。我把这个写进过不少工具类,防止它在小几率场景里“丢尾巴”。
3.3 Selector:一次select()处理上万连接的核心
Selector是NIO在高并发网络场景里的主角。它像饭店的叫号器:一堆SocketChannel注册到Selector上,程序调用select()后阻塞,一旦有连接进入、有数据可读、有数据可写,select()就会返回,程序遍历所有就绪的SelectionKey,逐个处理。
一个最基本的事件处理骨架是这样:
ServerSocketChannel server = ServerSocketChannel.open(); server.bind(new InetSocketAddress(8080)); server.configureBlocking(false); Selector selector = Selector.open(); server.register(selector, SelectionKey.OP_ACCEPT); while (true) { selector.select(); // 阻塞直到有事件就绪 Iterator<SelectionKey> it = selector.selectedKeys().iterator(); while (it.hasNext()) { SelectionKey key = it.next(); it.remove(); // 必须手动移除,否则事件会被重复处理 if (key.isAcceptable()) { SocketChannel socket = server.accept(); socket.configureBlocking(false); socket.register(selector, SelectionKey.OP_READ); } else if (key.isReadable()) { // 读取这个通道里可用的数据 } } }这段代码里有三个坑必须说透。
第一个坑:selectedKeys()返回的是集合,不是持续有效的队列。你用完之后不从迭代器里remove,下次select()返回时老事件又会出现在集合里,同一个连接会被处理多次,表现就是CPU高、业务重复执行。这个坑几乎所有手写NIO的人都踩过。
第二个坑:非阻塞不代表一次read能读完整条消息。TCP是流式协议,数据可能被拆成多个包到达,也可能多个包粘在一起。哪怕selector告诉你“可读”,你也可能只读到半个消息。网络应用必须在应用层自己定义消息边界——最常用的就是4字节长度头加上消息体,或者用特殊分隔符。我见过很多NIO入门项目,消息一多就开始错乱,根因就在这里。
第三个坑:事件处理里尽量不要做耗时业务。如果在一个连接被accept之后,业务处理花费了几百毫秒,Selector会一直卡在while循环里,其他所有连接的事件全部等待。这也是为什么后来成熟的网络框架都喜欢用单独的线程池去处理业务,IO线程只负责把数据搬到内存。
3.4 异步IO:AIO的位置在哪里
聊完Selector,顺便把AIO也提一句。NIO.2时代引入了AsynchronousSocketChannel、AsynchronousFileChannel,真正实现了异步非阻塞。Java 7的演示代码可以写成这样:
AsynchronousFileChannel channel = AsynchronousFileChannel.open(path, StandardOpenOption.READ); ByteBuffer buffer = ByteBuffer.allocate(1024 * 1024); channel.read(buffer, 0, buffer, new CompletionHandler<Integer, ByteBuffer>() { @Override public void completed(Integer result, ByteBuffer attachment) { System.out.println("read finished: " + result + " bytes"); } @Override public void failed(Throwable exc, ByteBuffer attachment) { exc.printStackTrace(); } });还有Future版本,但Future本质上还是要主动get才算拿结果,有点半同步。CompletionHandler版本更接近异步本质。
我的建议是:除非你在写底层组件,否则大部分业务系统不需要直接使用AIO。AIO的回调模型会让代码更复杂,线程池的管理也更讲究,而生产级网络服务通常会直接选用成熟的通信框架,框架底层可能已经用NIO或AIO实现了这套机制,你与其重复造轮子,不如把Selector、Channel、Buffer这些基础概念理解透,出了问题才能看得懂框架日志。
4. NIO.2:真正面向开发者的文件与目录API
4.1 Path到底比File强在哪
Path是JDK 7开始推荐使用的路径抽象,和File类比,它有三个让我觉得舒服的地方:
第一,路径拼接优雅。File类拼接子路径要自己拼字符串带分隔符,Path提供了resolve方法:
Path base = Paths.get("/data/system"); Path configPath = base.resolve("config/app.yml");第二,路径规范化不再是麻烦事。normalize会处理掉“.”和“..”,toAbsolutePath能快速拿到绝对路径,startsWith和endsWith直接判断路径层级关系。
第三,Path天然配合Files工具类,几乎所有文件操作都变成了静态方法调用。File类能做的,Path几乎都能做;File类做起来别扭的事情,比如遍历目录树、监听文件变化,Path和Files直接给出了一等解法。
如果项目代码还在大量使用java.io.File,我建议逐步迁移。File类用起来别扭,也容易受平台路径风格影响,NIO.2的这套API才是正确方向。
4.2 Files工具类的高频操作
Files是一个静态方法组成的工具类,覆盖增删改查、复制、移动、创建目录、读写文本等几乎所有日常文件操作。我常用的组合是这样:
Path dir = Paths.get("/data/export"); Files.createDirectories(dir); // 层级不存在时会一并创建 Path source = Paths.get("/data/raw/data.txt"); Path target = dir.resolve("data_backup.txt"); Files.copy(source, target, StandardCopyOption.REPLACE_EXISTING); if (Files.exists(target)) { long size = Files.size(target); System.out.println("size: " + size); }这里最容易忽略的是三个点。
createDirectories会创建整条目录链,createDirectory只会创建单层;copy、move的时候如果不传StandardCopyOption.REPLACE_EXISTING,目标已存在时会直接抛FileAlreadyExistsException。
还有Files.readAllBytes和Files.readAllLines这两个便利方法。它们一次性把整个小文件读进内存,真香;但如果你对一个2GB的日志文件调用readAllBytes,内存瞬间被打爆。我在实际项目里见过不止一次这种用法,后面会专门复盘。
4.3 目录遍历与监听:WatchService的正确姿势
目录遍历在File时代要写递归,Files.walk一行就搞定:
Path start = Paths.get("/data/archive"); try (var stream = Files.walk(start)) { stream.filter(Files::isRegularFile) .forEach(System.out::println); } catch (IOException e) { e.printStackTrace(); }注意Files.walk返回的是Stream,同样需要释放底层资源,放进try-with-resources是一个好习惯。
如果你想监听一个目录里文件的新增、修改、删除,就要用WatchService:
Path watchedDir = Paths.get("/data/input"); try (WatchService watcher = FileSystems.getDefault().newWatchService()) { watchedDir.register(watcher, StandardWatchEventKinds.ENTRY_CREATE, StandardWatchEventKinds.ENTRY_MODIFY, StandardWatchEventKinds.ENTRY_DELETE); WatchKey key = watcher.take(); // 阻塞直到有事件 for (WatchEvent<?> event : key.pollEvents()) { System.out.println(event.kind() + " -> " + event.context()); } key.reset(); // 不reset的话,这个key会失效,事件再也不来 } catch (IOException | InterruptedException e) { e.printStackTrace(); }这里有个容易踩的坑:不调用key.reset(),WatchKey回收后再也不会报告新事件。很多代码把pollEvents写对了,却忘了reset,结果程序“跑起来没报错,就是不触发第二次”。
另外WatchService监听的是被注册目录的一层事件,不递归子目录。想要监听整个目录树,需要自己递归注册每一个子目录,这在实际项目中经常会被忽略。
4.4 内存映射文件:把文件当作内存来操作
MappedByteBuffer是FileChannel.map的产物,底层是操作系统的内存映射机制,把文件的某段区域直接映射到进程地址空间,读写映射区域时由操作系统负责换页。可以做到“像操作内存一样操作文件”,省去一堆read/write调用。
Path path = Paths.get("/data/mapped.dat"); try (FileChannel channel = FileChannel.open(path, StandardOpenOption.READ, StandardOpenOption.WRITE)) { MappedByteBuffer mapped = channel.map(FileChannel.MapMode.READ_WRITE, 0, channel.size()); mapped.put("hello".getBytes(StandardCharsets.UTF_8)); mapped.force(); // 刷到磁盘 } catch (IOException e) { e.printStackTrace(); }它非常适合超大数据量的读写场景,比如秒级处理上百MB甚至几个GB的数据文件。但它不是银弹:首先,映射的大小受限于地址空间,不要试图映射几十GB的大文件还幻想进程内存很稳;其次,force()必须自己调,否则进程崩溃或断电时数据可能停留在页缓存里;最后,如果映射后对文件做了奇怪的修改又没有正确关闭,某些系统上文件可能处于不一致状态。
我的观点是:不超过1GB的文件、读写频繁、追求低延迟的场景,可以考虑mmap;普通文件复制、配置读取,直接用Files或FileChannel就够,没必要把一个简单功能复杂化。
5. 序列化:看似简单却暗藏杀机
5.1 serialVersionUID:一行声明,深夜救命
Java原生序列化有一个看似无关紧要的声明:serialVersionUID。很多人看到IDE黄色警告就随手忽略,直到线上出现InvalidClassException——本地序列化到Redis里的对象,升级版本后反序列化失败,应用启动直接崩了。
原理想清楚就不难了。序列化时会把serialVersionUID写进流里;反序列化时会校验本地类的serialVersionUID和流中的是否一致,不一致就拒绝。如果没有显式声明,JVM会根据类结构生成默认版本号,字段一增一减、方法签名一变,默认版本号就变了,旧数据自然反序列化失败。
正确的做法是每个需要序列化的类都写死一行:
private static final long serialVersionUID = 1L;这行声明的作用不是防止类结构变化,而是让版本变化由你掌控。当你明确知道某个结构变更会导致老数据无法兼容时,你可以选择将serialVersionUID也换掉,让系统明确抛异常;当你只是想新增一个可忽略的字段时,保留旧版本号,老数据就能正常反序列化为新对象,新字段取默认值。
经验之谈:与长期存储相关的对象,比如缓存、调度任务、历史事件,只要涉及序列化,就一定要画一条“版本边界”。先把serialVersionUID写死,然后每次结构变更都对照一下兼容性,别偷懒。
5.2 transient:哪些字段不值得落盘
transient修饰的字段不会参与序列化。密码、令牌、临时会话对象、从其他数据源可以重建的缓存,这些字段就该加transient。
原因有两个层面:一是安全,密码、密钥这类敏感数据不应该出现在序列化流里,否则无论是落到文件还是发到网络,都是风险的种子;二是冗余,序列化结果体积大,把不需要持久化的临时数据带进去,浪费空间和传输带宽。
要注意transient字段在反序列化后是Java类型的默认值——对象是null,int是0,布尔是false。如果你业务逻辑依赖这些字段,反序列化后必须通过自定义readObject或专门的初始化逻辑重建它们。否则你会遇到“上一秒还在的字段,重启后就变成null”的诡异问题。
5.3 为什么说Java原生序列化不适合长期存在
这是我异常坚持的观点。Java原生序列化,也就是ObjectOutputStream/ObjectInputStream这套,在你的应用和存储都在同一个封闭JVM体系里时能用,但放到如今的环境里限制太明显:
第一,跨语言是硬伤。Java数据Java自己读,别的语言读不了。现在的系统动辄对接多个服务,大概率不是同一种语言,一个纯Java私有协议会让协作变得异常痛苦。
第二,体积大。它会写入完整的类描述信息、字段修饰符、继承结构等,同一个对象序列化出来的体积可能是JSON的数倍。存缓存、发消息队列,空间浪费明显。
第三,结构变化极度敏感,上面serialVersionUID只是第一道关卡,字段类型变化、继承层级变化都可能引发连锁的兼容问题。
第四,安全问题。Java原生反序列化历史上曝出过不少严重漏洞,攻击者可以构造恶意的序列化流来触发任意代码执行。凡是接收外部输入的地方,如果直接做了反序列化,风险一下就上来了。
所以我的选型习惯是:简单的内部缓存或存JSON,Jackson可以搞定;跨语言、跨版本的高吞吐场景上Protobuf或类似方案;除非技术栈里所有节点都是同一个老Java版本,并且数据几乎没有长期留存,我才考虑Java原生序列化。现在新的项目里,我基本把ObjectOutputStream请出了日常工具箱。
6. 实战中的IO性能观察与调优经验
6.1 缓冲区大小不是拍脑袋定的
缓冲区大小对IO性能的影响,是网上讨论最多的主题之一。有说4KB的,有说8KB的,有说64KB的,我一开始也照着别人的参数抄,后来自己做对比测试才发现,不同场景结论完全不同。
我对一个文本解析程序做过简单压测:固定文件大小不变,分别用1KB、8KB、64KB、256KB的缓冲区读取,64KB相对1KB的吞吐提升非常明显,但256KB和64KB之间几乎没什么差异,反而内存占用量上去了。原因在于,缓冲区小的时候系统调用次数多,上下文切换开销大;缓冲区超过一定阈值后,单次系统调用内部的时间占比成为主导,再加大缓冲区只是在搬更长的内存块,边际收益递减。
现在我的做法是:先按64KB起手,稳定跑一轮压测,再向下微调,找到一个平台拐点。不要盲目信论坛里的“最优值”,你的磁盘类型、文件系统块大小、JVM堆设置都不一样,唯一能信的是你自己压出来的数据。
6.2 零拷贝不是所有IO的万能钥匙
零拷贝的价值在于减少用户态和内核态之间的数据复制次数。在日志归档、文件下载、大文件传输这些场景里,FileChannel.transferTo确实能让CPU占用明显下降。但用之前要想清楚,零拷贝省的是数据搬运成本,省不了等待和延迟。
我在某个下载服务里用过transferTo优化,优化前后CPU从85%降到30%,效果很直观;但另外一个小文件频繁读取的工具,用了transferTo之后反而更慢了。原因很好理解:小文件场景下,创建Channel、打开文件、注册事件的固定开销远远大于那一点数据复制的节省,零拷贝的优势完全体现不出来。
所以遇到性能问题,不要开场就喊“我要上零拷贝”,先看看数据规模、CPU是否确实耗在了字节拷贝上,用线程栈确认一下热点,再决定值不值得动手。
6.3 一个典型的IO性能问题排查过程
我遇到过的一个典型案例是:某个数据同步服务每天加载一个2GB左右的文本日志,解析后写入下游系统。上线第一周CPU持续高,内存也快要顶到容器上限。
排查链路大概是这样的:
先用top看到进程CPU接近100%,内存RSS明显偏高;再用jstat -gcutil观察GC,老年代频繁Full GC;接着用jstack抓线程栈,发现大量业务线程停在String构造函数和字节数组拷贝的位置;最后代码走查,定位到了这行:
String content = new String(Files.readAllBytes(path), StandardCharsets.UTF_8); // 2GB,一次读入问题找到了。Files.readAllBytes底层是一次性把整个文件读进内存,再转成String对象,2GB的日志直接撑爆堆。改造方案很简单,改成Files.lines按行读取,或者BufferedReader逐行处理,解析完一行就丢一行。
改造后再压测,CPU从接近100%回落到了30%以下,内存也稳定了。这个案例很像一些“新手错误”,但它其实不止发生在新手身上——Files提供的便利方法太诱人,开发时文件只有几十KB,上线后数据量翻了几十倍,代码里那个“读整个文件”就变成定时炸弹。
从那之后我给自己定了一个习惯:凡是任何地方出现“读取整个文件”的调用,先问一句,这个文件可能的最大规模是多少?超过JVM堆的十分之一,就必须改成流式方案。
6.4 我平时审查IO代码时坚持的几条判断标准
结合这几年的经验,我在做代码审查时,对IO部分基本按以下顺序判断:
先看数据规模。几十KB级别,Files.readAllBytes这种便利方法完全没问题;MB级及以上,面向流去读;GB级,除了流式处理还要考虑分批和分片。
再看字符集。凡是文本读写,必须显式指定UTF-8或项目统一字符集,绝不依赖平台默认值。
然后看是不是典型高并发场景。如果是网络通信,优先用成熟的通信框架,不要手写Selector;手写的话,必须确认事件处理里不混入耗时业务。
最后看资源关闭。所有IO资源必须有明确的关闭路径;能写进try-with-resources的,一律写进去。
这几条标准写出来很简单,但每一条都是在真实事故里筛过一遍的。IO问题本质上是“规模与API不匹配”的问题,把数据规模、API边界、资源生命周期这三件事想明白,大部分坑都可以绕过去。