
文章目录
-
- 第一章:一切的基础------char原始类型
-
- [1.1 定义与本质](#1.1 定义与本质)
- [1.2 字符编码的演变:从char到byte](#1.2 字符编码的演变:从char到byte)
- [1.3 char的初始化与赋值](#1.3 char的初始化与赋值)
- [1.4 char的运算](#1.4 char的运算)
- 第二章:不可变的字符串------String类
-
- [2.1 类的定义与不可变性](#2.1 类的定义与不可变性)
- [2.2 不可变性的优势](#2.2 不可变性的优势)
- [2.3 创建String对象的两种方式](#2.3 创建String对象的两种方式)
- [2.4 操作的真相:总是生成新对象](#2.4 操作的真相:总是生成新对象)
- [2.5 字符串拼接的陷阱与优化](#2.5 字符串拼接的陷阱与优化)
- 第三章:可变的字符序列------StringBuilder与StringBuffer
-
- [3.1 AbstractStringBuilder:共同的祖先](#3.1 AbstractStringBuilder:共同的祖先)
- [3.2 StringBuilder:非线程安全的"快枪手"](#3.2 StringBuilder:非线程安全的“快枪手”)
- [3.3 StringBuffer:线程安全的"老大哥"](#3.3 StringBuffer:线程安全的“老大哥”)
- [3.4 核心API对比](#3.4 核心API对比)
- [3.5 性能对比](#3.5 性能对比)
- 第四章:横向对比与选型指南
-
- [4.1 选型指南:到底该用谁?](#4.1 选型指南:到底该用谁?)
- 第五章:常见面试题深度剖析
-
- [1. 谈谈你对String的理解,它为什么是不可变的?](#1. 谈谈你对String的理解,它为什么是不可变的?)
- [2. String、StringBuilder、StringBuffer的区别?](#2. String、StringBuilder、StringBuffer的区别?)
- [3. String s = new String("xyz"); 创建了几个对象?](#3. String s = new String("xyz"); 创建了几个对象?)
- [4. 为什么用StringBuilder要好于用String的"+"拼接?](#4. 为什么用StringBuilder要好于用String的“+”拼接?)
- 总结

在Java编程中,char、String、StringBuilder和StringBuffer是处理字符和字符串的四个基石。理解它们的设计哲学、底层实现和性能差异,对于编写高效、健壮的代码至关重要。本文将深入浅出地为你剖析这四大金刚的方方面面。
第一章:一切的基础------char原始类型
在探讨复杂的字符串类之前,我们首先需要了解构成字符串的最基本单元:char。
1.1 定义与本质
char是Java中的一种原始数据类型(Primitive Type) ,用于表示一个单一的16位Unicode字符。在Java诞生之初,设计者就采用了Unicode字符集,这使得Java天生具有良好的国际化支持。
- 大小 :16位(2个字节),范围从
0到65,535(\u0000到\uffff)。 - 无符号性 :
char是一个无符号类型,这意味着它不能表示负数。
1.2 字符编码的演变:从char到byte
在JDK 9之前,String类的内部实现也是采用 char[] 数组来存储字符。然而,一个深刻的洞察是,大多数应用程序使用的字符串主要由Latin-1字符集(如英文、数字)构成,这些字符仅需一个字节(8位)即可表示,用两个字节的char来存储会造成一半的内存浪费。
因此,从JDK 9开始,为了优化内存占用,String(以及StringBuilder和StringBuffer的底层)不再使用 char[],而是改用了 byte[] 数组,并引入一个 coder(编码器)字段来标识使用的是LATIN1(每个字符1字节)还是UTF16(每个字符2字节)编码。这是一个非常重要的底层变化,但对开发者来说是透明的,我们在逻辑上依然可以将它们视为字符序列。
1.3 char的初始化与赋值
char的赋值方式非常灵活,可以通过以下几种方式:
-
字符字面量 :用单引号括起来的单个字符。可以是英文字母,也可以是中文字符。
javachar c1 = 'A'; char c2 = '中'; -
整数编码值 :直接赋值为字符在Unicode表中的码点(整数)。
javachar c3 = 65; // 十进制,对应 'A' char c4 = 0101; // 八进制,对应 'A' char c5 = 0x41; // 十六进制,对应 'A' -
Unicode转义序列 :使用
\u前缀加上4位十六进制数。javachar c6 = '\u0041'; // 对应 'A' -
转义字符 :表示一些特殊功能字符。
javachar c7 = '\n'; // 换行符 char c8 = '\''; // 单引号字符本身 char c9 = '\\'; // 反斜杠字符本身
1.4 char的运算
由于char底层存储的是整数值,因此它可以进行算术运算和比较。
java
char ch = 'A';
System.out.println("ch is " + ch); // 输出: ch is A
ch = (char)(ch + 1); // 将 'A' 的码点 (65) 加 1,得到 66,再强转为 char
System.out.println("ch is now " + ch); // 输出: ch is now Y
char ch2 = 'a' + 'b'; // 'a'(97) + 'b'(98) = 195,结果在int范围内
System.out.println(ch2); // 输出:195对应的字符?这里实际上输出的是195作为char类型的字符,需要查码表。
int sum = 'a' + 'b';
System.out.println(sum); // 输出: 195
关键点 :当 char 和 char 或 char 和 int 进行运算时,结果会被提升为 int 类型。如果需要重新赋给 char 变量,必须进行显式的强制类型转换 (char)。
第二章:不可变的字符串------String类
String 类是Java中使用频率最高的类之一,其"不可变性"是其最核心的特征。
2.1 类的定义与不可变性
查看 String 类的源码(以JDK 8为例,后续版本底层数组变为byte[],但逻辑一致),我们可以清晰地看到其不可变性的实现:
java
public final class String
implements java.io.Serializable, Comparable<String>, CharSequence {
/** The value is used for character storage. */
private final char value[]; // JDK 9 之后变为 private final byte[] value
/** Cache the hash code for the string */
private int hash; // Default to 0
// ... 其他代码
}
- 类被
final修饰 :这意味着String类不能被继承,防止子类破坏其不可变行为。 - 存储数组被
private final修饰 :value数组的引用不可变,且无法从外部访问。final保证了数组的引用一旦指向某个地址后就不能再改变。虽然没有直接的语法阻止数组内部元素的变化,但String类没有提供任何可以修改数组元素的方法,从而确保了内部的字符数组也"不可变"。
2.2 不可变性的优势
这种精心的设计带来了许多好处:
-
线程安全:由于对象内容不可变,它可以在多个线程之间自由共享,无需任何同步措施。
-
字符串常量池(String Pool) :这是不可变性带来的最大性能优化之一。当创建一个字符串字面量(如
String s = "hello";)时,JVM会检查常量池中是否已存在相同内容的字符串。如果存在,则直接返回其引用;如果不存在,则在池中创建新字符串并返回引用。这种机制极大地节省了内存。javaString s1 = "hello"; String s2 = "hello"; System.out.println(s1 == s2); // 输出 true,因为指向常量池中的同一个对象 -
哈希值缓存 :如上源码所示,
String类中有一个hash字段。因为字符串不可变,其哈希值在第一次计算后就可以被缓存起来,之后直接返回。这使得String非常适合作为HashMap或HashTable的键,提高了查找效率。
2.3 创建String对象的两种方式
-
方式一:字面量赋值
String str1 = "abc";这种方式可能会从字符串常量池中获取对象。
-
方式二:new关键字
String str2 = new String("abc");这种方式一定会 在堆(Heap)中创建一个新的
String对象。如果常量池中还没有"abc"这个字符串,JVM会先在常量池中创建,然后再在堆中创建对象。因此,这种方式通常会创建1个或2个对象。javaString s3 = new String("hello"); String s4 = new String("hello"); System.out.println(s1 == s3); // 输出 false,s1指向常量池,s3指向堆 System.out.println(s3 == s4); // 输出 false,s3和s4指向堆中不同的对象
2.4 操作的真相:总是生成新对象
理解了不可变性,就不难明白,对 String 对象的任何修改操作(如拼接、替换、截取),都不是在原对象上进行的,而是返回一个新的 String 对象。
java
String original = "Hello";
String modified = original.concat(" World");
System.out.println(original); // 输出: Hello (原对象未变)
System.out.println(modified); // 输出: Hello World (新对象)
String upper = original.toUpperCase();
System.out.println(original); // 输出: Hello
System.out.println(upper); // 输出: HELLO
2.5 字符串拼接的陷阱与优化
正是由于上述特性,在循环中使用 + 进行字符串拼接会带来严重的性能问题。
java
// 低效的写法
String result = "";
for (int i = 0; i < 1000; i++) {
result = result + i; // 每次循环都会创建新的String对象
}
在JDK 5之后,Java编译器会对 + 运算符进行优化,自动将其转换为 StringBuilder 的 append 操作。例如 String c = a + b; 会被编译为 (new StringBuilder()).append(a).append(b).toString();。但是,在循环体内,这种优化依然会导致每次循环都新建一个 StringBuilder 对象 ,反编译后的字节码可以清晰地证明这一点。因此,在循环或频繁修改字符串的场景下,我们必须手动使用 StringBuilder 或 StringBuffer。
第三章:可变的字符序列------StringBuilder与StringBuffer
为了解决 String 不可变带来的性能问题,Java提供了两个可变的字符序列类:StringBuilder 和 StringBuffer。它们都继承自 AbstractStringBuilder 类,底层使用可变的字符数组(JDK 9后为byte[])来存储数据。
3.1 AbstractStringBuilder:共同的祖先
虽然我们不能直接使用 AbstractStringBuilder,但它是理解这两个类的关键。
java
// 以JDK 8为例
abstract class AbstractStringBuilder implements Appendable, CharSequence {
char[] value; // 非final,存储字符序列,JDK 9后变为 byte[]
int count; // 已使用的字符个数
// 扩容机制
public void ensureCapacity(int minimumCapacity) {
if (minimumCapacity > value.length) {
expandCapacity(minimumCapacity);
}
}
void expandCapacity(int minimumCapacity) {
int newCapacity = value.length * 2 + 2; // 新容量通常是旧容量的2倍+2
if (newCapacity < minimumCapacity) {
newCapacity = minimumCapacity;
}
// 创建新数组并复制原数据
value = Arrays.copyOf(value, newCapacity);
}
// ...
}
- 可变性 :
value数组没有被final修饰,可以被重新赋值指向一个新的数组地址,也可以修改数组内部元素。 - 自动扩容 :当调用
append或insert方法时,如果当前字符序列的长度超过了底层数组的容量,它会自动触发expandCapacity方法,创建一个更大的新数组(通常是原容量的2倍+2),并将原内容复制过去。
3.2 StringBuilder:非线程安全的"快枪手"
- 诞生时间:JDK 1.5引入。
- 核心特点 :非线程安全 。它的所有方法(如
append,insert,delete等)都没有使用synchronized关键字进行同步。 - 适用场景 :单线程环境下操作字符串缓冲区。因为避免了锁的竞争和获取开销,它通常拥有最好的性能,是单线程字符串操作的默认选择。
3.3 StringBuffer:线程安全的"老大哥"
-
诞生时间:JDK 1.0就已存在。
-
核心特点 :线程安全 。它的绝大多数方法都使用了
synchronized关键字修饰,确保在多线程并发访问时,不会出现数据错乱的问题。java// StringBuffer 的 append 方法 @Override public synchronized StringBuffer append(String str) { toStringCache = null; super.append(str); return this; } -
适用场景 :多线程环境下 ,多个线程可能同时操作同一个
StringBuffer对象时。在这种场景下,为了保证数据的正确性,必须使用StringBuffer。
3.4 核心API对比
三个类都实现了 CharSequence 接口,因此它们的方法非常相似。StringBuilder 和 StringBuffer 的API是兼容的,可以无缝替换。
| 方法分类 | 常用方法 | 描述 |
|---|---|---|
| 构造器 | StringBuilder() / StringBuffer() |
创建一个初始容量为16字符的空对象。 |
StringBuilder(int capacity) |
指定初始容量。 | |
StringBuilder(String str) |
根据字符串创建,初始容量为 16 + str.length()。 |
|
| 追加 | append(任意类型 x) |
将参数的字符串表示形式追加到序列末尾。这是最常用的方法,支持重载。 |
| 插入 | insert(int offset, 任意类型 x) |
在指定位置插入参数的字符串表示形式。 |
| 删除 | delete(int start, int end) |
删除从 start 到 end-1 的子序列。 |
deleteCharAt(int index) |
删除指定位置的字符。 | |
| 替换与反转 | replace(int start, int end, String str) |
用 str 替换指定范围的字符。 |
reverse() |
将序列反转。 | |
| 修改 | setCharAt(int index, char ch) |
修改指定位置的字符。 |
| 查询 | charAt(int index) / length() |
获取指定字符/长度。 |
indexOf(String str) / lastIndexOf(String str) |
查找子串位置。 | |
| 转String | toString() |
返回此序列中数据的字符串表示形式。 |
3.5 性能对比
下面通过一个简单的性能测试来直观感受三者的差异:
java
public class PerformanceTest {
private static final int TIMES = 20000;
public static void main(String[] args) {
testString();
testStringBuffer();
testStringBuilder();
}
public static void testString() {
long start = System.currentTimeMillis();
String str = "";
for (int i = 0; i < TIMES; i++) {
str += "java";
}
long end = System.currentTimeMillis();
System.out.println("String 拼接耗时: " + (end - start) + "ms");
}
public static void testStringBuffer() {
long start = System.currentTimeMillis();
StringBuffer sb = new StringBuffer();
for (int i = 0; i < TIMES; i++) {
sb.append("java");
}
String str = sb.toString();
long end = System.currentTimeMillis();
System.out.println("StringBuffer 拼接耗时: " + (end - start) + "ms");
}
public static void testStringBuilder() {
long start = System.currentTimeMillis();
StringBuilder sb = new StringBuilder();
for (int i = 0; i < TIMES; i++) {
sb.append("java");
}
String str = sb.toString();
long end = System.currentTimeMillis();
System.out.println("StringBuilder 拼接耗时: " + (end - start) + "ms");
}
}
典型输出结果(不同机器配置有差异,但趋势一致):
String 拼接耗时: 1500ms
StringBuffer 拼接耗时: 3ms
StringBuilder 拼接耗时: 1ms
结论:
String的+操作在大量拼接时性能最差,差了几个数量级。StringBuilder通常比StringBuffer快(本例中快2-3倍),因为省去了同步开销。
第四章:横向对比与选型指南
为了让你一目了然,这里将四个核心概念进行横向对比:
| 特性 | char | String | StringBuilder | StringBuffer |
|---|---|---|---|---|
| 类型 | 原始数据类型 | 类 | 类 | 类 |
| 不可变性 | / | 不可变 (Immutable) | 可变 (Mutable) | 可变 (Mutable) |
| 线程安全 | / | 线程安全 (通过不可变性) | 非线程安全 | 线程安全 (通过synchronized) |
| 底层存储 | 16位Unicode值 | byte[] (JDK 9+) |
byte[] (JDK 9+) |
byte[] (JDK 9+) |
| 性能(修改操作) | N/A | 极差(创建大量对象) | 最高 | 中等(有同步开销) |
| 适用场景 | 存储单个字符 | 操作少的字符串、常量、作为键的HashMap | 单线程下大量字符串操作(如循环拼接) | 多线程下共享的字符串缓冲区 |
4.1 选型指南:到底该用谁?
- 处理单个字符 :毫无疑问,使用
char。 - 操作少量、不变的字符串 :使用
String。例如配置项、常量、不经常变化的文本。 - 单线程环境下,需要大量操作字符串内容(拼接、删除、修改) :首选
StringBuilder。例如,在方法内部构建复杂的SQL语句、处理JSON字符串、日志组装等。这是最常见的场景。 - 多线程环境下,多个线程需要操作同一个字符串缓冲区 :必须使用
StringBuffer来保证数据同步和安全。例如,一个全局的日志缓冲区,多个线程都要向其追加内容。 - 字符串作为HashMap的键 :必须使用
String,因为其不可变性和正确的hashCode()实现。
第五章:常见面试题深度剖析
1. 谈谈你对String的理解,它为什么是不可变的?
答:String的不可变性体现在:
- 类本身被
final修饰,不可继承,防止子类破坏。 - 底层存储字符的
byte[](或char[]) 数组被private final修饰 ,引用不可变,且String类没有提供任何可以修改该数组内部元素的方法(如setCharAt())。所有看似修改的操作,如concat(),substring(),replace()等,都是返回一个新的String对象。
这种设计带来了线程安全、字符串常量池的内存复用、哈希值缓存等巨大优势。
2. String、StringBuilder、StringBuffer的区别?
答:三者都是用来处理字符串的,主要区别如下:
- 可变性 :
String是不可变类,操作后产生新对象。StringBuilder和StringBuffer是可变类,可以在原对象上修改。 - 线程安全 :
String是线程安全的。StringBuffer的方法使用了synchronized修饰,是线程安全的。StringBuilder的方法没有同步,是线程不安全的。 - 性能 :在单线程环境下,
StringBuilder的性能最好,因为它没有同步开销。StringBuffer次之。String在进行大量修改时性能最差。
3. String s = new String("xyz"); 创建了几个对象?
答:这取决于"xyz"这个字符串常量是否已经存在于字符串常量池中。
- 如果常量池中已经存在"xyz" :那么只在堆中创建一个
String对象。答案是 1个。 - 如果常量池中还没有"xyz" :JVM会先在常量池中创建字面量"xyz",然后再在堆中创建一个
String对象。答案是 2个。
4. 为什么用StringBuilder要好于用String的"+"拼接?
答 :虽然编译器会将 + 优化为 StringBuilder,但在循环等场景中,优化是局部的。例如 for 循环内的 +,每次循环都会在循环体内生成一个新的 StringBuilder 对象,然后调用 append(),最后调用 toString() 返回。这会产生大量的中间对象,给垃圾回收带来巨大压力,严重影响性能。而手动使用 StringBuilder 可以在循环外部创建一个对象,在整个循环中复用这一个对象进行 append,极大地减少了对象创建,提升了效率。
总结
本文从微观的 char 原始类型讲起,深入剖析了其在Java中的Unicode特性和运算规则。随后,我们详细探讨了 String 类的不可变性及其带来的设计权衡,并由此引出为了解决性能问题而诞生的 StringBuilder 和 StringBuffer。通过对它们底层实现(继承自 AbstractStringBuilder 的可变数组与扩容机制)和线程安全性(synchronized 关键字的有无)的对比,我们明确了各自的优缺点和适用场景。
理解这些核心概念的区别与联系,不仅仅是应对面试,更是为了在日常开发中做出正确的技术选型,写出既高效又稳健的代码。希望这篇长达数千字的详解能帮助你彻底掌握Java中字符与字符串的奥秘。