跳至主要內容

String 类

AruNi_LuJavaJava 基础约 5807 字大约 19 分钟

本文内容

1. String 基础

String 类表示字符串。Java 程序中的所有字符串字面值,例如 "abc",都被实现为该类的实例,因此字符串属于对象。

public final class String
    implements java.io.Serializable, Comparable<String>, CharSequence,
               Constable, ConstantDesc {
}

可以看见,String 实现了如下类:

  • java.io.Serializable:可以被序列化;
  • Comparable<String>:支持作比较;
  • CharSequence:描述字符串结构的接口,String、StringBuilder、StringBuffer 都实现了这个类;

1.1 创建字符串的两种方式

有两种方式可以创建字符串,分别是直接通过 "xxx" 和 通过 new String(),例如:

String s = "hello";

String s = new String("hello");

这两种方式是有区别的,因为字符串在 方法区中(JDK 1.6)有一个属于自己的 字符串常量池,是 JVM 为 String 开辟的一块内存缓冲区,好处如下:

  • 提高性能 (直接从常量池取字符串)
  • 减少内存开销 (避免重复创建字符串)

以下内容都基于 JDK 1.6 的方法区来讲,后面会详细讲解 JDK 1.8 后的情况

当创建 String 对象时,JVM 会先检查字符串常量池

  • 若这个字符串的常量值已经存在在池中,就直接返回池中对象的引用;
  • 若不在池中,就会实例化一个字符串并放入池中。

如果使用 String s = "hello",那么 只会在字符串常量池中创建一个 "hello" 对象

image-20221219012225042

如果使用 String s = new String("hello"),那么 除了会先在字符串常量池中创建一个 "hello" 对象外,还会为 new String() 在堆中创建一个 String 对象的实例

image-20221219013221404

所以,String s = new String("hello") 这句话会 创建一个或者两个对象,取决于字符串常量池中是否含有 "hello"。

1.2 String 常用方法

image-20221218152907259

2. String 的不可变性

2.1 什么是不可变?

String 不可变很简单,给定一个字符串 String s = new String("hi"),在将这个字符串 s 替换为 "hello",这个操作 不是在原内存地址上替换数据,而是将替换后的字符串重新指向一个新对象、新地址

如下图(未画字符串常量池):

image-20221219012154649

示例:

System.identityHashCode(Object o) 方法返回对象的哈希码,不管是否重写了 hashCode() 方法

class Test {
    public static void main(String[] args) {
        String s = "hi";
        System.out.println(System.identityHashCode(s));	// 295530567
        s = s.replace("hi", "hello");
        System.out.println(System.identityHashCode(s));	// 2003749087
    }
}

可以发现,s.replace("hi", "hello") 后,s 的哈希码改变了,说明 s 指向的地址改变了。

其实,任何会改变 String 的方法都会新创建一个 String 变量,将改变后的 String 赋值给这个变量返回,而 不会在原地修改

下面再使用可变类型的 StringBuilder 来测试:

class Test {
    public static void main(String[] args) {
        StringBuilder sb = new StringBuilder("hi");
        System.out.println(System.identityHashCode(sb));	// 1324119927
        sb = sb.append("hello");
        System.out.println(System.identityHashCode(sb));	// 1324119927
    }
}

可以发现,sb 对象拼接上一个字符串后,还是指向它原来的地址。

2.2 探索 String 不可变的真正原因

首先来看看 final 的作用:

  • final 关键字修饰的 类不能被继承,修饰的 方法不能被重写
  • 修饰 变量 分两种情况(重要):
    • 基本类型:值不能改变;
    • 引用类型:不能再指向其他对象;

接下来直接翻开 JDK 源码,java.lang.String 类起手前三行是这样的:

public final class String
    implements java.io.Serializable, Comparable<String>, CharSequence,
               Constable, ConstantDesc {

    @Stable
    private final byte[] value;
    // ......
}

从源码中可以得知一下信息:

  • String 类用 final 修饰,说明 String 类 不可继承
  • String 类的主力成员字段 value 是一个 byte[] 数组(JDK 9 之前是 char[]),也是用 final 修饰,被 final 修饰的字段创建后就 不可改变。注意 数组是引用类型,说明 value 只是不能再指向其他对象
    • 注意:是这个 byte[] 类型的 变量 value 不可改变,并不是这个数组不可变。也就是说只是 value 变量引用的地址不可改变,而 数组本身是可变的
  • value 变量 只是 stack 上的一个引用数组的本体结构在 heap 中。String 类里的 valuefinal 修饰,只是说 stack 里的这个叫 value 所引用的地址不可变,没有说 heap 中数组本身中的数据不可变;

image-20221218183323883

举个例子:

final int[] value = {1, 2, 3};
int[] another = {4, 5, 6};
value = another;  	// 编译报错:不能给 final 变量value 赋值。

因为把 value 重新指向了 another 指向的地址,而 value 用 final 修饰,所以自然是不允许的。

但是如果直接对数组本身进行修改,如下:

final int[] value = {1, 2, 3};
value[2] = 10;	// 允许直接对数组本身进行修改
System.out.println(Arrays.toString(value)); 	// [1, 2, 10]

所以,并不是因为 final 修饰了 String 和 value 就是不可变的了,主要是编写这个类的程序员 把 String 封装得很好,具体体现如下:

  • String 被 final 修饰,不能被继承,这 避免了其他人继承后破坏
  • byte[] valueprivate修饰,只能在本类中对 value 进行操作
  • String 类中 没有提供任何能修改 byte[] 数组数据的方法

所以 String 不可变的关键都在底层的实现,而不是单单一个final

2.3 String 为什么要设计成不可变?

最简单地原因,就是为了 安全

例如,我们要在某个函数中对字符串进行修改,然后返回新的字符串,如下:

class Test {
    public static void main(String[] args) {
        String origin = "aaa";
        // 不可变的 String 做参数
        String updatedStr = updateStr(origin);
        System.out.println("origin String: aaa ->" + origin);

        StringBuilder originSb = new StringBuilder("aaa");
        // 可变的 StringBuilder 做参数
        StringBuilder updatedSb = updateSb(originSb);
        System.out.println("origin StringBuilder: aaa ->" + originSb);
    }

    // 不可变的 String
    public static String updateStr(String s) {
        String newStr = s + "bbb";     // +: 底层使用 StringBuilder 拼接,返回一个新对象
        return newStr;
    }

    // 可变的 StringBuilder
    public static StringBuilder updateSb(StringBuilder sb) {
        StringBuilder newSb = sb.append("bbb");
        return newSb;
    }
}

/* 
输出:
origin String: aaa ->aaa
origin StringBuilder: aaa ->aaabbb
/*

可以发现:若使用可变的 StringBuilder 做参数,因为 Java 是值传递,引用类型传递的是地址,所以把我们原来的 originSb 的地址传过去,然后进行改变了,但是我们 本意是不想改变它的,只是想把它作为参数进行修改,然后返回一个新的修改后的字符串给我们。

而不可变的 String 做参数的时候,就不会修改掉我们原来的 origin 字符串。

再看下面这个 HashSet 用 StringBuilder 做元素的场景,问题就更严重了,而且更隐蔽。

class Test {
    public static void main(String[] args) {
        HashSet<StringBuilder> set = new HashSet<>();
        StringBuilder sb1 = new StringBuilder("aaa");
        StringBuilder sb2 = new StringBuilder("aaabbb");
        set.add(sb1);
        set.add(sb2);
        System.out.println(set);    // [aaabbb, aaa]

        StringBuilder sb3 = sb1;	// 把 sb3 也指向 sb1 的地址
        sb3.append("bbb");		// 修改 sb3,其实也修改了 sb1,因为它们指向相同的地址
        System.out.println(set);    // [aaabbb, aaabbb]
    }
}

StringBuilder 型变量 sb1 和 sb2 分别指向了堆内的字面量 "aaa" 和 "aaabbb"。把他们都插入一个HashSet,到这一步没问题。但如果后面不小心 把变量 sb3 也指向 sb1 的地址,再改变 sb3 的值,因为StringBuilder 没有不可变性的保护,sb3 直接在原先 "aaa" 的地址上改,导致 sb1 的值也变了。这时候,HashSet 上就出现了两个相等的键值 "aaabbb”。破坏了 HashSet 键值的唯一性。所以千万不要用可变类型做 HashMap 和 HashSet 键值。

还有一个原因是 String 会保存在字符串常量池中,这样在大量使用字符串的情况下,可以 节省内存空间和提高效率。但之所以能实现这个特性,String 的不可变性是最基本的一个必要条件。要是 内存里字符串内容能改来改去,这么做就完全没有意义了

此外,String 类中还有一个 hash 变量,如下所示:

public final class String
    implements java.io.Serializable, Comparable<String>, CharSequence {
    // ......

    /** Cache the hash code for the string */
    private int hash; // Default to 0
    
    // ......
}

从源码种的注释就能知道,该变量是 给 String 类型变量的 hashcode 值做缓存,这样下次需要获取该 String 变量的 hashcode 值时,就不用再通过哈希运算了。

顺便提一下,像基本数据类型的包装类也是使用 final 修饰的,而且保存值的变量 value 也是使用 final 修饰,所以也是不可变类。因为 Java 也为包装类型提供了缓存,例如 Integer 的缓存范围在 -128 至 127 之间,因为这个范围的数字是经常使用的。

不过需要注意的是,该缓存只适用于自动装箱时使用,当用构造函数时,不会使用缓存。

Integer i1 = 10;
Integer i2 = 10;
System.out.println(i1 == i2);	// true(i2 直接指向缓存的数据)

Integer i1 = 1000;
Integer i2 = 1000;
System.out.println(i1 == i2);	// false(超过了缓存范围)

Integer i1 = new Integer(10);
Integer i2 = new Integer(10);
System.out.println(i1 == i2);	// false(只有自动装箱时才会用到缓存)

3. String 常见问题

3.1 String、StringBuffer、StringBuilder 的区别

从可变性来说:

  • String 是不可变的,它被 finalprivate 修饰;

  • StringBuilderStringBuffer 都继承自 AbstractStringBuilder 类,在 AbstractStringBuilder 中也是使用 byte 数组保存字符串,不过没有使用 finalprivate 关键字修饰,最关键的是这个 AbstractStringBuilder 类还提供了很多修改字符串的方法 比如 append 方法。

    abstract class AbstractStringBuilder implements Appendable, CharSequence {
        byte[] value;
        
        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;
        }
      	//...
    }
    

从线程安全性来说:

  • String 中的对象是不可变的,也就可以理解为常量,线程安全。
  • AbstractStringBuilderStringBuilderStringBuffer 的公共父类,定义了一些字符串的基本操作,如 expandCapacityappendinsertindexOf 等公共方法。
    • StringBuffer 对方法加了同步锁或者对调用的方法加了同步锁,所以是线程安全的。
    • StringBuilder 并没有对方法进行加同步锁,所以是非线程安全的。

从使用性能来说:

  • 每次对 String 类型进行改变的时候,都会生成一个新的 String 对象,然后将指针指向新的 String 对象。在修改较多的场景性能不太好。
  • StringBuffer 每次都会对 StringBuffer 对象本身进行操作,而不是生成新的对象并改变对象引用。
  • 相同情况下使用 StringBuilder 相比使用 StringBuffer 仅能获得 10%~15% 左右的性能提升,但却要冒多线程不安全的风险。

对于三者的使用总结:

  1. 操作少量的数据适用 String
  2. 单线程操作字符串缓冲区下操作大量数据适用 StringBuilder
  3. 多线程操作字符串缓冲区下操作大量数据适用 StringBuffer

3.2 字符串拼接用 "+" 还是 StringBuilder?

Java 语言本身并不支持运算符重载,“+” 和 “+=” 是专门为 String 类重载过的运算符,也是 Java 中仅有的两个重载过的运算符。

String str1 = "he";
String str2 = "llo";
String str3 = "world";
String str4 = str1 + str2 + str3;

上面的代码对应的字节码如下:

img

可以看出,字符串变量通过 “+” 的字符串拼接方式,实际上是 通过 StringBuilder 调用 append() 方法实现的,拼接完成之后调用 toString() 得到一个新的 String 对象

注意:在循环内使用 “+” 进行字符串的拼接的话,存在比较明显的缺陷:编译器不会创建单个 StringBuilder 以复用,会导致创建过多的 StringBuilder 对象

String[] arr = {"he", "llo", "world"};
String s = "";
for (int i = 0; i < arr.length; i++) {
    s += arr[i];
}
System.out.println(s);

StringBuilder 对象是在循环内部被创建的,这意味着 每循环一次就会创建一个 StringBuilder 对象,所以效率是很低的。

如果直接使用 StringBuilder 对象进行字符串拼接的话,就不会存在这个问题了。

String[] arr = {"he", "llo", "world"};
StringBuilder s = new StringBuilder();
for (String value : arr) {
    s.append(value);
}
System.out.println(s);

img

3.3 String 类型的变量和常量做 "+" 运算时发生了什么?

先来看字符串不加 final 关键字拼接的情况(JDK1.8):

String str1 = "str";
String str2 = "ing";
String str3 = "str" + "ing";	// 常量做 +
String str4 = str1 + str2;		// 变量做 +
String str5 = "string";
System.out.println(str3 == str4);	//false
System.out.println(str3 == str5);	//true
System.out.println(str4 == str5);	//false

对于 编译期可以确定值的字符串,也就是 常量字符串 ,JVM 会将其 存入字符串常量池。并且,字符串常量 拼接得到的字符串常量在编译阶段就已经被存放字符串常量池,这个得益于编译器的优化。

对于 String str3 = "str" + "ing"; 编译器会优化成 String str3 = "string";

而字符串类型的变量通过 “+” 的字符串拼接方式,实际上是 通过 StringBuilder 调用 append() 方法实现的,拼接完成之后调用 toString() 得到一个新的 String 对象

不过,字符串使用 final 关键字声明之后,可以让编译器当做常量来处理

final String str1 = "str";
final String str2 = "ing";
// 下面两个表达式其实是等价的
String c = "str" + "ing";	// 常量池中的对象
String d = str1 + str2; 	// 常量池中的对象
System.out.println(c == d);	// true

final 关键字修改之后的 String 会被编译器当做常量来处理,编译器在程序编译期就可以确定它的值,其效果就相当于访问常量。

3.4 JDK 9 为什么要把 char[] 改为 byte[]?

在 Java 9 之后,String 、StringBuilder 与 StringBuffer 的实现改用 byte 数组存储字符串。

新版的 String 其实支持两个编码方案: Latin-1 和 UTF-16。

如果字符串中包含的字符没有超过 Latin-1 可表示范围内的字符,那就会使用 Latin-1 作为编码方案。

Latin-1 编码方案下,byte 占 1 个字节,char 占用 2 个字节,byte 相较 char 节省一半的内存空间

如果字符串中包含的字符超过 Latin-1 可表示范围内的字符,byte 和 char 所占用的空间是一样的。

4. JDK 8 方法区的更变

4.1 永久代与元空间

在 JDK 1.6 的 HotSpot 中,把方法区称为 永久代,本质上两者并不等价,仅仅是因为 HotSpot 虚拟机的设计团队选择把 GC 分代收集至方法区,或者说用永久代来实现方法区而已。这样 HotSpot 的垃圾收集器可以像管理 Java 堆一样管理这部分内存,能省去专门为方法区编写内存管理代码的工作。

JDK 8 开始,使用 元空间 取代了永久代。元空间本质和永久代类似,都是对 JVM 规范中方法区的实现。不过元空间与永久代之间最大的区别在于:元空间并不在虚拟机中,而是使用本地内存

image-20221218233629283

image-20221218233701610

4.2 字符串延迟加载

字符串对象的创建都是 懒惰的执行到的时候才会加载,不是一次性加载完。

只有当运行到那一行字符串且在串池中不存在的时候时,该字符串才会被创建并放入串池中。

利用 IDEA 中的 Memory(查看运行时类对象个数)验证:

public static void main(String[] args) throws InterruptedException {
    System.out.println();       // java.lang.String     2093

    System.out.println("1");    // java.lang.String     2094
    System.out.println("2");
    System.out.println("3");
    System.out.println("4");
    System.out.println("5");
    System.out.println("6");
    System.out.println("7");
    System.out.println("8");
    System.out.println("9");
    System.out.println("0");    // java.lang.String     2103

    // 以下的字符串与上面重复,直接从字符串池中获取,String对象的数量不会增加
    System.out.println("1");    // java.lang.String     2103
    System.out.println("2");    // java.lang.String     2103
    System.out.println("3");
    System.out.println("4");
    System.out.println("5");
    System.out.println("6");
    System.out.println("7");
    System.out.println("8");
    System.out.println("9");
    System.out.println("0");
}

4.3 intern() 方法

字符串常量池主要用于存储在 编译期 生成的字符串对象,而如果 想要在运行期将字符串对象加入到串池中,则可以使用 intern() 方法

调用字符串对象的 intern() 方法,主动 将字符串对象放入到串池中:

  • JDK1.8:将这个字符串对象尝试放入串池,如果有则并不会放入,如果没有则将该字符串放入串池, 最后把串池中的对象的引用返回
  • JDK1.6:将这个字符串对象尝试放入串池,如果有则并不会放入,如果没有会把 此对象复制一份将复制的新对象放入串池, 原来的对象还是在堆中,最后把串池中的对象返回

可以发现在 JDK 1.8 和 JDK 1.6 中,无论字符串放入串池是否成功,最后都会返回串池中的对象

下面分别在 JDK 1.8 和 JDK 1.6 环境中来举例子,彻底搞懂 intern() 方法。

JDK 1.8 环境

在 JDK 1.8 环境下:

  • 如果调用 intern() 方法成功,堆内存与串池中的字符串对象是同一个对象,毕竟串池也在堆内存中;
  • 如果失败,则 不是同一个对象

示例 1:

注意"a" + "b" 会在编译时优化 ,而 new String("a") + new String("b"); 不会,它相等于使用 StringBuilder 的 append() 方法拼接,然后再返回一个新字符串对象 "ab"。

public class Main {
    public static void main(String[] args) {
        // "a"和"b" 被放入串池中,str 则存在于堆内存之中
        String str = new String("a") + new String("b");
        
        // 此时串池中没有 "ab" ,则会将该字符串对象放入到串池中,此时堆内存与串池中的 "ab" 是同一个对象
        String str2 = str.intern();
        
        // 给 str3 赋值,因为此时串池中已有 "ab" ,则直接将串池中的内容返回
        String str3 = "ab";
        
        // 堆内存与串池中的 "ab" 是同一个对象
        System.out.println(str == str2);		// true
        System.out.println(str2 == str3);	// true
    }
}

调用 intern() 前:

image-20221219004913144

调用 intern() 后:

image-20221219004824083

示例 2,将 str3 放在前面:

public class Main {
    public static void main(String[] args) {
        // 此处创建字符串对象 "ab" ,因为串池中还没有 "ab" ,所以将其放入串池中
        String str3 = "ab";
        
        // "a" 和 "b" 被放入串池中,str 则存在于堆内存之中
        String str = new String("a") + new String("b");
        
        // 此时"ab" 已存在与串池中,所以放入失败,str 还是堆中的"ab",但是会返回串池中的 "ab" 
        String str2 = str.intern();

        System.out.println(str == str2);	// false,堆中的str没有放入串池中,而str2是从串池的返回的

        System.out.println(str2 == str3);	 // true
    }
}

调用 intern() 前:

image-20221219005445197

调用 intern() 后:

image-20221219005635419

JDK 1.6 环境

在 JDK 1.6 环境下,此时无论调用 intern() 方法成功与否,串池中的字符串对象和堆内存中的字符串对象 都不是同一个对象,因为串池在方法区,不在堆区内。

示例 1,和 JDK1.8 情况 不同

public class Main {
    public static void main(String[] args) {
        // "a"和"b" 被放入串池中,str 则存在于堆内存之中
        String str = new String("a") + new String("b");
        
        // 此时串池中没有 "ab" ,JDK1.6 则会将该字符串复制一份,将复制的新对象放入到串池中,
        // str 本身还只是在堆中,最后也会返回串池中的 "ab" 对象,所以 str2 在串池中
        String str2 = str.intern();
        
        // 给 str3 赋值,因为此时串池中已有 "ab" ,则直接将串池中的内容返回
        String str3 = "ab";
        
        // str 不在串池中
        System.out.println(str == st2);		// flase
        System.out.println(str2 == str3);	// true
    }
}

调用 intern() 前:

image-20221219005744474

调用 intern() 后:

image-20221219005935429

示例 2,将 str3 放在前面,和 JDK1.8 情况相同:

public class Main {
    public static void main(String[] args) {
        // 此处创建字符串对象 "ab" ,因为串池中还没有 "ab" ,所以将其放入串池中
        String str3 = "ab";
        
        // "a" 和 "b" 被放入串池中,str 则存在于堆内存之中
        String str = new String("a") + new String("b");
        
        // 此时 "ab" 已存在与串池中,所以放入失败,str 还是堆中的"ab",但是会返回串池中的 "ab" 
        String str2 = str.intern();

        System.out.println(str == str2);	// false,堆中的 str 没有放入串池中,而 str2 是从串池的返回的

        System.out.println(str2 == str3);	 // true
    }
}

调用 intern() 前:

image-20221219010038781

调用 intern() 后:

image-20221219010152676

intern() 使用场景

我们就使用 JDK 1.8 来讲解了,intern() 有两个作用:

  • 将字符串对象放入串池(若没有);
  • 返回对象在串池中的引用。

我们知道,在编译期就能确定的字符串,则会添加进串池中,但是如果需要在运行期添加,则需要使用 intern()。所以就意味着不能乱使用该方法。

例如,像 String s = new String("hello").intern(); 这样使用该方法时,其实 intern() 是多余的,因为在编译期字符串 hello 就已经确定好了,因此会被加入到串池中,无需 intern() 方法将其放入。

再比如,像 String s = "hello" + "world";,由于编译期的优化,会将此优化为字符串 helloworld,所以串池中只会存在 helloworld,而不存在 helloworld

再比如:

String s1 = "hello";
String s2 = "world";
String s3 = s1 + s2;
  • 此时就不存在优化了,helloworld 存在串池中,而 helloworld 不存在。所以在有需要时得使用 intern() 方法将其放入串池。

通过上面的例子,可以得出 intern() 的使用场景主要是 得到的字符串在编译期间无法确定,而是在运行期间才能确定的,则需要使用 intern() 方法将其放入串池中

比如下面的例子,我们需要创建 MAX 个字符串,存入 arr 数组中:

static final int MAX = 1000 * 10000;
static final String[] arr = new String[MAX];

public static void main(String[] args) throws Exception {
    Integer[] DB_DATA = new Integer[10];
    Random random = new Random(10 * 10000);
    for (int i = 0; i < DB_DATA.length; i++) {
        DB_DATA[i] = random.nextInt();
    }
    long t = System.currentTimeMillis();
    for (int i = 0; i < MAX; i++) {
        //arr[i] = new String(String.valueOf(DB_DATA[i % DB_DATA.length]));
         arr[i] = new String(String.valueOf(DB_DATA[i % DB_DATA.length])).intern();
    }

    System.out.println((System.currentTimeMillis() - t) + "ms");
    System.gc();
}

我们使用一个长度为 10 的数组 DB_DATA 来为字符串数组 arr 填充数据,所以其实不同的字符串就 10 个,但是字符串数组长度有 10000000:

  • 不使用 intern() 时,由于这些字符串都是 在运行时才能确定的,所以会生成 1000w 个字符串;
  • 使用 intern() 后,前 10 此循环就会把这十个字符串都放入串池,后续的字符串都是直接引用串池中的即可,因此就生成了 10 个字符串。

因此,使用 intern() 节约了非常大的空间,不过消耗的时间也会稍微长一点,因为每次都是用了 new String 后,然后又进行 intern() 操作的消耗时间。但是相比于节约出来的大量空间,这点耗时也是不亏的。

5. 参考文章

上次编辑于: