详解String、StringBuffer和StringBuilder

本博客来源于B站视频: 你真的了解String StringBuilder StringBuffer吗link

本博客对于String底层有一定的见解,但是还不够底层。没有给出如何做到线程安全的解释、也没有给出StringBuffer效率高的原因,等后面了解更多会继续更新


1. String类


1.1 String对象内容不会发生改变

知识点1:String类被final修饰(String类如下图所示)表明不能被继承,并且一个类如果被final修饰,那么他所有的方法相当于也被final修饰,这些方法都不能被重写。
String类源码

知识点2:在早期的jdk版本中,String类默认会加一些final方法,这些加了final的方法会转为内嵌调用,用来提高代码的效率,现在jdk已经不需要用这种方法来提高性能了(现在String所有的方法都不会被重写,因为String类加了final)

知识点3:底层是一个char数组(如下图所示),可以看到这个char数组也是被final修饰了,说明他是不能被改变的:
String源码2
知识点4:String类的方法,是不会影响原对象的。
  我们来看String类中的几个方法:substring、concat、replace方法,源码如下

  (1)substring方法(截取部分字符串):代码如下所示,一共两种重载形式,注意两段代码最后一行,返回的都是new了一个新的string。

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);
}

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);
}

(2)concat方法(字符串拼接,jdk1.8->String类2026行):

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);
}

(3)replace方法(字符串替换方法,jdk1.8->String类2066行):

public String replace(char oldChar, char newChar) {
    if (oldChar != newChar) {
        int len = value.length;
        int i = -1;
        char[] val = value; /* avoid getfield opcode */

        while (++i < len) {
            if (val[i] == oldChar) {
                break;
            }
        }
        if (i < len) {
            char buf[] = new char[len];
            for (int j = 0; j < i; j++) {
                buf[j] = val[j];
            }
            while (i < len) {
                char c = val[i];
                buf[i] = (c == oldChar) ? newChar : c;
                i++;
            }
            return new String(buf, true);
        }
    }
    return this;
}

举例以上三种方法,是为了得出下面这样一个结论:
  String类的方法,是不会影响原对象的,也就是说我现在有一个string对象str1,我现在调用方法substring、concat、replace等等其他方法,是不会影响str1对象的,调用这些方法都会生成新的String类对象。这就说明,无论如何,String对象是不会被改变的

结论:
无论如何,String对象是不会被改变的

1.2 String对象的比较(即字符串的比较)

背景知识:在运行过程中,需要把 .java文件编译成 .class文件,而 .class文件中的一部分是用来存储编译期间产生的字面常量和符号引用,称之为class文件的常量池,对应的是运行时常量池。而运行时常量池在jdk1.7之后被移到堆当中去存储了。

先看下面的代码:利用不同的构造来判别字符串是否相等

public class StringTest {
    public static void main(String[] args) {
        String s1 = "hello world";
        String s2 = new String("hello world");
        String s3 = "hello world";
        String s4 = new String("hello world");

        System.out.println(s1 == s2);   // false
        System.out.println(s1 == s3);   // true
       System.out.println(s2 == s4);   // false 
    }
}

现在解析上面这段代码:

  1. 对于s1和s3,为什么相等呢?因为在编译期间,生成了字面常量和符号引用,s1和s3会被存储在对应的运行时常量池中。由于s1和s3的内容一样(都是hello world),在运行时常量池中只存储一次。
  2. 对于1)中最后一句话(在运行时常量池中只存储一次),具体是怎么存储的呢?
      先去运行时常量池中找有没有 hello world 这个字符串存在。如果存在,就令变量直接指向该字符串的位置;如果不存在,常量池会创建一个内存空间来存储这个字符串,再令变量指向这个字符串。
    (1)对于我们上述的s1和s3:当我们执行到上述代码第三行:
    String s1 = "hello world";
    
      此时常量池中没有hello world 这个字符串,这个时候会开辟一个内存空间来存储hello world,然后令s1指向字符串 hello world的内存空间!(注意是指向内存空间)
    (2)当执行到上述代码第五行:
    String s3 = "hello world";
    
      这个时候常量池中有 hello world ,不会创建新的内存空间,会直接令s3指向字符串hello world的内存空间。
    (3)所以s1和s3指向了同一个内存空间,也正是因此在上述代码第9行结果为false。
    System.out.println(s1 == s3);   // true
    
  3. 对于s1和s2、s2和s4为什么返回false呢?
    (1)众所周知,通过new关键词创建出来的对象,一定是一个新的对象,即在堆当中创建了一个新的对象。即使堆当中已经有相同内容的字符串,new出来的对象也会新开辟一个内存空间存储当前字符串。
    (2)对于上述s2、s4,都是通过new关键字创建的对象,即使堆中已经有了 hello world字符串,此时也会创建新的内存空间存储字符串hello world,然后令s2和s4分别指向这两个内存空间;既然是新开辟的内存空间,那么内存空间的位置必然不一样,所以在比较s1 == s2和s2 == s4时都会返回false。

注意:上述3)中我提到了堆而没有说常量池,是因为我不太确定new出来的新对象是否在常量池中,或者是在堆的其他空间?(常量池是在堆中,堆的定义范围更大)。不过我可以确定new出来的新对象是在堆中。这方面知识涉及jvm的内存空间分配,我还不了解。我使用堆这个更大的范围应该是没有问题的。非常欢迎各位共同讨论~

结论:
  1. 编译期间,会生成了字面常量和符号引用
  2. 运行时相同字符串常量池中只存储一次。
  3. new关键词创建出来的对象,一定是一个新的对象


2. String和StringBuilder的比较

既然已经有了String,为什么还要提出StringBuilder?

1.先来一个小栗子~

public class Test {
    public static void main(String[] args) {
        String s1 = "";
        for (int i = 0; i < 1000; i++) {
            s1 += "a";
        }
        System.out.println(s1); // aaaaaa......1000个a
    }
}

  1)上述代码会会拼接字符串成1000个a,然后令s1指向它(实际是内存空间)。
  2)通过查看jvm指令(如下图),可以看到每一次循环,jvm都创建了一个StringBuilder对象;也就说原代码中的第四行:s1 += “a”; jvm都会自动优化成创建一个StringBuilder对象,然后调用append方法去完成。那么循环1000次,就创建了1000个StringBuilder对象,调用了1000次append方法。
   3)既然创建了1000个StringBuilder对象,并且这些对象都是在堆当中。这对内存资源是一种极大的消耗,
查看jvm指令
2.可以对上述代码进行优化如下:

public class Test {
    public static void main(String[] args) {
        StringBuilder builder = new StringBuilder();
        for (int i = 0; i < 1000; i++) {
            builder.append("a");
        }
        System.out.println(builder); // aaaaaa......1000个a
    }
}

  显然可以看出这里我们最开始创建了一个StringBuilder对象,然后在1000次循环中,每次都利用append追加字符串,避免了代码1循环时每次都要新建StringBuilder对象,节约了内存。

结论:
  利用StringBuilder拼接字符串效率更高

3. String、StringBuilder和StringBuffer三者比较


3.1 StringBuffer与StringBuilder的区别:线程安全

  查看StringBuffer源码(如下图):可以发现StringBuffer每个方法中都有一个synchronized标识,这个是用来保证多线程安全访问,所以StringBuffer是线程安全的,StringBuilder是是非线程安全的。

  注:这里为什么能够起到线程安全与否的作用,我暂时还不理解,后面学到了会及时更新,也欢迎朋友们互相探讨~

在这里插入图片描述

3.2 实例对比区别

1.String、StringBuilder和StringBuffer三个对象拼接十万次字符串 ”a“。

public class Test {
    public static int time = 100000;
    public static void main(String[] args) {
        testString();   // String拼接用时:5570
        testStringBuffer();   // StringBuffer拼接用时:4
        testStringBuilder();    // StringBuilder拼接用时:5
    }
    public static void testString(){
        String s1 = "";
        long start = System.currentTimeMillis();
        for (int i = 0; i < time; i++) {
            s1 += "a";
        }
        long end = System.currentTimeMillis();
        System.out.println("String拼接用时:"+(end-start));
    }
    public static void testStringBuffer(){
        StringBuffer buffer = new StringBuffer();
        long start = System.currentTimeMillis();
        for (int i = 0; i < time; i++) {
             buffer.append("a");
        }
        long end = System.currentTimeMillis();
        System.out.println("StringBuffer拼接用时:"+(end-start));
    }
    public static void testStringBuilder(){
        StringBuilder builder = new StringBuilder();
        long start = System.currentTimeMillis();
        for (int i = 0; i < time; i++) {
            builder.append("a");
        }
        long end = System.currentTimeMillis();
        System.out.println("StringBuilder拼接用时:"+(end-start));
    }
}

  1)执行结果如下(每次执行结果不会完全相同):

String拼接用时:5570
StringBuffer拼接用时:4
StringBuilder拼接用时:5

  2)可以看到String拼接时间非常大,大约5秒钟,其他两个为4和5毫秒;所以以后尽量不要用String拼接字符串。

结论:
  1.String拼接字符串效率低、时间久;StringBuffer和StringBuilder效率高
  2.StringBuffer是线程安全的,StringBuilder是是非线程安全的

注意:这里为什么StringBuffer和StringBuilder效率高(似乎和底层定义的数组有关)和线程安全我还不了解,后续深入学习后再更新!

3.3 String直接和间接拼接的区别

public class Test {
    public static int time = 100000;
    public static void main(String[] args) {
        testString1();   // String直接拼接用时:2
        testString2();   // String间接拼接用时:41

    }
    // String直接拼接
    public static void testString1(){
        String s1 = "";
        long start = System.currentTimeMillis();
        for (int i = 0; i < time; i++) {
            s1 = "a" + "b" + "c";
        }
        long end = System.currentTimeMillis();
        System.out.println("String直接拼接用时:"+(end-start));
    }
    // String间接拼接
    public static void testString2(){
        String a = "a";
        String b = "b";
        String c = "c";
        long start = System.currentTimeMillis();
        for (int i = 0; i < time; i++) {
            String s1 = a + b + c;
        }
        long end = System.currentTimeMillis();
        System.out.println("String间接拼接用时:"+(end-start));
    }
}

1)执行结果如下:20倍的差距

String直接拼接用时:2
String间接拼接用时:41

2)直接拼接和间接拼接的区别:

  1. 直接拼接:编译期间就可以确定要拼接的值:将”a“ + “b” + “c"三个字符串优化成 ”abc”,然后再拼接。
  2. 间接拼接:不会进行拼接,所以效率低。

4. 常见问题


例1:字符串直接拼接
String s1 = "helloa";
String s2 = "hello" + "a";
System.out.println(s1 == s2);   // true

  解释:对于s2,编译期中编译器就会将“hello" + "a"自动优化为”helloa“(即上述提到的直接拼接),那么程序运行到第二行 String s2 = “hello” + “a”;时已经线程池已经有了“helloa”这个字符串,会直接将该地址赋值给s2(即我们上述提到的常量池原理),也因此判断二者相等时,返回true。


例2:符号引用的拼接

String s1 = "helloa";
String s2 = "hello";
String s3 = s2 + "a";
Syste.out.println(s1 == s3);   // flase

  解释:由于上述第三行即String s3 = s1 + “a”;的等号右边是s1 + ”a”;并非两个字符串之间拼接,属于符号引用,在编译期间并不会被优化。这个时候会生成一个新的对象保存至堆内存中,因此s1和s3并非同一个对象。

例3:final关键字修饰的符号引用(在代码2的基础上给s2加了final修饰)

String s1 = "helloa";
final String s2 = "hello";
String s3 = s2 + "a";
Syste.out.println(s1 == s3);   // true

  解释:由于s2被final修饰,在编译期间会被代替为真正的值,即第三行在编译期间将s2代替为"hello",然后进行直接拼接。那么因此会拼接后变成"helloa",当执行到第三行时,由于常量池中已经有了”helloa“(s1已经存在),所以将s3指向了该字符串内存空间。自然而然,s1 == s3返回true。


例4:编译期和运行期间创建对象的区别

String str = new String("abc");

  问题:以上代码在执行期间创建了几个对象?
  答案:1个。
  解释:上述完整过程会创建两个对象:第一个是字符串"abc",会被创建在常量池中。第二个是new String()对象,即在堆中new出来一个对象。但是,第一个字符串对象“abc”是编译期间在常量池中创建的,而不是执行期间!所以执行期间创建的对象只有一个,就是new String()创建出来的对象。

例5:利用方法赋值后拼接字符串

public class Test {
    public static void main(String[] args) {
        String s1 = "helloa";
        final String s2 = hello();
        String s3 = s2 + "a";
        System.out.println(s1 == s3);   // false
    }
    public static String hello(){
        return "hello";
    }
}

  解释:上述例子3中提到被final修饰,在编译过程中会被替代为真实值。但是我们这里第4行中,调用了方法hello(),即使这里s2被final修饰了,但是s2的值是方法hello() 的返回值。那么在编译期间方法hello()的返回值是不能确定的,只能在运行期确定,自然而然s2的值也只能等到方法执行后确定。所以返回false。


例6:方法intern()

public class Test {
    public static void main(String[] args) {
        String s1 = "abc";
        String s2 = new String("abc");
        System.out.println(s1 == s2.intern());   // true
    }
}

解释:方法intern()在jdk1.7后发生了改变。
  1)jdk1.7之前,s2.intern();会先判断在字符串常量池是否有字符串s2的值,如果没有就把s2的值复制一份到常量池,即在常量池中开辟空间写入s2的真实值,然后方法intern()返回的就是常量池中开辟中的内存空间的地址。注意:b没有变,b的真实值和b所指向的空间位置都没有改变,只是方法intern()返回的是新开辟的空间的地址。
  2)jdk1.7之后,s2.intern();会先判断在字符串常量池是否有字符串s2的值,如常量池中没有,并不会把s2的值复制一份到常量池,而是在常量池中会产生一个引用,该引用指向了s2的具体的值,方法intern()会返回这个引用。

 String str = s.intern();

1.8:将字符串的值尝试放入StringTable;如果StringTable中已经存在,则不会放入;如果没有则放入StringTable,并返回StringTable中的字符串对象。
1.6:将字符串的值尝试放入StringTable;如果StringTable中已经存在,则不会放入;如果没有会把此对象复制一份,放入串池,会把串池中的对象返回。
https://www.cnblogs.com/feizhai/p/10196955.html intern方法在1.6和1.8可以参考这篇文章。
注意:jdk1.8中intern方法,比如 s.intern(); 如果StringTable中没有s的值,不仅会把s的字符串内容复制一份到StringTable,还会更改s的引用,令s指向StringTable中的这个值。


**总结:**   ==1.直接拼接发生在编译期,效率更高   2.符号引用不能进行直接拼接   3.被final修饰的变量符号,在编译期间会被代替为真正的值,从而进行符号拼接。   4.编译期在字符串常量池新建对象,运行期在堆中new出对象。   5.方法在运行期执行,其结果也在方法执行后返回   6.方法intern()在jdk1.7之前为调用者在常量池复制一份,jdk1.7之后为复制引用。==
  • 0
    点赞
  • 2
    收藏
    觉得还不错? 一键收藏
  • 4
    评论
评论 4
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值