`
jarfield
  • 浏览: 202640 次
  • 性别: Icon_minigender_1
  • 来自: 北京
社区版块
存档分类
最新评论

优化变成了忧患:String.split引发的“内存泄露”

阅读更多

    一直赞叹Sun对待技术的严谨和优雅(bless Sun)。Sun JDK中Java库的源代码,连注释都清清楚楚、规规范范,javadoc注解的使用也一丝不苟,读起来很熟舒服。因此,在日常工作和学习中,经常读读Java库的源代码,不亦乐乎?如果遇到诡异问题,源代码的帮助就更大了。

 

    闲话少说,回归正题。这几天,一直在为Java的“内存泄露”问题纠结。Java应用程序占用的内存在不断的、有规律的上涨,最终超过了监控阈值。福尔摩斯不得不出手了!

 

    说起Java的内存泄露,其实定义不是那么明确。首先,如果JVM没有bug,那么理论上是不会出现“无法回收的堆空间”,也就是说C/C++中的那种内存泄露在Java中不存在的。其次,如果由于Java程序一直持有某个对象的引用,但是从程序逻辑上看,这个对象再也不会被用到了,那么我们可以认为这个对象被泄露了。如果这样的对象数量很多,那么很明显,大量的内存空间就被泄露(“浪费”更准确一些)了。

 

    不过,本文要说的内存泄露,并不属于上述原因,因此打上了引号。其具体原因,确实出乎意料。欲知详情,请看下面讲解。

分析内存泄露的一般步骤

 

    如果发现Java应用程序占用的内存出现了泄露的迹象,那么我们一般采用下面的步骤分析

  1. 把Java应用程序使用的heap dump下来
  2. 使用Java heap分析工具,找出内存占用超出预期(一般是因为数量太多)的嫌疑对象
  3. 必要时,需要分析嫌疑对象和其他对象的引用关系。
  4. 查看程序的源代码,找出嫌疑对象数量过多的原因。

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的分析结果概述

MAT分析对象的大小及数量

    言归正传,我用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[]都那么大。

原因解释

 

    其实上一节已经分析出了原因,这一节再整理一下:

  1. 程序从每个请求中得到一个String大对象,该对象内部char[]的长度达数百K。
  2. 程序对String大对象做split,将split得到的String小对象放到HashMap中,用作缓存。
  3. Sun JDK6对String.split方法做了优化,split出来的Stirng对象直接使用原String对象的char[]
  4. HashMap中的每个String对象其实都指向了一个巨大的char[]
  5. HashMap的上限是万级的,因此被缓存的Sting对象的总大小=万*百K=G级。
  6. G级的内存被缓存占用了,大量的内存被浪费,造成内存泄露的迹象。

解决方案

 

    原因找到了,解决方案也就有了。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做各种可能的优化都是可以理解的。但是优化带来了忧患,我们程序员足够了解他们,才能用好他们。

22
0
分享到:
评论
19 楼 lliiqiang 2015-10-03  
substring内存泄露问题只需要再加一个方法,让程序员明白优缺点自己决定使用哪个方法即可。
18 楼 kaven276 2012-10-26  
其实还是需要 GC 需要考虑这种情况,
将 string cache 中较大的串拿来看看,如果只用到一小部分 slice 就进行回收。
具体的度可以根据当前内存紧张程度和 slice 占总长度比来掌握。
17 楼 robinjoe 2012-06-12  
就这个问题进一步顺藤摸瓜,看看String大对象是如何被放到HashMap中的。通过查看程序的源代码,我发现,确实有String大对象,不过并没有把String大对象放到HashMap中,而是把String大对象进行split(调用String.split方法),然后将split出来的String小对象放到HashMap中 了
robinjoe 写道
jarfield 写道
TO sodarfish:你说的这个地方确实容易误解,和本文讨论的场景有关。并不是多个String对象引用浪费了空间。而是:本来想用split方法把大String对象切割成小String对象,后来发现小String对象占用的空间还是原来的大String对象,因此内存被浪费了。

意思是不是说:split过的string由于继续沿用底层没有被切割的array,这个array没有变小,所以本应释放的大数组没有被释放,所以造成内存浪费。

我才看到作者这句话:“就这个问题进一步顺藤摸瓜,看看String大对象是如何被放到HashMap中的。通过查看程序的源代码,我发现,确实有String大对象,不过并没有把String大对象放到HashMap中,而是把String大对象进行split(调用String.split方法),然后将split出来的String小对象放到HashMap中 了”,作者的意思是表达,本来放的应该是小String,但是没有如愿,还是放的原来几百K的大数据,找到的原因是jdk6优化带来的隐患:“jdk6为了节省空间,不去重新生成新的底层array,而是通过改变索引值来截取小String”,这样就导致了大String的空间实际没有被释放出来。与问者提出的问题实际上关注的不是一个方向。(小string确实节省了空间了,这是优化带来的好处)
16 楼 robinjoe 2012-06-12  
jarfield 写道
TO sodarfish:你说的这个地方确实容易误解,和本文讨论的场景有关。并不是多个String对象引用浪费了空间。而是:本来想用split方法把大String对象切割成小String对象,后来发现小String对象占用的空间还是原来的大String对象,因此内存被浪费了。

意思是不是说:split过的string由于继续沿用底层没有被切割的array,这个array没有变小,所以本应释放的大数组没有被释放,所以造成内存浪费。
15 楼 zxh603 2011-11-10  
看String的源码,其中关于String的拷贝构造函数String(String original),看的不太明白,然后百度了一下通过别人的链接找到了楼主的文章解决了我的疑惑,情不自禁的想赞美一下,真的不错!
14 楼 li200429 2010-12-01  
jarfield 写道
li200429 写道
使用StreamTokenizer,原代码中还是调用了String的substring方法啊,见原码:return str.substring(start, currentPosition);  那博主为什么说在你那样的场景下用StreamTokenizer是最合适的呢?不太明白呢?

兄弟,你看的可能是 StringTokenizer 而不是 StreamTokenizer 


博主:我看了StreamTokenizer里的方法,这是对字符流的操作,也没有像对字符串的split这样的方法啊?怎么使用还是有点不明白?请详解!
13 楼 jarfield 2010-11-27  
li200429 写道
使用StreamTokenizer,原代码中还是调用了String的substring方法啊,见原码:return str.substring(start, currentPosition);  那博主为什么说在你那样的场景下用StreamTokenizer是最合适的呢?不太明白呢?

兄弟,你看的可能是 StringTokenizer 而不是 StreamTokenizer 
12 楼 li200429 2010-11-25  
使用StreamTokenizer,原代码中还是调用了String的substring方法啊,见原码:return str.substring(start, currentPosition);  那博主为什么说在你那样的场景下用StreamTokenizer是最合适的呢?不太明白呢?
11 楼 jarfield 2010-11-15  
javatophp 写道
[root@host150 ZendStudio-7.1.2]# ps aux|grep java
root      3117  4.3 36.8 1779180 755328 pts/1  Sl   13:13  17:29 /usr/local/Zend/ZendStudio-7.1.2/jre/bin/java -Xms128M -Xmx512M -XX:PermSize=128M -XX:MaxPermSize=256M -jar /usr/local/Zend/ZendStudio-7.1.2/plugins/org.eclipse.equinox.launcher_1.0.201.v20091227.jar -os linux -ws gtk -arch x86_64 -showsplash -launcher /usr/local/Zend/ZendStudio-7.1.2/ZendStudio -name ZendStudio --launcher.library /usr/local/Zend/ZendStudio-7.1.2/plugins/org.eclipse.equinox.launcher.gtk.linux.x86_64_1.0.200.v20090519/eclipse_1206.so -startup /usr/local/Zend/ZendStudio-7.1.2/plugins/org.eclipse.equinox.launcher_1.0.201.v20091227.jar -exitdata 8000a -vm /usr/local/Zend/ZendStudio-7.1.2/jre/bin/java -vmargs -Xms128M -Xmx512M -XX:PermSize=128M -XX:MaxPermSize=256M -jar /usr/local/Zend/ZendStudio-7.1.2/plugins/org.eclipse.equinox.launcher_1.0.201.v20091227.jar
root     12476  0.0  0.0  61148   724 pts/1    R+   19:51   0:00 grep java
[root@host150 ZendStudio-7.1.2]# 
[root@host150 ZendStudio-7.1.2]# 
[root@host150 ZendStudio-7.1.2]# 
[root@host150 ZendStudio-7.1.2]# jmap  -dump:format=b,file=heap.bin  3117
3117: The VM does not support the attach mechanism
The -F option can be used when the target process is not responding



这个是 什么错误 ?搂住可以帮我解答吗


3117: The VM does not support the attach mechanism

看起来,这是ZendStudio使用的jre版本,不支持attachment。ZendStudio 7 自带的jre应该是5.0版本的。
10 楼 javatophp 2010-11-11  
[root@host150 ZendStudio-7.1.2]# ps aux|grep java
root      3117  4.3 36.8 1779180 755328 pts/1  Sl   13:13  17:29 /usr/local/Zend/ZendStudio-7.1.2/jre/bin/java -Xms128M -Xmx512M -XX:PermSize=128M -XX:MaxPermSize=256M -jar /usr/local/Zend/ZendStudio-7.1.2/plugins/org.eclipse.equinox.launcher_1.0.201.v20091227.jar -os linux -ws gtk -arch x86_64 -showsplash -launcher /usr/local/Zend/ZendStudio-7.1.2/ZendStudio -name ZendStudio --launcher.library /usr/local/Zend/ZendStudio-7.1.2/plugins/org.eclipse.equinox.launcher.gtk.linux.x86_64_1.0.200.v20090519/eclipse_1206.so -startup /usr/local/Zend/ZendStudio-7.1.2/plugins/org.eclipse.equinox.launcher_1.0.201.v20091227.jar -exitdata 8000a -vm /usr/local/Zend/ZendStudio-7.1.2/jre/bin/java -vmargs -Xms128M -Xmx512M -XX:PermSize=128M -XX:MaxPermSize=256M -jar /usr/local/Zend/ZendStudio-7.1.2/plugins/org.eclipse.equinox.launcher_1.0.201.v20091227.jar
root     12476  0.0  0.0  61148   724 pts/1    R+   19:51   0:00 grep java
[root@host150 ZendStudio-7.1.2]# 
[root@host150 ZendStudio-7.1.2]# 
[root@host150 ZendStudio-7.1.2]# 
[root@host150 ZendStudio-7.1.2]# jmap  -dump:format=b,file=heap.bin  3117
3117: The VM does not support the attach mechanism
The -F option can be used when the target process is not responding



这个是 什么错误 ?搂住可以帮我解答吗
9 楼 jarfield 2010-10-21  
TO sodarfish:你说的这个地方确实容易误解,和本文讨论的场景有关。并不是多个String对象引用浪费了空间。而是:本来想用split方法把大String对象切割成小String对象,后来发现小String对象占用的空间还是原来的大String对象,因此内存被浪费了。
8 楼 jarfield 2010-10-21  
TO ch_space:所言甚是!这确实不能说是bug,文中也没有把这个问题定性为bug,充其量是“隐患”,呵呵
7 楼 ch_space 2010-10-12  
JVM的优化是没有问题的!你split出来的小的string都指向原string,map中只是存放了一个指向原string的引用,如果split出来的很多string都要放进map,反而是减少了内存的使用,否则的话,LZ的解释还是合理的,应该拷贝split出来的小string放进map,以释放原string内存。这不是jvm的bug。:)
6 楼 sodarfish 2010-10-06  
不太明白,既然多个String 都指向同一个大的char[], 那么也只是占用多个引用, 真正的char[]对象就一个呀

楼主能帮忙解释下吗?谢谢
5 楼 ch_space 2010-07-22  
今天也遇到这个问题了,终于明白,谢谢分享~
4 楼 jianxiangyi 2010-07-13  
赞一个.....
3 楼 yvfish 2010-04-14  
呵呵,内存泄漏追踪还真从来没追过这么深, 佩服
2 楼 moonese 2010-03-31  
实践出真知。
1 楼 sw1982 2010-03-29  
兄弟造诣比较深啊,呵呵。头一次听说这个地方也有隐藏的bug

相关推荐

Global site tag (gtag.js) - Google Analytics