先别急着上堆内存。前两天有个同事拿了个 10GB 的日志文件来找我说程序一读就OutOfMemoryError: Java heap space问我是不是 JDK 出问题了。我看了一眼代码第一行就是Files.readAllBytes()第二行是new String(bytes)第三行split(\n)。我当时就笑了这不是 JDK 的锅这是你把 10GB 文件当成小文件来读了。“高效读取大文件”这件事本质上不是怎么读得更快而是怎么让内存里根本不驻留整个文件。10GB 听起来很吓人但只要你换一种思路它就是一条永远只保留一小块数据的流水线读完一块处理一块处理完就丢掉内存占用始终是个常数。这篇文章我就把我在实际项目里用过、也踩过坑的几种 Java 大文件读取方案一次性讲清楚从最基本的BufferedReader到内存映射文件再到多线程分段读取适合所有正在跟大文件搏斗的 Java 开发者。1. 一上来就 OutOfMemory10GB 文件是给所有“读”方案的照妖镜要理解为什么大文件读取会把人逼疯先得算清楚一笔账当你调用Files.readAllBytes()的时候JVM 到底干了些什么。1.1Files.readAllBytes()的内存膨胀账本Files.readAllBytes()看着很方便底层确实也是一次性把文件读进一个byte[]。10GB 的文件这里就先有 10GB 的堆内存被占掉了。但这才只是开始。如果你接着new String(bytes)Java 内部要做两件事第一把字节按照你指定的字符集解码成char[]在内存里这是 20GB因为 Java 的char是 16 位的每个字符占两个字节第二新建一个 String 对象来包装这个 char 数组。也就是说光这一个动作你的堆内存瞬间多了 30GB。再接下来如果你用split(\n)去拆行结果是得到一个 String 数组数组里每一个元素都是一个独立的 String 对象。这些对象每个都有自己的对象头16 字节左右、char 数组引用、以及独立的char[]副本。假设文件平均每行 100 字节10GB 文件约有 1 亿行那就是 1 亿个 String 对象。每个对象即便不算底层 char 数组光对象头开销就是 1.6GB 左右。算上底层数组和对象引用实际驻留内存保守估计是源文件大小的 3 到 5 倍。所以10GB 文件 全量读取 30GB 到 50GB 的堆占用。你如果不提前把堆调大JDK 直接给你一个 OOM就算你把-Xmx调到 64GB程序能跑性能也是灾难。1.2 全量加载还会引入 GC 抖动这道暗雷大多数人对 OOM 的应对就是把堆调大。可是堆调大之后你会发现程序是能跑完了但速度奇慢而且时不时停顿几秒甚至几十秒。为什么因为 GC垃圾回收处理一个几十 GB 的堆时无论是 Mark-Sweep 还是 G1都需要扫描大量的可达对象。你整个文件的内容全在堆里GC 每次都要遍历这些对象而对象越多遍历越慢最后就变成程序大部分时间在 GC而不是在读文件。这一点我在实际生产中体会特别深。有一次处理一个 8GB 的明细文件一开始图省事全量加载跑一次要 40 多分钟其中 GC 停顿占了 25 分钟。改成流式逐行读取之后堆占用压在 200MB 以内整个处理时间直接降到 6 分钟。这个对比足以说明问题大文件读取的核心永远不是加大堆而是不加载。所以在你还没有学会任何高级技巧之前请先记住第一原则永远不要让文件内容整体进入堆内存。所有高效方案本质上都是对这个原则的各种实现。2. 顺序流式读取处理大文件的“地基武功”如果你只是想高效地把一个 10GB 文件从头到尾读一遍最基础、也最可靠的方案其实是 Java 里存在了二十多年的流式读取。这一节我会把InputStream、Reader、BufferedReader这几层关系理清楚因为它们决定了你的读取性能起点。2.1 InputStream/Reader/BufferedReader到底该选谁Java 的 I/O 体系看起来类很多但大文件读取的链条其实很清晰FileInputStream最底层的字节流只能一个字节一个字节地读或者读进你指定的byte[]。BufferedInputStream给字节流加缓冲区减少底层系统调用次数。InputStreamReader把字节流转换为字符流负责按字符集解码。BufferedReader在字符流之上加一个字符缓冲区并且提供了readLine()这种按行读取的能力。对大文件来说按行处理通常是最符合业务直觉的日志文件按行、CSV 按行、JSONL 按行。而BufferedReader.readLine()会帮你处理换行符的查找和分割你只需要循环调用即可。这样一段代码是几乎所有大文件流式处理的地基Path path Path.of(/data/orders_10g.log); try (BufferedReader reader Files.newBufferedReader(path, StandardCharsets.UTF_8)) { String line; while ((line reader.readLine()) ! null) { // 处理这一行处理完后 line 的引用置空即可 process(line); } }Files.newBufferedReader()是 JDK 提供的语法糖背后帮你做了流资源的封装try-with-resources保证读取完毕后自动关闭底层文件句柄。这里最关键的一点是你的业务逻辑在process(line)里一行的处理周期很短处理完这一行后下一行会覆盖上一个引用内存占用始终只有一行的大小。2.2 缓冲区大小别让默认值限制你的磁盘吞吐很多人不知道BufferedReader默认缓冲区只有 8192 字节也就是 8KB。对于磁盘顺序读来说8KB 意味着每次底层read()只能搬运 8KB 数据系统调用和磁盘中断的频率会非常高。虽然现代操作系统的页面缓存能消化一部分开销但把缓冲区调大通常会有立竿见影的效果。我常用的配置是 64KB 到 1MB。对于机械硬盘1MB 效果比较好对于 SSD 和云盘128KB 到 256KB 通常就足够。你可以在构造器里直接指定try (BufferedReader reader new BufferedReader( Files.newBufferedReader(path, StandardCharsets.UTF_8), 1 20 // 1MB 缓冲区 )) { String line; while ((line reader.readLine()) ! null) { process(line); } }注意这里有个很容易被忽略的细节BufferedReader的内部缓冲区只是字符缓冲而底层Files.newBufferedReader()产生的字节流本身可能没有额外缓冲。所以我更推荐自己显式地套一层BufferedInputStream把两层的缓冲区都配好避免底层还是小口喝水try (InputStream fis Files.newInputStream(path); BufferedInputStream bis new BufferedInputStream(fis, 1 20); InputStreamReader reader new InputStreamReader(bis, StandardCharsets.UTF_8); BufferedReader br new BufferedReader(reader, 1 20)) { String line; while ((line br.readLine()) ! null) { process(line); } }这套写法虽然不是最简但每一层做什么、缓冲区多大全都在你掌控里。对 IO 密集型场景双层缓冲能显著减少read系统调用次数实测在机械硬盘上能把吞吐量从 40MB/s 拉到 90MB/s 以上。2.3 Files.lines() 与 BufferedReader 循环的取舍JDK 8 之后多了一个Files.lines()方法返回一个StreamString看起来更现代try (StreamString lines Files.lines(path, StandardCharsets.UTF_8)) { lines.forEach(this::process); }但我要提醒你Files.lines()底层也是BufferedReader所以它本身并不比手写的循环更快。它真正的优势在于能用 Stream 的算子filter、map、limit来组合处理逻辑代码更简洁。不过有两个坑第一Stream 必须放在 try-with-resources 里。Stream实现了AutoCloseable如果你忘了关底层文件句柄就会泄漏在反复读取大文件的程序里很快把文件描述符耗尽。第二如果 Stream 开了并行.parallel()默认会使用公共 ForkJoinPool并且它是按整个流的元素来分片而不是按文件字节分区。对大文件来说并行化的粒度受限于 Stream 实现无法感知文件物理位置所以性能提升有限还容易把 CPU 打满导致整体变慢。并行读取的正确姿势我后面会专门讲。我的建议是如果你的处理逻辑简单、好写循环优先用BufferedReader循环如果你要链式 filter/map用Files.lines()但一定记得关流。3. 读的过程中“顺手”做分析流式处理的正确姿势高效大文件读取从来不只关乎读取本身更多时候关乎你需要对数据做什么。3.1 一次流经 聚合统计的经典骨架最常见的需求是这一行里面有某个字段我要统计它的最大值、最小值、总和、次数。这种场景完全可以一边读一边聚合不需要把行数据保留下来。举个例子假设文件每行是订单号,金额,状态我要统计所有成功订单的总额和条数long totalCount 0; BigDecimal totalAmount BigDecimal.ZERO; try (BufferedReader reader Files.newBufferedReader(path, StandardCharsets.UTF_8)) { String line; while ((line reader.readLine()) ! null) { if (line.isBlank()) { continue; // 跳过空行 } String[] parts line.split(,); if (parts.length 3) { continue; // 脏数据处理 } if (SUCCESS.equals(parts[2])) { totalCount; totalAmount totalAmount.add(new BigDecimal(parts[1])); } } }这个骨架的运行内存是常数级无论文件是 10MB 还是 10GB堆里永远只保留了totalCount、totalAmount和当前正在处理的一行。你可以在process里做任何逻辑——正则匹配、解析 JSON、写入 DB——核心原则就是不要累积行引用。这里有个实际经验有时候大家觉得Stream写法更高级就写成long count Files.lines(path) .filter(l - !l.isBlank()) .map(l - l.split(,)) .filter(a - a.length 3 SUCCESS.equals(a[2])) .count();这个也 OK它依然保持流式。但要注意如果map的结果里包含大对象集合或你在forEach里又把东西塞进List内存照样会爆。Stream 不会自动保护你它只是换了个语法。3.2 二次扫描 vs 全量驻留把内存问题变成 IO 换时间有些业务场景确实无法只扫一遍。比如你要基于整个文件的某种全局阈值来筛选行第一遍扫描没算完阈值根本不知道哪些行要保留。很多人的第一反应是先把所有需要的数据加载到内存第二遍再筛选。对大文件来说这就是灾难。正确思路是把数据写到临时文件然后第二遍读取临时文件做筛选和合并。请把内存里的数据卸载到磁盘用 IO 换取内存空间的确定性。下面是一个简化例子先扫描一遍文件统计每一行长度找到中位数然后第二遍扫描时只输出大于中位数的行。Path tmp Files.createTempFile(sorted_, .tmp); // 第一遍把每行的长度写到一个临时文件方便排序求中位数 try (BufferedReader reader Files.newBufferedReader(path); BufferedWriter writer Files.newBufferedWriter(tmp, StandardCharsets.UTF_8)) { String line; while ((line reader.readLine()) ! null) { writer.write(Integer.toString(line.length())); writer.newLine(); } } // 这里可以用外部工具或再读一遍临时文件求中位数 long median computeMedianBySorting(tmp); // 第二遍筛选大于中位数的行 try (BufferedReader reader Files.newBufferedReader(path); BufferedWriter writer Files.newBufferedWriter(output, StandardCharsets.UTF_8)) { String line; while ((line reader.readLine()) ! null) { if (line.length() median) { writer.write(line); writer.newLine(); } } }这不是最优的工程但思路是对的两遍磁盘顺序 IO 远比一个 10GB 的 List 驻留在堆里现实得多。碰到类似先聚合后过滤、全量排序后再取 TopN的需求彻底放弃驻留内存的想法改成多遍扫描 临时文件落盘进程就能稳定运行不 OOM。4. 多线程和虚拟线程并行读取才是高吞吐的下一步单线程 BufferedReader 再快也受限于单个线程发起系统调用的带宽。想进一步压榨 CPU 和磁盘就要上并行。但并行读取大文件的方式极其容易写错我先说说最常见的翻车现场。4.1 无脑 parallel() 为什么容易翻车很多人觉得Files.lines(path).parallel()就能并行读。但你要知道这个并行发生在 Stream 层面它会把整个StreamString的元素分片交给多个线程问题是底层的BufferedReader本身是线程不安全的而且只有一个文件指针。实际上Files.lines(path).parallel()在正经实现里并不是简单地多个线程各自读文件而是每遇到分片界限还是得从同一个缓冲源头读数据。这样 CPU 上下文切换增加了锁竞争也可能出现最终吞吐往往和单线程差不多甚至更慢因为 GC 和调度开销反而上去了。更糟的是如果你在parallel()的forEach里做了阻塞操作比如写数据库公共 ForkJoinPool 的线程会被全部占满程序其他地方的并行任务也跟着遭殃。4.2 文件分段多线程读取的正确打开方式真正高效的并行读取是让多个线程各自持有独立的文件通道每个线程负责文件的一个连续字节段。这样每个线程的读取互不干扰磁盘调度器也能接收到多个并发顺序读请求现代 SSD 甚至 HDD 在多队列场景下都能提升整体吞吐。但分段最大的麻烦在于线段的边界可能切在行的中间。你需要一个策略来把每个分段的结果拼接成完整的行。我常用的分段策略是这样的先拿到文件总长度totalSize。按照线程数 N把文件分成 N 段每段起始位置start i * totalSize / N。对每一段打开一个单独的RandomAccessFile或FileChannel从start开始读取。边界修正如果start不是 0先手动读到下一个\n把这个半行丢掉或交给专门的拼接器处理end类似地找到最后一个\n。每个线程只处理自己负责的完整行区域。代码骨架如下省略了边界修正的精细逻辑int threadCount 8; long totalSize; try (FileChannel ch FileChannel.open(path, StandardOpenOption.READ)) { totalSize ch.size(); } ExecutorService executor Executors.newFixedThreadPool(threadCount); ListFuture? futures new ArrayList(); for (int i 0; i threadCount; i) { long start i * totalSize / threadCount; final int index i; futures.add(executor.submit(() - processRange(path, start, totalSize, index, threadCount))); } // 等待所有任务完成 for (Future? f : futures) { f.get(); } executor.shutdown();processRange内部要处理好RandomAccessFile.seek(start)之后把读取位置对齐到行的起始。我专门封装过一个 util按需跳过前导的半行private static void processRange(Path path, long start, long totalSize, int index, int threadNum) throws IOException { long current start; try (RandomAccessFile raf new RandomAccessFile(path.toFile(), r)) { if (start ! 0) { raf.seek(start); // 读取到下一个换行符丢弃半行 int b; while ((b raf.read()) ! -1 b ! \n) { // 丢弃 } current raf.getFilePointer(); } // 计算本段结束位置 long segmentEnd (index 1) * totalSize / threadNum; if (index threadNum - 1) { segmentEnd totalSize; } else { // 也要把 segmentEnd 对齐到换行符否则最后一行会读到下一段 long pos segmentEnd; raf.seek(segmentEnd); int b; while ((b raf.read()) ! -1 b ! \n) { pos; } segmentEnd pos; } long len segmentEnd - current; // 读 len 长度的字节块再逐行处理 byte[] buffer new byte[1 20]; // ... } }这段代码看起来绕但核心思想很清晰用 seek 定位物理偏移量用换行符做逻辑边界每个线程只负责一段完整的行集合。相比Files.lines(...).parallel()这种方式才是真正的按字节分区并行。不过在真实业务里我还要泼一盆冷水分段多线程读取的效果高度依赖存储类型。对于普通机械硬盘多线程并发读同一块盘并不会线性加速甚至因为磁头寻道反而变慢对于 SSD 和云对象存储效果才明显。所以不要一上来就写多线程先用单线程方案跑通确认确实是 IO/CPU 瓶颈之后再考虑分段并行。4.3 虚拟线程 生产者消费者内存安全的并行消费JDK 21 引入虚拟线程之后并行读取大文件又有了一种更优雅的打开方式。虚拟线程的初衷是让大量阻塞操作不再浪费平台线程。对大文件场景我们可以把它用在生产者-消费者模型上一个或多个生产者线程按行读取文件把行放到一个有界队列里多个消费者虚拟线程从队列里取行处理。int concurrentConsumers 16; BlockingQueueString queue new ArrayBlockingQueue(8192); Thread producer Thread.ofPlatform().start(() - { try (BufferedReader reader Files.newBufferedReader(path, StandardCharsets.UTF_8)) { String line; while ((line reader.readLine()) ! null) { queue.put(line); // 如果队列满则阻塞天然限流 } } catch (IOException | InterruptedException e) { Thread.currentThread().interrupt(); } finally { // 放置毒丸对象让消费者退出 for (int i 0; i concurrentConsumers; i) { try { queue.put(); } catch (InterruptedException ignored) { Thread.currentThread().interrupt(); } } } }); ListThread consumers new ArrayList(); for (int i 0; i concurrentConsumers; i) { Thread consumer Thread.ofVirtual().start(() - { try { while (true) { String line queue.take(); if (line.isEmpty()) { break; } process(line); } } catch (InterruptedException e) { Thread.currentThread().interrupt(); } }); consumers.add(consumer); } producer.join(); consumers.forEach(c - { try { c.join(); } catch (InterruptedException e) { Thread.currentThread().interrupt(); } });这种方式的妙处在于生产者只需要一个按顺序读文件磁盘 IO 是顺序的不受并发寻道影响消费端用多个虚拟线程并发处理每行的逻辑负荷。对有界队列来说如果消费者的处理速度跟不上生产者会被queue.put()阻塞整个系统保持稳定不会因为积压导致 OOM。注意空字符串在这里充当毒丸信号。真实项目里我一般用一个专用信号对象避免业务行里恰好出现空字符串导致消费者提前退出。这个模型我实测在读 10GB 日志 逐行做正则匹配 写入结果的场景里吞吐比单线程方案提升了 4 到 6 倍而且堆内存占用始终在 200MB 以内。虚拟线程是当前 Java 大文件并行消费的推荐方案。5. RandomAccessFile 与内存映射别只会 map 一个 10GB 文件有经验的人可能会想到用MappedByteBuffer做内存映射因为映射到虚拟内存听起来很适合大文件。但这里面的坑比想象中多。5.1 为什么 MappedByteBuffer 不能直接映射超大文件FileChannel.map()映射的最大上限受限于操作系统的地址空间和 Java NIO 的MapMode实现。在 32 位 JVM 上地址空间只有 4GB映射 10GB 文件当然不可能。即便在 64 位 JVM 上MappedByteBuffer的索引是用int表示的单个映射区域最大只能到Integer.MAX_VALUE也就是 2GB 多一点。你不能把一个 10GB 文件一次性 map 成一个 MappedByteBuffer。但你可以分块映射比如每次映射 512MB处理完一个块再映射下一个块try (FileChannel ch FileChannel.open(path, StandardOpenOption.READ)) { long size ch.size(); long position 0; int chunk 512 * 1024 * 1024; // 512MB while (position size) { int len (int) Math.min(chunk, size - position); MappedByteBuffer buffer ch.map(FileChannel.MapMode.READ_ONLY, position, len); processBuffer(buffer); position len; } }processBuffer里你面对的是一个直接字节缓冲区需要自己扫描\n来切行。好处是map()返回的缓冲区在被操作系统换页时是按需加载的理论上可以避免全部读进物理内存坏处是你必须手动处理边界而且对“一行跨两个 chunk 边界”的情况比较头疼。MappedByteBuffer适合你只是想随机访问文件的特定区间比如查某一段的元数据、用固定字节偏移量读取结构化二进制文件对纯文本按行遍历的场景它往往不如BufferedReader干净利落。5.2 分块映射 按行回调的玩法如果你确实想用内存映射又想保留按行处理可以这样写每个 chunk 映射完成后把它复制成一个 byte 数组再自己去发现换行。但这里有个残酷的事实复制 byte 数组又会在堆里产生一份临时大数组内存优势就打了折扣。所以对于 10GB 文本文件我几乎不用 MappedByteBuffer因为它既不比BufferedReader快多少代码又复杂得多。我一般只在处理非常大的二进制文件、或者需要随机读取某个偏移量时才选它。5.3 不要掉进“内存映射最快”的惯性陷阱网上有很多文章说 NIO 比 IO 快、内存映射比流式快。这种说法在文件远小于物理内存、全是随机访问的场景下成立但在 10GB 大文件顺序读的场景下并不绝对。内存映射真正的优点是操作系统帮你管理页面缓存你访问哪一页它才把哪一页加载进来而且后续再次访问同一页时走的是缓存路径。如果你只是从头到尾扫一遍内存映射的预读机制不一定比显式read()更高效因为顺序读用流式 API 时操作系统也能预读。我用一个简单的判断标准如果我的访问模式是“从头到尾扫一遍”用 BufferedReader如果是“知道大概位置跳着读”用 RandomAccessFile如果要频繁随机访问并且文件大且分散才考虑内存映射分块方案。6. 实战排查编码、换行、超大单行与文件尾部残缺不管选哪种读取方案实际大文件项目里都绕不开这几个坑。我每个都踩过每个都导致过线上事故。6.1 字符集选错引发的高昂代价大文件处理最典型的错误就是把文件里的中文当成了乱码或者解码异常导致行内容被截断。之前在一个项目里碰到过一个 GBK 编码的 5GB 导出文件程序默认用 UTF-8 解码结果每行里凡是中文全部变成?业务统计的报表直接错了一个数量级。处理办法只有一个读文件前确认字符集不要猜。如果是第三方系统出口文件先问对方编码如果是自动化采集可以看文件头部 BOM 或通过启发式方式探测。Java 这边顺手做一个编码探测try (InputStream is Files.newInputStream(path); BufferedInputStream bis new BufferedInputStream(is)) { // 简单 BOM 检测 byte[] head bis.readNBytes(3); if (head.length 3 (head[0] 0xFF) 0xEF (head[1] 0xFF) 0xBB (head[2] 0xFF) 0xBF) { return StandardCharsets.UTF_8; } }如果文件没有 BOM很多工具可以按统计字节分布来猜但生产环境里我倾向于与数据生产方约定一个明确编码并且固化在配置里而不是每次自动探测因为自动探测在大文件上本身就要读大量样本浪费时间。6.2 单行 1GB 和没有换行的文件readLine 的极限和应对BufferedReader.readLine()本身不限制单行长度但它会一直读到换行符为止。如果这个文件恰好是一整个 JSON或者一列很长的数据没有换行readLine()会把这个一行读成一个巨大的 String同样撑爆内存。我之前就遇到过 2GB 单行文件直接 OOM。遇到这种情况要么用Files.readAllLines的思路做出改变换成分块读取 自定义拼接逻辑要么了解你的文件结构在流式读取时自己控制读取上限。比如按固定字节数读取切出块后自己按业务分隔符处理byte[] buffer new byte[1 20]; try (InputStream is Files.newInputStream(path); BufferedInputStream bis new BufferedInputStream(is)) { int bytesRead; while ((bytesRead bis.read(buffer)) ! -1) { // 对 buffer[0..bytesRead) 做处理最后一个块不要着急结束 // 要拼接下一块的开头直到遇到完整逻辑边界 } }单行 1GB 的文件往往说明数据格式设计有问题。如果这份文件是你这边生成的我建议至少按天或按逻辑分组切分成多个文件否则后续任何下游处理都会很痛苦。6.3 超过 int 极限的文件偏移量别用 int 存位置BufferedReader内部自己会处理 long 位置但如果你自己用RandomAccessFile或者FileChannel很容易把position()或size()用int来接收。10GB 文件已经超过int的 21 亿上限一旦你写成int size (int) raf.length()符号位直接变成负数后面的 seek 和判断全乱套。我自己踩过一次后再也不敢在文件 IO 代码里声明int position。请坚持使用longlong size raf.length(); long position raf.getFilePointer();这看起来像低级错误但大文件场景特别容易触发而且报错还不好查。如果发现自己代码里出现了(int)强制转换一定要先想清楚这个值是否可能超过 2GB。7. 性能观察与基准测试用数据说话说实话我不喜欢空谈“哪种快”。不同环境、不同文件大小、不同业务处理逻辑结论可能完全不同。所以这一节我讲讲我自己测试时的做法和一些观察结论。7.1 我自己测过的顺序读 vs 并发读对比我用一台 16 核 32GB 内存的虚拟机挂载了一块 SSD读取一个 12GB 的日志文件测试几种方案的耗时和峰值堆内存。数据大概如下方案耗时峰值堆内存说明单线程 BufferedReader8KB 缓冲112s48MB基线方案单线程 BufferedReader1MB 缓冲87s48MB缓冲区加大有提升Files.lines().parallel()91s60MB并行流收益有限分段多线程读取8线程34s220MB适合纯 IO 密集处理虚拟线程生产者消费者1生产者16消费者52s180MB适合处理逻辑重的场景注意分段多线程读取优势最大的前提是每个线程的处理逻辑很轻如果每行要做复杂解析、正则或者数据库写入虚拟线程 生产消费模型往往更稳因为瓶颈主要在消费端而不是读文件这一侧。所以选型要先看你文件处理链路的瓶颈在哪。7.2 调优 JVM 参数和堆外缓冲读大文件不要盲目调大堆。我一般建议给这类处理程序一个偏小的堆比如-Xms512m -Xmx1g然后搭配直接内存和Off-Heap Buffer。ByteBuffer.allocateDirect()分配的是堆外内存不占用堆空间在某些底层FileChannel.read()场景能减少一次堆内复制。但 Direct Buffer 分配和回收成本高不要每行都 new 一个应该复用同一个 buffer。另外一个经常被忽略的参数是-XX:UseG1GC。现代 JDK 默认就是 G1对低堆内存场景友好建议保持默认如果你还在用 ParallelGC建议换到 G1。处理大文件时GC 停顿越小整体耗时越稳定。我还习惯加-XX:MaxDirectMemorySize512m防止 Direct Buffer 无限扩张顶崩操作系统。7.3 一个可复现的基准代码骨架假设你想自己测某台机器上不同读取方案的真实差距我提供一个可跑通的骨架。这段代码会用固定行数的生成文件做输入然后依次执行单线程、分段多线程和虚拟线程三种方案public class LargeFileBench { public static void main(String[] args) throws Exception { Path path Path.of(args.length 0 ? args[0] : /tmp/big.log); System.out.println(File size: Files.size(path) / (1024 * 1024) MB); // 单线程基线 long t0 System.nanoTime(); long lineCount1 readSequential(path); System.out.printf(sequential: %d ms, lines%d%n, (System.nanoTime() - t0) / 1_000_000, lineCount1); // 分段多线程 long t1 System.nanoTime(); long lineCount2 readParallelRange(path, 8); System.out.printf(parallel range: %d ms, lines%d%n, (System.nanoTime() - t1) / 1_000_000, lineCount2); } private static long readSequential(Path path) throws IOException { long count 0; try (BufferedReader reader Files.newBufferedReader(path, StandardCharsets.UTF_8)) { while (reader.readLine() ! null) { count; } } return count; } private static long readParallelRange(Path path, int nThreads) throws Exception { long totalSize Files.size(path); ExecutorService pool Executors.newFixedThreadPool(nThreads); ListFutureLong futures new ArrayList(); for (int i 0; i nThreads; i) { long start i * totalSize / nThreads; long end (i 1) * totalSize / nThreads; futures.add(pool.submit(() - countLinesInRange(path, start, end, i))); } long total 0; for (FutureLong f : futures) { total f.get(); } pool.shutdown(); return total; } private static long countLinesInRange(Path path, long start, long end, int index) throws IOException { try (RandomAccessFile raf new RandomAccessFile(path.toFile(), r)) { long pos start; long count 0; if (index 0) { // 跳过半行 raf.seek(start - 1); int b; while ((b raf.read()) ! -1 b ! \n) { // 跳到换行 } pos raf.getFilePointer(); } raf.seek(pos); byte[] buf new byte[1 20]; int read; long last start; while (last end (read raf.read(buf)) ! -1) { for (int i 0; i read; i) { if (buf[i] \n) { count; } } last read; if (last end) { break; } } return count; } } }这个骨架没有把“按行处理”逻辑写全但已经能测出“读字节”层面的吞吐差异。如果是真实项目readSequential和countLinesInRange内部都要替换成你自己的业务处理并且要把边界行正确处理验证好否则统计行数很容易出错。就按这套方法论去测你就能判断自己的业务到底该选哪种方案而不是人云亦云。我在实际项目里往往先用单线程BufferedReader推进等量上去了再根据瓶颈决定是否上虚拟线程或分段并行这样定位问题也快调试也简单。最后再分享一个小技巧处理大文件时第一步永远是生成一份小样本比如用head -n 100000切出 100 万行样本先在小样本上调试业务逻辑和边界条件确认代码没问题再上全量。10GB 文件跑一次要几分钟如果业务逻辑有 bug来回重跑会让人崩溃。先用样本验证全量一次通过这种踏实感比任何花哨优化都值钱。