String字符串小结

String可以说是java编程中最常用的对象。用了这么久,大家对它的印象有多深呢?下面我就谈谈我自己对它的一些认知吧。

不可变对象

我们可以看到,String被声明成了final,这样我们可以得到String是不可继承的对象,也是线程安全的对象。

public final class String
implements java.io.Serializable, Comparable<String>, CharSequence {
    /** The value is used for character storage. */
    private final char value[];

    /** Cache the hash code for the string */
    private int hash; // Default to 0
}

concat

接下来我们看String的concat方法。由于String是不可变的,所以当我们连接一个字符串时,它会合并2哥字符串的value数组,然后用这个数组创建一个新的字符串。

public String concat(String str) {
    int otherLen = str.length();
    if (otherLen == 0) {
        return this;
    }
    int len = value.length;
    char buf[] = Arrays.copyOf(value, len + otherLen);
    str.getChars(buf, len);
    return new String(buf, true);
  }

“+”连接符的处理

Java 语言提供对字符串串联符号(”+”)以及将其他对象转换为字符串的特殊支持。字符串串联是通过 StringBuilder(或 StringBuffer)类及其 append 方法实现的。

String str1 = "aabc0";
String str2 = "aa" + "bc"+"0";
System.out.println(str1+"  "+str2);
System.out.println(str1==str2);
for (int i = 0; i < 1; i++) {
    String str3 = "aa" + "bc"+i;
    System.out.println(str1+"  "+str3);
    System.out.println(str1==str3);
}

打印结果:

aabc0  aabc0
true
aabc0  aabc0
false

如果在编译期间就能确定字符串,编译器就会优化String,合并到一起。如上例所示str1==str2为true,表明为同一字符串。

intern浅析

对于字符串常量池,看了网上的很多文章,大部分人貌似都不对。现在在此在总结一遍。

String str1 = new StringBuilder("chaofan").append("wei").toString();
System.out.println(str1.intern() == str1);

String str2 = new StringBuilder("ja").append("va").toString();
System.out.println(str2.intern() == str2);

打印结果:

jdk6false false
jdk7 下true false

产生的差异在于在jdk1.6中 intern 方法会把首次遇到的字符串实例复制到永久待(常量池)中,并返回此引用;但在jdk1.7中,只是会把首次遇到的字符串实例的引用添加到常量池中(没有复制),并返回此引用。
所以在jdk1.7中执行上面代码,str1返回true是引用他们指向的都是str1对象(堆中)(池中不存在,返回原引用),而str2返回false是因为池中已经存在”java”了(关键词),所以返回的池的对象,因此不相等。

相信很多 JAVA 程序员都做做类似 String s = new String(“abc”)这个语句创建了几个对象的题目。 这种题目主要就是为了考察程序员对字符串对象的常量池掌握与否。上述的语句中是创建了2个对象,第一个对象是”abc”字符串存储在常量池中,第二个对象在JAVA Heap中的 String 对象。接下来我们分析另一端代码。

String s = new String("1");
s.intern();
String s2 = "1";
System.out.println(s == s2);

String s3 = new String("1") + new String("1");
s3.intern();
String s4 = "11";
System.out.println(s3 == s4);

打印结果是

  • jdk6 下false false
  • jdk7 下false true

为什么会这样呢?美团网给出了很好的解释?我觉得上面的说法比美团网的解释更好,大家可以参考http://tech.meituan.com/in_depth_understanding_string_intern.html。
最根本的原因应该是:
jdk7 版本对 intern 操作和常量池都做了一定的修改。主要包括2点:

  • 将String常量池 从 Perm 区移动到了 Java Heap区
  • String#intern 方法时,如果存在堆中的对象,会直接保存对象的引用,而不会重新创建对象。

intern需要注意的地方

String的String Pool是一个固定大小的Hashtable,默认值大小长度是1009,如果放进String Pool的String非常多,就会造成Hash冲突严重,从而导致链表会很长,而链表长了后直接会造成的影响就是当调用String.intern时性能会大幅下降(因为要一个一个找)。

在 jdk6中StringTable是固定的,就是1009的长度,所以如果常量池中的字符串过多就会导致效率下降很快。在jdk7中,StringTable的长度可以通过一个参数指定:

  • -XX:StringTableSize=99991

例如:在fastjson 中对所有的 json 的 key 使用了 intern 方法,缓存到了字符串常量池中,这样每次读取的时候就会非常快,大大减少时间和空间。而且 json 的 key 通常都是不变的。这个地方没有考虑到大量的 json key 如果是变化的,那就会给字符串常量池带来很大的负担。

这个问题 fastjson 在1.1.24版本中已经将这个漏洞修复了。程序加入了一个最大的缓存大小,超过这个大小后就不会再往字符串常量池中放了。
更多详情请参考http://tech.meituan.com/in_depth_understanding_string_intern.html

subString内存泄露

首先我们看subString方法。

public String substring(int beginIndex) {
    if (beginIndex < 0) {
        throw new StringIndexOutOfBoundsException(beginIndex);
    }
    int subLen = value.length - beginIndex;
    if (subLen < 0) {
        throw new StringIndexOutOfBoundsException(subLen);
    }
    return (beginIndex == 0) ? this : new String(value, beginIndex, subLen);
}

在这个方法中,新建了一个String对象。

在jdk1.7,1.8中,如下:

public String(char value[], int offset, int count) {
     ....
    this.value = Arrays.copyOfRange(value, offset, offset+count);
}

在jdk1.6中

String(int offset, int count, char value[]) {
    this.value = value;
    this.offset = offset;
    this.count = count;
}

我们可以看到,1.6中还是引用了原来的字符串,存在内存泄露问题。1.7及以上使用了数组复制的方法创建了一个新的数组,不存在内存泄露。

其他

字符串分割

在jdk1.6中:优先考虑使用indexOf方法,最后才使用spilt方法。
在jdk1.8中:优先考虑spilt方法,最后是indexOf方法。
为什么?我们对下面代码进行测试:

StringBuffer sb = new StringBuffer();
int i = 1000;
for (int i1 = i; i1 > 0; i1--) {
    sb.append(i1).append(";");
}
String old = sb.toString();
int k = 10000;
long l = System.currentTimeMillis();
for (int k1 = k; k1 > 0; k1--) {
    old.split(";");
}
System.out.println(System.currentTimeMillis() - l);
long l1 = System.currentTimeMillis();
String tmp = old;
for (int k1 = k; k1 > 0; k1--) {
    while (true) {
        int j = tmp.indexOf(";");
        if (j < 0) {
            break;
        }
        tmp.substring(0, j);
        tmp = tmp.substring(j + 1);
    }
    tmp = old;
}
System.out.println(System.currentTimeMillis() - l1);

结果如下:

  • jdk1.6中 1073 295
  • jdk1.8中 606 7177

对比1073与606,在1.8中由于取消了offset和count2个字段,所以spilt的性能有了较大的提升。
对比295与7177,在1.6中,由于substring方法不会产生新字符串,所以速度更快。在1.8中每次spilt都会产生新的字符串,造成了性能瓶颈。

startWith替代

我们看看下面一段代码:

String ss = "abcdefgg";
int i = 10000000;
long l = System.currentTimeMillis();
for (int i1 = i; i1 > 0; i1--) {
    boolean a = ss.startsWith("abc");
}
System.out.println(System.currentTimeMillis() - l);
long l1 = System.currentTimeMillis();
for (int i1 = i; i1 > 0; i1--) {
    boolean c = ss.charAt(0)=='a'&&ss.charAt(1)=='b'&&ss.charAt(2)=='c';
}
System.out.println(System.currentTimeMillis() - l1);

打印结果如下:

  • 在jdk1.6中 109 26
  • 在jdk1.8中 22 21

为什么会出现这样的结果呢,我们分析代码,发现

//1.6
public boolean startsWith(String prefix, int toffset) {
    char ta[] = value;
    int to = offset + toffset;
    char pa[] = prefix.value;
    int po = prefix.offset;
    int pc = prefix.count;
    // Note: toffset might be near -1>>>1.
    if ((toffset < 0) || (toffset > count - pc)) {
        return false;
    }
    while (--pc >= 0) {
        if (ta[to++] != pa[po++]) {
            return false;
        }
    }
    return true;
}

//1.8
public boolean startsWith(String prefix, int toffset) {
    char ta[] = value;
    int to = toffset;
    char pa[] = prefix.value;
    int po = 0;
    int pc = prefix.value.length;
    // Note: toffset might be near -1>>>1.
    if ((toffset < 0) || (toffset > value.length - pc)) {
        return false;
    }
    while (--pc >= 0) {
        if (ta[to++] != pa[po++]) {
            return false;
        }
    }
    return true;
}

在1.6中第四行,int to = offset + toffset;进行了整形相加操作,这里影响了方法的执行时间。1.7及以后去掉了offset和count,性能有了极大的提升。

参考资料