Java基础:字符串详解

1 深入解读String类源码

1.1 String类的声明

public final class String
    implements java.io.Serializable, Comparable<String>, CharSequence {
}
  • String类是final的,意味着它不能被子类继承;
  • String 类实现了Serializable接口,意味着它可以序列化
  • String 类实现了Comparable接口,意味着最好不要用‘==’来比较两个字符串是否相等,而应该用compareTo() 方法去比较。
    • 因为 == 是用来比较两个对象的地址,如果只是说比较字符串内容的话,可以使用 String 类的 equals 方法,源码和注释如下所示:
public boolean equals(Object anObject) {
    // 检查是否是同一个对象的引用,如果是,直接返回 true
    if (this == anObject) {
        return true;
    }
    // 检查 anObject 是否是 String 类的实例
    if (anObject instanceof String) {
        String anotherString = (String) anObject; // 将 anObject 强制转换为 String 类型
        int n = value.length; // 获取当前字符串的长度
        // 检查两个字符串长度是否相等
        if (n == anotherString.value.length) {
            char v1[] = value; // 当前字符串的字符数组
            char v2[] = anotherString.value; // 另一个字符串的字符数组
            int i = 0; // 用于遍历字符数组的索引
            // 遍历比较两个字符串的每个字符
            while (n-- != 0) {
                // 如果在任何位置字符不同,则返回 false
                if (v1[i] != v2[i])
                    return false;
                i++;
            }
            // 所有字符都相同,返回 true
            return true;
        }
    }
    // 如果 anObject 不是 String 类型或长度不等,则返回 false
    return false;
}
  • String和StringBuffer、StringBuilder一样,都实现了 CharSequence 接口,所以它们仨属于近亲。由于 String 是不可变的,所以遇到字符串拼接的时候就可以考虑一下String 的另外两个好兄弟,StringBuffer 和 StringBuilder,它俩是可变的。

1.2 String底层为什么由char数组优化为byte数组

private final char value[];

Java 9 以前,String 是用 char 型数组实现的,之后改成了 byte 型数组实现,并增加了 coder 来表示编码。下面是 JDK 11 版本中的 String 类源码,注意和 JDK 8 的不同。

public final class String
    implements java.io.Serializable, Comparable<String>, CharSequence {
    @Stable
    private final byte[] value;
    private final byte coder;
    private int hash;
}
  • 从 char[] 到 byte[],最主要的目的是节省字符串占用的内存空间。内存占用减少带来的另外一个好处,就是GC次数也会减少。
  • 使用 jmap -histo:live pid | head -n 10 命令就可以查看到堆内对象示例的统计信息、ClassLoader 的信息以及 finalizer 队列等。
  • Java 的对象基本上都在堆上。 pid 就是进程号,可以通过 ps -ef | grep java 命令查看。
  • char 类型的数据在 JVM 中是占用两个字节的,并且使用的是 UTF-8编码,其值范围在 ‘\u0000’(0)和 ‘\uffff’(65,535)(包含)之间。
  • 也就是说,使用 char[] 来表示 String 就会导致,即使 String 中的字符只用一个字节就能表示,也得占用两个字节。
  • 仅仅将 char[] 优化为 byte[] 是不够的,还要配合 Latin-1 的编码方式,该编码方式是用单个字节来表示字符的,这样就比 UTF-8 编码节省了更多的空间。
  • 针对 JDK 9 的 String 源码里,为了区别编码方式,追加了一个 coder 字段来区分。private final byte coder;Java 会根据字符串的内容自动设置为相应的编码,要么 Latin-1 要么 UTF16。
/**
 * The identifier of the encoding used to encode the bytes in
 * {@code value}. The supported values in this implementation are
 *
 * LATIN1
 * UTF16
 *
 * @implNote This field is trusted by the VM, and is a subject to
 * constant folding if String instance is constant. Overwriting this
 * field after construction will cause problems.
 */
private final byte coder;
  • 也就是说,从 char[] 到 byte[],中文是两个字节,纯英文是一个字节,在此之前呢,中文是两个字节,英文也是两个字节

1.3 String类的hashCode方法

  • 每一个字符串都会有一个 hash 值,这个哈希值在很大概率是不会重复的,因此 String 很适合来作为HahMap的键值。
  • 来看 String 类的 hashCode 方法。
private int hash; // 缓存字符串的哈希码

public int hashCode() {
    int h = hash; // 从缓存中获取哈希码
    // 如果哈希码未被计算过(即为 0)且字符串不为空,则计算哈希码
    if (h == 0 && value.length > 0) {
        char val[] = value; // 获取字符串的字符数组

        // 遍历字符串的每个字符来计算哈希码
        for (int i = 0; i < value.length; i++) {
            h = 31 * h + val[i]; // 使用 31 作为乘法因子
        }
        hash = h; // 缓存计算后的哈希码
    }
    return h; // 返回哈希码
}
  • hashCode 方法首先检查是否已经计算过哈希码,如果已经计算过,则直接返回缓存的哈希码。否则,方法将使用一个循环遍历字符串的所有字符,并使用一个乘法和加法的组合计算哈希码。

  • 这种计算方法被称为“31 倍哈希法”。计算完成后,将得到的哈希值存储在 hash 成员变量中,以便下次调用 hashCode 方法时直接返回该值,而不需要重新计算。这是一种缓存优化,称为“惰性计算”。

  • 31 倍哈希法(31-Hash)是一种简单有效的字符串哈希算法,常用于对字符串进行哈希处理。该算法的基本思想是将字符串中的每个字符乘以一个固定的质数 31 的幂次方,并将它们相加得到哈希值。具体地,假设字符串为 s,长度为 n,则 31 倍哈希值计算公式如下:
    H(s) = (s[0] * 31^(n-1)) + (s[1] * 31^(n-2)) + ... + (s[n-1] * 31^0)

  • 其中,s[i]表示字符串 s 中第 i 个字符的 ASCII 码值,^表示幂运算。

  • 31 倍哈希法的优点在于简单易实现,计算速度快,同时也比较均匀地分布在哈希表中。

1.4 String类的substring()方法

String 类中还有一个方法比较常用 substring,用来截取字符串的,来看源码

public String substring(int beginIndex) {
    // 检查起始索引是否小于 0,如果是,则抛出 StringIndexOutOfBoundsException 异常
    if (beginIndex < 0) {
        throw new StringIndexOutOfBoundsException(beginIndex);
    }
    // 计算子字符串的长度
    int subLen = value.length - beginIndex;
    // 检查子字符串长度是否为负数,如果是,则抛出 StringIndexOutOfBoundsException 异常
    if (subLen < 0) {
        throw new StringIndexOutOfBoundsException(subLen);
    }
    // 如果起始索引为 0,则返回原字符串;否则,创建并返回新的字符串
    return (beginIndex == 0) ? this : new String(value, beginIndex, subLen);
}
  • substring 方法首先检查参数的有效性,如果参数无效,则抛出 StringIndexOutOfBoundsException 异常,接下来,方法根据参数计算子字符串的长度。如果子字符串长度小于零,也会抛出 StringIndexOutOfBoundsException 异常。
  • 如果 beginIndex 为 0,说明子串与原字符串相同,直接返回原字符串。否则,使用 value 数组(原字符串的字符数组)的一部分 new 一个新的 String 对象并返回。

1.5 String类的indexOf方法

indexOf 方法用于查找一个子字符串在原字符串中第一次出现的位置,并返回该位置的索引。来看该方法的源码:

/*
 * 查找字符数组 target 在字符数组 source 中第一次出现的位置。
 * sourceOffset 和 sourceCount 参数指定 source 数组中要搜索的范围,
 * targetOffset 和 targetCount 参数指定 target 数组中要搜索的范围,
 * fromIndex 参数指定开始搜索的位置。
 * 如果找到了 target 数组,则返回它在 source 数组中的位置索引(从0开始),
 * 否则返回-1。
 */
static int indexOf(char[] source, int sourceOffset, int sourceCount,
        char[] target, int targetOffset, int targetCount,
        int fromIndex) {
    // 如果开始搜索的位置已经超出 source 数组的范围,则直接返回-1(如果 target 数组为空,则返回 sourceCount)
    if (fromIndex >= sourceCount) {
        return (targetCount == 0 ? sourceCount : -1);
    }
    // 如果开始搜索的位置小于0,则从0开始搜索
    if (fromIndex < 0) {
        fromIndex = 0;
    }
    // 如果 target 数组为空,则直接返回开始搜索的位置
    if (targetCount == 0) {
        return fromIndex;
    }

    // 查找 target 数组的第一个字符在 source 数组中的位置
    char first = target[targetOffset];
    int max = sourceOffset + (sourceCount - targetCount);

    // 循环查找 target 数组在 source 数组中的位置
    for (int i = sourceOffset + fromIndex; i <= max; i++) {
        /* Look for first character. */
        // 如果 source 数组中当前位置的字符不是 target 数组的第一个字符,则在 source 数组中继续查找 target 数组的第一个字符
        if (source[i] != first) {
            while (++i <= max && source[i] != first);
        }

        /* Found first character, now look at the rest of v2 */
        // 如果在 source 数组中找到了 target 数组的第一个字符,则继续查找 target 数组的剩余部分是否匹配
        if (i <= max) {
            int j = i + 1;
            int end = j + targetCount - 1;
            for (int k = targetOffset + 1; j < end && source[j]
                    == target[k]; j++, k++);

            // 如果 target 数组全部匹配,则返回在 source 数组中的位置索引
            if (j == end) {
                /* Found whole string. */
                return i - sourceOffset;
            }
        }
    }
    // 没有找到 target 数组,则返回-1
    return -1;
}

1.6 String类的其他方法

  • 比如说 length() 用于返回字符串长度。

  • 比如说 isEmpty() 用于判断字符串是否为空。

  • 比如说 charAt() 用于返回指定索引处的字符。

  • 比如说 valueOf() 用于将其他类型的数据转换为字符串。

String str = String.valueOf(123);  // 将整数 123 转换为字符串

valueOf 方法的背后其实调用的是包装器类的 toString 方法,比如说整数转为字符串调用的是 Integer 类的 toString 方法。

public static String valueOf(int i) {
    return Integer.toString(i);
}

而 Integer 类的 toString 方法又调用了 Integer 类的静态方法 toString(int i):

public static String toString(int i) {
    // 最小值返回 "-2147483648"
    if (i == Integer.MIN_VALUE)
        return "-2147483648";
    // 整数的长度,负数的长度减 1
    int size = (i < 0) ? stringSize(-i) + 1 : stringSize(i);
    // 把整数复制到字符数组中
    char[] buf = new char[size];
    // 具体的复制过程
    getChars(i, size, buf);
    // 通过 new 返回字符串
    return new String(buf, true);
}

至于 getChars 方法,就是把整数复制到字符数组中的具体过程了,这里就不展开了。

  • 比如说 getBytes() 用于返回字符串的字节数组,可以指定编码方式,比如说:
String text = "沉默王二";
System.out.println(Arrays.toString(text.getBytes(StandardCharsets.UTF_8)));
  • 比如说 trim() 用于去除字符串两侧的空白字符,来看源码:
public String trim() {
    int len = value.length;
    int st = 0;
    char[] val = value;    /* avoid getfield opcode */

    while ((st < len) && (val[st] <= ' ')) {
        st++;
    }
    while ((st < len) && (val[len - 1] <= ' ')) {
        len--;
    }
    return ((st > 0) || (len < value.length)) ? substring(st, len) : this;
}

2 字符串为什么不可变

2.1 String不变性

  • String 类被final关键字修饰,所以它不会有子类,这就意味着没有子类可以重写它的方法,改变它的行为。
  • String 类的数据存储在 char[] 数组中,而这个数组也被 final 关键字修饰了,这就表示 String 对象是没法被修改的,只要初始化一次,值就确定了。
public final class String
    implements java.io.Serializable, Comparable<String>, CharSequence {
    /** The value is used for character storage. */
    private final char value[];
}

2.2 字符串如此设计的原因

  • 第一,可以保证 String 对象的安全性,避免被篡改,毕竟像密码这种隐私信息一般就是用字符串存储的。
  • 第二,保证哈希值不会频繁变更。由于 String 对象是不可变的,其哈希值在创建后不会发生变化。这使得 String 类可以缓存哈希值,提高哈希表等数据结构的性能。如果 String 是可变的,那么在每次修改时都需要重新计算哈希值,这会降低性能。
    • 在 String 类中,哈希值是在第一次计算时缓存的,后续对该哈希值的请求将直接使用缓存值。这有助于提高哈希表等数据结构的性能。
  • 可以实现字符串常量池,Java 会将相同内容的字符串存储在字符串常量池中。这样,具有相同内容的字符串变量可以指向同一个 String 对象,节省内存空间。

2.3 字符串不可变设计的结果

由于字符串的不可变性,String 类的一些方法实现最终都返回了新的字符串对象。

  • substring() 方法用于截取字符串,最终返回的都是 new 出来的新字符串对象
  • concat() 方法用于拼接字符串,不管编码是否一致,最终也返回的是新的字符串对象。
  • replace() 替换方法其实也一样,也是返回新的字符串对象。
  • 不管是截取、拼接,还是替换,都不是在原有的字符串上进行的,而是重新生成了新的字符串对象。也就是说,这些操作执行过后,原来的字符串对象并没有发生改变

String 对象一旦被创建后就固定不变了,对 String 对象的任何修改都不会影响到原来的字符串对象,都会生成新的字符串对象。

3 字符串常量池

3.1 new String("二哥")创建了几个对象

String s = new String("二哥");

这行代码创建了几个对象?

  • 两个!使用 new 关键字创建一个字符串对象时,Java 虚拟机会先在字符串常量池中查找有没有‘二哥’这个字符串对象,如果有,就不会在字符串常量池中创建‘二哥’这个对象了,直接在堆中创建一个‘二哥’的字符串对象,然后将堆中这个‘二哥’的对象地址返回赋值给变量 s。”
  • 如果没有,先在字符串常量池中创建一个‘二哥’的字符串对象,然后再在堆中创建一个‘二哥’的字符串对象,然后将堆中这个‘二哥’的字符串对象地址返回赋值给变量 s。
  • 在Java中,栈上存储的是基本数据类型的变量和对象的引用,而对象本身则存储在堆上。
  • 这行代码 String s = new String(“二哥”);,它创建了两个对象:一个是字符串对象 “二哥”,它被添加到了字符串常量池中,另一个是通过 new String() 构造函数创建的字符串对象 “二哥”,它被分配在堆内存中,同时引用变量 s 存储在栈上,它指向堆内存中的字符串对象 “二哥”

为什么要先在字符串常量池中创建对象,然后再在堆上创建呢

  • 由于字符串的使用频率实在是太高了,所以 Java 虚拟机为了提高性能和减少内存开销,在创建字符串对象的时候进行了一些优化,特意为字符串开辟了一块空间——也就是字符串常量池。

3.2 字符串常量池的作用

通常情况下,我们会采用双引号的方式来创建字符串对象,而不是通过 new 关键字的方式

  • 当执行String s = "三妹"时,Java 虚拟机会先在字符串常量池中查找有没有“三妹”这个字符串对象,如果有,则不创建任何对象,直接将字符串常量池中这个“三妹”的对象地址返回,赋给变量 s;如果没有,在字符串常量池中创建“三妹”这个对象,然后将其地址返回,赋给变量 s。
  • Java 虚拟机创建了一个字符串对象 “三妹”,它被添加到了字符串常量池中,同时引用变量 s 存储在栈上,它指向字符串常量池中的字符串对象 “三妹”。

有了字符串常量池,就可以通过双引号的方式直接创建字符串对象,不用再通过 new 的方式在堆中创建对象了。

new 的方式始终会创建一个对象,不管字符串的内容是否已经存在,而双引号的方式会重复利用字符串常量池中已经存在的对象。

  • String s = new String("二哥");String s1 = new String("二哥");这两行代码会创建三个对象,字符串常量池中一个,堆上两个。
  • String s = "三妹";String s1 = "三妹";这两行代码只会创建一个对象,就是字符串常量池中的那个。

3.3 字符串常量池在内存中的位置

3.3.1 JDK7之前

  • 在 Java 7 之前,字符串常量池位于永久代(Permanent Generation)的内存区域中,主要用来存储一些字符串常量(静态数据的一种)。
  • 永久代是 Java 堆(Java Heap)的一部分,用于存储类信息、方法信息、常量池信息等静态数据。
  • 而 Java 堆是 JVM 中存储对象实例和数组的内存区域,也就是说,永久代是 Java 堆的一个子区域。
  • 永久代和堆的大小是相互影响的,因为它们都使用了 JVM 堆内存,因此它们的大小都受到 JVM 堆大小的限制。
    在这里插入图片描述
  • 当我们创建一个字符串常量时,它会被储存在永久代的字符串常量池中。如果我们创建一个普通字符串对象,则它将被储存在堆中。如果字符串对象的内容是一个已经存在于字符串常量池中的字符串常量,那么这个对象会指向已经存在的字符串常量,而不是重新创建一个新的字符串对象。

3.3.2 JDK7

  • 永久代的大小是有限的,并且很难准确地确定一个应用程序需要多少永久代空间。如果我们在应用程序中使用了大量的类、方法、常量等静态数据,就有可能导致永久代空间不足。这种情况下,JVM 就会抛出 OutOfMemoryError 错误。
  • 从 Java 7 开始,为了解决永久代空间不足的问题,将字符串常量池从永久代中移动到堆中。这个改变也是为了更好地支持动态语言的运行时特性。

在这里插入图片描述

3.3.3 JDK8

到了 Java 8,永久代(PermGen)被取消,并由元空间(Metaspace)取代。

  • 元空间是一块本机内存区域,和 JVM 内存区域是分开的。不过,元空间的作用依然和之前的永久代一样,用于存储类信息、方法信息、常量池信息等静态数据。
  • 元空间具有一些优点,例如:
    • 它不会导致 OutOfMemoryError 错误,因为元空间的大小可以动态调整。
    • 元空间使用本机内存,而不是 JVM 堆内存,这可以避免堆内存的碎片化问题。
    • 元空间中的垃圾收集与堆中的垃圾收集是分离的,这可以避免应用程序在运行过程中因为进行类加载和卸载而频繁地触发 Full GC。

在这里插入图片描述

3.4 永久代、方法区、元空间

  • 方法区是 Java 虚拟机规范中的一个概念,就像是一个接口
  • 永久代是 HotSpot 虚拟机中对方法区的一个实现,就像是接口的实现类;
  • Java 8 的时候,移除了永久代,取而代之的是元空间,是方法区的另外一种实现,更灵活了。
  • 永久代是放在运行时数据区中的,所以它的大小受到 Java 虚拟机本身大小的限制,所以 Java 8 之前,会经常遇到 java.lang.OutOfMemoryError: PremGen Space 的异常,PremGen Space 就是方法区的意思;而元空间是直接放在内存中的,所以只受本机可用内存的限制。

4 String.intern详解

  • 第一,使用双引号声明的字符串对象会保存在字符串常量池中。
  • 第二,使用 new 关键字创建的字符串对象会先从字符串常量池中找,如果没找到就创建一个,然后再在堆中创建字符串对象;如果找到了,就直接在堆中创建字符串对象。
  • 第三,针对没有使用双引号声明的字符串对象来说,就像下面代码中的 s1 那样:String s1 = new String("二哥") + new String("三妹");,如果想把 s1 的内容也放入字符串常量池的话,可以调用 intern() 方法来完成。
  • 需要注意的是,Java 7 的时候,字符串常量池从永久代中移动到了堆中,虽然此时永久代还没有完全被移除。Java 8 的时候,永久代被彻底移除。
  • String.intern() 方法在执行时的策略
    • Java 7 之前,执行String.intern()方法的时候,不管对象在堆中是否已经创建,字符串常量池中仍然会创建一个内容完全相同的新对象;
    • Java 7 之后,由于字符串常量池放在了堆中,执行 String.intern() 方法的时候,如果对象在堆中已经创建了,字符串常量池中就不需要再创建新的对象了,而是直接保存堆中对象的引用,也就节省了一部分的内存空间。
String s1 = new String("二哥三妹");
String s2 = s1.intern();
System.out.println(s1 == s2);
  • 第一行代码,字符串常量池中会先创建一个“二哥三妹”的对象,然后堆中会再创建一个“二哥三妹”的对象,s1 引用的是堆中的对象。
  • 第二行代码,对 s1 执行 intern() 方法,该方法会从字符串常量池中查找“二哥三妹”这个字符串是否存在,此时是存在的,所以 s2 引用的是字符串常量池中的对象。
  • 也就意味着 s1 和 s2 的引用地址是不同的,一个来自堆,一个来自字符串常量池,所以输出的结果为 false。
String s1 = new String("二哥") + new String("三妹");
String s2 = s1.intern();
System.out.println(s1 == s2);
  • 第一行代码,会在字符串常量池中创建两个对象,一个是“二哥”,一个是“三妹”,然后在堆中会创建两个匿名对象“二哥”和“三妹”,最后还有一个“二哥三妹”的对象,s1 引用的是堆中“二哥三妹”这个对象。
  • 第二行代码,对 s1 执行 intern() 方法,该方法会从字符串常量池中查找“二哥三妹”这个对象是否存在,此时不存在的,但堆中已经存在了,所以字符串常量池中保存的是堆中这个“二哥三妹”对象的引用,也就是说,s2 和 s1 的引用地址是相同的,所以输出的结果为 true。

解释一下 String s1 = new String("二哥") + new String("三妹") 这行代码

  • 创建 “二哥” 字符串对象,存储在字符串常量池中。

  • 创建 “三妹” 字符串对象,存储在字符串常量池中。

  • 执行 new String("二哥"),在堆上创建一个字符串对象,内容为 “二哥”。

  • 执行 new String("三妹"),在堆上创建一个字符串对象,内容为 “三妹”。

  • 执行 new String("二哥") + new String("三妹"),会创建一个 StringBuilder 对象,并将 “二哥” 和 “三妹” 追加到其中,然后调用 StringBuilder 对象的 toString() 方法,将其转换为一个新的字符串对象,内容为 “二哥三妹”。这个新的字符串对象存储在堆上。

  • 也就是说,当编译器遇到 + 号这个操作符的时候,会将 new String("二哥") + new String("三妹") 这行代码编译为以下代码:new StringBuilder().append("二哥").append("三妹").toString();

  • 实际执行过程如下:

    • 创建一个 StringBuilder 对象。
    • 在 StringBuilder 对象上调用 append(“二哥”),将 “二哥” 追加到 StringBuilder 中。
    • 在 StringBuilder 对象上调用 append(“三妹”),将 “三妹” 追加到 StringBuilder 中。
    • 在 StringBuilder 对象上调用 toString() 方法,将 StringBuilder 转换为一个新的字符串对象,内容为 “二哥三妹”。
  • 尽管 intern 可以确保所有具有相同内容的字符串共享相同的内存空间,但也不要烂用 intern,因为任何的缓存池都是有大小限制的,不能无缘无故就占用了相对稀缺的缓存空间,导致其他字符串没有坑位可占。

  • 字符串常量池本质上是一个固定大小的 StringTable,如果放进去的字符串过多,就会造成严重的哈希冲突,从而导致链表变长,链表变长也就意味着字符串常量池的性能会大幅下降,因为要一个一个找是需要花费时间的。

5 StringBuilderStringBuffer

5.1 String BuilderStringBuffer的区别

由于字符串是不可变的,所以当遇到字符串拼接(尤其是使用+号操作符)的时候,就需要考量性能的问题,你不能毫无顾虑地生产太多 String 对象,对珍贵的内存造成不必要的压力。Java 就设计了一个专门用来解决此问题的 StringBuffer 类。

由于 StringBuffer 操作字符串的方法加了synchronized关键字进行了同步,主要是考虑到多线程环境下的安全问题,所以如果在非多线程环境下,执行效率就会比较低,因为加了没必要的锁。

Java 就给 StringBuffer “生了个兄弟”,名叫 StringBuilder,别管线程安全了,你就在单线程环境下使用,这样效率会高得多,如果要在多线程环境下修改字符串,你到时候可以使用ThreadLocal来避免多线程冲突。

  • StringBuffer源码
public final class StringBuffer extends AbstractStringBuilder implements Serializable, CharSequence {

    public StringBuffer() {
        super(16);
    }
    
    public synchronized StringBuffer append(String str) {
        super.append(str);
        return this;
    }

    public synchronized String toString() {
        return new String(value, 0, count);
    }

    // 其他方法
}
  • StringBuilder源码
public final class StringBuilder extends AbstractStringBuilder
    implements java.io.Serializable, CharSequence
{
    // ...

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

    public String toString() {
        // Create a copy, don't share the array
        return new String(value, 0, count);
    }

    // ...
}
  • 除了类名不同,方法没有加 synchronized,基本上完全一样。
  • 实际开发中,StringBuilder 的使用频率也是远高于 StringBuffer。

5.2 StringBuilder的使用

  • 当编译器遇到 + 号这个操作符的时候,会将 new String("二哥") + new String("三妹") 这行代码解释为以下代码:new StringBuilder().append("二哥").append("三妹").toString();
  • Java可以在编译的时候偷偷地帮我们做很多优化,这样既可以提高我们的开发效率(+ 号写起来比创建 StringBuilder 对象便捷得多),也不会影响 JVM 的执行效率。
  • Java 编译器将字符串拼接操作(+)转换为了 StringBuilder 对象的 append 方法,然后再调用 StringBuilder 对象的 toString 方法返回拼接后的字符串。

5.3 StringBuilder的内部实现

StringBuilder 的 toString 方法

public String toString() {
    return new String(value, 0, count);
}
  • value 是一个 char 类型的数组:char[] value;
  • 在 StringBuilder 对象创建时,会为 value 分配一定的内存空间(初始容量 16),用于存储字符串。
/**
 * Constructs a string builder with no characters in it and an
 * initial capacity of 16 characters.
 */
public StringBuilder() {
    super(16);
}
  • 随着字符串的拼接,value 数组的长度会不断增加,因此在 StringBuilder 对象的实现中,value 数组的长度是可以动态扩展的
  • value 用于存储 StringBuilder 对象中包含的字符序列。count 是一个 int 类型的变量,表示字符序列的长度。toString() 方法会调用 new String(value, 0, count),使用 value 数组中从 0 开始的前 count 个元素创建一个新的字符串对象,并将其返回。

StringBuilder的append 方法

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

实际上是调用了 AbstractStringBuilder 中的 append(String str) 方法。在 AbstractStringBuilder 中,append(String str) 方法会检查当前字符序列中的字符是否够用,如果不够用则会进行扩容,并将指定字符串追加到字符序列的末尾。

public AbstractStringBuilder append(String str) {
    if (str == null)
        return appendNull();
    int len = str.length();
    ensureCapacityInternal(count + len);
    str.getChars(0, len, value, count);
    count += len;
    return this;
}
  • append(String str) 方法将指定字符串追加到当前字符序列中。如果指定字符串为 null,则追加字符串 “null”;否则会检查指定字符串的长度,然后根据当前字符序列中的字符数和指定字符串的长度来判断是否需要扩容。
  • 如果需要扩容,则会调用 ensureCapacityInternal(int minimumCapacity) 方法。扩容之后,将指定字符串的字符拷贝到字符序列中。

看一下 ensureCapacityInternal 方法

private void ensureCapacityInternal(int minimumCapacity) {
    // 不够用了,扩容
    if (minimumCapacity - value.length > 0)
        expandCapacity(minimumCapacity);
}

void expandCapacity(int minimumCapacity) {
    // 扩容策略:新容量为旧容量的两倍加上 2
    int newCapacity = value.length * 2 + 2;
    // 如果新容量小于指定的最小容量,则新容量为指定的最小容量
    if (newCapacity - minimumCapacity < 0)
        newCapacity = minimumCapacity;
    // 如果新容量小于 0,则新容量为 Integer.MAX_VALUE
    if (newCapacity < 0) {
        if (minimumCapacity < 0) // overflow
            throw new OutOfMemoryError();
        newCapacity = Integer.MAX_VALUE;
    }
    // 将字符序列的容量扩容到新容量的大小
    value = Arrays.copyOf(value, newCapacity);
}
  • ensureCapacityInternal(int minimumCapacity) 方法用于确保当前字符序列的容量至少等于指定的最小容量 minimumCapacity。如果当前容量小于指定的容量,就会为字符序列分配一个新的内部数组。新容量的计算方式如下:

    • 如果指定的最小容量大于当前容量,则新容量为两倍的旧容量加上 2。为什么要加 2 呢?对于非常小的字符串(比如空的或只有一个字符的 StringBuilder),仅仅将容量加倍可能仍然不足以容纳更多的字符。在这种情况下,+ 2 提供了一个最小的增长量,确保即使对于很小的初始容量,扩容后也能至少添加一些字符而不需要立即再次扩容。
    • 如果指定的最小容量小于等于当前容量,则不会进行扩容,直接返回当前对象。
  • 在进行扩容之前,ensureCapacityInternal(int minimumCapacity) 方法会先检查当前字符序列的容量是否足够,如果不足就会调用 expandCapacity(int minimumCapacity) 方法进行扩容。expandCapacity(int minimumCapacity) 方法首先计算出新容量,然后使用 Arrays.copyOf(char[] original, int newLength) 方法将原字符数组扩容到新容量的大小。

5.4 StringBuilderreverse方法

reverse 方法,用于反转当前字符序列中的字符。

public StringBuilder reverse() {
    super.reverse();
    return this;
}

也是调用了父类 AbstractStringBuilder 中的 reverse() 方法。

public AbstractStringBuilder reverse() {
    int n = count - 1; // 字符序列的最后一个字符的索引
    // 遍历字符串的前半部分
    for (int j = (n-1) >> 1; j >= 0; j--) {
        int k = n - j; // 计算相对于 j 对称的字符的索引
        char cj = value[j]; // 获取当前位置的字符
        char ck = value[k]; // 获取对称位置的字符
        value[j] = ck; // 交换字符
        value[k] = cj; // 交换字符
    }
    return this; // 返回反转后的字符串构建器对象
}
  • 初始化: n 是字符串中最后一个字符的索引。
  • 字符串反转
    • 方法通过一个 for 循环遍历字符串的前半部分和后半部分,这是一个非常巧妙的点,比从头到尾遍历省了一半的时间。(n-1) >> 1 是 (n-1) / 2 的位运算表示,也就是字符串的前半部分的最后一个字符的索引。
    • 在每次迭代中,计算出与当前索引 j 对称的索引 k,并交换这两个索引位置的字符。

6 如何判断字符串相等

6.1 .equals()方法

.equals()‘==’ 操作符有什么区别

  • “==”操作符用于比较两个对象的地址是否相等。
  • .equals() 方法用于比较两个对象的内容是否相等。
public boolean equals(Object obj) {
    return (this == obj);
}

Object 类的 .equals() 方法默认采用的是“==”操作符进行比较。假如子类没有重写该方法的话,那么“==”操作符和.equals()方法的功效就完全一样——比较两个对象的内存地址是否相等。

字符串有字符串常量池的概念,本身就推荐使用 String s = “字符串” 这种形式来创建字符串对象,而不是通过 new 关键字的方式,因为可以把字符串缓存在字符串常量池中,方便下次使用,不用遇到 new 就在堆上开辟一块新的空间。

String 类的 .equals() 方法的源码

  • JDK8版本
public boolean equals(Object anObject) {
    // 判断是否为同一对象
    if (this == anObject) {
        return true;
    }
    // 判断对象是否为 String 类型
    if (anObject instanceof String) {
        String anotherString = (String)anObject;
        int n = value.length;
        // 判断字符串长度是否相等
        if (n == anotherString.value.length) {
            char v1[] = value;
            char v2[] = anotherString.value;
            int i = 0;
            // 判断每个字符是否相等
            while (n-- != 0) {
                if (v1[i] != v2[i])
                    return false;
                i++;
            }
            return true;
        }
    }
    return false;
}
  • JDK17版本
public boolean equals(Object anObject) {
    if (this == anObject) {
        return true;
    }
    if (anObject instanceof String) {
        String aString = (String)anObject;
        if (coder() == aString.coder()) {
            return isLatin1() ? StringLatin1.equals(value, aString.value)
                    : StringUTF16.equals(value, aString.value);
        }
    }
    return false;
}

首先,如果两个字符串对象的可以“==”,那就直接返回 true 了,因为这种情况下,字符串内容是必然相等的。否则就按照字符编码进行比较,分为 UTF16 和 Latin1,差别不是很大,就拿 Latin1 的来说吧

@HotSpotIntrinsicCandidate
public static boolean equals(byte[] value, byte[] other) {
    if (value.length == other.length) {
        for (int i = 0; i < value.length; i++) {
            if (value[i] != other[i]) {
                return false;
            }
        }
        return true;
    }
    return false;
}

如果要进行两个字符串对象的内容比较,除了 .equals() 方法,还有其他两个可选的方案

6.2 Objects.equals()方法

public static boolean equals(Object a, Object b) {
    return (a == b) || (a != null && a.equals(b));
}
  • Objects.equals() 这个静态方法的优势在于不需要在调用之前判空。
  • 如果直接使用 a.equals(b),则需要在调用之前对 a 进行判空,否则可能会抛出空指针 java.lang.NullPointerExceptionObjects.equals() 用起来就完全没有这个担心。
Objects.equals("小萝莉", new String("小" + "萝莉")) // --> true
Objects.equals(null, new String("小" + "萝莉")); // --> false
Objects.equals(null, null) // --> true

String a = null;
a.equals(new String("小" + "萝莉")); // throw exception

6.3 String 类的 .contentEquals()

.contentEquals() 的优势在于可以将字符串与任何的字符序列(StringBuffer、StringBuilder、String、CharSequence)进行比较。

  • JDK 8 的源码
public boolean contentEquals(CharSequence cs) {
    // argument can be any CharSequence implementation
    if (cs.length() != value.length) {
        return false;
    }
    // Argument is a StringBuffer, StringBuilder or String
    if (cs instanceof AbstractStringBuilder) {
        char v1[] = value;
        char v2[] = ((AbstractStringBuilder)cs).getValue();
        int i = 0;
        int n = value.length;
        while (n-- != 0) {
            if (v1[i] != v2[i])
                return false;
            i++;
        }
        return true;
    }
    // Argument is a String
    if (cs.equals(this))
        return true;
    // Argument is a non-String, non-AbstractStringBuilder CharSequence
    char v1[] = value;
    int i = 0;
    int n = value.length;
    while (n-- != 0) {
        if (v1[i] != cs.charAt(i))
            return false;
        i++;
    }
    return true;
}

首先判断参数长度是否相等,不相等则返回 false。如果参数是 AbstractStringBuilder 的实例,则取出其 char 数组,遍历比较两个 char 数组的每个元素是否相等。如果参数是 String 的实例,则直接调用 equals 方法比较两个字符串是否相等。如果参数是其他实现了 CharSequence 接口的对象,则遍历比较两个对象的每个字符是否相等。

  • JDK17源码
public boolean contentEquals(CharSequence cs) {
    // Argument is a StringBuffer, StringBuilder
    if (cs instanceof AbstractStringBuilder) {
        if (cs instanceof StringBuffer) {
            synchronized(cs) {
                return nonSyncContentEquals((AbstractStringBuilder)cs);
            }
        } else {
            return nonSyncContentEquals((AbstractStringBuilder)cs);
        }
    }
    // Argument is a String
    if (cs instanceof String) {
        return equals(cs);
    }
    // Argument is a generic CharSequence
    int n = cs.length();
    if (n != length()) {
        return false;
    }
    byte[] val = this.value;
    if (isLatin1()) {
        for (int i = 0; i < n; i++) {
            if ((val[i] & 0xff) != cs.charAt(i)) {
                return false;
            }
        }
    } else {
        if (!StringUTF16.contentEquals(val, cs, n)) {
            return false;
        }
    }
    return true;
}

从源码上可以看得出,如果 cs 是 StringBuffer,该方法还会进行同步,非常的智能化;如果是 String 的话,其实调用的还是 equals() 方法。当然了,这也就意味着使用该方法进行比较的时候,多出来了很多步骤,性能上有些损失。

7 如何拼接字符串

7.1 +号操作符的本质

+ 号操作符其实被 Java 在编译的时候重新解释了,换一种说法就是,+ 号操作符是一种语法糖,让字符串的拼接变得更简便了。

class Demo {
    public static void main(String[] args) {
        String chenmo = "沉默";
        String wanger = "王二";
        System.out.println(chenmo + wanger);
    }
}

在Java8环境下,上面这段代码编译出的class文件换成 Java 代码来表示的话,大概是这个样子:

class Demo {
    public static void main(String[] args) {
        String chenmo = "沉默";
        String wanger = "王二";
        System.out.println((new StringBuilder(chenmo)).append(wanger).toString());
    }
}

编译的时候把“+”号操作符替换成了 StringBuilder 的 append() 方法。

7.2 为什么要编译为StringBuilder.append

循环体内,拼接字符串最好使用 StringBuilder 的 append() 方法,而不是 + 号操作符。原因就在于循环体内如果用 + 号操作符的话,就会产生大量的 StringBuilder 对象,不仅占用了更多的内存空间,还会让 Java 虚拟机不停的进行垃圾回收,从而降低了程序的性能。

更好的写法就是在循环的外部新建一个 StringBuilder 对象,然后使用 append() 方法将循环体内的字符串添加进来。

class Demo {
    public static void main(String[] args) {
        StringBuilder sb = new StringBuilder();
        for (int i = 1; i < 10; i++) {
            String chenmo = "沉默";
            String wanger = "王二";
            sb.append(chenmo);
            sb.append(wanger);
        }
        System.out.println(sb);
    }
}

7.3 append方法源码解析

StringBuilder 类的 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);
    str.getChars(0, len, value, count);
    count += len;
    return this;
}
  • 判断拼接的字符串是不是 null,如果是,当做字符串“null”来处理。appendNull() 方法的源码如下。
private AbstractStringBuilder appendNull() {
    int c = count;
    ensureCapacityInternal(c + 4);
    final char[] value = this.value;
    value[c++] = 'n';
    value[c++] = 'u';
    value[c++] = 'l';
    value[c++] = 'l';
    count = c;
    return this;
}
  • 获取字符串的长度。
  • ensureCapacityInternal() 方法的源码如下。
private void ensureCapacityInternal(int minimumCapacity) {
    // overflow-conscious code
    if (minimumCapacity - value.length > 0) {
        value = Arrays.copyOf(value,
                newCapacity(minimumCapacity));
    }
}

由于字符串内部是用数组实现的,所以需要先判断拼接后的字符数组长度是否超过当前数组的长度,如果超过,先对数组进行扩容,然后把原有的值复制到新的数组中。

  • 将拼接的字符串 str 复制到目标数组 value 中。
str.getChars(0, len, value, count)
  • 更新数组的长度 count。

7.4 String.concat拼接字符串

String chenmo = "沉默";
String wanger = "王二";
System.out.println(chenmo.concat(wanger));

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);
}
  • 1)如果拼接的字符串的长度为 0,那么返回拼接前的字符串。
  • 2)将原字符串的字符数组 value 复制到变量 buf 数组中。
  • 3)把拼接的字符串 str 复制到字符数组 buf 中,并返回新的字符串对象。

和 + 号操作符相比,concat() 方法在遇到字符串为 null 的时候,会抛出 NullPointerException,而“+”号操作符会把 null 当做是“null”字符串来处理。

如果拼接的字符串是一个空字符串(“”),那么 concat 的效率要更高一点,毕竟不需要 new StringBuilder 对象。

如果拼接的字符串非常多,concat() 的效率就会下降,因为创建的字符串对象越来越多。

7.5 String.join拼接字符串

String 类有一个静态方法 join(),可以这样来使用

String chenmo = "沉默";
String wanger = "王二";
String cmower = String.join("", chenmo, wanger);
System.out.println(cmower);

第一个参数为字符串连接符,比如说

String message = String.join("-", "王二", "太特么", "有趣了");

输出结果为:王二-太特么-有趣了

来看一下 join 方法的源码

public static String join(CharSequence delimiter, CharSequence... elements) {
    Objects.requireNonNull(delimiter);
    Objects.requireNonNull(elements);
    // Number of elements not likely worth Arrays.stream overhead.
    StringJoiner joiner = new StringJoiner(delimiter);
    for (CharSequence cs: elements) {
        joiner.add(cs);
    }
    return joiner.toString();
}

里面新建了一个叫 StringJoiner 的对象,然后通过 for-each 循环把可变参数添加了进来,最后调用toString()方法返回 String。

7.6 StringUtils.join拼接字符串

org.apache.commons.lang3.StringUtilsjoin() 方法也经常用来进行字符串拼接。该方法不用担心 NullPointerException

StringUtils.join(null)            = null
StringUtils.join([])              = ""
StringUtils.join([null])          = ""
StringUtils.join(["a", "b", "c"]) = "abc"
StringUtils.join([null, "", "a"]) = "a"

看一下源码,内部使用的仍然是 StringBuilder

public static String join(final Object[] array, String separator, final int startIndex, final int endIndex) {
    if (array == null) {
        return null;
    }
    if (separator == null) {
        separator = EMPTY;
    }

    final StringBuilder buf = new StringBuilder(noOfItems * 16);

    for (int i = startIndex; i < endIndex; i++) {
        if (i > startIndex) {
            buf.append(separator);
        }
        if (array[i] != null) {
            buf.append(array[i]);
        }
    }
    return buf.toString();
}

8 如何拆分字符串

在 Java 中,拆分字符串是一个常见的操作。通常我们会使用 String 类的 split() 方法来实现。然而,由于某些特殊字符在正则表达式中有特殊含义,直接使用这些字符作为分隔符可能会导致错误。

8.1 使用 String.split() 方法

String.split() 方法可以根据指定的分隔符将字符串拆分为多个部分。例如:

String cmower = "hello,world";
String[] parts = cmower.split(",");

8.2 处理特殊字符

在正则表达式中,某些字符(如 .*+?\^$()[]{} 等)具有特殊含义。如果直接使用这些字符作为分隔符,可能会导致错误。例如:

String cmower = "hello.world";
String[] parts = cmower.split("."); // 错误!

上述代码会导致 ArrayIndexOutOfBoundsExceptionPatternSyntaxException 错误。为了避免这些问题,可以使用以下方法:

  • 使用转义字符

    String[] parts = cmower.split("\\.");
    
  • 使用方括号包裹特殊字符

    String[] parts = cmower.split("[.]");
    
  • 使用 Pattern.quote() 方法

    String[] parts = cmower.split(Pattern.quote("."));
    

8.3 使用 PatternMatcher

除了 String.split() 方法,还可以使用 PatternMatcher 类进行字符串拆分。这种方法的好处是可以对要拆分的字符串进行更严格的限制。例如:

String cmower = "hello.world";
Pattern pattern = Pattern.compile("(.+)\\.(.+)");
Matcher matcher = pattern.matcher(cmower);

if (matcher.matches()) {
    String part1 = matcher.group(1);
    String part2 = matcher.group(2);
    System.out.println(part1); // 输出 "hello"
    System.out.println(part2); // 输出 "world"
}

8.4 使用正则表达式断言模式

如果你想在拆分字符串时将分隔符包裹在拆分后的字符串的第一部分或第二部分,可以使用正则表达式的断言模式。例如:
在这里插入图片描述

  • 包裹在第一部分

    String cmower = "hello,world";
    String[] parts = cmower.split("(?<=,)");
    
  • 包裹在第二部分

    String cmower = "hello,world";
    String[] parts = cmower.split("(?=,)");
    

8.5. 传递两个参数的 split() 方法

split() 方法可以传递两个参数:第一个是分隔符,第二个是拆分的字符串个数。例如:

String cmower = "hello,world,java";
String[] parts = cmower.split(",", 2);

在这种情况下,split() 方法会直接调用 substring() 进行截取,第二个分隔符后的内容将不再拆分。

8.6 预编译模式以提高效率

如果模式是确定的,可以通过 static 的预编译功能提高程序的效率。例如:

private static final Pattern PATTERN = Pattern.compile("(.+)\\.(.+)");

public static void main(String[] args) {
    String cmower = "hello.world";
    Matcher matcher = PATTERN.matcher(cmower);

    if (matcher.matches()) {
        String part1 = matcher.group(1);
        String part2 = matcher.group(2);
        System.out.println(part1); // 输出 "hello"
        System.out.println(part2); // 输出 "world"
    }
}

9 参考链接

  1. 深入解读String类的源码
  2. 为什么Java字符串是不可变的
  3. 深入理解Java字符串常量池
  4. 详解String.intern()方法
  5. 聊聊 Java StringBuilder和StringBuffer 两兄弟
  6. 字符串相等判断:Java中的equals()与==的区别与用法
  7. 最优雅的Java字符串String拼接是哪种方式
  8. 如何在Java中拆分字符串:详解String类的split()方法
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值