掘金 后端 ( ) • 2024-06-21 10:00

theme: vue-pro

本文已收录于:https://github.com/danmuking/all-in-one(持续更新)

前言

哈喽,大家好,我是 DanMu。今天这边文章,想和大家聊聊有关字符串的问题,字符串似乎很简单,但其实字符串几乎在所有编程语言里都是个特殊的存在,因为不管是数量还是体积,字符串都是大多数应用中的重要组成。这篇文章就让我们深入理解一下字符串相关的知识点。

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. java.io.Serializable:序列化接口,表明String具有序列化的能力。
  2. Comparable<String>:默认实现了比较方法,因此可以采用compareTo()来比较两个字符串是否相同(不过更经常用的是equals()方法)
  3. CharSequence:一个声明,不重要

并且特别需要注意的是 String 类被**final**关键字修饰,这表明String类无法被子类继承,在一定程度上保证了String的不可变要求。

String的不可变性

为什么希望String是不可变的?

  1. 可以缓存 hash 值

String 的 hash 值经常被使用,例如 String 用做 HashMap 的 key。不可变的特性可以使得 hash 值也不可变,因此只需要进行一次计算。

  1. 线程安全

String 不可变性天生具备线程安全,可以在多个线程中安全地使用。

  1. String Pool 的需要

如果一个 String 对象已经被创建过了,那么就会从共享的字符串常量池中取得引用。只有 String 是不可变的,才可能使用字符串常量池。 这里放张图

  1. 安全性

String 经常作为参数,String 不可变性可以保证参数不可变。例如在作为网络连接参数的情况下如果 String 是可变的,那么在网络连接过程中,String 被改变,改变 String 对象的那一方以为现在连接的是其它主机,而实际情况却不一定是。

String 的不可变性是如何保证的?

Java 主要通过两点来保证 String 具有不可变性:

  1. 保存字符串的数组被 final 修饰且为私有的,并且 String 类没有提供/暴露修改这个字符串的方法。
  2. String 类被 final 修饰导致其不能被继承,进而避免了通过子类破坏 String 不可变。

字符串常量池

字符串常量池是什么

字符串常量池 是 JVM 为了提升性能和减少内存消耗针对字符串(String 类)专门开辟的一块区域,主要目的是为了避免字符串的重复创建,可以简单理解成是一个所有字符串共享的内存区域,如果创建的字符串已经在常量池中存在,那么将直接引用常量池对象,避免重复创建相同的字符串。

// 在堆中创建字符串对象”ab“
// 将字符串对象”ab“的引用保存在字符串常量池中
String aa = "ab";
// 直接返回字符串常量池中字符串对象”ab“的引用
String bb = "ab";
System.out.println(aa==bb);// true

如果是通过**new**关键字创建的 String 对象是无法使用常量池的。

来看一道简单的面试题加深一下对字符串常量池的理解:

String s1 = new String("abc");创建了几个字符串对象?

会创建 1 或 2 个字符串对象。

  1. 一个字符串“abc”和一个 new 出来的 String 对象

如果字符串常量池中不存在字符串对象“abc”的引用,那么它会在两个字符串对象,其中“abc”是字符串常量,将会在常量池中创建,而 new String 是一个new 出来的对象,将会在对上创建。 示例代码(JDK 1.8):

String s1 = new String("abc");

对应的字节码: ldc 命令用于判断字符串常量池中是否保存了对应的字符串对象的引用,如果保存了的话直接返回,如果没有保存的话,会在堆中创建对应的字符串对象并将该字符串对象的引用保存到字符串常量池中。

  1. 只创建 new 出来的 String 对象

如果字符串常量池中已存在字符串对象“abc”的引用,则只会在堆中创建 1 个字符串对象“abc”。 示例代码(JDK 1.8):

// 字符串常量池中已存在字符串对象“abc”的引用
String s1 = "abc";
// 下面这段代码只会在堆中创建 1 个字符串对象“abc”
String s2 = new String("abc");

对应的字节码: 这里就不对上面的字节码进行详细注释了,7 这个位置的 ldc 命令不会在堆中创建新的字符串对象“abc”,这是因为 0 这个位置已经执行了一次 ldc 命令,已经在堆中创建过一次字符串对象“abc”了。7 这个位置执行 ldc 命令会直接返回字符串常量池中字符串对象“abc”对应的引用。

String、StringBuffer、StringBuilder 的区别?

StringStringBufferStringBuilder三者的区别也是面试当中的常客了,这三者的区别可以从以下几个部分来进行分析。 可变性 String 是不可变的,这点我们在前面已经进行了详细的分析:为什么String是不可变的。 StringBuilder 与 StringBuffer 都继承自 AbstractStringBuilder 类,在 AbstractStringBuilder 中也是使用字符数组保存字符串,不过没有使用 final 和 private 关键字修饰,最关键的是这个 AbstractStringBuilder 类还提供了很多修改字符串的方法比如 append 方法。 线程安全性 String 中的对象是不可变的,也就可以理解为常量,线程安全。AbstractStringBuilder 是 StringBuilder 与 StringBuffer 的公共父类,定义了一些字符串的基本操作,如 expandCapacity、append、insert、indexOf 等公共方法。StringBuffer 对方法加了同步锁或者对调用的方法加了同步锁,所以是线程安全的。StringBuilder 并没有对方法进行加同步锁,所以是非线程安全的。 性能 每次对 String 进行改变的时候,都会生成一个新的 String 对象,然后将指针指向新的 String 对象。(在 Java 9 以前)StringBuffer 每次都会对 StringBuffer 对象本身进行操作,而不是生成新的对象并改变对象引用。相同情况下使用 StringBuilder 相比使用 StringBuffer 仅能获得 10%~15% 左右的性能提升,但却要冒多线程不安全的风险。 对于三者使用的总结:

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

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 会将其存入字符串常量池。并且,字符串常量拼接得到的字符串常量在编译阶段就已经被存放字符串常量池,这个得益于编译器的优化。 在编译过程中,Javac 编译器(下文中统称为编译器)会进行一个叫做 常量折叠(Constant Folding) 的代码优化。《深入理解 Java 虚拟机》中是也有介绍到:image-20210817142715396.png 常量折叠会把常量表达式的值求出来作为常量嵌在最终生成的代码中,这是 Javac 编译器会对源代码做的极少量优化措施之一(代码优化几乎都在即时编译器中进行)。 对于String str3 = "str" + "ing";编译器会给你优化成String str3 = "string";。 并不是所有的常量都会进行折叠,只有编译器在程序编译期就可以确定值的常量才可以:

  • 基本数据类型( byte、boolean、short、char、int、float、long、double)以及字符串常量。
  • final 修饰的基本数据类型和字符串变量
  • 字符串通过 “+”拼接得到的字符串、基本数据类型之间算数运算(加减乘除)、基本数据类型的位运算(<<、>>、>>> )

引用的值在程序编译期是无法确定的,编译器无法对其进行优化。 对象引用和“+”的字符串拼接方式,实际上是通过 StringBuilder 调用 append() 方法实现的,拼接完成之后调用 toString() 得到一个 String 对象 。

String str4 = new StringBuilder().append(str1).append(str2).toString();

我们在平时写代码的时候,尽量避免多个字符串对象拼接,因为这样会重新创建对象。如果需要改变字符串的话,可以使用 StringBuilder 或者 StringBuffer。(Java 9之前) 不过,字符串使用 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 会被编译器当做常量来处理,编译器在程序编译期就可以确定它的值,其效果就相当于访问常量。 如果 ,编译器在运行时才能知道其确切值的话,就无法对其优化。 示例代码(str2 在运行时才能确定其值):

final String str1 = "str";
final String str2 = getStr();
String c = "str" + "ing";// 常量池中的对象
String d = str1 + str2; // 在堆上创建的新的对象
System.out.println(c == d);// false
public static String getStr() {
      return "ing";
}

Java 对 String 进行了哪些优化?

  1. Java 9 将 String 的底层实现由 char[] 改成了 byte[]

新版的 String 支持两个编码方案:Latin-1 和 UTF-16。如果字符串中包含的汉字没有超过 Latin-1 可表示范围内的字符,那就会使用 Latin-1 作为编码方案。Latin-1 编码方案下,byte 占一个字节(8 位),char 占用 2 个字节(16),byte 相较 char 节省一半的内存空间。并且大部分情况下,使用的字符串对象都只包含 Latin-1 可表示的字符,因此将在相当程度上减少内存使用。

  1. 对于使用 + 拼接字符串的情况进行了优化

在上面我们提到了,每次对 String 类型进行改变的时候,都会生成一个新的 String 对象,然后将指针指向新的 String 对象。因此,如果采用 + 进行大量的拼接字符串操作,会极大程度上的影响性能,Java 的开发者也注意到了这点,因此在 Java 9 中特别针对这种情况进行了优化。 Java 语言本身并不支持运算符重载,“+”和“+=”是专门为 String 类重载过的运算符,也是 Java 中仅有的两个重载过的运算符。

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

上面的代码对应的字节码如下: image-20220422161637929.png 可以看出,字符串对象通过“+”的字符串拼接方式,实际上是通过 StringBuilder 调用 append() 方法实现的,拼接完成之后调用 toString() 得到一个 String 对象 。 不过,在循环内使用“+”进行字符串的拼接的话,jdk9以前存在比较明显的缺陷:编译器不会创建单个 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 对象。image-20220422161320823.png 如果直接使用 StringBuilder 对象进行字符串拼接的话,就不会存在这个问题了。

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

image-20220422162327415.png 不过,**使用 “+” 进行字符串拼接会产生大量的临时对象的问题在 JDK9 中得到了解决。**在 JDK9 当中,字符串相加 “+” 改为了用动态方法 makeConcatWithConstants() 来实现,而不是大量的 StringBuilder 了。

很多面试者现在都知道StringStringBufferStringBuilder三者的区别,但是对于 Java 9 中对String的优化知道的人却很少,因此,在面试中,如果面试官问到这方面的问题,不妨也把这个知识点加上去,说不定能够帮你脱颖而出哦~

点关注,不迷路

好了,以上就是这篇文章的全部内容了,如果你能看到这里,非常感谢你的支持! 如果你觉得这篇文章写的还不错, 求点赞👍 求关注❤️ 求分享👥 对暖男我来说真的 非常有用!!! 白嫖不好,创作不易,各位的支持和认可,就是我创作的最大动力,我们下篇文章见! 如果本篇博客有任何错误,请批评指教,不胜感激 !

最后推荐我的IM项目DiTinghttps://github.com/danmuking/DiTing-Go),致力于成为一个初学者友好、易于上手的 IM 解决方案,希望能给你的学习、面试带来一点帮助,如果人才你喜欢,给个Star⭐叭!

参考资料

如何理解 String 类型值的不可变? - 知乎