Java NIO 实战

Java NIO 实战:三种文件处理场景的生产写法


场景一:大文件上传

前端传文件过来,后端接收并保存到本地或附件服务。

从 multipart 中读取保存到本地

java 复制代码
@PostMapping("/upload")
public String upload(MultipartFile file) throws IOException {
    String destPath = "/data/uploads/" + UUID.randomUUID() + "_" + file.getOriginalFilename();

    // MultipartFile 本身提供了 transferTo,但大文件建议用 FileChannel 更可控
    try (FileChannel out = FileChannel.open(Paths.get(destPath),
            StandardOpenOption.WRITE,
            StandardOpenOption.CREATE,
            StandardOpenOption.TRUNCATE_EXISTING)) {

        FileChannel in = ((FileChannel) file.getInputStream().getChannel());
        // FileInputStream 获取 Channel
        // 零拷贝传输
        long transferred = 0;
        long fileSize = in.size();
        while (transferred < fileSize) {
            transferred += in.transferTo(transferred, fileSize - transferred, out);
        }
    }

    return destPath;
}

边读边写(不依赖 multipart 的 InputStream)

有时候前端不是 multipart 上传,而是直接传二进制流,比如大文件分片上传的场景。

java 复制代码
@PostMapping("/upload/raw")
public void uploadRaw(HttpServletRequest request) throws IOException {
    String destPath = "/data/uploads/" + UUID.randomUUID() + ".bin";

    try (FileChannel out = FileChannel.open(Paths.get(destPath),
            StandardOpenOption.WRITE,
            StandardOpenOption.CREATE,
            StandardOpenOption.TRUNCATE_EXISTING)) {

        // 从 request 的 InputStream 包装成 Channel
        ReadableByteChannel in = Channels.newChannel(request.getInputStream());
        ByteBuffer buf = ByteBuffer.allocate(8192); // 8K 缓冲区

        while (in.read(buf) != -1) {
            buf.flip();
            out.write(buf);
            buf.clear();
        }
    }
}

上传到 MinIO(不走本地磁盘)

如果你们用的是 MinIO 或 S3 之类的对象存储,不需要落盘:

java 复制代码
@PostMapping("/upload/oss")
public String uploadToOss(MultipartFile file) throws IOException {
    String objectName = "uploads/" + UUID.randomUUID() + "_" + file.getOriginalFilename();
    minioClient.putObject(PutObjectArgs.builder()
            .bucket("my-bucket")
            .object(objectName)
            .stream(file.getInputStream(), file.getSize(), -1)
            .contentType(file.getContentType())
            .build());
    return objectName;
}

大文件上传的注意事项

  • 不要 file.getBytes() --- 大文件会直接撑爆内存
  • 不要 FileUtils.copyInputStreamToFile --- 本质也是先读到内存再写
  • 分段传 --- 前端分片 + 后端按 offset 写入(如果文件超过 500M,建议前端分片)
  • 限流 --- Nginx 层限制请求体大小,防止有人直接传几个 G 过来
  • 临时文件清理 --- 上传过程中崩溃了,记得兜底删除残留文件

场景二:文件拷贝

把本地一个文件复制到另一个地方。

零拷贝复制(最推荐)

java 复制代码
public static void copyFile(String source, String target) throws IOException {
    try (FileChannel in = FileChannel.open(Paths.get(source), StandardOpenOption.READ);
         FileChannel out = FileChannel.open(Paths.get(target),
                 StandardOpenOption.WRITE,
                 StandardOpenOption.CREATE,
                 StandardOpenOption.TRUNCATE_EXISTING)) {

        long transferred = 0;
        long fileSize = in.size();
        while (transferred < fileSize) {
            transferred += in.transferTo(transferred, fileSize - transferred, out);
        }
    }
}

带进度的复制(大文件时给前端反馈)

java 复制代码
public static void copyFileWithProgress(String source, String target, ProgressCallback callback) throws IOException {
    try (FileChannel in = FileChannel.open(Paths.get(source), StandardOpenOption.READ);
         FileChannel out = FileChannel.open(Paths.get(target),
                 StandardOpenOption.WRITE,
                 StandardOpenOption.CREATE,
                 StandardOpenOption.TRUNCATE_EXISTING)) {

        long transferred = 0;
        long fileSize = in.size();
        while (transferred < fileSize) {
            transferred += in.transferTo(transferred, fileSize - transferred, out);
            callback.onProgress(transferred, fileSize);
        }
    }
}

@FunctionalInterface
interface ProgressCallback {
    void onProgress(long transferred, long total);
}

目录下的批量复制

java 复制代码
public static void copyDir(String sourceDir, String targetDir) throws IOException {
    Files.createDirectories(Paths.get(targetDir));
    Files.walk(Paths.get(sourceDir)).forEach(sourcePath -> {
        try {
            String relative = sourceDir.relativize(sourcePath).toString();
            Path targetPath = Paths.get(targetDir, relative);
            if (Files.isDirectory(sourcePath)) {
                Files.createDirectories(targetPath);
            } else {
                copyFile(sourcePath.toString(), targetPath.toString());
            }
        } catch (IOException e) {
            throw new UncheckedIOException(e);
        }
    });
}

FileChannel 和 Files.copy 选哪个?

java 复制代码
// 方式一:FileChannel.transferTo(零拷贝)
try (FileChannel in = ...; FileChannel out = ...) {
    in.transferTo(0, in.size(), out);
}

// 方式二:Files.copy(简单,但内部实现取决于平台)
Files.copy(sourcePath, targetPath, StandardCopyOption.REPLACE_EXISTING);
  • 同一个机器上的文件复制,两者差别不大。Files.copy 底层在 Linux 上也走 sendfile,是一样的
  • 跨机器场景(本地文件系统 → 远程 socket),只能用 transferTo
  • 从代码简洁角度看,小文件直接用 Files.copy。几百兆以上的用 FileChannel.transferTo

场景三:大文件导出

本地文件通过网络传给前端。这是最容易踩坑的场景------很多人直接把文件全读到内存再输出,文件一大就 OOM。

标准写法:FileChannel 直接写到 Response

java 复制代码
@GetMapping("/export")
public void export(HttpServletResponse response) throws IOException {
    File file = new File("/data/reports/monthly-report.xlsx");

    response.setContentType("application/octet-stream");
    response.setHeader("Content-Disposition", "attachment; filename=" + file.getName());
    response.setContentLengthLong(file.length());

    try (FileChannel in = FileChannel.open(file.toPath(), StandardOpenOption.READ);
         WritableByteChannel out = Channels.newChannel(response.getOutputStream())) {

        long transferred = 0;
        long fileSize = in.size();
        while (transferred < fileSize) {
            transferred += in.transferTo(transferred, fileSize - transferred, out);
        }
    }
}

用 OutputStream 写(如果框架限制只能用 OutputStream)

如果你的框架不支持 Channel,退而求其次用 Buffer 流式写:

java 复制代码
@GetMapping("/download")
public void download(HttpServletResponse response) throws IOException {
    Path file = Paths.get("/data/reports/monthly-report.xlsx");

    response.setContentType("application/vnd.openxmlformats-officedocument.spreadsheetml.sheet");
    response.setHeader("Content-Disposition", "attachment; filename=report.xlsx");
    response.setContentLengthLong(Files.size(file));

    try (InputStream is = Files.newInputStream(file);
         OutputStream os = response.getOutputStream()) {

        byte[] buf = new byte[8192];
        int len;
        while ((len = is.read(buf)) != -1) {
            os.write(buf, 0, len);
        }
    }
}

Files.newInputStream 返回的是一个 FileInputStream,它底层读文件不会把整个文件加载到堆内存,而是走系统调用,每次只读 8K。所以即使文件几个 G,内存占用也是稳定的。

动态生成内容直接导出(不落盘)

有时候文件是实时生成的,比如导出数据库数据为 Excel。这时候不需要先保存成文件再发送,边生成边发:

java 复制代码
@GetMapping("/export/excel")
public void exportExcel(HttpServletResponse response) throws IOException {
    response.setContentType("application/vnd.openxmlformats-officedocument.spreadsheetml.sheet");
    response.setHeader("Content-Disposition", "attachment; filename=export.xlsx");

    // 用 SXSSFWorkbook(不缓存数据在内存)
    try (SXSSFWorkbook workbook = new SXSSFWorkbook()) {
        Sheet sheet = workbook.createSheet("数据");

        // 边写行
        for (int i = 0; i < 100000; i++) {
            Row row = sheet.createRow(i);
            row.createCell(0).setCellValue("第" + i + "行");
        }

        // 直接写到 response 流,不经过本地磁盘
        workbook.write(response.getOutputStream());
    }
}

大文件导出的注意事项

不要这么做 原因
FileUtils.readFileToByteArray 整个文件读到堆内存,文件稍大就 OOM
IOUtils.toByteArray 同上
byte[] fileBytes = Files.readAllBytes(path) 同上
先保存到本地再返回 要兜底清理临时文件,而且多了 IO 开销
不设置 ContentLength 前端不知道文件多大,不能显示进度条

导出时的超时处理

大文件导出时间较长,Nginx 和 Spring Boot 都有默认超时,要提前配好:

yaml 复制代码
# application.yml
server:
  servlet:
    session:
      timeout: 30m

Nginx 侧:

nginx 复制代码
proxy_read_timeout 600s;
proxy_send_timeout 600s;

总结

场景 推荐方式 核心代码
上传保存 FileChannel.transferTo in.transferTo(offset, size, out)
文件复制 FileChannel.transferTo in.transferTo(offset, size, out)
文件导出 FileChannel.transferTo 到 Response in.transferTo(offset, size, Channels.newChannel(response.getOutputStream()))

核心思想就一条:数据不经过 Java 堆内存,在内核态完成传输。 你只需要管理好 offset 和 while 循环确保全量传输,剩下的交给操作系统。

相关推荐
二宝哥1 小时前
springboot项目使用Gradle工具实现依赖版本控制
java·spring boot·后端·gradle·版本
浩瀚之水_csdn2 小时前
Python 3 网络编程详解:从原理到实战
开发语言·网络·python
济*沧*海2 小时前
Set集合深度解析:原理、特性与应用场景全攻略
java
geovindu2 小时前
java: Singleton Pattern
java·开发语言·后端·单例模式·设计模式·创建型模式
AI行业学习2 小时前
2026 版 Notepad++ 完整图文安装指南|官方渠道无捆绑,一键切换中文界面
开发语言·人工智能·python·html·notepad++
鹏易灵2 小时前
C++——7.类与对象,掌握封装、继承、多态.详解
开发语言·c++·算法
一路向北North2 小时前
Spring Security OAuth2.0(16):密码模式
java·后端·spring
水无痕simon2 小时前
5 多表操作
java·开发语言·数据库
初阳7852 小时前
【Qt】系统相关(2)——文件
开发语言·qt