读书笔记之Java程序性能优化

时间:2023-01-03 21:35:23

最近在“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

http://www.cnblogs.com/techyc/p/3324021.html