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打开了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方法的实现:

[java] view plaincopy

  1. public
  2. String[] split(String regex, int limit) {
  3. return Pattern.compile(regex).split(this, limit);
  4. }

可以看出,Stirng.split方法调用了Pattern.split方法。继续看Pattern.split方法的代码:

[java] view plaincopy

  1. public
  2. String[] split(CharSequence input, int limit) {
  3. int index = 0;
  4. boolean matchLimited = limit > 0;
  5. ArrayList<String> matchList = new
  6. ArrayList<String>();
  7. Matcher m = matcher(input);
  8. // Add segments before each match found
  9. while(m.find()) {
  10. if (!matchLimited || matchList.size() < limit - 1) {
  11. String match = input.subSequence(index,
  12. m.start()).toString();
  13. matchList.add(match);
  14. index = m.end();
  15. } else if (matchList.size() == limit - 1) { // last one
  16. String match = input.subSequence(index,
  17. input.length()).toString();
  18. matchList.add(match);
  19. index = m.end();
  20. }
  21. }
  22. // If no match was found, return this
  23. if (index == 0)
  24. return new String[] {input.toString()};
  25. // Add remaining segment
  26. if (!matchLimited || matchList.size() < limit)
  27. matchList.add(input.subSequence(index,
  28. input.length()).toString());
  29. // Construct result
  30. int resultSize = matchList.size();
  31. if (limit == 0)
  32. while (resultSize > 0 &&
  33. matchList.get(resultSize-1).equals(""))
  34. resultSize--;
  35. String[] result = new String[resultSize];
  36. return matchList.subList(0, resultSize).toArray(result);
  37. }

注意看第11行:Stirng match = input.subSequence(intdex, m.start()).toString();

这里的match就是split出来的String小对象,它其实是String大对象subSequence的结果。继续看 String.subSequence的代码:

[java] view plaincopy

  1. public
  2. CharSequence subSequence(int beginIndex, int endIndex) {
  3. return this.substring(beginIndex, endIndex);
  4. }

String.subSequence有调用了String.subString,继续看:

[java] view plaincopy

  1. public String
  2. substring(int beginIndex, int endIndex) {
  3. if (beginIndex < 0) {
  4. throw new StringIndexOutOfBoundsException(beginIndex);
  5. }
  6. if (endIndex > count) {
  7. throw new StringIndexOutOfBoundsException(endIndex);
  8. }
  9. if (beginIndex > endIndex) {
  10. throw new StringIndexOutOfBoundsException(endIndex - beginIndex);
  11. }
  12. return ((beginIndex == 0) && (endIndex == count)) ? this :
  13. new String(offset + beginIndex, endIndex - beginIndex, value);
  14. }

看第12、13行,我们终于看出眉目,如果subString的内容就是完整的原字符串,那么返回原String对象;否则,就会创建一个新的 String对象,但是这个String对象貌似使用了原String对象的char[]。我们通过String的构造函数确认这一点:

[java] view plaincopy

  1. // Package
  2. private constructor which shares value array for speed.
  3. String(int offset, int count, char value[]) {
  4. this.value = value;
  5. this.offset = offset;
  6. this.count = count;
  7. }

为了避免内存拷贝、加快速度,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),这个构造函数是安全的,具体可以看代码:

[java] view plaincopy

  1. /**
  2. * Initializes a newly created {@code String} object so that it
  3. represents
  4. * the same sequence of characters as the argument; in other words,
  5. the
  6. * newly created string is a copy of the argument string. Unless an
  7. * explicit copy of {@code original} is needed, use of this
  8. constructor is
  9. * unnecessary since Strings are immutable.
  10. *
  11. * @param  original
  12. *         A {@code String}
  13. */
  14. public String(String original) {
  15. int size = original.count;
  16. char[] originalValue = original.value;
  17. char[] v;
  18. if (originalValue.length > size) {
  19. // The array representing the String is bigger than the new
  20. // String itself.  Perhaps this constructor is being called
  21. // in order to trim the baggage, so make a copy of the array.
  22. int off = original.offset;
  23. v = Arrays.copyOfRange(originalValue, off, off+size);
  24. } else {
  25. // The array representing the String is the same
  26. // size as the String, so no point in making a copy.
  27. v = originalValue;
  28. }
  29. this.offset = 0;
  30. this.count = size;
  31. this.value = v;
  32. }

只是,new String(string)的代码很怪异,囧。或许,subString和split应该提供一个选项,让程序员控制是否复用String对象的 char[]。

是否是Bug

 

虽然,subString和split的实现造成了现在的问题,但是这能否算String类的bug呢?个人觉得不好说。因为这样的优化是比较合理 的,subString和spit的结果肯定是原字符串的连续子序列。只能说,String不仅仅是一个核心类,它对于JVM来说是与原始类型同等重要的 类型。

JDK实现对String做各种可能的优化都是可以理解的。但是优化带来了忧患,我们程序员足够了解他们,才能用好他们。

时间: 2024-10-07 01:55:32

String.split引发的“内存泄露”的相关文章

Android中使用Handler引发的内存泄露

转载请注明出处:http://blog.csdn.net/allen315410/article/details/43638373 本文翻译自:国外某位开发者的博客How to Leak a Context: Handlers & Inner Classes,英文可以的朋友可以直接点击原文查看. 在Android常用编程中,Handler在进行异步操作并处理返回结果时经常被使用.通常我们的代码会这样实现. public class SampleActivity extends Activity

Android 源码系列之&lt;十四&gt;从源码的角度深入理解LeakCanary的内存泄露检测机制(下)

转载请注明出处:http://blog.csdn.net/llew2011/article/details/52958567 在上边文章Android 源码系列之<十三>从源码的角度深入理解LeakCanary的内存泄露检测机制(中)由于篇幅原因仅仅向小伙伴们讲述了在Android开发中如何使用LeakCanary来检测应用中出现的内存泄露,并简单的介绍了LeakCanary的相关配置信息.根据上篇文章的介绍我们知道LeakCanary为了不给APP进程造成影响所以新开启了一个进程,在新开启的

内存泄露之常见问题解决--初级篇

身为一个段子猿,我决定来写写最近的学习心得. 1.简介 在整个Android开发过程中,内存泄露是导致OOM的一个重点因素.大概意思就是:GC无法回收原本应该被回收的对象,这个对象就引发了内存泄露.那有什么危害呢?手机的内存大小是有限的,如果不能释放的话,你就无法创建新的对象,你的新界面等等就无法正常运行,然后程序就OOM了(OutOfMemory). 2.OOM以及内存泄露 OOM通俗点讲就是,你家里有2个厕所,本来你和你老婆用的话,都是够用的,有一天你不小心造人了,从此家里有了1+1=3个人

Mysql: Connect/C++ 使用过程中发现返回 std::string 造成的内存泄露

在使用 Connect/C++ ,测试时发现在调用 getString 出现了内存增长的情况. ConstructOutput(); //打印出当前内存 for(int i=0;i<1000;++i) { prepareState.reset(con->prepareStatement("call test.testproc3(?)")); prepareState->setInt(1,1001); prepareState->executeUpdate();

如何用Java编写一段代码引发内存泄露

Q:刚才我参加了面试,面试官问我如何写出会发生内存泄露的Java代码.这个问题我一点思路都没有,好囧. A1:通过以下步骤可以很容易产生内存泄露(程序代码不能访问到某些对象,但是它们仍然保存在内存中): 应用程序创建一个长时间运行的线程(或者使用线程池,会更快地发生内存泄露). 线程通过某个类加载器(可以自定义)加载一个类. 该类分配了大块内存(比如new byte[1000000]),在某个静态变量存储一个强引用,然后在ThreadLocal中存储它自身的引用.分配额外的内存new byte[

怎样用Java编写一段代码引发内存泄露

通过下面步骤能够非常easy产生内存泄露(程序代码不能訪问到某些对象,可是它们仍然保存在内存中): 应用程序创建一个长时间执行的线程(或者使用线程池,会更快地发生内存泄露). 线程通过某个类载入器(能够自己定义)载入一个类. 该类分配了大块内存(比方new byte[1000000]),在某个静态变量存储一个强引用,然后在ThreadLocal中存储它自身的引用.分配额外的内存new byte[1000000]是可选的(类实例泄露已经足够了),可是这样会使内存泄露更快. 线程清理自己定义的类或者

Java String.substring内存泄露?

String可以说是最常用的Java类型之一了,但是最近听说JDK6里面String.substring存在内存泄露的bug,伙惊呆!一起来看看到底是啥情况吧. 这个是可以导致Exception in thread "main" java.lang.OutOfMemoryError: Java heap space 的代码: public class TestGC {     private String largeString = new String(new byte[100000

ThreadLocal是否会引发内存泄露的分析(转)

这篇文章,主要解决一下疑惑: 1. ThreadLocal.ThreadLocalMap中提到的弱引用,弱引用究竟会不会被回收? 2. 弱引用什么情况下回收? 3. JAVA的ThreadLocal和在什么情况下会内存泄露? 带着这些疑问,自己模拟了一下ThreadLocal.ThreadLocalMap的结构,先展示下自己涉及的结构: 自己实现一个simple的ThreadLocalMap,里面用一个entry用来存放由自己模拟的ThreadLocal调用set方法set进去的值. 并且和JD

JAVA 内存泄露详解(原因、例子及解决)

转载请注明出处:http://blog.csdn.net/anxpp/article/details/51325838,谢谢! Java的一个重要特性就是通过垃圾收集器(GC)自动管理内存的回收,而不需要程序员自己来释放内存.理论上Java中所有不会再被利用的对象所占用的内存,都可以被GC回收,但是Java也存在内存泄露,但它的表现与C++不同. JAVA 中的内存管理 要了解Java中的内存泄露,首先就得知道Java中的内存是如何管理的. 在Java程序中,我们通常使用new为对象分配内存,而