⭐⭐⭐ Spring Boot 项目实战 ⭐⭐⭐ Spring Cloud 项目实战
《Dubbo 实现原理与源码解析 —— 精品合集》 《Netty 实现原理与源码解析 —— 精品合集》
《Spring 实现原理与源码解析 —— 精品合集》 《MyBatis 实现原理与源码解析 —— 精品合集》
《Spring MVC 实现原理与源码解析 —— 精品合集》 《数据库实体设计合集》
《Spring Boot 实现原理与源码解析 —— 精品合集》 《Java 面试题 + Java 学习指南》

摘要: 原创出处 http://www.iocoder.cn/Fight/guanfangzhongyutiankengJDK-26-toutoujialezheigefangfayihouzaiyebuyongzhetengdawenjianfenpianshangchuanle/ 「芋道源码」欢迎转载,保留摘要,谢谢!


🙂🙂🙂关注微信公众号:【芋道源码】有福利:

  1. RocketMQ / MyCAT / Sharding-JDBC 所有源码分析文章列表
  2. RocketMQ / MyCAT / Sharding-JDBC 中文注释源码 GitHub 地址
  3. 您对于源码的疑问每条留言都将得到认真回复。甚至不知道如何读源码也可以请教噢。
  4. 新的源码解析文章实时收到通知。每周更新一篇左右。
  5. 认真的源码交流微信群。

事故开篇:5GB 视频上传,凌晨堆爆 1.2GB

去年接手一个在线课程项目,最大的功能是用户上传视频——动辄 3-5GB。用的是分片上传:把文件切成块、并行上传、最后让 OSS 拼回完整文件。逻辑听起来不复杂。

直到上线第二周——凌晨 3 点告警:

OutOfMemoryError: Java heap space at java.nio.HeapByteBuffer.<init> ——堆从 512MB 飙到 1.2GB 才被 GC 拽住,幸亏没崩。

翻日志才反应过来——真正吃内存的不是视频本身,是分片 buffer。一个 5GB 的视频切 500 个 10MB 分片,并发上传 20 个就是 200MB 堆 buffer——再叠上一层 OSS SDK 的内部缓存,1GB+ 实属正常。

这个坑当时绕了一大圈才填上——自己手写 RandomAccessFile.seek + read 反复折腾,代码 30 多行,还是没真正解决内存峰值。

JDK 26 把这个事一行 API 解决了——HttpRequest.BodyPublishers.ofFileChannel(channel, offset, size)。核心代码 3 行、堆压力降一大截——HttpClient 内部分块从 channel 读,不需要把整个分片一次性塞进 byte[]。

横向对比:5 种分片上传方案的真实差距

在动手写代码前,先看下生态里主流的 5 种分片上传方案——很多人以为只有”自己写”和”调 SDK”两条路,实际上有 5 种:

核心结论——

  • OSS SDK 看着省事——但你绑定了厂商,而且 SDK 内部其实也是 byte[] 绕路;
  • DirectByteBuffer 看着省内存——但 direct memory 出问题排查更难(GC 不管它);
  • JDK 26 ofFileChannel 是”标准库级的分块流式上传”——HttpClient 内部按需从 channel 分块拉取数据,不需要把整片塞进 byte[]——堆压力比手写老路径降一大截。

⚠️ 澄清一下「零拷贝」误区:ofFileChannel 不是 OS 级 zero-copy(sendfile)——OpenJDK 实现里 HttpClient 仍会用小 ByteBuffer 分块读 channel;它的核心收益是不把整个分片驻留在堆 byte[]——对 JVM 堆压力的优化是真的,对内核态拷贝次数没影响。别被”零拷贝”这个词唬住。

演进 5 步:从 30 行 / 100MB 到 3 行 / 堆压力降一大截

看分片上传这件事从原始写法到 JDK 26 的 5 步演进,每一步都解决前一步的具体痛点:

Step 1:原始写法 —— RandomAccessFile + byte[]

long offset = partIndex * partSize;                              // 该分片在文件里的起始字节
byte[] buffer = new byte[(int) partSize]; // ⚠️ 每分片在【堆】上 new 一块 byte[]
try (RandomAccessFile raf = new RandomAccessFile(file, "r")) { // ⚠️ 每分片各开一个 RAF(FD 翻倍)
raf.seek(offset); // 移动文件指针到分片起点
raf.read(buffer); // 读分片数据到堆 buffer
// 包成 BodyPublisher 上传
var pub = HttpRequest.BodyPublishers.ofByteArray(buffer); // BodyPublisher 还要再持有 buffer 引用
// ...
}

问题:每个分片在堆上 new byte[]——10MB × 20 并发 = 200MB 堆 buffer;每分片一个 RandomAccessFile 实例——100 并发 = 100 个 FD。

Step 2:NIO 改造 —— FileChannel.read(buffer, position)

FileChannel channel = FileChannel.open(path, READ);              // 打开 NIO channel
ByteBuffer buffer = ByteBuffer.allocate((int) partSize); // ⚠️ buffer 还是分配在【堆】里
channel.read(buffer, offset); // 关键:传 position 参数——不会修改 channel 自身当前位置(线程安全)

改进:channel.read(buffer, position) 不修改 channel 当前位置——多线程读不同区间线程安全。

还有问题:ByteBuffer.allocate 还是堆里——内存峰值没变。

Step 3:共享一个 FileChannel

// 全局共享一个 channel——一次 open() / 多线程并发用,省 FD + 省 open 开销
static final FileChannel CHANNEL = FileChannel.open(path, READ);

// 所有线程都从这个 CHANNEL 按 (offset, length) 区间读,不用各自 open / close
// 配合 Step 2 的 channel.read(buffer, position)——本身就是线程安全的

改进:100 并发只要 1 个 FD——节省系统资源、页缓存利用率拉满。

还有问题:buffer 仍在堆——GC 压力没减。

Step 4:DirectByteBuffer 绕堆

// ⚠️ allocateDirect 分配的是 direct memory(堆【外】)—— 不归 GC 管,但堆压力几乎为 0
ByteBuffer direct = ByteBuffer.allocateDirect((int) partSize);
channel.read(direct, offset); // 数据直接落到堆外 buffer

改进:allocateDirect 分配的是 direct memory(堆外)——堆几乎 0 压力。

还有问题:

  • direct memory 不归 GC 管——忘了释放就泄漏;
  • 还需要自己接到 HTTP 请求体——代码复杂度直接拉满;
  • 这块逻辑写错一行就是生产事故。

Step 5:JDK 26 ofFileChannel —— 一行收口

// JDK 26 一行 API 把上面 4 步的所有事全包好:
// 共享 channel + 按区间读 + 分块流式发到网络 + 不改 position + 资源生命周期归调用方
var pub = HttpRequest.BodyPublishers.ofFileChannel(channel, offset, length);

改进——上面 4 步的所有事 JDK 一行 API 包好:

  • ✅ 共享 channel(你自己开一个就行);
  • ✅ 按区间读(position + size 直接传);
  • ✅ 分块流式发到网络——HttpClient 内部用小 ByteBuffer 分次读 channel,不需要把整个分片塞进 byte[];
  • ✅ 不修改 channel position(并发安全);
  • ✅ 资源生命周期归调用方(你管 channel close)。

演进的本质——把”自己拼装 4 个底层细节”升级为”调一个 API 就全有”。

JDK 26 新写法:核心代码就 3 行

ofFileChannel 完整签名:

HttpRequest.BodyPublishers.ofFileChannel(FileChannel chan, long position, long size)

3 个参数——chan 已经打开的 FileChannel;position 起始字节;size 读多少字节。返回 BodyPublisher,可直接挂到 HttpRequest 上。

实际并行分片上传的核心代码——精简到 3 行(生产代码请用下面完整版做异常处理):

// 1. 全局开一个 channel(多线程共享)
FileChannel channel = FileChannel.open(path, StandardOpenOption.READ);

// 2. 每个分片按区间构建 publisher(不需要把整片塞进 byte[],HttpClient 内部分块读)
var pub = HttpRequest.BodyPublishers.ofFileChannel(channel, offset, length);

// 3. 并行提交
client.send(HttpRequest.newBuilder(uri).POST(pub).build(), discarding());

完整带并发 + 错误处理的版本:

// try-with-resources 全权管理 channel 和 client 的生命周期
try (FileChannel channel = FileChannel.open(filePath, StandardOpenOption.READ); // 全局一个 channel
HttpClient client = HttpClient.newHttpClient()) { // 全局一个 HttpClient

long fileSize = channel.size(); // 文件总大小
long totalParts = (fileSize + PART_SIZE - 1) / PART_SIZE; // 向上取整算分片数

List<CompletableFuture<Void>> futures = new ArrayList<>();
for (int i = 0; i < totalParts; i++) {
final int partIndex = i; // 分片序号
final long offset = (long) i * PART_SIZE; // 这片在文件里的起始字节
final long length = Math.min(PART_SIZE, fileSize - offset); // 这片实际大小(最后一片可能小于 PART_SIZE)

// 每个分片异步并行上传——多线程读同一个 channel 是安全的(见 Step 2 说明)
futures.add(CompletableFuture.runAsync(() -> {
try {
// ⭐ 核心:用 ofFileChannel(channel, offset, length) 构造 BodyPublisher
// HttpClient 内部分块从 channel 读,不需要把整片塞进 byte[]
// 注意:ofFileChannel 会抛 IOException,必须在 try 内
var pub = HttpRequest.BodyPublishers.ofFileChannel(channel, offset, length);
var req = HttpRequest.newBuilder()
.uri(URI.create(UPLOAD_URL + "?partNumber=" + partIndex)) // OSS / S3 协议要求带 partNumber
.POST(pub).build();
client.send(req, HttpResponse.BodyHandlers.discarding()); // 丢弃响应体,省内存
} catch (Exception e) {
throw new RuntimeException("分片 " + partIndex + " 上传失败", e);
}
}));
}

// 等所有分片都跑完才继续——任一失败会在这里抛 CompletionException
CompletableFuture.allOf(futures.toArray(new CompletableFuture[0])).join();
}

对应的 OpenJDK issue 是 JDK-8329829,2026 年 3 月 17 日随 JDK 26 正式发布。

为什么是 FileChannel + 怎么省内存(澄清零拷贝误区)

设计决策:为什么是 FileChannel,不是 ofFile(path, offset, size)

OpenJDK 团队也讨论过另一种 API:

// 曾经讨论但没采用
HttpRequest.BodyPublishers.ofFile(Path path, long position, long size)

最后没选这条——2 个原因:

  • 资源生命周期归调用方管——如果 API 内部自己打开文件,每次调用都创建新 FileChannel,调用方看不见。把 FileChannel 交给调用方 try-with-resources 包,关闭时机自己说了算;
  • 高并发资源复用——100 个分片如果各开一个 channel,就是 100 个 FD + 100 次 open() 系统调用。共享一个 channel,FD 1 个、open 1 次、页缓存命中率拉满。

怎么省内存:分块流式读,不再持有整片 buffer

老写法的核心问题是——每分片都在堆上 new byte[],并发越多 / 分片越大、堆压力越大:

磁盘 → 堆上的整片 byte[] → 网络栈

ofFileChannel 的做法不一样——HttpClient 内部用一个小 ByteBuffer 分次从 channel 读数据再写到网络:

磁盘 → HttpClient 内部一个小 ByteBuffer(按需流式)→ 网络栈

⚠️ 不要把它当成 OS 级 zero-copy(sendfile)——OpenJDK 的 ofFileChannel 实现仍然是用 ByteBuffer.allocate(...) 分块读 channel;它真正省的是:不需要把整个分片一次性塞进堆 byte[],对 JVM 堆压力的优化是真的,对内核态拷贝次数没影响。

分片越多、文件越大,堆压力的差距越明显:

场景 老写法堆内存峰值 新写法堆内存峰值
1GB 文件 / 100 分片 / 10 并发 100MB × 10 = 1GB+ 几个 MB(HttpClient 内部分块 buffer)
5GB 文件 / 500 分片 / 10 并发 OOM 警告 几个 MB(同上)

3 个最直接的受益场景

ofFileChannel 不是革命性 API——更像一块以前缺失的拼图。最直接受益的 3 个场景:

场景 1:上传大文件到对象存储(OSS / S3 / MinIO)

最直接的受益场景。几 GB 文件分成几十上百分片并行上传,整个过程对 JVM 堆的压力可以控制在非常低的水平。

阿里云 OSS / AWS S3 / 七牛 / MinIO 这些对象存储,分片上传协议本就是这套——以前都得用各家 SDK,SDK 内部其实也是绕路实现。JDK 26 之后,用原生 HttpClient 也能写得干净。

场景 2:实现断点续传

断点续传变得简单——记录上次上传到了哪个 partIndex,下次直接从对应 offset 继续:

// 从本地 / DB 读出上次上传到的最后一个分片序号
int lastUploadedPartIndex = readResumeRecord();

// 从下一个分片继续上传——之前的全部跳过
for (int i = lastUploadedPartIndex + 1; i < totalParts; i++) {
long offset = (long) i * PART_SIZE; // 算出这片的起始字节
long length = Math.min(PART_SIZE, fileSize - offset); // 最后一片可能小于 PART_SIZE
// 用 ofFileChannel 按 (offset, length) 区间直接读——不需要 seek、不需要 byte[]
var publisher = HttpRequest.BodyPublishers.ofFileChannel(channel, offset, length);
// 上传 partIndex = i 的分片(HTTP 请求构造略)
}

代码量比以前少一大截。

场景 3:对同一文件做多区间读取

ofFileChannel 不改变 FileChannel 的 position,多个任务并发读取不同区间时不需要额外同步。

老写法用 RandomAccessFile.seek() + read() 是状态化的——多线程读同一 RAF 必踩 race condition。新写法直接根除。

顺带提一个小 bugfix

JDK 26 同期还有一个相关改动值得一提——BodyPublishers.ofFile(Path) 在路径不属于默认文件系统时,以前抛 NoSuchFileException,但 API 文档说应该抛 FileNotFoundException。

JDK 26 把这个不一致的行为修正了。算是个小但实际的 bugfix——如果你的代码里有针对这个异常的 catch 逻辑,升级前最好检查一下。

就一句话

ofFileChannel 这个 API 没有性能黑魔法,它只是把”分片上传”这件事的工程心智成本压到了底——

5 步演进,最后一步:3 行 API 替代以前的 30 行 + 让堆压力从”100MB × 并发”降到”HttpClient 内部一个小 buffer”。

我自己还没在生产跑(项目还在 JDK 21 LTS),但写 Demo 测下来——代码行数少了一半、堆压力肉眼可见地下降。

不一定要马上升 JDK 26——这个 API 的思路(共享 FileChannel、按区间读、分块流式发到网络)值得参考:哪怕你还在 JDK 17,老写法里也至少应该用 FileChannel.read(buffer, position) 替代 RandomAccessFile.seek + read——少一份并发风险。

Java 8 / 11 / 17 / 21 老用户值得收藏一份 ofFileChannel ——下次升级写代码会顺很多。

文章目录
  1. 1. 事故开篇:5GB 视频上传,凌晨堆爆 1.2GB
  2. 2. 横向对比:5 种分片上传方案的真实差距
  3. 3. 演进 5 步:从 30 行 / 100MB 到 3 行 / 堆压力降一大截
    1. 3.0.1. Step 1:原始写法 —— RandomAccessFile + byte[]
    2. 3.0.2. Step 2:NIO 改造 —— FileChannel.read(buffer, position)
    3. 3.0.3. Step 3:共享一个 FileChannel
    4. 3.0.4. Step 4:DirectByteBuffer 绕堆
    5. 3.0.5. Step 5:JDK 26 ofFileChannel —— 一行收口
  • 4. JDK 26 新写法:核心代码就 3 行
  • 5. 为什么是 FileChannel + 怎么省内存(澄清零拷贝误区)
    1. 5.0.1. 设计决策:为什么是 FileChannel,不是 ofFile(path, offset, size)
    2. 5.0.2. 怎么省内存:分块流式读,不再持有整片 buffer
  • 6. 3 个最直接的受益场景
    1. 6.0.1. 场景 1:上传大文件到对象存储(OSS / S3 / MinIO)
    2. 6.0.2. 场景 2:实现断点续传
    3. 6.0.3. 场景 3:对同一文件做多区间读取
  • 7. 顺带提一个小 bugfix
  • 8. 就一句话