Java NIO 实战
Java NIO 实战:三种文件处理场景的生产写法
场景一:大文件上传
前端传文件过来,后端接收并保存到本地或附件服务。
从 multipart 中读取保存到本地
@PostMapping("/upload")publicStringupload(MultipartFilefile)throwsIOException{StringdestPath="/data/uploads/"+UUID.randomUUID()+"_"+file.getOriginalFilename();// MultipartFile 本身提供了 transferTo,但大文件建议用 FileChannel 更可控try(FileChannelout=FileChannel.open(Paths.get(destPath),StandardOpenOption.WRITE,StandardOpenOption.CREATE,StandardOpenOption.TRUNCATE_EXISTING)){FileChannelin=((FileChannel)file.getInputStream().getChannel());// FileInputStream 获取 Channel// 零拷贝传输longtransferred=0;longfileSize=in.size();while(transferred<fileSize){transferred+=in.transferTo(transferred,fileSize-transferred,out);}}returndestPath;}边读边写(不依赖 multipart 的 InputStream)
有时候前端不是 multipart 上传,而是直接传二进制流,比如大文件分片上传的场景。
@PostMapping("/upload/raw")publicvoiduploadRaw(HttpServletRequestrequest)throwsIOException{StringdestPath="/data/uploads/"+UUID.randomUUID()+".bin";try(FileChannelout=FileChannel.open(Paths.get(destPath),StandardOpenOption.WRITE,StandardOpenOption.CREATE,StandardOpenOption.TRUNCATE_EXISTING)){// 从 request 的 InputStream 包装成 ChannelReadableByteChannelin=Channels.newChannel(request.getInputStream());ByteBufferbuf=ByteBuffer.allocate(8192);// 8K 缓冲区while(in.read(buf)!=-1){buf.flip();out.write(buf);buf.clear();}}}上传到 MinIO(不走本地磁盘)
如果你们用的是 MinIO 或 S3 之类的对象存储,不需要落盘:
@PostMapping("/upload/oss")publicStringuploadToOss(MultipartFilefile)throwsIOException{StringobjectName="uploads/"+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(FileChannelin=FileChannel.open(Paths.get(source),StandardOpenOption.READ);FileChannelout=FileChannel.open(Paths.get(target),StandardOpenOption.WRITE,StandardOpenOption.CREATE,StandardOpenOption.TRUNCATE_EXISTING)){longtransferred=0;longfileSize=in.size();while(transferred<fileSize){transferred+=in.transferTo(transferred,fileSize-transferred,out);}}}带进度的复制(大文件时给前端反馈)
publicstaticvoidcopyFileWithProgress(Stringsource,Stringtarget,ProgressCallbackcallback)throwsIOException{try(FileChannelin=FileChannel.open(Paths.get(source),StandardOpenOption.READ);FileChannelout=FileChannel.open(Paths.get(target),StandardOpenOption.WRITE,StandardOpenOption.CREATE,StandardOpenOption.TRUNCATE_EXISTING)){longtransferred=0;longfileSize=in.size();while(transferred<fileSize){transferred+=in.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{Stringrelative=sourceDir.relativize(sourcePath).toString();PathtargetPath=Paths.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 直接写到 Response
@GetMapping("/export")publicvoidexport(HttpServletResponseresponse)throwsIOException{Filefile=newFile("/data/reports/monthly-report.xlsx");response.setContentType("application/octet-stream");response.setHeader("Content-Disposition","attachment; filename="+file.getName());response.setContentLengthLong(file.length());try(FileChannelin=FileChannel.open(file.toPath(),StandardOpenOption.READ);WritableByteChannelout=Channels.newChannel(response.getOutputStream())){longtransferred=0;longfileSize=in.size();while(transferred<fileSize){transferred+=in.transferTo(transferred,fileSize-transferred,out);}}}用 OutputStream 写(如果框架限制只能用 OutputStream)
如果你的框架不支持 Channel,退而求其次用 Buffer 流式写:
@GetMapping("/download")publicvoiddownload(HttpServletResponseresponse)throwsIOException{Pathfile=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(InputStreamis=Files.newInputStream(file);OutputStreamos=response.getOutputStream()){byte[]buf=newbyte[8192];intlen;while((len=is.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; filename=export.xlsx");// 用 SXSSFWorkbook(不缓存数据在内存)try(SXSSFWorkbookworkbook=newSXSSFWorkbook()){Sheetsheet=workbook.createSheet("数据");// 边写行for(inti=0;i<100000;i++){Rowrow=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 都有默认超时,要提前配好:
# application.ymlserver:servlet:session:timeout:30mNginx 侧:
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 循环确保全量传输,剩下的交给操作系统。
