在Java中,String是最常用的数据类型,String有一个substring方法用来截取字符串,或许我们没注意到该方法可能会引起内存泄露问题(出现于Java6中
)。
方法介绍:
在Java中提供了两个截取子字符串的方法:
substring(int beginIndex)
substring(int beginIndex, int endIndex)
问题重现:
public class Test {
private String largeString = new String(new byte[100000]);
String getString() {
return this.largeString.substring(0, 2);
}
public static void main(String[] args) {
List<String> list = new ArrayList<String>();
for (int i = 0; i < 1000000; i++) {
Test t = new Test();
list.add(t.getString());
}
}
}
运行上面代码,如果使用Java6(Java7以上不会抛异常)运行一下就会报如下异常,说明没有足够的堆内存供我们创建对象
Exception in thread "main" java.lang.OutOfMemoryError: Java heap space
at java.lang.StringCoding$StringDecoder.decode(StringCoding.java:133)
at java.lang.StringCoding.decode(StringCoding.java:173)
at java.lang.StringCoding.decode(StringCoding.java:185)
于是有人会说,我们每个循环都创建一个Test对象,100万条数据存储到ArrayList中,这样必然会造成OOM,其实不然,看下面这段代码,只修改getString()方法
public class Test {
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) {
List<String> list = new ArrayList<String>();
for (int i = 0; i < 1000000; i++) {
Test t = new Test();
list.add(t.getString());
}
}
}
执行上面的方法,并不会导致OOM异常,因为我们持有的时1000000个ab字符串对象,而Test对象(包括其中的largeString)会在java的垃圾回收中释放掉。所以这里不会存在内存溢出。
那么究竟是什么导致的内存泄露呢?
在 JDK 1.6 中 String.substring(int, int)的源码为:
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 构造函数源码为:
String(int offset, int count, char value[]) {
this.value = value;
this.offset = offset;
this.count = count;
}
我们发现 String.substring()所返回的 String 仍然会保存原始 String,其实substring中生成的字符串与原字符串共享内容数组是一个很棒的设计,这样避免了每次进行substring重新进行字符数组复制。这种设计在很多时候可以很大程度的节省内存,因为这些 String 都复用了原始 String,只是通过 int 类型的 start, end 等值来标识每一个 String。而对于上面的案例,从一个巨大的 String 截取少数 String 为以后所用,这样的设计则造成大量冗余数据。
既然导致大量内存占用的根源是 String.substring()返回结果中包含大量原始 String,那么一个显而易见的减少内存浪费的的途径就是去除这些原始 String。办法有很多种,在此我们采取比较直观的一种,即再次调用 new String构造一个的仅包含截取出的字符串的 String
String newString = new String(largeString.substring(0,2));
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);
}