最近在“Java程序性能优化”一书上看到“subString内存溢出优化”,一开始以为这是Java的Bug,看完才明白Java是采用空间换时间的策略,来提升生成字符串的速度,但因为牺牲了空间所以有可能出现内存溢出。
首先说明一下:因为此问题是在Java6会出现,而Java7采用其他方法避免了。所以我演示使用的JDK分别是jdk1.6.0_45和jdk1.7.0_40。
问题重现
因为Java官网有说明,所以使用他们代码重现这个Bug。
Bug地址:http://bugs.java.com/view_bug.do?bug_id=4513622
public class TestGC {
private String largeString = new String(new byte[100000]);
String getString() {
return this.largeString.substring(0,2);
}
public static void main(String[] args) {
java.util.ArrayList list = new java.util.ArrayList();
for (int i = 0; i < 1000000; i++) {
TestGC gc = new TestGC();
list.add(gc.getString());
}
}
}
上面的代码在Java 6 (Java 7不会抛出异常)运行一下就会报java.lang.OutOfMemoryError: Java heap space的异常,这说明没有足够的堆内存供我们创建对象,JVM选择了抛出异常操作。
于是有人会说,是因为你每个循环中创建了一个TestGC对象,虽然我们加入ArrayList只是两个字符的字符串,但是这个对象中又存储largeString这么大的对象,这样必然会造成OOM的。
然而,其实你说的不对。比如我们看一下这样的代码,我们只修改getString方法。
public class TestGC {
private String largeString = new String(new byte[100000]);
String getString() {
//return this.largeString.substring(0,2);
return new String("ab");
}
public static void main(String[] args) {
java.util.ArrayList list = new java.util.ArrayList();
for (int i = 0; i < 1000000; i++) {
TestGC gc = new TestGC();
list.add(gc.getString());
}
}
}
执行上面的方法,并不会导致OOM异常,因为我们持有的时1000000个ab字符串对象,而TestGC对象(包括其中的largeString)会在java的垃圾回收中释放掉。所以这里不会存在内存溢出。
那么究竟是什么导致的内存泄露呢?要研究这个问题,我们需要看一下方法的实现,即可。
源码分析
在String类中存在这样三个属性
value 字符数组,存储字符串实际的内容
offset 该字符串在字符数组value中的起始位置
count 字符串包含的字符的长度
Java 6中substring的实现有两个:
public String substring(int beginIndex)
public String substring(int beginIndex, int endIndex)
查阅源代码可知,第一个substring实质是调用第二个substring的,如下:
public String substring(int beginIndex) {
return substring(beginIndex, count);
}
第二个substring的代码如下:
public String substring(int beginIndex, int endIndex) {
if (beginIndex < 0) {
throw new StringIndexOutOfBoundsException(beginIndex);
}
if (endIndex > count) {
throw new StringIndexOutOfBoundsException(endIndex);
}
if (beginIndex > endIndex) {
throw new StringIndexOutOfBoundsException(endIndex - beginIndex);
}
return ((beginIndex == 0) && (endIndex == count)) ? this :
new String(offset + beginIndex, endIndex - beginIndex, value);
}
方法最后,返回一个新建的String对象。查看该Stirng 的构造函数:
//Package private constructor which shares value array for speed.
String(int offset, int count, char value[]) {
this.value = value;
this.offset = offset;
this.count = count;
}
在源代码的注释中说明,这是一个包作用域的构造函数,其目的是快速地共享String内的char数组对象。但在这种通过偏移量来截取字符串的方法中,String的原生内容value数组被复制到新的子字符串中。设想,如果原字符串很大,截取的字符串长度缺很短,那么截取的字符串中包含了原生字符串的所有内容,并占据了相应的内存空间,而仅仅通过偏移量和长度来决定自己的实际取值。这种算法提高了运算速度却浪费了大量的内存空间。
如何解决
对于之前比较不常见的1G字符串只截取2个字符的情况可以使用下面的代码,这样的话,就不会持有1G字符串的内容数组引用了。
String littleString = new String(largeString.substring(0,2));
下面的这个构造方法,在源字符串内容数组长度大于字符串长度时,进行数组复制,新的字符串会创建一个只包含源字符串内容的字符数组。
public String(String original) {
int size = original.count;
char[] originalValue = original.value;
char[] v;
if (originalValue.length > size) {
// The array representing the String is bigger than the new
// String itself. Perhaps this constructor is being called
// in order to trim the baggage, so make a copy of the array.
int off = original.offset;
v = Arrays.copyOfRange(originalValue, off, off+size);
} else {
// The array representing the String is the same
// size as the String, so no point in making a copy.
v = originalValue;
}
this.offset = 0;
this.count = size;
this.value = v;
}
Java 7 实现
在Java 7 中substring的实现抛弃了之前的内容字符数组共享的机制,对于子字符串(自身除外)采用了数组复制实现单个字符串持有自己的应该拥有的内容。
public String substring(int beginIndex, int endIndex) {
if (beginIndex < 0) {
throw new StringIndexOutOfBoundsException(beginIndex);
}
if (endIndex > value.length) {
throw new StringIndexOutOfBoundsException(endIndex);
}
int subLen = endIndex - beginIndex;
if (subLen < 0) {
throw new StringIndexOutOfBoundsException(subLen);
}
return ((beginIndex == 0) && (endIndex == value.length)) ? this
: new String(value, beginIndex, subLen);
}
substring方法中调用的构造方法,进行内容字符数组复制。
public String(char value[], int offset, int count) {
if (offset < 0) {
throw new StringIndexOutOfBoundsException(offset);
}
if (count < 0) {
throw new StringIndexOutOfBoundsException(count);
}
// Note: offset or count might be near -1>>>1.
if (offset > value.length - count) {
throw new StringIndexOutOfBoundsException(offset + count);
}
this.value = Arrays.copyOfRange(value, offset, offset+count);
}
参考资料
Java程序性能优化一书
Java对此Bug的说明:http://bugs.java.com/view_bug.do?bug_id=4513622
http://droidyue.com/blog/2014/12/14/substring-memory-issue-in-java/
http://mouselearnjava.iteye.com/blog/1929037