该帖已经被评为精华帖
|
|
---|---|
作者 | 正文 |
发表时间:2010-03-28
最后修改:2010-04-01
原文地址:http://jarfield.iteye.com/admin/blogs/583946
一直赞叹Sun对待技术的严谨和优雅(可怜的Sun)。Sun JDK中Java库的源代码,连注释都清清楚楚、规规范范,javadoc注解的使用也一丝不苟,读起来很熟舒服。因此,在日常工作和学习中,经常读读 Java库的源代码,不亦乐乎?如果遇到诡异问题,源代码的帮助就更大了。
闲话少说,回归正题。这几天,一直在为Java的“内存泄露”问题纠结。Java应用程序占用的内存在不断的、有规律的上涨,最终超过了监控阈值。福尔摩 斯不得不出手了!
说起Java的内存泄露,其实定义不是那么明确。首先,如果JVM没有bug,那么理论上是不会出现“无法回收的堆空间”,也就是说C/C++中的那种内 存泄露在Java中不存在的。其次,如果由于Java程序一直持有某个对象的引用,但是从程序逻辑上看,这个对象再也不会被用到了,那么我们可以认为这个 对象被泄露了。如果这样的对象数量很多,那么很明显,大量的内存空间就被泄露(“浪费”更准确一些)了。
不过,本文要说的内存泄露,并不属于上述原因,因此打上了引号。其具体原因,确实出乎意料。欲知详情,请看下面讲解。 分析内存泄露的一般步骤
如果发现Java应用程序占用的内存出现了泄露的迹象,那么我们一般采用下面的步骤分析
dump heap
如果Java应用程序出现了内存泄露,千万别着急着把应用杀掉,而是要保存现场。如果是互联网应用,可以把流量切到其他服务器。保存现场的目的就是为了把 运行中JVM的heap dump下来。
JDK自带的jmap工具,可以做这件事情。它的执行方法是: jmap -dump:format=b,file=heap.bin <pid> format=b的含义是,dump出来的文件时二进制格式。 file-heap.bin的含义是,dump出来的文件名是heap.bin。 <pid>就是JVM的进程号。 (在linux下)先执行ps aux | grep java,找到JVM的pid;然后再执行jmap -dump:format=b,file=heap.bin <pid>,得到heap dump文件。 analyze heap
将二进制的heap dump文件解析成human-readable的信息,自然是需要专业工具的帮助,这里推荐Memory Analyzer 。
Memory Analyzer,简称MAT,是Eclipse基金会的开源项目,由SAP和IBM捐助。巨头公司出品的软件还是很中用的,MAT可以分析包含数亿级对 象的heap、快速计算每个对象占用的内存大小、对象之间的引用关系、自动检测内存泄露的嫌疑对象,功能强大,而且界面友好易用。
MAT的界面基于Eclipse开发,以两种形式发布:Eclipse插件和Eclipe RCP。MAT的分析结果以图片和报表的形式提供,一目了然。总之个人还是非常喜欢这个工具的。下面先贴两张官方的screenshots: 言归正传,我用MAT打开了heap.bin,很容易看出,char[]的数量出其意料的多,占用90%以上的内存 。一般来说,char[]在JVM确实会占用很多内存,数量也非常多,因为String对象以char[]作为内部存储。但是这次的char[]太贪婪 了,仔细一观察,发现有数万计的char[],每个都占用数百K的内存 。这个现象说明,Java程序保存了数以万计的大String对象 。结合程序的逻辑,这个是不应该的,肯定在某个地方出了问题。
顺藤摸瓜
在可疑的char[]中,任意挑了一个,使用Path To GC Root功能,找到该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方法有问题?
查看代码
带着上述疑问,我查阅了Sun JDK6中String类的代码,主要是是split方法的实现: 可以看出,Stirng.split方法调用了Pattern.split方法。继续看Pattern.split方法的代码: 注意看第9行:Stirng match = input.subSequence(intdex, m.start()).toString(); 这里的match就是split出来的String小对象,它其实是String大对象subSequence的结果。继续看 String.subSequence的代码: String.subSequence有调用了String.subString,继续看: 看第11、12行,我们终于看出眉目,如果subString的内容就是完整的原字符串,那么返回原String对象;否则,就会创建一个新的 String对象,但是这个String对象貌似使用了原String对象的char[]。我们通过String的构造函数确认这一点: 为了避免内存拷贝、加快速度,Sun JDK直接复用了原String对象的char[],偏移量和长度来标识不同的字符串内容。也就是说,subString出的来String小对象 仍然会指向原String大对象的char[],split也是同样的情况 。这就解释了,为什么HashMap中String对象的char[]都那么大。 原因解释
其实上一节已经分析出了原因,这一节再整理一下:
解决方案
原因找到了,解决方案也就有了。split是要用的,但是我们不要把split出来的String对象直接放到HashMap中,而是调用一下 String的拷贝构造函数String(String original),这个构造函数是安全的,具体可以看代码: 只是,new String(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。
“抛出异常的爱”同学,在回帖(第7页)中建议用"java.io.StreamTokenizer"来解决本文的问题。确实是终极解决方案,比我上面提到的“new String()”,要好很多很多。 声明:ITeye文章版权属于作者,受法律保护。没有作者书面许可不得转载。
推荐链接
|
|
返回顶楼 | |
发表时间:2010-03-29
最后修改:2010-03-29
我第一次这么仔细看一个贴子, 我读了6次。。。
每个String对象其实都指向了一个巨大的char[] : 指向一个相同的内存, 会造成内存浪费? 我很是不明白, 你一次输入的切割是多少数据, 如果你一次给了程序100M, 而你实际你只需要切割出1M的数据, 那么你说的问题是存在, 但是也仅仅是多引用了98M, 因为使用了内存共享。为了进一步降低内存使用, intern方法也是不错的, 可以避免相同的字符串被独立建立。 我想应该找找别的原因, 虽然你每次步骤是正确的, 但是你的结论我觉得不对。 使用split的地方非常多, 如果有这么严重的问题, java系统出大问题了。 在一个正常业务系统中char[] 占用70-90%的是非常正常的。 另外dump heap的时候, 最好带上live选项(非live大部分是分析应用动态情况下需要内存数量), 这样更能清楚的看到,是不是内存被长期暂用的部分。 |
|
返回顶楼 | |
发表时间:2010-03-29
lz是split后丢弃大部分char[]这种用法吗,你的方案解决了问题吗
|
|
返回顶楼 | |
发表时间:2010-03-29
最后修改:2010-03-29
sdh5724 写道 我想应该找找别的原因, 虽然你每次步骤是正确的, 但是你的结论我觉得不对。 使用split的地方非常多, 如果有这么严重的问题, java系统出大问题了。 我觉得楼主分析的很有道理啊,因为他这里split之后的String放在了全局的hashMap缓存中了,然后GC不能释放这个才是重点。 我觉得楼主后来的解决方案是可行的,但是缓存这个东西一般是把需要缓存的对象先序列化之后保存到hashMap的。 |
|
返回顶楼 | |
发表时间:2010-03-29
sdh5724 写道 我第一次这么仔细看一个贴子, 我读了6次。。。
每个String对象其实都指向了一个巨大的char[] : 指向一个相同的内存, 会造成内存浪费? 我很是不明白, 你一次输入的切割是多少数据, 如果你一次给了程序100M, 而你实际你只需要切割出1M的数据, 那么你说的问题是存在, 但是也仅仅是多引用了98M, 因为使用了内存共享。为了进一步降低内存使用, intern方法也是不错的, 可以避免相同的字符串被独立建立。 我想应该找找别的原因, 虽然你每次步骤是正确的, 但是你的结论我觉得不对。 使用split的地方非常多, 如果有这么严重的问题, java系统出大问题了。 在一个正常业务系统中char[] 占用70-90%的是非常正常的。 另外dump heap的时候, 最好带上live选项(非live大部分是分析应用动态情况下需要内存数量), 这样更能清楚的看到,是不是内存被长期暂用的部分。 读了6次,实在感动,呵呵! “每个String对象其实都指向了一个巨大的char[] : 指向一个相同的内存, 会造成内存浪费?” 这个地方我没有说清楚。我的程序是一个Web程序,每次接受请求,就会创建一个大的String对象,然后对该String对象进行split。如果接收了5W个请求,那么就会有5W个大String对象,因此会造成内存泄漏。我原以为缓存的是5W个小String,结果都是大String。 “用split的地方非常多, 如果有这么严重的问题, java系统出大问题了。 ” 我遇到的问题,不能算是split的bug。应该是,我不了解split的实现而误用了split。但是我的这种用法,应该也是比较直观的,不算什么奇技淫巧。所以,我最后感慨,还是要对JDK库的实现有所了解才行。 感谢live选项的建议 ![]() |
|
返回顶楼 | |
发表时间:2010-03-29
fxsc 写道 lz是split后丢弃大部分char[]这种用法吗,你的方案解决了问题吗
使用String(String str)这个构造函数,是可以解决该问题的。因为该构造函数,对char[]做了拷贝,而且只拷贝有效的那部分char[]。 |
|
返回顶楼 | |
发表时间:2010-03-29
我也没看懂,hold的是引用,又不是char,怎么会有问题。
|
|
返回顶楼 | |
发表时间:2010-03-29
最后修改:2010-03-29
大家也可以看看这两篇文章,讨论的是类似的问题:
http://www.iteye.com/topic/130984 http://blog.xebia.com/2007/10/04/leaking-memory-in-java/ |
|
返回顶楼 | |
发表时间:2010-03-29
http://bugs.sun.com/bugdatabase/view_bug.do?bug_id=4513622
|
|
返回顶楼 | |
发表时间:2010-03-29
看明白了,问题出现在保留原有大char[]上。
比如1m的大字符串,split后可能只有长度10的小字符串,楼主的本意是将这长度为10的字符串保留使用,比如放hashmap。但是由于split后得到的那个长度很小的小字符串实际是保留了大字符串的完整的char[],因此1m的原始大字符串依然留在内存中。因此造成巨大浪费: length == 10的字符串里面的char[]是1m! 上面的问题在每次处理时重复,前提是每次的大字符串都不是同一个,因此问题被放大。 |
|
返回顶楼 | |