Java基础之【深入讲解String类】

String 的不可变性


📝 String 不可变的原理

我们先来看一下 String 的源码是怎么存储数据的

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

从 String 的源码我们可以看出:

  • 1、String 是一个 final 类,这就意味着 String 是不能被继承的,通过这种方式防止出现:程序员通过继承重写 String 类的方法的手段来使得String类成为“可变的”的情况。

  • 2、数组 value 是String的底层数组,用于存储字符串的内容,而且是 private final

但数组是引用类型,只能限制引用不改变而已,也就是说数组元素的值是可以改变的,而且String 有一个可以传入数组的构造方法,那么我们可不可以通过修改外部 char 数组元素的方式来“修改” String 的内容呢?比如下面的例子:

public static void main(String[] args) {
    char[] arr = new char[]{'a','b','c','d'};       
    String str = new String(arr);       
    arr[3]='x';     
    System.out.println("str:" + str);
    System.out.println("arr[]:" + Arrays.toString(arr));
}
>>>>>>>>
str: abcd
arr[]: [a, b, c, x]

结果好像与我们所想不一样:字符串 str 使用数组 arr 来构造一个对象,当数组 arr 修改其元素值后,字符串 str 并没有跟着改变。那我们就去源码里看一下这个构造方法是怎么处理的:

public String(char value[]) {
    this.value = Arrays.copyOf(value, value.length);
}

原来 String 在使用外部 char 数组构造对象时,是重新复制了一份外部 char 数组,从而不会让外部 char 数组的改变影响到 String 对象。


📝 String 的改变即创建

从上面的分析我们知道,我们是无法从外部修改String对象的,那么可不可能使用String提供的方法,因为有不少方法看起来是可以改变String对象的,如 replace()、replaceAll()、substring() 等。我们以 substring() 为例,看一下源码:

 public String substring(int beginIndex, int endIndex) {
    //........
    return ((beginIndex == 0) && (endIndex == value.length)) ? this
            : new String(value, beginIndex, subLen);
}

从源码可以看出,如果不是切割整个字符串的话,就会新建一个对象。也就是说,只要与原字符串不相等,就会新建一个String对象。


📝 缓存 Hashcode

Java中经常会用到字符串的哈希码(hashcode)。例如,在HashMap中,字符串的不可变能保证其hashcode永远保持一致,这样就可以避免一些不必要的麻烦。这也就意味着每次在使用一个字符串的hashcode的时候不用重新计算一次,这样更加高效。

在 String 类中,有以下代码:

private int hash;  //this is used to cache hash code.

以上代码中hash变量中就保存了一个String对象的hashcode,因为String类不可变,所以一旦对象被创建,该hash值也无法改变。所以,每次想要使用该对象的hashcode的时候,直接返回即可.


字符串拼接

其实,所有的所谓字符串拼接,都是重新生成了一个新的字符串,那么,在Java中,到底如何进行字符串拼接呢?字符串拼接有很多种方式,这里简单介绍几种比较常用的

📖 使用 “+” 拼接字符串

我们先来看一个例子:

public class Test {
    public static void main(String[] args) {
    	// 创建4个字符串:创建方式不同但字符串内容一样
        String s = "Hello Java";
        String s2 = "Hello" + " Java";
        String s3 = s2 + "";
        String s4 = new String("Hello Java");

        // 对这4个字符串使用相等比较
        System.out.println("s == s2: " + (s == s2));
        System.out.println("s == s3: " + (s == s3));
        System.out.println("s == s4: " + (s == s4));
    }
}
>>>>>
s == s2: true
s == s3: false
s == s4: false

为什么结果是这样子的?我们来慢慢分析一下,首先,我们要知道编译器有个优点:在编译期间会尽可能地优化代码,所以能由编译器完成的计算,就不会等到运行时计算,如常量表达式的计算就是在编译期间完成的。所以,s2 的结果其实在编译期间就已经计算出来了,与 s 的值是一样,所以两者相等,即都属于字面常量,在类加载时创建并维护在字符串常量池中。但 s3 的表达式中含有变量 s2,只能是运行时才能执行计算,也就是说,在运行时才计算结果,在堆中创建对象,自然与 s 不相等。而 s4 使用new直接在堆中创建对象,更不可能相等。

那在运行期间,是如何完成String的+号拼接操作的呢,要知道String对象可是不可改变的对象。我们反编译上面例子的 class 文件来看一下原因:

public class Test
{
    public Test()
    {
    }
    public static void main(String args[])
    {
        String s = "Hello Java";
        String s2 = "Hello Java";  //已经得到计算结果
        String s3 = (new StringBuilder(String.valueOf(s2))).toString();
        String s4 = new String("Hello Java");

        // + 号处理成了 StringBuilder.append() 方法
        System.out.println((new StringBuilder("s == s2 ")).append(s == s2).toString());
        System.out.println((new StringBuilder("s == s3 ")).append(s == s3).toString());
        System.out.println((new StringBuilder("s == s4 ")).append(s == s4).toString());
    }
}

可以看出,编译器将 + 号处理成了StringBuilder.append()方法。也就是说,在运行期间,链接字符串的计算都是通过 创建 StringBuilder 对象,调用 append() 方法来完成的

📖 使用 concat 拼接字符串

除了使用+拼接字符串之外,还可以使用String类中的方法concat方法来拼接字符串。如:

String s1 = "Hello";
String s2 = "Java";
String s3 = s1.concat(" ").concat(s2);

我们再来看一下 concat 方法的源代码,看一下这个方法又是如何实现的:

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

从源码可以看出,concat 的原理是创建了一个字符数组,长度是已有字符串的长度和待拼接字符串的长度之和,再把两个字符串的值复制到新的字符数组中,并使用这个字符数组创建一个新的 String 对象并返回。所以,经过 concat 方法,其实是 new 了一个新的 String 对象,这也验证了前面我们说的字符串的不变性原理。


StringBuffer 和 StringBuilder

接下来我们看看 StringBuilder 和 StringBuffer 的实现原理。和 String 类似,StringBuilder 类也封装了一个字符数组,定义如下:

char[] value;  // The value is used for character storage.

但与 String 不同的是,它并不是 final 的,所以他是可以修改的,并且 StringBuilder 的字符数组不一定所有位置都已经被使用,因为它有一个实例变量,用来表示数组中已经使用的字符个数,定义如下:

int count;  // The count is the number of characters used.

其append源码如下:

public StringBuilder append(String str) {
    super.append(str);
    return this;
}

该类继承了 AbstractStringBuilder 类,我们进去看下其 append 方法的源码:

public AbstractStringBuilder append(String str) {
    if (str == null) {
        return appendNull();
    }
    int len = str.length();
    ensureCapacityInternal(count + len);
    putStringAt(count, str);
    count += len;
    return this;
}

append 会直接拷贝字符到内部的字符数组中,并且使用 ensureCapacityInternal() 来检查字符数组容量是否足够,如果字符数组容量不够,则会进行扩展。

StringBuffer 和 StringBuilder 类似,都是继承于 AbstractStringBuilder,最大的区别就是 StringBuffer 是线程安全的,我们可以看一下 StringBuffer 的 append 方法的源码:

public synchronized StringBuffer append(String str) {
    toStringCache = null;
    super.append(str);
    return this;
}

该方法使用 synchronized 进行声明,说明是一个线程安全的方法,而 StringBuilder 则不是线程安全的。

字符串拼接效率比较

字符串的拼接方式比较多:使用+号拼接、使用 concat() 方法拼接、使用 StringBuilder 或 StringBuffer 拼接、使用 StringUtils.join 拼接等等。当同一时刻频繁进行大量拼接时(比如 for 循环中循环次数很大),他们的拼接效率从高到低排序为:

StringBuilder >> StringBuffer >> concat >> + >> StringUtils.join

StringBuffer 是在 StringBuilder 的基础上做了同步处理,所以会在耗时上比 StringBuilder 多一点,这个很好理解。但问题是,使用 + 进行拼接字符串的原理也是使用 StringBuilder 的呀,为什么效率会差这么远的?来看一下下面的例子:

String str = "";
for (int i = 0; i < 500; i++) {
    String temp = String.valueOf(i);
    str += temp;
}

反编译后代码如下

String str = "";
for (int i = 0; i < 500; i++) {
    String temp = String.valueOf(i);
    str = (new StringBuilder()).append(str).sppend(temp).toString();
}

可以看到使用 + 来拼接,每次都是 new 了一个 StringBuilder,然后再把 String 转成 StringBuilder,再进行 append 操作,如果频繁的新建对象当然要耗费很多时间了,不仅仅会耗费时间,频繁的创建对象,还会造成内存资源的浪费。

所以:循环体内,字符串的连接方式,使用StringBuilder 的 append 方法进行扩展。而不要使用 +


总结

下面总结一下这篇文章的主要内容

  • String 是不可变的,一旦创建将不可修改,所有看似修改 String 的方法都是通过创建返回新的 String 来处理的
  • 常用的字符串拼接方式有五种,分别是使用 +、使用concat、使用 StringBuilder、使用 StringBuffer 以及使用 StringUtils.join
  • 由于字符串拼接过程中会创建新的对象,所以如果要在一个循环体中进行字符串拼接,就要考虑内存问题和效率问题
  • 使用 StringBuilder 的方式是效率最高的。因为 StringBuilder 天生就是设计来定义可变字符串和字符串的变化操作的

使用的基本原则是:

  • 如果不是在循环体中进行字符串拼接的话,直接使用 + 就可以了
  • 如果在并发场景中进行字符串拼接的话,要使用 StringBuffer 来代替 StringBuilder
發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章