【Java】String - String,StringBuilder 和 StringBuffer

Posted by 西维蜀黍 on 2019-03-11, Last Modified on 2021-10-17

String类

打开 String 类就会发现,它是被 final 修饰的:

public final class String
    implements java.io.Serializable, Comparable<String>, CharSequence
{
    /** The value is used for character storage. */
    private final char value[];
 
    /** The offset is the first index of the storage that is used. */
    private final int offset;
 
    /** The count is the number of characters in the String. */
    private final int count;
 
    /** Cache the hash code for the string */
    private int hash; // Default to 0
 
    /** use serialVersionUID from JDK 1.0.2 for interoperability */
    private static final long serialVersionUID = -6849794470754667710L;
    ......
}

从上面可以看出几点:

  • String 类是 final 类,也即意味着 String 类不能被继承,并且它的成员方法都默认为 final 方法。在 Java 中,被 final 修饰的类是不允许被继承的,并且该类中的成员方法都默认为 final 方法。对于用 final 来修饰方法,只有在确定不想让该方法被覆盖时,才将方法设置为final。
  • 上面列举出了 String 类中所有的成员属性,从上面可以看出String类其实是通过char数组来保存字符串的,而且这个 char 数组不能被改变。

而且,对于 String 类来说,无论是 sub、concat 还是 replace 操作都不是在原有的字符串上进行的,而是重新生成了一个新的字符串对象。也就是说进行这些操作后,最原始的字符串并没有被改变。

在这里要永远记住一点:

“对String对象的任何改变都不影响到原对象,相关的任何改变操作都会生成新的对象”。

String 的不可变性

虽然 String、StringBuffer 和 StringBuilder 都是 final 类,它们生成的对象都是不可变的,而且它们内部也都是靠 char 数组实现的。

但是不同之处在于,String类中定义的char数组被 final 修饰,而StringBuffer和StringBuilder都是继承自AbstractStringBuilder类。

在AbstractStringBuilder中,char数组只是一个普通是私有变量:

abstract class AbstractStringBuilder implements Appendable, CharSequence {
    /**
     * The value is used for character storage.
     */
    char[] value;
 		...
} 	

因此,对于StringBuffer 和 StringBuilder 对象,都可以通过调用它们的append()方法来不断修改value属性。

String类不可变性的好处

  1. 只有当字符串是不可变的,字符串池才有可能实现。字符串池的实现可以在运行时节约很多heap空间,因为不同的字符串变量都指向池中的同一个字符串。但如果字符串是可变的,那么 String interning 将不能实现(String interning 是指对不同的字符串仅仅只保存一个,即不会保存多个相同的字符串)。因为这样的话,如果变量改变了它的值,那么其它指向这个值的变量的值也会一起改变。
  2. 如果字符串是可变的,那么会引起很严重的安全问题。譬如,数据库的用户名、密码都是以字符串的形式传入来获得数据库的连接,或者在socket编程中,主机名和端口都是以字符串的形式传入。因为字符串是不可变的,所以它的值是不可改变的,否则黑客们可以钻到空子,改变字符串指向的对象的值,造成安全漏洞。
  3. 因为字符串是不可变的,所以是多线程安全的,同一个字符串实例可以被多个线程共享。这样便不用因为线程安全问题而使用同步。字符串自己便是线程安全的。

String str=“hello world"和String str=new String(“hello world”)的区别

public class Main {
         
    public static void main(String[] args) {
        String str1 = "hello world";
        String str2 = new String("hello world");
        String str3 = "hello world";
        String str4 = new String("hello world");
         
        System.out.println(str1==str2);
        System.out.println(str1==str3);
        System.out.println(str2==str4);
        
        //false
        //true
        //false
    }
}

分析

在上述代码中,String str1 = “hello world”; 和 String str3 = “hello world”; 都在编译期间生成了字面常量(literal)和符号引用(symbolic reference),运行期间字面常量 “hello world” 被存储在运行时常量池(当然只保存了一份)。

通过这种方式(String str = “hello world”;)来将String对象跟引用绑定的话,JVM 执行引擎会先在运行时常量池查找是否存在相同的字面常量,如果存在,则直接将引用指向已经存在的字面常量;否则在运行时常量池开辟一个空间来存储该字面常量,并将引用指向该字面常量。

String 和 StringBuilder 的区别

对比 1

既然在Java中已经存在了 String 类,那为什么还需要 StringBuilder 呢?

那么看下面这段代码:

public class Main {
    public static void main(String[] args) {
        String string = "";
        for(int i=0;i<10000;i++){
            string += "hello";
        }
    }
}

这句 string += “hello”; 的过程,相当于将原有的 String 变量指向对象的内容取出,并与"hello"作字符串相加操作,再存进另一个新的 String 对象当中,再让 string 变量指向新生成的对象。

分析

  public com.concretepage.lang.Main();
    Code:
       0: aload_0
       1: invokespecial #1                  // Method java/lang/Object."<init>":()V
       4: return

  public static void main(java.lang.String[]);
    Code:
       0: ldc           #2                  // String
       2: astore_1
       3: iconst_0
       4: istore_2
       5: iload_2
       6: sipush        10000
       9: if_icmpge     38
      12: new           #3                  // class java/lang/StringBuilder
      15: dup
      16: invokespecial #4                  // Method java/lang/StringBuilder."<init>":()V
      19: aload_1
      20: invokevirtual #5                  // Method java/lang/StringBuilder.append:(Ljava/lang/String;)Ljava/lang/StringBuilder;
      23: ldc           #6                  // String hello
      25: invokevirtual #5                  // Method java/lang/StringBuilder.append:(Ljava/lang/String;)Ljava/lang/StringBuilder;
      28: invokevirtual #7                  // Method java/lang/StringBuilder.toString:()Ljava/lang/String;
      31: astore_1
      32: iinc          2, 1
      35: goto          5
      38: return

从这段反编译出的字节码文件可以很清楚地看出:从第 12 行开始到第 35 行是整个循环的执行过程,并且每次循环会 new 出一个 StringBuilder 对象,然后进行append操作,最后通过toString方法返回String对象。也就是说这个循环执行完毕new出了10000个对象。

因此,当这些对象还没有及时被回收时,大量的内存资源就暂时无法被使用。而且,对于回收大量对象这个操作本身,也是非常消耗资源的。

我们基于上面的字节码的语义进行分析,其实,上面的代码就等价于下面这样:

public class Main {
    public static void main(String[] args) {
        String string = "";
        for(int i=0;i<10000;i++){
          	StringBuilder str = new StringBuilder(string);
         	  str.append("hello");
						string = str.toString();
        }
    }
}

再看下面这段代码:

public class Main {
         
    public static void main(String[] args) {
        StringBuilder stringBuilder = new StringBuilder();
        for(int i=0;i<10000;i++){
            stringBuilder.append("hello");
        }
    }
}

反编译字节码文件得到:

  public static void main(java.lang.String[]);
    Code:
       0: new           #2                  // class java/lang/StringBuilder
       3: dup
       4: invokespecial #3                  // Method java/lang/StringBuilder."<init>":()V
       7: astore_1
       8: iconst_0
       9: istore_2
      10: iload_2
      11: sipush        10000
      14: if_icmpge     30
      17: aload_1
      18: ldc           #4                  // String hello
      20: invokevirtual #5                  // Method java/lang/StringBuilder.append:(Ljava/lang/String;)Ljava/lang/StringBuilder;
      23: pop
      24: iinc          2, 1
      27: goto          10
      30: return

从这里可以明显看出,这段代码的for循环只进行了一次 new 操作,也就是说只生成了一个对象,append 操作是在原有对象的基础上进行的。因此在循环了 10000 次之后,这段代码所占的资源要比上面小得多。

对比 2

在某些特殊情况下, String 对象的字符串拼接其实是被 JVM 解释成了 StringBuffer 对象的拼接,所以这些时候 String 对象的速度并不会比 StringBuffer 对象慢,而特别是以下的字符串对象生成中, String 效率是远要比 StringBuffer 快的:

String S1 = This is only a +  simple +  test;
StringBuffer Sb = new StringBuilder(This is only a).append( simple).append( test);

你会很惊讶的发现,生成 String S1 对象的速度简直太快了,而这个时候 StringBuffer 居然速度上根本一点都不占优势。

其实这是 JVM 的一个把戏,在 JVM 眼里,这个String S1 = “This is only a” + “ simple” + “test”; 其实就是:String S1 = “This is only a simple test”; 所以当然不需要太多的时间了。但大家这里要注意的是,如果你的字符串是来自另外的 String 对象的话,速度就没那么快了,譬如:

String S2 = This is only a;
String S3 =  simple;
String S4 =  test;
String S1 = S2 + S3 + S4;

这时候 JVM 会规规矩矩的按照原来的方式去做。

StringBuffer 和 StringBuilder 的区别

那么有人会问既然有了StringBuilder类,为什么还需要StringBuffer类?查看源代码便一目了然,事实上,StringBuilder和StringBuffer类拥有的成员属性以及成员方法基本相同,区别是StringBuffer类的成员方法前面多了一个关键字:synchronized,不用多说,这个关键字是在多线程访问时起到安全保护作用的,也就是说StringBuffer是线程安全的。

下面摘了2段代码分别来自StringBuffer和StringBuilder,insert方法的具体实现:

StringBuilder的insert方法

public StringBuilder insert(int index, char str[], int offset, int len) {
    super.insert(index, str, offset, len);
    return this;
}

StringBuffer的insert方法:

public synchronized StringBuffer insert(int index, char str[], int offset, int len)
{
    super.insert(index, str, offset, len);
    return this;
}

结论

  • 对于直接相加字符串,效率很高,因为在编译器便确定了它的值,也就是说形如"I”+“love”+“java”; 的字符串相加,在编译期间便被优化成了"Ilovejava"。这个可以用javap -c命令反编译生成的class文件进行验证。
  • 对于间接相加(即包含字符串引用),形如s1+s2+s3; 效率要比直接相加低,因为在编译器不会对引用变量进行优化。
  • String、StringBuilder、StringBuffer三者的执行效率:StringBuilder > StringBuffer > String
  • 当字符串相加操作或者改动较少的情况下,建议使用 String str=“hello"这种形式;
  • 当字符串相加操作较多的情况下,建议使用StringBuilder,如果采用了多线程,则使用StringBuffer。

Reference