JAVA基础篇 001:一文彻底搞懂String类:定义、原理与优化

41 阅读28分钟

本篇文件将近1w字,基本涵盖了你能碰到的或者想知道的关于String的全部知识点,如有错误或遗漏欢迎在评论区指正,感激不尽!

一、String类基础

1.1 什么是String?

  String 是 Java 编程语言中代表字符串的一个类。  一个字符串本质上是一个不可变的连续的字符序列。你可以把它理解为用来存储和操作文本数据的基本工具。在 Java 中,所有用双引号 " " 引起来的文字都是 String 对象。我们来看一下在Java源码中String长什么样子。

public final class String
    implements java.io.Serializable, Comparable<String>, CharSequence {
    // 内部使用字符数组存储
    private final char value[];
    // 其他字段...
}

   在上述代码中我们可以看到,首先String类是一个final类,在Java中,当一个类被 final 关键字修饰时,它就成为了一个 final 类(最终类)。 最直接、最重要的特性是:final 类不能被继承,也就是说,不允许有任何类成为它的子类。

这里需要强调的是,String类在Java中并不属于八大基本数据类型,而是一个引用数据类型

image.png   在 Java 中,基本数据类型有固定的大小和默认值,而 String 类型是不定长度的,可以存储任意长度的字符串。由于 String 类具有对象的特性,它可以调用方法来进行字符串操作,而基本数据类型需要转化为对应的引用数据类型才可以进行对象操作。尽管它在使用时类似于基本数据类型,但实际上它是一个类,属于引用数据类型的范畴

虽然String是引用数据类型,但 Java 在字符串处理上进行了优化,允许字符串直接使用类似基本数据类型的赋值方式,这被称为字符串常量池。在许多情况下,字符串常量池的使用方式让String类型的使用看起来更像是基本数据类型,但它仍然是引用数据类型。


1.2 两种创建方式及其内存影响创建

String对象主要有两种方式,它们在JVM内存中的分配位置是截然不同的:

  1. 字面量方式String s1 = "Hello";
    • JVM首先检查字符串常量池String Pool。如果发现内容相同的字符串,则直接返回其引用,不创建新对象
    • 内存优化: 这种机制保证了内存中相同内容的字符串只存在一份,极大地节省了堆内存空间。
  2. 构造器方式String s2 = new String("Hello");
    • 无论常量池中是否已存在该字符串,JVM都会在**堆(Heap)**上创建一个新的String对象。
    • 如果常量池中没有"Hello",还会先在常量池中创建一个对象,再在堆上创建一个对象。因此,至少会创建 1 个对象(最多 2 个)。

1.3 String类的特性

1.3.1 不可变性

这是 String 最根本、最重要的特性。

  • 定义:一旦一个 String 对象被创建,其包含的字符序列就永远不能被改变
  • 表现:任何看似修改字符串的操作(如连接、替换、截取),实际上都是创建并返回一个全新的 String 对象,原始对象保持不变。
  • 实现机制
    • 存储字符的 char[] value 数组被声明为 private final
    • 不提供任何能修改内部字符数组的公共方法
  • 优点
    • 线程安全:不可变对象天生线程安全,多线程环境下可以安全共享,无需同步,避免了复杂的线程同步问题
    • 安全性:防止敏感信息(密码、密钥等)被意外修改,在网络传输和文件操作中保证数据完整性
    • 哈希码缓存:哈希值在第一次计算后被缓存,提高了HashMap、HashSet等集合的操作性能
    • 字符串常量池:相同的字符串字面量共享同一对象,节省内存空间,提高性能
    • 作为HashMap键值的可靠性:哈希值在对象生命周期内不会改变,确保在哈希表中能正确定位和检索
    • 类加载机制:字符串不可变性是类加载器的基础,确保类名、方法名等元数据的稳定性
    • 代码优化:编译器可以进行字符串连接优化,提高运行时性能
    • 设计简化:不可变性简化了程序设计和代码维护,减少了出错的可能性

image.png

String str1 = "Hello";
String str2 = str1; // str1和str2指向同一个对象

System.out.println("修改前:");
System.out.println("str1: " + str1 + " | hashCode: " + System.identityHashCode(str1));
System.out.println("str2: " + str2 + " | hashCode: " + System.identityHashCode(str2));

// 修改前:
// str1: Hello | hashCode: 1324119927
// str2: Hello | hashCode: 1324119927

str1 = str1 + " World"; // 看似修改,实则是指向了一个新的对象

System.out.println("\n修改后:");
System.out.println("str1: " + str1 + " | hashCode: " + System.identityHashCode(str1));
System.out.println("str2: " + str2 + " | hashCode: " + System.identityHashCode(str2));

// 修改后:
// str1: Hello World | hashCode: 990368553
// str2: Hello | hashCode: 1324119927

一旦创建了字符串对象,它的内容就无法被修改。例如,当对一个已经存在的字符串进行拼接、插入或删除操作时,会创建一个新的 String 对象,然后将新的值赋给原来的字符串引用。原来的字符串对象本身不会被修改,而是指向了一个全新的字符串对象。对于字符串拼接的情况,Java 编译器会自动优化一些简单的拼接操作,将其转换为使用 StringBuilder 或 StringBuffer 来处理,以提高性能。但是不论是否使用这些类,基本思想仍然是创建一个新的字符串对象。

1.3.2 字符串常量池

为了优化性能和减少内存开销,JVM 提供了字符串常量池机制。

  • 字面量创建String s = "hello";

    • JVM 首先检查常量池中是否存在内容相同的字符串
    • 如果存在,直接返回池中对象的引用(实现重用)
    • 如果不存在,在池中创建新对象并返回引用
  • new 创建String s = new String("hello");

    • 强制在堆中创建新对象,绕过常量池
    • 每次都会创建新的对象实例
  • intern() 方法:手动将字符串放入常量池或获取池中已有对象的引用

二、String类的原理

2.1 String类的不可变性是如何实现的?

  初学者常有的一个疑问是:如果 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[]; // 在 JDK 9 之后,为了优化,改为了 `byte[]`
    // ... 其他代码
}

2.1.1 使用final关键字修饰类和字符数组

  • final class: 这意味着 String 类不能被继承。防止了子类通过重写方法来改变其行为,从而破坏不可变性。
  • private final char value[] : 这是存储字符串字符的核心数组。
    • private: 外部代码无法直接访问这个数组。
    • final: 这个引用一旦被初始化,就不能再指向另一个数组。这保证了 String 对象一生只与一个字符数组绑定。

2.1.2 没有公开的修改内部状态的方法

  String 类提供了很多操作字符串的方法,如 substring()concat()toUpperCase() 等,但所有这些方法都不是在原地修改字符数组,而是返回一个全新的 String 对象。我们看如下代码,可以看到,除非是截取整个字符串(beginIndex == 0),否则它一定会 new 一个新的 String 对象。也就是说String类并没有对外提供Set类似的方法。

public String substring(int beginIndex) {
    if (beginIndex < 0) {
        throw new StringIndexOutOfBoundsException(beginIndex);
    }
    int subLen = value.length - beginIndex;
    if (subLen < 0) {
        throw new StringIndexOutOfBoundsException(subLen);
    }
    // 关键行:创建了一个新的 String 对象
    return (beginIndex == 0) ? this : new String(value, beginIndex, subLen);
}

2.1.3 保护性拷贝

  当通过字符数组构造 String 时,String 类不会直接使用外部传入的数组引用,而是会拷贝一份数据到自己内部的 value 数组中。以 String(char value[]) 构造函数为例:

public String(char value[]) {
    // 关键行:将传入的 char 数组拷贝一份,赋值给内部的 final 数组
    this.value = Arrays.copyOf(value, value.length);
}

  这样做是为了防止外部的修改“渗透”进来。即使外部代码在创建 String 后修改了原始的 char[],也不会影响已经创建的 String 对象内部的 value

2.2 String 对象创建与内存管理

  String 对象主要有两种创建方式,它们在内存管理上有根本性的区别。

2.2.1 使用字面量方式创建

String s1 = "Hello";
String s2 = "Hello";
  • 过程:当 JVM 遇到字面量 "Hello" 时,它会去检查字符串常量池
    • 如果池中存在:则直接返回池中该字符串的引用,不会创建新的对象。
    • 如果池中不存在:则在池中创建这个字符串对象,并返回其引用。
  • 内存位置:字符串常量池(在 HotSpot JVM 中,JDK 7 之后,常量池位于堆内存中)。
  • 结果s1 == s2 的结果是 true,因为它们指向同一个内存地址的对象。

2.2.2 使用 new 关键字创建

String s3 = new String("Hello");
String s4 = new String("Hello");
  • 过程

    1. 首先,"Hello" 这个字面量会按照方式 1 来处理,确保常量池中存在 "Hello" 对象。
    2. 然后,new 关键字会在堆内存(非常量池区域)中强制创建一个新的 String 对象
    3. 这个新对象的内部 value 数组会指向常量池中 "Hello" 对象的 value 数组(在较新 JDK 中,可能是拷贝,也可能是直接指向,但对象本身是独立的)。
  • 内存位置:堆内存(非常量池区域)。

  • 结果s3 == s4 的结果是 false,因为 s3 和 s4 是堆中两个不同的对象,尽管它们的内容相同

2.2.3 关键方法:intern()

intern() 是一个本地方法,它的行为可以概括为:

如果常量池中已经包含一个等于此 String 对象的字符串(用 equals(Object) 方法确定),则返回池中的字符串。否则,将此 String 对象添加到池中,并返回此 String 对象的引用。

intern() 方法在 JDK 6、7、8 中的差异

1. JDK 6 中的 intern()

  • 无论字符串是否已存在,都会在永久代的常量池中创建一份完整的副本
  • 问题:如果对大量、唯一的字符串调用 intern(),会在永久代中产生大量数据,容易导致 OutOfMemoryError: PermGen space

2. JDK 7+ 中的 intern()

  • 常量池在堆中。
  • 当调用 intern() 时,如果池中没有该字符串,并不会复制整个字符串对象,而是将堆中当前这个对象的引用记录到常量池中
  • 优势:节省内存,因为常量池和堆中的字符串对象是同一个。

在看代码之前,我们先预习一个小的知识点,在下面代码会用到

== 操作符

  • 比较的是对象的「引用」(内存地址)
  • 它检查两个变量是否指向堆内存中的同一个对象
  • 它是 Java 的基本操作符,不涉及任何方法调用。

equals() 方法

  • 比较的是对象的「内容」或「逻辑相等性」
  • 它检查两个对象在逻辑上是否代表相同的值
  • 它是 Object 类的一个方法,可以被任何类重写以定义自己的相等逻辑。
// 字面量创建,池中和管理
String s1 = "Java";
String s2 = "Java";

// new创建,在堆中
String s3 = new String("Java");

// 运行时拼接,情况复杂
String s4 = "Ja" + "va"; // 编译器优化为"Java",指向常量池
String s5 = "Ja";
String s6 = "va";
String s7 = s5 + s6; // 运行时拼接,会在堆中创建新对象

// 使用intern,强制放入池(或返回池中引用)
String s8 = s7.intern();

System.out.println(s1 == s2); // true
System.out.println(s1 == s3); // false
System.out.println(s1 == s4); // true
System.out.println(s1 == s7); // false
System.out.println(s1 == s8); // true (在JDK 7+)
}
  • s1 和 s2 都指向常量池中的同一个 "Java" 对象。
  • s3 指向通过 new 在堆中创建的另一个 "Java" 对象。
  • s4 由于编译器优化,直接等同于 "Java",所以也指向常量池的对象。
  • s7 是运行时拼接,会在堆中新建一个 String 对象(s5 + s6 会被编译为 new StringBuilder().append(s5).append(s6).toString(),而 toString() 内部是 new String(...))。
  • s8 是 s7.intern() 的返回值。由于常量池中已存在 "Java",所以 s8 直接指向常量池中的对象。

2.2.4 结论

  1. 优先使用字面量方式String s = "hello"; 可以复用常量池中的对象,节省内存。
  2. 避免在循环中使用 + 进行字符串拼接:因为会创建大量中间 StringBuilder 和 String 对象。应使用 StringBuilder 或 StringBuffer
  3. 谨慎使用 intern() :对于重复率高的字符串,使用 intern() 可以节省内存。但对于大量随机、各不相同的字符串,使用 intern() 反而会占用更多内存(因为池本身也有开销),并增加 GC 负担。在现代 JVM 中,默认情况下,只有字面量会入池,通常足够。
  4. 理解 == 和 equals()== 比较的是引用地址(是否同一个对象),equals() 比较的是内容。除非你明确知道自己在比较常量池引用,否则永远使用 equals() 来比较字符串内容。

三、String 类的常用 API 方法详解

3.1 📋字符串基本信息方法

方法返回值描述示例
length()int返回字符串长度"Hello".length() → 5
isEmpty()boolean判断字符串是否为空"".isEmpty() → true
isBlank()boolean判断是否为空或只包含空白字符(Java 11+)" ".isBlank() → true

3.2 🔍 字符串查找方法

方法返回值描述示例
charAt(int index)char返回指定索引处的字符"Java".charAt(1) → 'a'
indexOf(String str)int返回指定字符串第一次出现的索引"Java".indexOf("a") → 1
lastIndexOf(String str)int返回指定字符串最后一次出现的索引"Java".lastIndexOf("a") → 3
contains(CharSequence s)boolean判断是否包含指定字符序列"Java".contains("av") → true

3.3 ⚖️ 字符串比较方法

方法返回值描述示例
equals(Object obj)boolean比较字符串内容是否相等"Java".equals("java") → false
equalsIgnoreCase(String str)boolean忽略大小写比较字符串"Java".equalsIgnoreCase("java") → true
startsWith(String prefix)boolean判断是否以指定字符串开头"Java".startsWith("Ja") → true
endsWith(String suffix)boolean判断是否以指定字符串结尾"test.java".endsWith(".java") → true
compareTo(String str)int按字典顺序比较字符串"apple".compareTo("banana") → 负数

3.4 ✂️ 字符串截取与拆分方法

方法返回值描述示例
substring(int beginIndex)String从指定位置开始截取到末尾"Hello".substring(1) → "ello"
substring(int begin, int end)String截取指定范围的子串"Hello".substring(1,4) → "ell"
split(String regex)String[]按正则表达式拆分字符串"a,b,c".split(",") → ["a","b","c"]
split(String regex, int limit)String[]限制拆分次数"a,b,c".split(",", 2) → ["a","b,c"]

3.5 🔧 字符串修改方法

方法返回值描述示例
toLowerCase()String转换为小写JAVA.toLowerCase() → java
toUpperCase()String转换为大写java.toUpperCase() → JAVA
trim()String去除首尾空白字符" hi ".trim() → hi
strip()String去除首尾空白字符(Java 11+)" hi ".strip() → hi
replace(char old, char new)String替换字符hello.replace(l,L) → heLLo
replace(CharSequence target, CharSequence replacement)String替换字符串cats.replace(cats,dogs) → dogs

3.6 🔗 字符串连接与格式化方法

方法返回值描述示例
concat(String str)String连接字符串Hello.concat(World) → HelloWorld
join(CharSequence delimiter, CharSequence... elements)String使用分隔符连接多个字符串String.join(-,a,b) → a-b
format(String format, Object... args)String格式化字符串String.format(%s-%d,id,123) → id-123

3.7 🛠️ 其他实用方法

方法返回值描述示例
toCharArray()char[]转换为字符数组hi.toCharArray() → [h,i]
valueOf(Object obj)String将其他类型转换为字符串String.valueOf(123) → 123
repeat(int count)String重复字符串(Java 11+)ab.repeat(2) → abab

四、字符串与基本类型转换详解

转换方向方法描述示例
字符串 → 基本类型Integer.parseInt()字符串转int123 → 123
Double.parseDouble()字符串转double3.14 → 3.14
Boolean.parseBoolean()字符串转booleantrue → true
类型.valueOf()字符串转包装类123 → Integer(123)
基本类型 → 字符串String.valueOf()基本类型转字符串123 → 123
Integer.toString()int转字符串123 → 123
String.format()格式化转换3.14 → 3.14
字符串拼接隐式转换值: + 123 → 值:123

4.1 字符串->基本类型

// 方法1: Integer.parseInt() - 最常用
String str1 = "123";
int num1 = Integer.parseInt(str1);
System.out.println("parseInt结果: " + num1); // 123
// 方法2: Integer.valueOf() - 返回Integer对象,自动拆箱
int num2 = Integer.valueOf("456");
System.out.println("valueOf结果: " + num2); // 456
    
// 字符串转double
String str1 = "3.14159";
double d1 = Double.parseDouble(str1);
System.out.println("double值: " + d1); // 3.14159

// 字符串转float
float f1 = Float.parseFloat("2.718");
System.out.println("float值: " + f1); // 2.718

// 字符串转long
long bigNum = Long.parseLong("123456789012");
System.out.println("long值: " + bigNum);

// 字符串转short
short s = Short.parseShort("100");
System.out.println("short值: " + s);

// 字符串转byte
byte b = Byte.parseByte("127");
System.out.println("byte值: " + b);

// 字符串转char(获取第一个字符)
String str = "A";
char c = str.charAt(0);
System.out.println("char值: " + c); // A

// 科学计数法
double scientific = Double.parseDouble("1.23E4");
System.out.println("科学计数法: " + scientific); // 12300.0

// 特殊值
double infinity = Double.parseDouble("Infinity");
double nan = Double.parseDouble("NaN");
System.out.println("无穷大: " + infinity); // Infinity
System.out.println("非数字: " + nan);       // NaN
    
// boolean转换比较宽松,只有"true"(忽略大小写)返回true
System.out.println(Boolean.parseBoolean("true"));   // true
System.out.println(Boolean.parseBoolean("TRUE"));   // true
System.out.println(Boolean.parseBoolean("True"));   // true
System.out.println(Boolean.parseBoolean("false"));  // false
System.out.println(Boolean.parseBoolean("abc"));    // false
System.out.println(Boolean.parseBoolean(""));       // false
System.out.println(Boolean.parseBoolean(null));     // false

4.2 基本类型->字符串

// 各种基本类型转字符串  使用valueOf
int num = 42;
double price = 19.99;
boolean flag = true;
char letter = 'A';

String str1 = String.valueOf(num);      // "42"
String str2 = String.valueOf(price);    // "19.99"
String str3 = String.valueOf(flag);     // "true"
String str4 = String.valueOf(letter);   // "A"

System.out.println("int转字符串: " + str1);
System.out.println("double转字符串: " + str2);
System.out.println("boolean转字符串: " + str3);
System.out.println("char转字符串: " + str4);

// 处理null值
Object obj = null;
String nullStr = String.valueOf(obj);   // "null" 字符串,不会抛异常
System.out.println("null转字符串: " + nullStr);
    
    
    
// 使用包装类的toString方法    
int num = 123;
double d = 45.67;

String str1 = Integer.toString(num);     // "123"
String str2 = Double.toString(d);        // "45.67"
String str3 = Boolean.toString(true);    // "true"
String str4 = Character.toString('X');   // "X"

System.out.println("Integer.toString: " + str1);
System.out.println("Double.toString: " + str2);
System.out.println("Boolean.toString: " + str3);
System.out.println("Character.toString: " + str4);

// 进制转换
String binary = Integer.toString(10, 2);  // 十进制转二进制
String hex = Integer.toString(255, 16);   // 十进制转十六进制
System.out.println("10的二进制: " + binary); // "1010"
System.out.println("255的十六进制: " + hex);  // "ff"

五、StringBuilder

  在Java编程中,我们经常需要处理字符串的拼接和修改操作。虽然String类功能强大,但由于其不可变性,每次修改都会创建新的字符串对象,这在频繁操作的场景下会带来严重的性能问题。

  StringBuilderJava 5中引入的一个类,专门为单线程环境下的字符串操作而设计。它的核心思想是"在保证功能的前提下,追求极致的性能"。

  看如下代码,我们之前学习过,如果使用String类的+操作符,每一次拼接其实都会创建一个新的对象并且将String对象的地址指向新的对象,这样做会产生大量的临时字符串对象,不仅消耗内存,还会增加垃圾回收的压力。这正是StringBuilderStringBuffer诞生的意义所在。

String result = "";
for (int i = 0; i < 1000; i++) {
    result += "data"; // 每次循环都创建新对象!
}

5.1 主要特点

  • 可变字符序列:StringBuilder 的核心设计理念是:可变字符序列。它内部维护一个可扩展的字符数组,所有的修改操作都在这个数组上进行,避免了不必要的对象创建。
abstract class AbstractStringBuilder {
    char[] value;     // 存储字符的数组
    int count;        // 当前已使用的字符数量
    // 其他成员和方法...
}
  • 容量管理机制:StringBuilder 采用智能的容量管理策略:
    • 初始容量:默认 16 个字符,或者根据构造参数指定
    • 扩容策略:当需要扩容时,新容量 = 旧容量 × 2 + 2
    • 容量确保:可以通过 ensureCapacity() 方法提前分配空间
  • 非线程安全:不包含同步机制,避免了不必要的性能开销
  • 高性能:相比StringBuffer有显著的性能优势,在单线程环境下,StringBuilder 的性能通常比 StringBuffer50%-100%,这是因为 StringBuffer 的同步机制带来了额外的开销。
  • 链式调用:大部分方法返回自身引用,支持方法链编程
String result = new StringBuilder()
    .append("SELECT ")
    .append(columnList)
    .append(" FROM ")
    .append(tableName)
    .append(" WHERE ")
    .append(condition)
    .append(" ORDER BY ")
    .append(sortField)
    .toString();

5.2 内部工作机制

  StringBuilder内部维护了一个字符数组(char[]),当我们需要添加内容时,它会在原有数组的基础上进行扩展,而不是创建全新的对象。这种机制类似于一个"动态数组",可以根据需要自动增长。

  当添加的内容超过当前容量时,StringBuilder会创建一个新的更大的数组(新容量 = 旧容量 × 2 + 2),将原有数据复制过去,然后继续操作。这个扩容过程虽然有一定开销,但相比String的每次修改都创建新对象,性能要好得多。

StringBuilder特别适合以下场景:

  • 方法内部的字符串构建
  • 循环中的字符串拼接
  • 大量字符串的批量处理
  • 任何确定只在单线程中使用的字符串操作

5.3 构造函数

// 默认构造函数 适用于不确定最终字符串长度的场景。
StringBuilder sb1 = new StringBuilder(); // 容量 = 16

// 指定初始容量 当能够预估大致长度时使用,可以避免多次扩容。
StringBuilder sb2 = new StringBuilder(100); // 容量 = 100

// 基于字符串初始化 在已有字符串基础上进行进一步构建时使用。
StringBuilder sb3 = new StringBuilder("Hello"); // 容量 = 16 + 字符串长度

5.4 常用 API 方法详解

5.4.1 🔧追加操作 (append)

方法参数类型返回值类型示例
append(String str)字符串StringBuildersb.append("Hello") → Hello
append(int i)整数StringBuildersb.append(100) → 100
append(double d)双精度数StringBuildersb.append(3.14) → 3.14
append(boolean b)布尔值StringBuildersb.append(true) → true
append(char c)字符StringBuildersb.append('A') → A
append(char[] str)字符数组StringBuildersb.append(new char[]{'a','b'}) → ab
append(Object obj)任意对象StringBuildersb.append(new Date()) → 日期字符串

5.4.2 📝 插入操作 (insert)

方法参数返回值示例
insert(int offset, String str)位置, 字符串StringBuildersb.insert(5, " World") → Hello World
insert(int offset, int i)位置, 整数StringBuildersb.insert(0, 123) → 123Hello
insert(int offset, double d)位置, 双精度数StringBuildersb.insert(3, 99.9) → Hel99.9lo
insert(int offset, char c)位置, 字符StringBuildersb.insert(1, 'X') → HXello
insert(int offset, char[] str)位置, 字符数组StringBuildersb.insert(0, new char[]{'A','B'}) → ABHello

5.4.3 🗑️ 删除与替换操作

方法参数返回值示例
delete(int start, int end)开始位置, 结束位置StringBuildersb.delete(2, 5) → He(原 Hello)
deleteCharAt(int index)要删除的位置StringBuildersb.deleteCharAt(1) → Hllo(原 Hello)
replace(int start, int end, String str)开始, 结束, 替换字符串StringBuildersb.replace(0, 5, "Hi") → Hi(原 Hello)
setCharAt(int index, char ch)位置, 新字符voidsb.setCharAt(1, 'a') → Hallo(原 Hello)

5.4.4 🔍 查询与转换操作

方法参数返回值示例
length()intsb.length() → 5(对于 Hello)
capacity()intsb.capacity() → 16(默认容量)
charAt(int index)字符位置charsb.charAt(1) → e(对于 Hello)
indexOf(String str)要查找的字符串intsb.indexOf("ll") → 2(对于 Hello)
substring(int start)开始位置Stringsb.substring(2) → llo(对于 Hello)
substring(int start, int end)开始, 结束位置Stringsb.substring(1, 4) → ell(对于 Hello)
toString()Stringsb.toString() → Hello

5.4.5 ⚡ 特殊操作

方法参数返回值示例
reverse()StringBuildersb.reverse() → olleH(原 Hello)
ensureCapacity(int capacity)最小容量voidsb.ensureCapacity(100) → 容量 ≥ 100
setLength(int newLength)新长度voidsb.setLength(3) → Hel(原 Hello)

5.5 性能优化实战技巧

方法使用场景效果
new StringBuilder(容量)已知大致长度时避免扩容,提升性能
ensureCapacity(容量)大量操作前预分配空间,减少扩容次数
链式调用多个操作连续时代码简洁,性能优化

5.5.1容量预分配策略

// 错误做法
StringBuilder sb = new StringBuilder(); // 默认容量16
for (int i = 0; i < 10000; i++) {
    sb.append("data"); // 需要多次扩容
}

// 预估最终长度
int estimatedLength = 10000 * 4; // 10000个条目,每个约4字符
StringBuilder sb = new StringBuilder(estimatedLength);
for (int i = 0; i < 10000; i++) {
    sb.append("data"); // 无需扩容
}

5.5.2 批量操作优化

public String buildCsv(List<String[]> data) {
    StringBuilder sb = new StringBuilder(data.size() * 50); // 预估容量
    
    for (String[] row : data) {
        // 一次性处理整行,避免中间字符串
        for (int i = 0; i < row.length; i++) {
            if (i > 0) sb.append(",");
            sb.append(row[i]);
        }
        sb.append("\n");
    }
    
    return sb.toString();
}

六、StringBuffer

  StringBuffer 是 Java 中一个线程安全的、可变的字符序列。它类似于 StringBuilder,但所有公共方法都使用 synchronized 关键字修饰,保证了多线程环境下的数据安全。

  StringBuffer 从 Java 1.0 开始就存在,是 Java 最早的字符串构建类。它的设计初衷是为了在多线程环境下安全地进行字符串操作。

6.1 主要特点

  • 线程安全:所有公共方法都使用synchronized关键字修饰
  • 同步开销:为了保证线程安全,付出了相应的性能代价
  • 功能完整:提供与StringBuilder相同的API接口
  • 稳定可靠:经过长期实践验证,可靠性高

6.2 同步机制详解

  StringBuffer的线程安全是通过在方法级别添加synchronized关键字实现的。这意味着任何时候只有一个线程能够执行StringBuffer的修改方法,其他线程必须等待当前操作完成。

synchronized 是 Java 用来解决“多线程同时访问同一份资源”时数据错乱问题的内置锁关键字。它保证:同一时刻最多只有一个线程能执行被锁住的代码段,其余线程必须排队

  这种同步机制虽然保证了数据安全,但也带来了明显的性能损耗。每次方法调用都需要获取和释放锁,在高并发场景下,这种开销会变得相当可观。

StringBuffer主要适用于:

  • 多线程共享的字符串缓冲区
  • Web应用中的全局字符串构建
  • 需要线程安全的字符串操作环境
  • 对性能要求不高但安全性要求高的场景

6.3 关于StringBuffer的线程安全

  我们看这段代码,他的最终长度输出为2000 符合我们的预期。为什么结果一定是 2000,而不是 1998、1999?因为 StringBuffer 把所有公共方法都加了 synchronized 关键字两个线程虽然并发,但每次 append 都必须拿到同一吧锁才能进去。

  • 不会出现“线程 A 正在扩容,线程 B 把 count++ 写丢”
  • 也不会出现“数组越界”或“少写一格”
StringBuffer sharedBuffer = new StringBuffer();

// 创建多个线程同时操作同一个 StringBuffer
Thread t1 = new Thread(() -> {
    for (int i = 0; i < 1000; i++) {
        sharedBuffer.append("A");
    }
});

Thread t2 = new Thread(() -> {
    for (int i = 0; i < 1000; i++) {
        sharedBuffer.append("B");
    }
});

t1.start();
t2.start();
t1.join();
t2.join();

// 由于 StringBuffer 的线程安全性,不会出现数据损坏
System.out.println("最终长度: " + sharedBuffer.length()); // 2000

  我们再看如果是看这段代码,他的最终长度输出为不定,可能是2000,也可能比2000少,这是因为:扩容、写数组、count += len ,这三个步骤并非原子性,线程 A 读到旧 count 值 → 写入数组 → 还没来得及 count++ 就被 B 打断,结果两个线程写进同一位置,出现“覆盖”或 count 更新丢失。

StringBuilder stringBuffer = new StringBuilder();
Thread t1 = new Thread(() -> {
    for (int i = 0; i < 1000; i++) {
        stringBuffer.append("A");
    }
});

Thread t2 = new Thread(() -> {
    for (int i = 0; i < 1000; i++) {
        stringBuffer.append("B");
    }
});

t1.start();
t2.start();
t1.join();
t2.join();
System.out.println("最终长度: " + stringBuffer.length()); // 2000  1998 2000 1956

6.4 常用API

参考StringBuilder

6.5 两者区别总结

特性StringBuilderStringBuffer
线程安全❌ 非线程安全✅ 线程安全
性能✅ 更高(无同步开销)❌ 较低(有同步开销)
版本Java 5+Java 1.0+
使用场景单线程环境多线程环境
方法同步无 synchronized所有公共方法都有 synchronized

七、String到底创建几个对象?

我曾经写过一篇文章,但当时是以jdk8为原型写的,当深入学习jdk9以上后,创建对象的过程有了变化,我这里再描述一下新的创建对象过程,有兴趣的可以去看看原文

String在生成的过程中如何创建对象?

String s1 = new String("hello")

String s2 = "world"

String s3 = new String("x") + new String("y")

String s5 = new String("abc") + "def"

String s6 = new String("x") + new String("y") + new String("z");

上述String对象在创建的过程中都创建了几个对象?

7.1 String s1 = new String("hello")

String s1 = new String("hello");

对象创建过程:

  1. new指令:在堆中创建一个新的String对象
  2. ldc指令:从字符串常量池加载"hello"(如果不存在则创建)
  3. 调用构造方法进行初始化

创建对象数量:1个或2个

  • 1个在字符串常量池中的"hello"字面量(如果有就不创建)
  • 1个在堆中的String对象

7.2String s2 = "world"

String s2 = "world";

对象创建过程:

  1. ldc指令:从字符串常量池加载"world"
  2. 如果常量池中不存在,则创建并放入常量池

创建对象数量:1个或0个

  • 如果"world"不在常量池:创建1个对象(常量池中)
  • 如果"world"已在常量池:创建0个对象(直接引用)
  • 通过这个例子可以看出,通过new的构造创建是不管常量池中是否有,都会强制创建一个对象。

7.3 String s3 = new String("x") + new String("y")

String s3 = new String("x") + new String("y");

JDK 8 的对象创建过程

创建的对象列表:

  1. 常量池中的"x"字符串对象 - 因为"x"是字面量,首次出现时在常量池创建
  2. 堆中的new String("x")对象 - 在堆内存中新建的String实例
  3. 常量池中的"y"字符串对象 - 因为"y"是字面量,首次出现时在常量池创建
  4. 堆中的new String("y")对象 - 在堆内存中新建的String实例
  5. StringBuilder对象 - 编译器自动创建的用于拼接的中间对象
  6. 最终结果的"xy"字符串对象 - StringBuilder.toString()在堆中创建的新String

总计:6个对象

JDK 9+ 的对象创建过程

创建的对象列表:

  1. 常量池中的"x"字符串对象 - 因为"x"是字面量,首次出现时在常量池创建
  2. 堆中的new String("x")对象 - 在堆内存中新建的String实例
  3. 常量池中的"y"字符串对象 - 因为"y"是字面量,首次出现时在常量池创建
  4. 堆中的new String("y")对象 - 在堆内存中新建的String实例
  5. 最终结果的"xy"字符串对象 - StringConcatFactory在堆中直接创建

总计:5个对象

关键差异

JDK 8的问题:

  • 需要创建StringBuilder作为中间载体
  • StringBuilder内部还有自己的char[]数组
  • 最终toString()时又创建新的char[]数组
  • 存在多次数组拷贝,效率较低

JDK 9的优化:

  • 移除了StringBuilder中间层
  • StringConcatFactory直接计算最终字符串长度
  • 一次性分配足够空间的char[]数组
  • 直接进行字符拷贝,减少内存分配和拷贝次数

如果常量池已存在:

  • 如果"x"或"y"已经在常量池中存在,则不会重复创建
  • 对象数量相应减少

关于"xy"是否在常量池:

  • 最终拼接结果的"xy"字符串不会自动放入常量池
  • 它只是一个普通的堆中String对象
  • 需要手动调用intern()方法才会进入常量池

7.4 String s5 = new String("abc") + "def"

String s5 = new String("abc") + "def";

JDK 8 的对象创建(修正版)

实际创建的对象:

  1. 常量池中的"abc"对象(如果不存在则创建)
  2. 常量池中的"def"对象(如果不存在则创建)
  3. 堆中的new String("abc")对象
  4. StringBuilder对象(编译器自动创建)
  5. StringBuilder.toString()生成的"abcdef"对象

总计:5个对象(如果"abc"和"def"已存在常量池中,则为3个)

JDK 9+ 的对象创建

实际创建的对象:

  1. 常量池中的"abc"对象(如果不存在则创建)
  2. 常量池中的"def"对象(如果不存在则创建)
  3. 堆中的new String("abc")对象
  4. StringConcatFactory生成的"abcdef"对象

总计:4个对象(如果"abc"和"def"已存在常量池中,则为2个)

八、复习与提问

8.1 什么是String?他有什么特性?每个特性有什么优点?

8.2 不同构造函数创建的对象有什么区别吗?

8.3 你能讲讲String的常量池吗?

8.4 StringBuilder与StringBuffer是什么?为什么会有这两个存在?优点是什么?

8.5 在String类中,JDK8与JKD8以上有什么区别?