Java NIO 实战
Java NIO 实战三种文件处理场景的生产写法场景一大文件上传前端传文件过来后端接收并保存到本地或附件服务。从 multipart 中读取保存到本地PostMapping(/upload)publicStringupload(MultipartFilefile)throwsIOException{StringdestPath/data/uploads/UUID.randomUUID()_file.getOriginalFilename();// MultipartFile 本身提供了 transferTo但大文件建议用 FileChannel 更可控try(FileChanneloutFileChannel.open(Paths.get(destPath),StandardOpenOption.WRITE,StandardOpenOption.CREATE,StandardOpenOption.TRUNCATE_EXISTING)){FileChannelin((FileChannel)file.getInputStream().getChannel());// FileInputStream 获取 Channel// 零拷贝传输longtransferred0;longfileSizein.size();while(transferredfileSize){transferredin.transferTo(transferred,fileSize-transferred,out);}}returndestPath;}边读边写不依赖 multipart 的 InputStream有时候前端不是 multipart 上传而是直接传二进制流比如大文件分片上传的场景。PostMapping(/upload/raw)publicvoiduploadRaw(HttpServletRequestrequest)throwsIOException{StringdestPath/data/uploads/UUID.randomUUID().bin;try(FileChanneloutFileChannel.open(Paths.get(destPath),StandardOpenOption.WRITE,StandardOpenOption.CREATE,StandardOpenOption.TRUNCATE_EXISTING)){// 从 request 的 InputStream 包装成 ChannelReadableByteChannelinChannels.newChannel(request.getInputStream());ByteBufferbufByteBuffer.allocate(8192);// 8K 缓冲区while(in.read(buf)!-1){buf.flip();out.write(buf);buf.clear();}}}上传到 MinIO不走本地磁盘如果你们用的是 MinIO 或 S3 之类的对象存储不需要落盘PostMapping(/upload/oss)publicStringuploadToOss(MultipartFilefile)throwsIOException{StringobjectNameuploads/UUID.randomUUID()_file.getOriginalFilename();minioClient.putObject(PutObjectArgs.builder().bucket(my-bucket).object(objectName).stream(file.getInputStream(),file.getSize(),-1).contentType(file.getContentType()).build());returnobjectName;}大文件上传的注意事项不要file.getBytes()— 大文件会直接撑爆内存不要FileUtils.copyInputStreamToFile— 本质也是先读到内存再写分段传— 前端分片 后端按 offset 写入如果文件超过 500M建议前端分片限流— Nginx 层限制请求体大小防止有人直接传几个 G 过来临时文件清理— 上传过程中崩溃了记得兜底删除残留文件场景二文件拷贝把本地一个文件复制到另一个地方。零拷贝复制最推荐publicstaticvoidcopyFile(Stringsource,Stringtarget)throwsIOException{try(FileChannelinFileChannel.open(Paths.get(source),StandardOpenOption.READ);FileChanneloutFileChannel.open(Paths.get(target),StandardOpenOption.WRITE,StandardOpenOption.CREATE,StandardOpenOption.TRUNCATE_EXISTING)){longtransferred0;longfileSizein.size();while(transferredfileSize){transferredin.transferTo(transferred,fileSize-transferred,out);}}}带进度的复制大文件时给前端反馈publicstaticvoidcopyFileWithProgress(Stringsource,Stringtarget,ProgressCallbackcallback)throwsIOException{try(FileChannelinFileChannel.open(Paths.get(source),StandardOpenOption.READ);FileChanneloutFileChannel.open(Paths.get(target),StandardOpenOption.WRITE,StandardOpenOption.CREATE,StandardOpenOption.TRUNCATE_EXISTING)){longtransferred0;longfileSizein.size();while(transferredfileSize){transferredin.transferTo(transferred,fileSize-transferred,out);callback.onProgress(transferred,fileSize);}}}FunctionalInterfaceinterfaceProgressCallback{voidonProgress(longtransferred,longtotal);}目录下的批量复制publicstaticvoidcopyDir(StringsourceDir,StringtargetDir)throwsIOException{Files.createDirectories(Paths.get(targetDir));Files.walk(Paths.get(sourceDir)).forEach(sourcePath-{try{StringrelativesourceDir.relativize(sourcePath).toString();PathtargetPathPaths.get(targetDir,relative);if(Files.isDirectory(sourcePath)){Files.createDirectories(targetPath);}else{copyFile(sourcePath.toString(),targetPath.toString());}}catch(IOExceptione){thrownewUncheckedIOException(e);}});}FileChannel 和 Files.copy 选哪个// 方式一FileChannel.transferTo零拷贝try(FileChannelin...;FileChannelout...){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 直接写到 ResponseGetMapping(/export)publicvoidexport(HttpServletResponseresponse)throwsIOException{FilefilenewFile(/data/reports/monthly-report.xlsx);response.setContentType(application/octet-stream);response.setHeader(Content-Disposition,attachment; filenamefile.getName());response.setContentLengthLong(file.length());try(FileChannelinFileChannel.open(file.toPath(),StandardOpenOption.READ);WritableByteChanneloutChannels.newChannel(response.getOutputStream())){longtransferred0;longfileSizein.size();while(transferredfileSize){transferredin.transferTo(transferred,fileSize-transferred,out);}}}用 OutputStream 写如果框架限制只能用 OutputStream如果你的框架不支持 Channel退而求其次用 Buffer 流式写GetMapping(/download)publicvoiddownload(HttpServletResponseresponse)throwsIOException{PathfilePaths.get(/data/reports/monthly-report.xlsx);response.setContentType(application/vnd.openxmlformats-officedocument.spreadsheetml.sheet);response.setHeader(Content-Disposition,attachment; filenamereport.xlsx);response.setContentLengthLong(Files.size(file));try(InputStreamisFiles.newInputStream(file);OutputStreamosresponse.getOutputStream()){byte[]bufnewbyte[8192];intlen;while((lenis.read(buf))!-1){os.write(buf,0,len);}}}Files.newInputStream返回的是一个FileInputStream它底层读文件不会把整个文件加载到堆内存而是走系统调用每次只读 8K。所以即使文件几个 G内存占用也是稳定的。动态生成内容直接导出不落盘有时候文件是实时生成的比如导出数据库数据为 Excel。这时候不需要先保存成文件再发送边生成边发GetMapping(/export/excel)publicvoidexportExcel(HttpServletResponseresponse)throwsIOException{response.setContentType(application/vnd.openxmlformats-officedocument.spreadsheetml.sheet);response.setHeader(Content-Disposition,attachment; filenameexport.xlsx);// 用 SXSSFWorkbook不缓存数据在内存try(SXSSFWorkbookworkbooknewSXSSFWorkbook()){Sheetsheetworkbook.createSheet(数据);// 边写行for(inti0;i100000;i){Rowrowsheet.createRow(i);row.createCell(0).setCellValue(第i行);}// 直接写到 response 流不经过本地磁盘workbook.write(response.getOutputStream());}}大文件导出的注意事项不要这么做原因FileUtils.readFileToByteArray整个文件读到堆内存文件稍大就 OOMIOUtils.toByteArray同上byte[] fileBytes Files.readAllBytes(path)同上先保存到本地再返回要兜底清理临时文件而且多了 IO 开销不设置 ContentLength前端不知道文件多大不能显示进度条导出时的超时处理大文件导出时间较长Nginx 和 Spring Boot 都有默认超时要提前配好# application.ymlserver:servlet:session:timeout:30mNginx 侧proxy_read_timeout 600s; proxy_send_timeout 600s;总结场景推荐方式核心代码上传保存FileChannel.transferToin.transferTo(offset, size, out)文件复制FileChannel.transferToin.transferTo(offset, size, out)文件导出FileChannel.transferTo到 Responsein.transferTo(offset, size, Channels.newChannel(response.getOutputStream()))核心思想就一条数据不经过 Java 堆内存在内核态完成传输。你只需要管理好 offset 和 while 循环确保全量传输剩下的交给操作系统。