string, stringbuilder and stringbuffer

前言

String类可谓是我们最常用的一个类了,但是对于这个类我们是否是真的了解呢?之前我们早就已经说过了String对象是一个不可变的对象,在说到基本数据类型的包装类的时候,我们也提高了String的常量池。虽然关于String,我们已经讲了不少,但是都是太过于零散,这里我准备看看String类型的源码是如何的。

String漫谈

String的声明

首先看一下String的声明是如何的。

public final class String
extends Object
implements Serializable, Comparable<String>, CharSequence
  • String类是final的,也就是我们是无法去继承String类的。

  • Serializable是一个标记接口,代表String对象是可以序列化的。

  • Comparable<String>是比较器。String类实现了自然排序,所以很多排序的操作时,我们并不需要指定一个比较器给String。(String的排序是按照字典顺序的,源码太复杂了,这里就不贴出来了)

  • CharSequence其实就是字符串的意思,其中只是提供了可以作为字符的基本方法。如

    • char charAt(int index),我们或许String对象的第几位的字符的时候不可以使用[]。比如说下面的代码示例。

      String a = "abc";
      char b = a[1]; // Worng
      char b = a.charAt(1); // Right
    • int length()。既然是字符串,那肯定是有长度的。

    • CharSequence subSequence(int start, int end)。这个方法倒是有点儿想String类中的subString方法有点儿类似了。

    • String toString()。这个方法其实每一个类都是有的,但是CharSequence接口将这个方法设置为抽象方法,这也是理所应当的,作为一个字符串toString的时候肯定是要输出点内容的。因为toString方法默认输出的是

      getClass().getName() + "@" + Integer.toHexString(hashCode())

上面就是对String类的声明做了一个简单的了解。现在就让我们看看String的源码,看看为啥这个东西是不可变对象。要看一个类首先是从这个类的构造器开始看起。不过String的构造器实在是太多了,我们就从官方文档中的说明开始看起。

String的源码

String str = "abc";
相当于
char data[] = {'a', 'b', 'c'};
String str = new String(data);

文档中说这是一样的,但是依据我们之前所学的知识,这个是完全不一样的。上面的str在Java的常量池中,而下面的str是在堆中的。

不过我们要来看一看String类是如何通过char[]创建String对象的。

public String(char value[]) {
    this(value, 0, value.length, null);
}

String(char[] value, int off, int len, Void sig) {
    if (len == 0) {
        this.value = "".value;
        this.coder = "".coder;
        return;
    }
    if (COMPACT_STRINGS) {
        byte[] val = StringUTF16.compress(value, off, len);
        if (val != null) {
            this.value = val;
            this.coder = LATIN1;
            return;
        }
    }
    this.coder = UTF16;
    this.value = StringUTF16.toBytes(value, off, len);
}

上面的第四个参数应该是一个返回值没有被用到的,但是又必须在函数的参数列表中存在的一个东西。因为我稍微看了一下源码,第三个参数基本上都是null。初始化中使用到了两个字段valuecoder。这个coder明显是一个编码的措施,那么这个value应该就是String内部用来存储数据的东西。

@Stable
private final byte[] value;

private final byte coder; // LATIN1 or UTF16

其实我们可以取消对coder的关注了。只用看value。他的声明是private final byte[]

  • 第一点,String的内部不是使用char[]来储存的。因为之前观察C++的string的源码,其实就是维护了一个char*。一个字符串使用byte[]作为底层,这真的很意外。我使用的jdk版本为jdk12,或许是我的jdk版本太高了,因为我之前在网上查阅资料的使用我看源码还是private final char[] value呢。为什么会变成byte[]呢?根据我查阅的资料,这个改变是从jdk9开始的,目的是为了节省字符串的内存占用从而优化性能。
  • 第二点,value被声明为是final的,而且还加上了注解@Stable,这说明了这个数组是稳定的不可变的,这也就解释了为什么String是一个不可变的对象。

这样我们基本上也就了解了String对象是如何存储的,以及为什么是一个不可变的对象。String类的原码有几千行,这并不是我们都需要看的,这里我们其实源码看的已经够了。

但是对于下面的代码,我确是有一个疑问。

String str = new String("abc");

这个到底是调用什么构造器呢?按道理来说应该是String(String str)

@HotSpotIntrinsicCandidate
public String(String original) {
    this.value = original.value;
    this.coder = original.coder;
    this.hash = original.hash;
}

那么仅仅是一个“abc”就可以作为是一个String吗?那么这个abc又是调用的什么构造器呢?这看起来是一个无限循环的问题。不过需要注意的是单独的“abc”还真的是可以作为String来使用的。

System.out.println("hello world".toUpperCase());

就比如说这行代码是没有任何问题的。其实上面是有String对象的产生的,只不过是编译器给你生成的,就像我们之前说的内部类那一块一样。

String str = "hello world";
System.out.println(str.toUpperCase());

其实是上面这样子的,hello world这个对象是位于常量池中的。后面的str.toUpperCase()产生了一个新的String对象,不过这个String对象是位于堆中的。也就是说,如果我们使用如下的代码的时候。

String str = new String("abc");

String xx = "abc";
String str = new String(xx);

会生成两个String对象,一个位于常量池中,一个位于堆中。至于常量池中的String是如何创建的,这就不是我们可以通过阅读源码就可以解决的问题了。

String相加

众所周知,C++是一门支持云算法重载的牛逼的语言,但是Java中是不支持运算符的重载的。不过有一个东西是非常的例外的,就是Strnig中的重载了++=。至于如何实现的这个肯定是没办法从源码处得知的了。

String str = "a" + "b";

按道理来说,我们知道String是一个不可变的对象。所以说应该是创建了两个String然后连接起来。但是真的如此吗?其实不是的,JVM可是非常聪明的,他会只创建一个ab字符串,然后直接把地址赋给str。

String a = "a";
String str = "1" + a + "2";

看一下上面的代码,那么以我们聪明的JVM来说,也应该是直接创建1a2。不过,可能JVM要让你失望了。普遍的说法是会生成字符串1a1a2。如果+的时候,存在已经定义过的变量,那么JVM就不会直接搞天神下凡似的直接创建目标字符串。而是一个一个创建。但是我上网查阅了资料,发现懂汇编的大佬使用javap -c反编译字节码文件的时候,发现实际上JVM是创建一个StringBuilder,然后使用append方法来连接字符串。

String str = "";
for(int i = 0; i < num; i++) {
    str += "a";
}

如果我们使用的是这样子的话,那就很恐怖了,每一次循环都会生成一个StringBuilder对象来连接字符串,效率是非常的低下。此时我们应该使用如下的方式。

StringBuilder sb = new StringBuilder();
for (int i = 0; i < num; i++) {
    sb.append("a")
}

这样的话,就只会产生一个StringBuilder对象了。

上面是看了其他的对字节码的反编译才知道其实JVM连接字符串的使用都是使用创建StringBuilder对象然后使用append方法的形式进行连接的。其实String类中也有一个方法用于字符串的连接,不过这个方法是不涉及到StringBuilder的,其实我一开始以为+就是使用concat方法的,没想到是使用StringBuilder

public String concat(String str) {
    if (str.isEmpty()) {
        return this;
    }
    if (coder() == str.coder()) {
        byte[] val = this.value;
        byte[] oval = str.value;
        int len = val.length + oval.length;
        byte[] buf = Arrays.copyOf(val, len);
        System.arraycopy(oval, 0, buf, val.length, oval.length);
        return new String(buf, coder);
    }
    int len = length();
    int olen = str.length();
    byte[] buf = StringUTF16.newBytesFor(len + olen);
    getBytes(buf, 0, UTF16);
    str.getBytes(buf, len, UTF16);
    return new String(buf, UTF16);
}

StringBuilderStringBuffer

前面谈及String相加的时候,提到了StringBuilder。其实通过名字也可以知道,StringBuilder使用构建String的,其底层的存储字符串的数组应该是可变的。

public final class StringBuilder
    extends AbstractStringBuilder
    implements java.io.Serializable, Comparable<StringBuilder>, CharSequence

这个声明是jdk11中的声明,多出了一个继承的类???jdk8中是直接继承Object的。看来是我太落后了,不过也没关系,我们直接看一下AbstractStringBuilder

abstract class AbstractStringBuilder implements Appendable, CharSequence

其中用来储存数据的数组的声明为

byte[] value;

不是final的,说明其是可变的。我们使用这个类主要就是使用append方法。那么我们就来简单的看一下append方法。StringBuilder中的append方法,其实是使用的AbstractStringBuilder中的append方法。这个方法是对Appendable接口的一个实现。

@Override
public AbstractStringBuilder append(char c) {
    ensureCapacityInternal(count + 1);
    if (isLatin1() && StringLatin1.canEncode(c)) {
        value[count++] = (byte)c;
    } else {
        if (isLatin1()) {
            inflate();
        }
        StringUTF16.putCharSB(value, count++, c);
    }
    return this;
}

这里是添加一个字符,看第三行,是确保value数组中是否可以放下这一个字符。

private void ensureCapacityInternal(int minimumCapacity) {
    // overflow-conscious code
    int oldCapacity = value.length >> coder;
    if (minimumCapacity - oldCapacity > 0) {
        value = Arrays.copyOf(value,
                newCapacity(minimumCapacity) << coder);
    }
}

private int newCapacity(int minCapacity) {
    // overflow-conscious code
    int oldCapacity = value.length >> coder;
    int newCapacity = (oldCapacity << 1) + 2;
    if (newCapacity - minCapacity < 0) {
        newCapacity = minCapacity;
    }
    int SAFE_BOUND = MAX_ARRAY_SIZE >> coder;
    return (newCapacity <= 0 || SAFE_BOUND - newCapacity < 0)
        ? hugeCapacity(minCapacity)
        : newCapacity;
}

可见,如果容量够那就直接放入那个字符。如果是没有足够的容量的时候,value数组会进行扩容。扩容的规则就在上面的newCapacity函数中,一般是二倍加上二,如果还不够大的话就变成minCapacity(也就是加上之后的长度)。但是数组的长度是有限制的,最大也不能超过那个值。

private static final int MAX_ARRAY_SIZE = Integer.MAX_VALUE - 8;

不过,这个值,,,也是非常大了!!

再来看一下StringBuilder的构造函数。

@HotSpotIntrinsicCandidate
public StringBuilder() {
    super(16);
}

@HotSpotIntrinsicCandidate
public StringBuilder(int capacity) {
    super(capacity);
}

@HotSpotIntrinsicCandidate
public StringBuilder(String str) {
    super(str.length() + 16);
    append(str);
}

可见,如果无参的话,默认的存储数组的大小是16。如果给定了一个String作为参数,那么默认的存储数组的大小就是str.length()+16。看来jdk的编写者是非常喜欢16这个数字的。

上面对StringBuilder的讲解基本上就结束了,至于StringBuffer,我感觉是不需要讲的。其实这个StringBuffer是和StringBuilder是一样的,不过StringBuilder是线程不安全的,但是StringBuffer是线程安全的。StringBuffer很多的方法都加上了synchronized用来同步。这个就有点像ArrayListVectorHashMapHashtable。都是一个是线程不安全,一个是线程安全。(其实不一样的地方还是非常多的!)

总结

上面就通过源码简单的学习了一下Java当中的StringStringBuilder以及StringBuffer的使用。不过基本上还是蛮简单的看源码的,没有很复杂的地方。还有就是关于String对象的常量池机制和什么==什么东西的,之前都是讲过的,这里就不再提及了。

参考文章

Java 深究字符串String类(1)之运算符”+”重载

OpenJDK源码阅读解析:Java11的String类源码分析详解