杰瑞科技汇

Java上传文件内存溢出,如何解决?

为什么上传文件会导致内存溢出?

内存溢出的核心原因是 试图将整个文件一次性加载到内存中,让我们看看一个典型的、有问题的上传代码流程:

Java上传文件内存溢出,如何解决?-图1
(图片来源网络,侵删)
  1. 客户端发送一个文件(一个 500MB 的视频文件)到服务器。
  2. 服务器端的代码(一个 Servlet)接收到这个 HTTP 请求。
  3. 为了处理文件,代码中可能会有类似这样的操作:
    // 错误示范:将整个请求体读取到一个字节数组中
    byte[] fileBytes = request.getInputStream().readAllBytes(); 
    // 或者使用 Apache Commons IO
    // byte[] fileBytes = IOUtils.toByteArray(request.getInputStream());
  4. readAllBytes() 方法会一直读取输入流,直到数据结束,然后将所有数据都存储在内存中的一个 byte[] 数组里。
  5. 如果上传的文件是 500MB,JVM 堆中就需要立即分配 500MB 的连续空间来存储这个数组,如果服务器内存不足,或者同时有多个大文件上传,JVM 堆内存很快就会被耗尽,从而抛出 OutOfMemoryError

问题出在“全量加载”这个行为上。 无论是 byte[]String 还是 InputStream 直接转成内存对象,只要文件大小超过了 JVM 剩余的可用内存,就会溢出。


解决方案:如何避免内存溢出?

解决这个问题的关键思想是:流式处理,不一次性加载整个文件,我们应该像水管一样,让数据从客户端流到服务器,再从服务器流到最终的存储位置(如硬盘、云存储),而不是用一个巨大的水桶(内存)去接住所有水。

以下是几种主流的解决方案,从推荐到备选排序:

使用 Servlet 3.0+ 的 Part API (推荐,原生且简单)

如果你的应用运行在支持 Servlet 3.0 的服务器(如 Tomcat 7+, Jetty 9+, Spring Boot 内嵌服务器等)上,这是最简单、最直接的方法。

Java上传文件内存溢出,如何解决?-图2
(图片来源网络,侵删)

工作原理: HttpServletRequest 提供了一个 getPart() 方法,它返回一个 Part 对象。Part 对象本质上是一个封装了上传文件数据的 InputStream,你可以直接从这个 InputStream 中读取数据,并写入到文件系统,而无需将整个文件内容加载到内存。

示例代码:

import javax.servlet.annotation.MultipartConfig;
import javax.servlet.http.HttpServlet;
import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;
import javax.servlet.http.Part;
import java.io.File;
import java.io.IOException;
import java.io.InputStream;
import java.nio.file.Files;
import java.nio.file.Paths;
import java.nio.file.StandardCopyOption;
// 在 Servlet 类上添加 @MultipartConfig 注解
@MultipartConfig(
    fileSizeThreshold = 1024 * 1024,       // 1MB 的内存缓冲区,超过这个大小就写入临时文件
    maxFileSize = 1024 * 1024 * 100,      // 单个文件最大 100MB
    maxRequestSize = 1024 * 1024 * 200    // 整个请求最大 200MB
)
public class FileUploadServlet extends HttpServlet {
    @Override
    protected void doPost(HttpServletRequest request, HttpServletResponse response) throws IOException {
        // 1. 获取上传的文件部分
        Part filePart = request.getPart("file"); // "file" 是前端 input 的 name 属性
        String fileName = getFileName(filePart); // 从 Part 对象中获取原始文件名
        // 2. 定义目标存储路径
        String uploadPath = getServletContext().getRealPath("") + File.separator + "uploads";
        File uploadDir = new File(uploadPath);
        if (!uploadDir.exists()) {
            uploadDir.mkdir();
        }
        String filePath = uploadPath + File.separator + fileName;
        // 3. 核心:使用 NIO 的 Files.copy 进行流式复制
        // 这是最关键的一步,它会将 InputStream 的数据直接写入到文件系统,
        // 而不会在内存中保存整个文件。
        try (InputStream fileContent = filePart.getInputStream()) {
            Files.copy(fileContent, Paths.get(filePath), StandardCopyOption.REPLACE_EXISTING);
        }
        response.getWriter().println("File " + fileName + " uploaded successfully to " + filePath);
    }
    private String getFileName(final Part part) {
        final String partHeader = part.getHeader("content-disposition");
        for (String content : partHeader.split(";")) {
            if (content.trim().startsWith("filename")) {
                return content.substring(content.indexOf('=') + 1).trim().replace("\"", "");
            }
        }
        return null;
    }
}

前端 HTML:

<form action="your-upload-servlet-url" method="post" enctype="multipart/form-data">
    <input type="file" name="file" />
    <input type="submit" value="Upload" />
</form>

使用成熟的框架库 (如 Apache Commons FileUpload)

如果你使用的是较旧的 Servlet 版本(2.x),或者需要更强大的功能(如进度监听、更灵活的配置),Apache Commons FileUpload 是一个绝佳的选择。

Java上传文件内存溢出,如何解决?-图3
(图片来源网络,侵删)

工作原理: 它通过 DiskFileItemFactory 来管理内存和临时文件,当上传的文件大小超过一个阈值时,它会自动将文件内容从内存转移到服务器的临时磁盘上,从而保护内存。

Maven 依赖:

<dependency>
    <groupId>commons-fileupload</groupId>
    <artifactId>commons-fileupload</artifactId>
    <version>1.5</version>
</dependency>
<dependency>
    <groupId>commons-io</groupId>
    <artifactId>commons-io</artifactId>
    <version>2.11.0</version>
</dependency>

示例代码:

import org.apache.commons.fileupload.FileItem;
import org.apache.commons.fileupload.disk.DiskFileItemFactory;
import org.apache.commons.fileupload.servlet.ServletFileUpload;
import javax.servlet.ServletException;
import javax.servlet.http.HttpServlet;
import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;
import java.io.File;
import java.io.IOException;
import java.io.PrintWriter;
import java.util.List;
public class CommonsFileUploadServlet extends HttpServlet {
    private static final long serialVersionUID = 1L;
    private static final String UPLOAD_DIRECTORY = "uploads";
    private static final int MEMORY_THRESHOLD = 1024 * 1024 * 3;  // 3MB
    private static final int MAX_FILE_SIZE = 1024 * 1024 * 40; // 40MB
    private static final int MAX_REQUEST_SIZE = 1024 * 1024 * 50; // 50MB
    @Override
    protected void doPost(HttpServletRequest request, HttpServletResponse response) throws ServletException, IOException {
        // 检查是否是 multipart 请求
        if (!ServletFileUpload.isMultipartContent(request)) {
            // 如果不是,则停止
            PrintWriter writer = response.getWriter();
            writer.println("Error: Form must has enctype=multipart/form-data.");
            writer.flush();
            return;
        }
        // 配置上传参数
        DiskFileItemFactory factory = new DiskFileItemFactory();
        factory.setSizeThreshold(MEMORY_THRESHOLD);
        factory.setRepository(new File(System.getProperty("java.io.tmpdir")));
        ServletFileUpload upload = new ServletFileUpload(factory);
        upload.setFileSizeMax(MAX_FILE_SIZE);
        upload.setSizeMax(MAX_REQUEST_SIZE);
        // 构建上传路径
        String uploadPath = getServletContext().getRealPath("") + File.separator + UPLOAD_DIRECTORY;
        File uploadDir = new File(uploadPath);
        if (!uploadDir.exists()) {
            uploadDir.mkdir();
        }
        try {
            // 解析请求内容,获取文件项
            @SuppressWarnings("unchecked")
            List<FileItem> formItems = upload.parseRequest(request);
            if (formItems != null && formItems.size() > 0) {
                for (FileItem item : formItems) {
                    // 处理不在表单中的字段(即文件)
                    if (!item.isFormField() && item.getName() != null) {
                        String fileName = new File(item.getName()).getName();
                        String filePath = uploadPath + File.separator + fileName;
                        File storeFile = new File(filePath);
                        // 在此处写入文件,item.write() 内部也是流式操作
                        item.write(storeFile);
                        System.out.println("File " + fileName + " uploaded successfully!");
                    }
                }
            }
        } catch (Exception ex) {
            throw new ServletException("Error uploading file", ex);
        }
        response.getWriter().println("File uploaded successfully!");
    }
}

直接操作 InputStream (基础方案)

如果你不想依赖任何库,也可以自己处理 HttpServletRequestInputStream,这种方法更底层,需要自己处理一些边界情况,但原理是一样的。

示例代码:

import javax.servlet.http.HttpServlet;
import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;
import java.io.*;
import java.nio.file.Files;
import java.nio.file.Paths;
import java.nio.file.StandardCopyOption;
public class ManualInputStreamServlet extends HttpServlet {
    @Override
    protected void doPost(HttpServletRequest request, HttpServletResponse response) throws IOException {
        // 注意:这种方法无法直接获取文件名,需要从请求头解析
        String fileName = "uploaded_file_" + System.currentTimeMillis() + ".dat";
        String uploadPath = getServletContext().getRealPath("") + File.separator + "uploads";
        File uploadDir = new File(uploadPath);
        if (!uploadDir.exists()) {
            uploadDir.mkdir();
        }
        String filePath = uploadPath + File.separator + fileName;
        // 使用 try-with-resources 确保 Stream 被正确关闭
        try (InputStream is = request.getInputStream();
             OutputStream os = Files.newOutputStream(Paths.get(filePath))) {
            byte[] buffer = new byte[4096]; // 4KB 的缓冲区
            int bytesRead;
            while ((bytesRead = is.read(buffer)) != -1) {
                os.write(buffer, 0, bytesRead);
            }
        }
        response.getWriter().println("File " + fileName + " uploaded successfully.");
    }
}

最佳实践和注意事项

  1. 配置合理的内存阈值

    • 对于 @MultipartConfigDiskFileItemFactory,设置一个合理的 fileSizeThreshold(如 1MB 或 10MB),这意味着小于这个大小的文件会保存在内存中,大于这个大小的文件会写入临时磁盘,这样可以平衡性能和内存使用。
  2. 限制上传文件大小

    • 必须在服务器和代码层面都设置文件大小限制,防止恶意用户上传超大文件耗尽服务器资源。
    • 服务器层面:在 Tomcat 的 conf/web.xml 中配置 maxPostSize
    • 代码层面:使用 @MultipartConfigmaxFileSizeServletFileUploadsetFileSizeMax
  3. 清理临时文件

    • 使用 Commons FileUpload 时,上传成功后,如果文件被写入临时目录,最好手动删除 FileItem 对象(通过 delete() 方法),避免磁盘空间被占满。
    • 对于 @MultipartConfig,服务器通常会在请求结束后自动清理临时文件,但了解其行为总是好的。
  4. 考虑云存储

    • 对于生产环境,特别是大文件上传,最佳实践是先将文件上传到你的应用服务器,然后立即将文件异步地转移到专业的云存储服务(如 Amazon S3, Google Cloud Storage, 阿里云 OSS)。
    • 这样做的好处:
      • 可靠性:云存储提供了持久性和高可用性。
      • 可扩展性:云存储可以轻松应对海量文件存储需求。
      • 性能:云存储服务通常能提供更好的下载速度和 CDN 加速。
      • 安全性:可以利用云存储的访问控制策略。
  5. 异步处理

    • 如果上传文件后还需要进行耗时处理(如视频转码、图片压缩),不要在请求线程中同步处理,这会导致客户端长时间等待,应该将任务放入消息队列(如 RabbitMQ, Kafka)或使用异步任务框架(如 Spring @Async),让请求线程快速返回一个“上传成功”的响应,后台再慢慢处理。
方案 优点 缺点 适用场景
Servlet 3.0+ Part API 简单、原生、无额外依赖、代码简洁 需要 Servlet 3.0+ 环境 强烈推荐,适用于所有现代 Java Web 应用。
Apache Commons FileUpload 功能强大、灵活、兼容旧版 Servlet 需要额外引入库、代码稍显繁琐 需要兼容旧项目或需要高级功能(如进度条)时。
直接操作 InputStream 无依赖、完全控制 代码复杂、需手动处理细节(如文件名)、容易出错 学习或特殊需求,不推荐用于生产项目。

在任何新的 Java Web 项目中,优先使用 Servlet 3.0+ 的 Part API,它完美地解决了内存溢出问题,并且使用起来非常简单,核心原则永远是 “流式处理,避免全量加载”

分享:
扫描分享到社交APP
上一篇
下一篇