从底层彻底搞懂String、StringBuffer、StringBuilder的实现

在深入学习字符串之前,我们先搞懂JVM是怎样处理新生字符串的。当你知道字符串的初始化细节后,再去写String str = "hello"或String str = new String(“hello”)等代码时,就能做到心中有数。
首先需要搞懂字符串常量池的概念。
常量池是Java的一项技术,八种基本数据类型除了float和double都实现了常量池技术。这项技术从字面上是很好理解的:把经常用到的数据存放在某块内存中,避免频繁的数据创建与销毁,实现数据共享,提高系统性能
字符串常量池是Java常量池技术的一种实现,在近代的JDK版本中(1.7后),字符串常量池被实现在Java堆内存中。
下面通过三行代码让大家对字符串常量池建立初步认识:

public static void main(String[] args) {
	String str1 = "hello";
	String str2 = new String(“hello”);
	System.out.println(str1 == str2); //false
}

我们来看看第一行代码Sting str1 = “hello”;在这里插入图片描述
对于这种直接通过双引号""声明字符串的方式,虚拟机首先会到字符床常量池中查找该字符串是否已经存在。如果存在直接返回该引用,如果不存在则会在堆内存中创建该字符串对象,然后到字符串常量池中注册该字符串。
在本案例中虚拟机首先会到字符串常量池中查找是否有存在"hello"字符串对应的引用。发现没有后会在堆内存中创建"hello"字符串对象(内存地址0x0001),然后到字符串常量池中注册地址为0x00001的"hello"对象,也就是添加指向0x0001的引用。最后把字符床对象返回给str1。

温馨提示:图中的字符串常量池中的数据是虚构的,由于字符串常量池底层是用HashTable实现的,存储的是键值对,为了方便理解,示意图简化了字符串常量池对照表,并采用了一些虚拟的数值。

下面看String str1 = new String(“hello”);的示意图:
在这里插入图片描述
当我们使用new关键字创建字符串对象的时候,JVM将不会查询字符床常量池,它将会直接在堆内存中创建一个字符串对象,并返回给所属变量。
所以str1和str2指向的是两个完全不同的对象,判断str1 == str2的时候返回false。
下面我们再来看下面代码:

public static void main(String[] args) {
	String str1 = new String("hello ") + new String("world");
	str1.intern();
	String str2 = "hello world";
	System.out.println(str1 == str2);   //true
}

第一行代码String str1 = new String("hello ") + new String(“world”);的执行过程如下:

  1. 依次在堆内存中创建"hello "和"world"两个字符串对象。
  2. 然后把它们拼接起来(底层使用StringBuilder实现)
  3. 在拼接完成后会新的"hello world"对象,这时变量str1指向新对象"hello world"
    执行完第一行代码后,内存是这样子的:
    。。。
    第二行代码str1.intern();
    String类的源码中对intern()方法的详细介绍为:当调用intern()方法时,首先会去常量池中查找是否有该字符串对应的引用,如果有就直接返回该字符串;如果没有,就会在常量池中注册该字符串的引用,然后返回该字符串。
    由于第一行代码采用的是new的方式创建字符串,所以在字符串常量池中没有保存"hello world"对应的引用,虚拟机会在常量池中进行注册,注册完后的内存示意图如下:
    在这里插入图片描述
    第三行代码String str2 = “hello world”;这种直接通过双引号""声明字符串,首先虚拟机会去检查字符串常量池,发现有向"hello world"的引用,然后把该引用所指向的字符串直接返回给所属变量。
    执行完第三行代码后,内存示意图如下:
    在这里插入图片描述
    如图所示,str1和str2指向的是相同的对象,所以判断str1==str2时返回true。

字符串常量池的总结

当用new关键字创建字符串对象时,不会查询字符串常量池;当用双引号直接声明字符串对象时,虚拟机将会查询字符串常量池。即:字符串常量池提供了字符串的复用功能,除非我们要显式创建新的字符串对象,否则对同一个字符串虚拟机只会维护一份拷贝。

配合反编译代码验证字符串初始化操作

代码如下:

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

首先第一行和第二行是常规的字符串对象声明,我们已经很熟悉了,它们分别会在堆内存创建字符串对象,并会在字符串常量池中进行注册。
影响我们做出判断的是第三行代码String str3 = str1 + str2;我们不知道str1+str2在创建完新字符串"hello world"后是否在字符串常量池进行注册。即:我们不知道这行代码是以双引号""形式声明字符串,还是new关键字创建字符串。这时,我们看一下这段代码的反编译代码。
在命令行中输入javap -c对应.class文件的绝对路径,按回车后即可看到反编译文件的代码段。如下所示:
在这里插入图片描述

  • 首先调用构造器完成main类的初始化
  • 0: ldc #2 // String hello
  • 从常量池中获取"hello "字符串并推送至栈顶,此时拿到了"hello "的引用
  • 2: astore_1
  • 将栈顶的字符串引用存入第二个本地变量str1,也就是str1已经指向了"hello "
  • 3: ldc #3 // String world
  • 5: astore_2
  • 重复开始的步骤,此时变量str2指向"world"
  • 6: new #4 // class java/lang/StringBuilder
  • 此时创建了一个StringBuilder,并把其引用值押到栈顶
  • 9: dup
  • 复制栈顶的值,并继续压入栈顶,也就意味着栈从上到下有两份StringBuilder的引用,将来要操作两次StringBuilder
  • 10: invokespecial #5 // Method java/lang/StringBuilder.""😦)V
  • 调用StringBuilder的一些初始化方法,静态方法或父类方法,完成初始化
  • 13: aload_1
  • 把第二个本地变量也就是str1压入栈顶,现在栈顶从上往下数两个数据依次是:str1变量和StringBuilder的引用
  • 14: invokevirtual #6 // Method java/lang/StringBuilder.append:(Ljava/lang/String;)Ljava/lang/StringBuilder;
  • 调用StringBuilder的append方法,栈顶的两个数据在这里调用方法时就用上了
  • 接下来又调用了一次append方法(之前StringBuilder的引用拷贝两份就用途在此)
  • 完成后,StringBuilder中已经拼接好了"hello world"
  • 21: invokevirtual #7 // Method java/lang/StringBuilder.toString:()Ljava/lang/String;
  • 24: astore_3
  • 拼接完字符串后,虚拟机调用**StringBuilder的toString()**方法获得字符串hello world,并存放至str3
    下面是StringBuilder的toString()方法源码:
@Override
    public String toString() {
        // Create a copy, don't share the array
        return new String(value, 0, count);
    }

所以,str3是通过new关键字获得字符串对象的。
由此说明,字符串常量池中没有存储"hello world"的引用,当str4以引号的形式声明字符串时,由于在字符串常量池中查询不到相应的引用,所以字符串常量池中新建一个。所以str3和str4指向的不是同一个字符串对象,结果为false。

详解字符串操作类

明白了字符串常量池,相信关于字符串的创建已经有了十足的把握。接下来我们对String、StringBuilder、StringBuffer三大字符串操作类背后的实现进行详解。

  • String、StringBuilder、StringBuffer的底层实现

点进String的源码,我们可以看见String类是通过char类型数组实现的,源码如下:

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

接着查看StringBuilder和StringBuffer的源码,发现这两者都继承自AbstractStringBuilder类,源码如下:

StringBuilder.java:
public final class StringBuilder
    extends AbstractStringBuilder
    implements java.io.Serializable, CharSequence
{
	...
}
StringBuffer.java:
 public final class StringBuffer
    extends AbstractStringBuilder
    implements java.io.Serializable, CharSequence
{
	...
}

通过查看该类的源码,发现这两个类也是通过char类型数组实现的,源码如下:

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

通过StringBuilder和StringBuffer继承自同一个父类这点,我们可以推断出它俩的方法都是差不多的。通过查看源码发现只不过StringBuffer在方法上添加了synchronized关键字,证明它的方法绝大多数都是线程同步方法。也就是说在多线程的环境下我们应该使用StringBuffer以保证线程安全,在单线程环境下我们应该使用StringBuilder以获得更高的效率。

  • 关于StringBuilder和String之间的比较

通过查看StringBuilder和String的源码发现两者之间一个关键的区别:对String,凡是涉及到返回参数类型为String类型的方法,在返回的时候都会通过new关键字创建一个新的字符串对象;而对于StringBuilder,大多数方法都会返回StringBuilder对象自身

/**
* 截取的几个String类的方法
*/
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);
    }
    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);
    }
    /**
	* 截取几个StringBuilder类的方法
	*/
	public StringBuilder append(String str) {
        super.append(str);
        return this;
    }
    public StringBuilder replace(int start, int end, String str) {
        super.replace(start, end, str);
        return this;
    }

因此,在操作字符串时在不同的场景下会体现出不同的效率。以拼接字符串为例比价一下两者的性能,代码如下:

	public static int time = 50000;
    public static void main(String[] args) {
        long start = System.currentTimeMillis();
        String str = "";
        for (int i = 0; i < time; i++) {
            str += "test";
        }
        long end = System.currentTimeMillis();
        System.out.println("String类使用时间:"+(end - start) + "毫秒");
        //String类使用时间:4005毫秒
  }     
	public static int time = 50000;
    public static void main(String[] args) {
        long start = System.currentTimeMillis();
        StringBuilder sb = new StringBuilder();
        for (int i = 0; i < time; i++) {
            sb.append("test");
        }
        long end = System.currentTimeMillis();
        System.out.println("StringBuilder类使用时间:"+(end - start) + "毫秒");
        //StringBuilder类使用时间:4毫秒
  }      

就拼接5万次字符串而言,StringBuilder的效率是远远高于String类的。
我们再次通过反编译看看造成两者性能差距的原因:
在这里插入图片描述
从汇编代码中可以看到,当用String类拼接字符串时,每次都会生成一个StringBuilder对象,然后调用两次append()方法把字符串拼接好,最后通过StringBuilder的toString()方法new一个新的字符串对象。也就是说每次拼接都会new出两个对象,并进行两次方法调用,如果拼接的次数过多,创建对象所带来的延迟会降低系统效率,同时会造成巨大的内存浪费,而且内存不够用时,虚拟机会进行垃圾回收,这也是一项相当耗时的操作,会大大降低系统性能。
下面是StringBuilder拼接字符串得到的反编译代码:
在这里插入图片描述
可以看到StringBuilder拼接字符串,直接把要拼接的字符串放到栈顶append。除了开始时创建了StringBuilder对象,运行期间没有创建过其他任何对象,每次循环只调用一次append方法,从效率上看,拼接大量字符串时,StringBuilder要比String类快的多。
当然String类也不是没有优势的,从操作字符串api的丰富度上来讲,String是要多于StringBuilder的,在日常操作中很多业务都需要用到String类的api。
在拼接字符串时,如果是简单的拼接,比如说是String str = "hello " + “world”;String类的效率会更高一点。但如果需要拼接大量字符串,StringBuilder无疑是更合适的选择。

  • 3
    点赞
  • 5
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值