Java 分析并解决内存泄漏的实例
这几天,一直在为Java的“内存泄露”问题纠结。Java应用程序占用的内存在不断的、有规律的上涨,最终超过了监控阈值。福尔摩斯不得不出手了!
分析内存泄露的一般步骤
如果发现Java应用程序占用的内存出现了泄露的迹象,那么我们一般采用下面的步骤分析:
- 把Java应用程序使用的heapdump下来
- 使用Javaheap分析工具,找出内存占用超出预期(一般是因为数量太多)的嫌疑对象
- 必要时,需要分析嫌疑对象和其他对象的引用关系。
- 查看程序的源代码,找出嫌疑对象数量过多的原因。
dumpheap
如果Java应用程序出现了内存泄露,千万别着急着把应用杀掉,而是要保存现场。如果是互联网应用,可以把流量切到其他服务器。保存现场的目的就是为了把运行中JVM的heapdump下来。
JDK自带的jmap工具,可以做这件事情。它的执行方法是:
jmap-dump:format=b,file=heap.bin
format=b的含义是,dump出来的文件时二进制格式。
file-heap.bin的含义是,dump出来的文件名是heap.bin。
就是JVM的进程号。
(在linux下)先执行psaux|grepjava,找到JVM的pid;然后再执行jmap-dump:format=b,file=heap.bin,得到heapdump文件。
analyzeheap
将二进制的heapdump文件解析成human-readable的信息,自然是需要专业工具的帮助,这里推荐MemoryAnalyzer。
MemoryAnalyzer,简称MAT,是Eclipse基金会的开源项目,由SAP和IBM捐助。巨头公司出品的软件还是很中用的,MAT可以分析包含数亿级对象的heap、快速计算每个对象占用的内存大小、对象之间的引用关系、自动检测内存泄露的嫌疑对象,功能强大,而且界面友好易用。
MAT的界面基于Eclipse开发,以两种形式发布:Eclipse插件和EclipeRCP。MAT的分析结果以图片和报表的形式提供,一目了然。总之个人还是非常喜欢这个工具的。下面先贴两张官方的screenshots:
言归正传,我用MAT打开了heap.bin,很容易看出,char[]的数量出其意料的多,占用90%以上的内存。一般来说,char[]在JVM确实会占用很多内存,数量也非常多,因为String对象以char[]作为内部存储。但是这次的char[]太贪婪了,仔细一观察,发现有数万计的char[],每个都占用数百K的内存。这个现象说明,Java程序保存了数以万计的大String对象。结合程序的逻辑,这个是不应该的,肯定在某个地方出了问题。
顺藤摸瓜
在可疑的char[]中,任意挑了一个,使用PathToGCRoot功能,找到该char[]的引用路径,发现String对象是被一个HashMap中引用的。这个也是意料中的事情,Java的内存泄露多半是因为对象被遗留在全局的HashMap中得不到释放。不过,该HashMap被用作一个缓存,设置了缓存条目的阈值,导达到阈值后会自动淘汰。从这个逻辑分析,应该不会出现内存泄露的。虽然缓存中的String对象已经达到数万计,但仍然没有达到预先设置的阈值(阈值设置地比较大,因为当时预估String对象都比较小)。
但是,另一个问题引起了我的注意:为什么缓存的String对象如此巨大?内部char[]的长度达数百K。虽然缓存中的String对象数量还没有达到阈值,但是String对象大小远远超出了我们的预期,最终导致内存被大量消耗,形成内存泄露的迹象(准确说应该是内存消耗过多)。
就这个问题进一步顺藤摸瓜,看看String大对象是如何被放到HashMap中的。通过查看程序的源代码,我发现,确实有String大对象,不过并没有把String大对象放到HashMap中,而是把String大对象进行split(调用String.split方法),然后将split出来的String小对象放到HashMap中了。
这就奇怪了,放到HashMap中明明是split之后的String小对象,怎么会占用那么大空间呢?难道是String类的split方法有问题?
查看代码
带着上述疑问,我查阅了SunJDK6中String类的代码,主要是是split方法的实现:
public String[]split(Stringregex,intlimit){ returnPattern.compile(regex).split(this,limit); }
可以看出,Stirng.split方法调用了Pattern.split方法。继续看Pattern.split方法的代码:
public String[]split(CharSequenceinput,intlimit){ intindex=0; booleanmatchLimited=limit>0; ArrayListmatchList=new ArrayList (); Matcherm=matcher(input); //Addsegmentsbeforeeachmatchfound while(m.find()){ if(!matchLimited||matchList.size() 0&& matchList.get(resultSize-1).equals("")) resultSize--; String[]result=newString[resultSize]; returnmatchList.subList(0,resultSize).toArray(result); } 注意看第9行:Stirngmatch=input.subSequence(intdex,m.start()).toString();
这里的match就是split出来的String小对象,它其实是String大对象subSequence的结果。继续看String.subSequence的代码:
public CharSequencesubSequence(intbeginIndex,intendIndex){ returnthis.substring(beginIndex,endIndex); } String.subSequence有调用了String.subString,继续看:
publicString substring(intbeginIndex,intendIndex){ if(beginIndex<0){ thrownewStringIndexOutOfBoundsException(beginIndex); } if(endIndex>count){ thrownewStringIndexOutOfBoundsException(endIndex); } if(beginIndex>endIndex){ thrownewStringIndexOutOfBoundsException(endIndex-beginIndex); } return((beginIndex==0)&&(endIndex==count))?this: newString(offset+beginIndex,endIndex-beginIndex,value); }
看第11、12行,我们终于看出眉目,如果subString的内容就是完整的原字符串,那么返回原String对象;否则,就会创建一个新的String对象,但是这个String对象貌似使用了原String对象的char[]。我们通过String的构造函数确认这一点:
//Package privateconstructorwhichsharesvaluearrayforspeed. String(intoffset,intcount,charvalue[]){ this.value=value; this.offset=offset; this.count=count; }
为了避免内存拷贝、加快速度,SunJDK直接复用了原String对象的char[],偏移量和长度来标识不同的字符串内容。也就是说,subString出的来String小对象仍然会指向原String大对象的char[],split也是同样的情况。这就解释了,为什么HashMap中String对象的char[]都那么大。
原因解释
其实上一节已经分析出了原因,这一节再整理一下:
程序从每个请求中得到一个String大对象,该对象内部char[]的长度达数百K。
程序对String大对象做split,将split得到的String小对象放到HashMap中,用作缓存。
SunJDK6对String.split方法做了优化,split出来的Stirng对象直接使用原String对象的char[]
HashMap中的每个String对象其实都指向了一个巨大的char[]
HashMap的上限是万级的,因此被缓存的Sting对象的总大小=万*百K=G级。
G级的内存被缓存占用了,大量的内存被浪费,造成内存泄露的迹象。
解决方案
原因找到了,解决方案也就有了。split是要用的,但是我们不要把split出来的String对象直接放到HashMap中,而是调用一下String的拷贝构造函数String(Stringoriginal),这个构造函数是安全的,具体可以看代码:
/** *Initializesanewlycreated{@codeString}objectsothatit represents *thesamesequenceofcharactersastheargument;inotherwords, the *newlycreatedstringisacopyoftheargumentstring.Unlessan *explicitcopyof{@codeoriginal}isneeded,useofthis constructoris *unnecessarysinceStringsareimmutable. * *@paramoriginal *A{@codeString} */ publicString(Stringoriginal){ intsize=original.count; char[]originalValue=original.value; char[]v; if(originalValue.length>size){ //ThearrayrepresentingtheStringisbiggerthanthenew //Stringitself.Perhapsthisconstructorisbeingcalled //inordertotrimthebaggage,somakeacopyofthearray. intoff=original.offset; v=Arrays.copyOfRange(originalValue,off,off+size); }else{ //ThearrayrepresentingtheStringisthesame //sizeastheString,sonopointinmakingacopy. v=originalValue; } this.offset=0; this.count=size; this.value=v; }
只是,newString(string)的代码很怪异,囧。或许,subString和split应该提供一个选项,让程序员控制是否复用String对象的char[]。
是否Bug
虽然,subString和split的实现造成了现在的问题,但是这能否算String类的bug呢?个人觉得不好说。因为这样的优化是比较合理的,subString和spit的结果肯定是原字符串的连续子序列。只能说,String不仅仅是一个核心类,它对于JVM来说是与原始类型同等重要的类型。
JDK实现对String做各种可能的优化都是可以理解的。但是优化带来了忧患,我们程序员足够了解他们,才能用好他们。
一些补充
有个地方我没有说清楚。
我的程序是一个Web程序,每次接受请求,就会创建一个大的String对象,然后对该String对象进行split,最后split之后的String对象放到全局缓存中。如果接收了5W个请求,那么就会有5W个大String对象。这5W个大String对象都被存储在全局缓存中,因此会造成内存泄漏。我原以为缓存的是5W个小String,结果都是大String。
有同学后续建议用"java.io.StreamTokenizer"来解决本文的问题。确实是终极解决方案,比我上面提到的“newString()”,要好很多很多。
以上就是Java分析并解决内存泄漏的实例的详细内容,更多关于Java内存泄漏的资料请关注毛票票其它相关文章!
声明:本文内容来源于网络,版权归原作者所有,内容由互联网用户自发贡献自行上传,本网站不拥有所有权,未作人工编辑处理,也不承担相关法律责任。如果您发现有涉嫌版权的内容,欢迎发送邮件至:czq8825#qq.com(发邮件时,请将#更换为@)进行举报,并提供相关证据,一经查实,本站将立刻删除涉嫌侵权内容。