昨天晚上测试了以下两组代码,其中一组很快就抛出了OOM(java.lang.OutOfMemoryError)错误,说的是java堆空间不足了,你们猜猜是哪一组代码:
//代码一
StringBuilder str = new StringBuilder();
while (true) {
str.append("1");
}
//代码二
String str = "";
while (true) {
str = str + "1";
}
经过测试,发现代码一会抛出OOM错误
那么这就让我很困惑了,不是说在大量拼接字符串时建议用append方法吗,那为什么现在又报错了呢,下面我就好好分析一下这个问题:
一.为什么建议在大量拼接字符串时采用append方法:
因为String底层是采用一个不可更改指向的字符数组来保存字符串中字符的:
private final char value[]; // final修饰不可更改value的指向
正是由于这个不可变更的value[],所以我们每次在做字符串拼接的时候,无法更换字符串底层的value[],只能重新创建出一个字符串对象。那么当我们用“+”进行字符串拼接时,jvm到底是如果新创建一个字符串对象的呢?为此,我们把代码二反编译一下看看:
0: ldc #2 // String
2: astore_1
3: new #3 // class java/lang/StringBuilder
6: dup
7: invokespecial #4 // Method java/lang/StringBuilder."<init>":()V
10: aload_1
11: invokevirtual #5 // Method java/lang/StringBuilder.append:(Ljava/lang/String;)Ljava/lang/StringBuilder;
14: ldc #6 // String 1
16: invokevirtual #5 // Method java/lang/StringBuilder.append:(Ljava/lang/String;)Ljava/lang/StringBuilder;
19: invokevirtual #7 // Method java/lang/StringBuilder.toString:()Ljava/lang/String;
22: astore_1
23: goto 3
从反编译的代码中可以看出,jvm先构造出一个StringBuilder对象,再调用它的append方法先后拼接上原来的字符串和待拼接的字符串,也就是str
和"1"
,最后再调用StringBuilder的toString方法转化为String类型输出。
那么在循环中反复的拼接字符串就会导致大量的中间字符串产生,因为我们每拼接一次就会调用一次toString方法,产生一个中间字符串。这些中间字符串最后都是需要GC来回收的,这样就大大增加了GC的负担,也无意义的消耗了系统的资源。下面是代码二运行时堆内存的使用情况:
可以看出来,此时gc正在很努力的回收这些中间字符串,这会消耗大量的系统资源。而使用StringBuilder类中的value[]不是final修饰的,所以当value[]容量不够用时不必新创建StringBuilder对象,可以只改变value[]的指向即可(指向一个扩容后的char[]),这样就可以避免产生大量中间字符串消耗系统资源了。
2.为什么代码一会抛出OOM错误?
我们回到开头的问题,仔细理一理这两个代码的执行过程,看看有什么不同:
我们先看看StringBuilder中append方法的源码
@Override
public StringBuilder append(String str) {
super.append(str); //调用父类append方法
return this;
}
发现他调用了父类的方法,再进入:
public AbstractStringBuilder append(String str) {
if (str == null)
return appendNull();
int len = str.length();
ensureCapacityInternal(count + len); //对value[]数组做扩容
str.getChars(0, len, value, count); //字符串迁移
count += len;
return this;
}
这里主要看一下ensureCapacityInternal
方法:
private void ensureCapacityInternal(int minimumCapacity) {
// overflow-conscious code
if (minimumCapacity - value.length > 0) {
value = Arrays.copyOf(value,
newCapacity(minimumCapacity)); //对原value[]做扩容
}
}
再进入newCapacity
方法:
private int newCapacity(int minCapacity) {
// overflow-conscious code
int newCapacity = (value.length << 1) + 2; //value[]数组每次扩容都会翻倍再加2
if (newCapacity - minCapacity < 0) {
newCapacity = minCapacity;
}
return (newCapacity <= 0 || MAX_ARRAY_SIZE - newCapacity < 0)
? hugeCapacity(minCapacity)
: newCapacity;
}
因为每次StringBuilder底层字符串扩容时,value[]的容量会翻倍再+2,所以在循环中反复翻倍多次,最后形成的数组长度将大大超出我们的使用范围,其所占用的内存空间也是很大的。所以很快的抛出了OOM错误。
那么String类型字符串的拼接底层也是append方法啊,它为什么不会抛出OOM错误呢?其原因就是对普通字符串的拼接时,调用append方法后都会再调用一次toString方法,转化为普通字符串再赋值。
private int newCapacity(int minCapacity) {
// overflow-conscious code
int newCapacity = (value.length << 1) + 2;
if (newCapacity - minCapacity < 0) { // 1
newCapacity = minCapacity;
}
return (newCapacity <= 0 || MAX_ARRAY_SIZE - newCapacity < 0)
? hugeCapacity(minCapacity)
: newCapacity;
}
这样一来,每次jvm都会创建一个新的StringBuilder进行拼接,这就没法叠加翻倍value[]的长度了。并且当字符串长度大于2时,代码每次都会走进1处的if中,最终将最小所需长度赋值给value[],这样一来,value[]就没法多占用内存空间了。
所以当我们反复对一个字符串拼接时,最后StringBuilder会占用更大的内存,不过如果使用String进行拼接的时候,所带来的反复创建字符串对象的问题和给gc带来的压力也不容忽视。所以还是用StringBuilder进行字符串拼接把。