好久没有更新博客了,最近项目也接近尾声了,今天记录一个case处理过程。
一、问题描述
1. 异常信息
java.lang.OutOfMemoryError: Java heap space
java.io.ByteArrayOutputStream.<init>(Unknown Source)
org.apache.commons.fileupload.DeferredFileOutputStream.<init>(DeferredFileOutputStream.java:131)
org.apache.commons.fileupload.DefaultFileItem.getOutputStream(DefaultFileItem.java:558)
org.apache.commons.fileupload.FileUploadBase.parseRequest(FileUploadBase.java:406)
org.apache.struts.upload.CommonsMultipartRequestHandler.handleRequest(CommonsMultipartRequestHandler.java:193)
org.apache.struts.util.RequestUtils.populate(RequestUtils.java:443)
org.apache.struts.action.RequestProcessor.processPopulate(RequestProcessor.java:804)
org.apache.struts.action.RequestProcessor.process(RequestProcessor.java:203)
org.apache.struts.action.ActionServlet.process(ActionServlet.java:1196)
org.apache.struts.action.ActionServlet.doPost(ActionServlet.java:432)
javax.servlet.http.HttpServlet.service(HttpServlet.java:637)
com.xxxx.ui.framework.ActionServlet.service(ActionServlet.java:138)
javax.servlet.http.HttpServlet.service(HttpServlet.java:717)
com.xxxx.ui.framework.SessionFilter.doFilter(SessionFilter.java:89)
2. 问题重现过程
1) 页面上有一个文件导入功能,可以导入IP信息列表,用户可以通过">>"和“<<”按钮对导入的信息进行添加和删除。
2) 当从文件中导入5000条IP信息时,数据导入过程可以从正常完成,导入后的数据也能正常显示。
3) 当将导入的5000条数据全部删除时,抛出上述OutOfMemoryError异常。
二、问题分析
第一次QA发现这个问题时,我想当然以为这是由于导入的数据量太大,导致内存用尽,从而报出这个错误。因为之前版本也有这个问题,所以优先级比较低。直到最近项目接近尾声,经老板提醒,IP地址信息每个存储都小于128byte, 5000个IP地址信息总共也只有500k, 为什么会导致内存耗尽呢? 此事背后一定隐藏着一个天大的秘密!
三、分析过程
1) 通过VisualVM连接tomcat进程,观察内存使用情况。
通过几次测试,发现每次提交删除大量IP地址数据时,内存会突然增大,从而导致OutOfMemoryError,而过段时间,通过垃圾回收,内存会重新回收。
2)排查代码
我在代码中处理IP信息删除的Action里设置断点,却发现在到达这个断点之前,已经抛出OutOfMemoryError,从而排除由于代码中创建大量对象导致OutOfMemoryError的可能性。
3) 通过观察异常堆栈,初步推断: Struts在处理Multipart request时,调用fileupload组件。 fileupload组件在创建流的过程中,内存不足导致OutOfMemoryError异常。
通过查看jsp文件,发现提交的form定义如下:
<html:form action="/saveSMTPConn.action" enctype="multipart/form-data">
由此可见Struts调用fileupload来处理multipart request可能有问题。 在Apache官网查阅了issue列表,只找到一个类似的issue: STR-1857. 这个issue中提到fileupload模块本身有缺陷,但没有提及详细原因。所以决定从源码入手进行分析。
四、源码分析
从官网上下载了Struts1.2.7和fileupload1.0的源码,导入IDE中开始调试。
1) 找到Struts调用fileupload进行Multipart Request解析的代码:
public void handleRequest(HttpServletRequest request)
throws ServletException {
// Get the app config for the current request.
ModuleConfig ac = (ModuleConfig) request.getAttribute(
Globals.MODULE_KEY);
// Create and configure a DIskFileUpload instance.
DiskFileUpload upload = new DiskFileUpload();
// The following line is to support an "EncodingFilter"
// see http:///bugzilla/show_bug.cgi?id=23255
upload.setHeaderEncoding(request.getCharacterEncoding());
// Set the maximum size before a FileUploadException will be thrown.
upload.setSizeMax(getSizeMax(ac));
// Set the maximum size that will be stored in memory.
upload.setSizeThreshold((int) getSizeThreshold(ac));
// Set the the location for saving data on disk.
upload.setRepositoryPath(getRepositoryPath(ac));
// Create the hash tables to be populated.
elementsText = new Hashtable();
elementsFile = new Hashtable();
elementsAll = new Hashtable();
// Parse the request into file items.
List items = null;
try {
items = upload.parseRequest(request);
} catch (DiskFileUpload.SizeLimitExceededException e) {
// Special handling for uploads that are too big.
request.setAttribute(
MultipartRequestHandler.ATTRIBUTE_MAX_LENGTH_EXCEEDED,
Boolean.TRUE);
return;
} catch (FileUploadException e) {
log.error("Failed to parse multipart request", e);
throw new ServletException(e);
}
// Partition the items into form fields and files.
Iterator iter = items.iterator();
while (iter.hasNext()) {
FileItem item = (FileItem) iter.next();
if (item.isFormField()) {
addTextParameter(request, item);
} else {
addFileParameter(item);
}
}
}
2) 查看upload.parseRequest(request)的具体处理过程。
public List /* FileItem */ parseRequest(HttpServletRequest req)
throws FileUploadException
{
if (null == req)
{
throw new NullPointerException("req parameter");
}
ArrayList items = new ArrayList();
String contentType = req.getHeader(CONTENT_TYPE);
...
try
{
int boundaryIndex = contentType.indexOf("boundary=");
if (boundaryIndex < 0)
{
throw new FileUploadException(
"the request was rejected because "
+ "no multipart boundary was found");
}
byte[] boundary = contentType.substring(
boundaryIndex + 9).getBytes();
InputStream input = req.getInputStream();
MultipartStream multi = new MultipartStream(input, boundary);
multi.setHeaderEncoding(headerEncoding);
boolean nextPart = multi.skipPreamble();
while (nextPart)
{
Map headers = parseHeaders(multi.readHeaders());
String fieldName = getFieldName(headers);
if (fieldName != null)
{
String subContentType = getHeader(headers, CONTENT_TYPE);
if (subContentType != null && subContentType
.startsWith(MULTIPART_MIXED))
{
...
}
else
{
if (getFileName(headers) != null)
{
....
}
else
{
// A form field. Important here!!!
// 1. 开始处理每个form field,为每个field创建一个FileItem
FileItem item = createItem(headers, true);
// 2. 为每个FileItem创建OutStream
OutputStream os = item.getOutputStream();
try
{
// 3. 读取Form Field数据,写入OutStream中
multi.readBodyData(os);
}
finally
{
os.close();
}
// 4. 将Form Field处理结果加入返回结果集中
items.add(item);
}
}
}
else
{
// Skip this part.
multi.discardBodyData();
}
nextPart = multi.readBoundary();
}
}
catch (IOException e)
{
throw new FileUploadException(
"Processing of " + MULTIPART_FORM_DATA
+ " request failed. " + e.getMessage());
}
return items;
}
3) 查看第二步中为每个FileItem创建OutStream的代码。
public OutputStream getOutputStream()
throws IOException
{
if (dfos == null)
{
File outputFile = getTempFile();
dfos = new DeferredFileOutputStream(sizeThreshold, outputFile);
}
return dfos;
}
public DeferredFileOutputStream(int threshold, File outputFile)
{
super(threshold);
this.outputFile = outputFile;
memoryOutputStream = new ByteArrayOutputStream(threshold);
currentOutputStream = memoryOutputStream;
}
五、结果分析
通过上面代码可以看出,FileUpload为每个FormField分配了256k的内存,用于存储parameter value,供后续与Struts框架数据交换。 如果Form表单中有1000个参数,将会使用256M内存。 我测试时导入的IP地址信息数量在4000左右,将消耗1G左右的内存,所以导致出现OutOfMemoryError异常。
我查阅了FileUpload的issue列表,找到了相关的case: FILEUPLOAD-59. 可以看到这个问题在FileUpload 1.1-dev版本中已经修复。 我下载了FileUpload1.1.1版本的源码,看到DiskFileItemFactory.DEFAULT_SIZE_THRESHOLD初始化为10240byte(10k), 相比原来的256k默认值已经大大减少。
通过将Struts升级到1.3.10(包含的FileUpload版本为1.1.1),该问题解决。
ps. 维护遗留系统的孩纸你伤不起,到处都是坑!