String:不可变的字符串
本章定位:String 是 Java 中使用频率最高的类,没有之一。理解它的不可变性、常量池机制和拼接原理,是区分"会用 Java"和"懂 Java"的分水岭。
小崔的困惑
飞翔科技技术部新入职的小崔正在写员工管理模块。他写了这样一段代码来比较两个员工的姓名:
String name1 = "大翔";
String name2 = new String("大翔");
System.out.println(name1 == name2); // false ???
"明明值一样,为什么 == 比较返回 false?"小崔百思不得其解。架构师白歌走过来,说:"这涉及到 String 的不可变性和常量池机制,来,我们从头讲起。"
String 的不可变性
定义与源码分析
String 类的不可变性由三个关键设计保证:
| 设计要素 | 源码体现 | 作用 |
|---|---|---|
final class | public final class String | 禁止子类化,防止通过继承破坏不可变性 |
private final char[] | private final char value[];(JDK 8) | 字符数组私有且不可变引用 |
| 所有方法返回新对象 | substring()、replace() 等均不修改原数组 | 任何"修改"操作都产生新的 String 对象 |
JDK 9 变化:JDK 9 起
char[]改为byte[],配合coder字段标识编码(LATIN1 / UTF16),称为"紧凑字符串"(Compact Strings)。但不变性的设计思想保持不变。本章基于 JDK 8。
不可变性的好处
- 线程安全:多个线程共享同一个 String 对象无需同步
- Hash 缓存:
hashCode()只计算一次并缓存(字段hash),提高 HashMap 性能 - 字符串常量池:可安全地复用相同的字符串字面量
- 安全:类加载器、网络连接参数等敏感字符串不会被意外修改
String 常量池(String Pool)
什么是字符串常量池?
字符串常量池是 JVM 堆内存中的一块特殊区域(JDK 7+ 移至堆中),用于存储字符串字面量。它的核心设计思想是复用:相同的字符串只存一份。
字面量创建 vs new 创建
| 创建方式 | 示例 | 对象数量 | 是否入池 |
|---|---|---|---|
| 字面量 | String s = "hello"; | 0~1 个(池中无则创建) | 自动入池 |
| new | String s = new String("hello"); | 1~2 个(池中无则先在池中创建字面量,再在堆中创建 new 对象) | 堆对象不入池 |
| intern() | s.intern(); | 0~1 个 | 手动入池 |
完整示例一:字面量与 new 的内存分析
场景
小崔需要存储飞翔科技三位创始人的姓名,白歌让他通过画内存图理解不同创建方式的行为。
代码
public class StringPoolDemo {
public static void main(String[] args) {
// ========== 字面量创建 ==========
String a = "飞翔科技"; // ① 常量池创建 "飞翔科技"
String b = "飞翔科技"; // ② 常量池已存在,直接复用
System.out.println("a == b: " + (a == b)); // true(同一引用)
// ========== new 创建 ==========
String c = new String("飞翔科技"); // ③ 堆中新建对象,常量池已有则不复建
String d = new String("飞翔科技"); // ④ 堆中再建一个对象
System.out.println("a == c: " + (a == c)); // false(常量池 vs 堆)
System.out.println("c == d: " + (c == d)); // false(两个不同堆对象)
// ========== intern() 手动入池 ==========
String e = c.intern(); // ⑤ 将 c 的值试图放入常量池,返回池中引用
System.out.println("a == e: " + (a == e)); // true(都指向常量池)
System.out.println("c == e: " + (c == e)); // false(堆 vs 常量池)
// ========== equals() 比较内容 ==========
System.out.println("a.equals(c): " + a.equals(c)); // true(内容相同)
}
}
运行输出
a == b: true
a == c: false
c == d: false
a == e: true
c == e: false
a.equals(c): true
内存图
结论:字面量创建的字符串指向常量池,
new创建的字符串指向堆对象。intern()可以将堆对象"拉回"常量池。
== 与 equals() 的区别
这是 Java 面试中的"送命题",也是小崔踩的第一个坑:
| 比较方式 | 比较内容 | String 中的行为 |
|---|---|---|
== | 引用地址(栈中存储的指针) | 判断是否指向同一对象 |
equals() | 内容(String 重写了 equals) | 判断字符序列是否相同 |
// String 类重写的 equals() 方法核心逻辑(简化版)
public boolean equals(Object anObject) {
if (this == anObject) return true; // 先比较地址,快速返回
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;
}
字符串拼接的底层原理
+ 运算符的编译优化
小崔写了这样一段代码做员工信息拼接:
String info = "姓名:" + name + ",部门:" + dept + ",薪资:" + salary;
白歌让他用 javap -c 反编译看看字节码。结果让所有人大跌眼镜——编译器把它变成了:
// 编译器优化后的等价代码
String info = new StringBuilder()
.append("姓名:")
.append(name)
.append(",部门:")
.append(dept)
.append(",薪资:")
.append(salary)
.toString();
循环拼接的性能陷阱
// ❌ 反例:循环中使用 + 拼接
String result = "";
for (int i = 0; i < 10000; i++) {
result += i; // 每次循环都 new StringBuilder() → toString() → 产生大量临时对象!
}
// 实际执行:创建了 10000 个 StringBuilder 对象 + 10000 个 String 对象
// ✅ 正确:循环外使用 StringBuilder
StringBuilder sb = new StringBuilder();
for (int i = 0; i < 10000; i++) {
sb.append(i); // 仅一个 StringBuilder,内部数组扩容
}
String result = sb.toString();
String 常用方法大全
速查表格
| 方法签名 | 功能 | 返回值 |
|---|---|---|
int length() | 返回字符串长度 | 字符个数 |
char charAt(int index) | 返回指定索引的字符 | char |
String substring(int begin, int end) | 截取 [begin, end) 子串 | 新 String |
int indexOf(String str) | 查找子串首次出现位置 | 索引,找不到返回 -1 |
boolean contains(CharSequence s) | 是否包含指定序列 | boolean |
String replace(char old, char new) | 替换所有匹配字符 | 新 String |
String[] split(String regex) | 按正则分割 | String 数组 |
String trim() | 去除首尾空白字符 | 新 String |
String toUpperCase() | 转大写 | 新 String |
String toLowerCase() | 转小写 | 新 String |
boolean startsWith(String prefix) | 是否以指定前缀开头 | boolean |
boolean endsWith(String suffix) | 是否以指定后缀结尾 | boolean |
String intern() | 返回常量池中的引用 | 池中 String |
完整示例二:飞翔科技员工姓名处理
场景
小崔需要从 HR 系统导出的数据中清洗员工姓名——去除空格、统一大小写、提取姓氏、判断邮箱前缀。
代码
public class EmployeeNameProcessor {
public static void main(String[] args) {
String rawName = " Zhang San "; // HR 导出的原始姓名
String email = "zhang.san@feixiang.com";
// 1. 去除首尾空格
String trimmed = rawName.trim();
System.out.println("trim: [" + rawName + "] → [" + trimmed + "]");
// 输出: trim: [ Zhang San ] → [Zhang San]
// 2. 转大写 / 小写
System.out.println("upper: " + trimmed.toUpperCase()); // ZHANG SAN
System.out.println("lower: " + trimmed.toLowerCase()); // zhang san
// 3. 提取姓和名
int spaceIndex = trimmed.indexOf(" "); // 查找空格位置
String surname = trimmed.substring(0, spaceIndex); // [0, spaceIndex)
String givenName = trimmed.substring(spaceIndex + 1); // [spaceIndex+1, end)
System.out.println("姓氏: " + surname); // Zhang
System.out.println("名字: " + givenName); // San
// 4. 从邮箱提取前缀
String emailPrefix = email.substring(0, email.indexOf("@"));
System.out.println("邮箱前缀: " + emailPrefix); // zhang.san
// 5. 判断邮箱是否公司邮箱
boolean isCompany = email.endsWith("@feixiang.com");
System.out.println("是否公司邮箱: " + isCompany); // true
// 6. 敏感信息脱敏(手机号中间四位变 ****)
String phone = "13812345678";
String masked = phone.substring(0, 3) + "****" + phone.substring(7);
System.out.println("脱敏手机号: " + masked); // 138****5678
// 7. split 分割
String departments = "技术部,产品部,市场部,财务部";
String[] deptArray = departments.split(",");
System.out.println("部门列表:");
for (String dept : deptArray) {
System.out.println(" - " + dept.trim());
}
// 8. replace 替换
String path = "C:\\Users\\feixiang\\documents";
String unixPath = path.replace('\\', '/');
System.out.println("Unix 路径: " + unixPath);
// C:/Users/feixiang/documents
}
}
运行输出
trim: [ Zhang San ] → [Zhang San]
upper: ZHANG SAN
lower: zhang san
姓氏: Zhang
名字: San
邮箱前缀: zhang.san
是否公司邮箱: true
脱敏手机号: 138****5678
部门列表:
- 技术部
- 产品部
- 市场部
- 财务部
Unix 路径: C:/Users/feixiang/documents
易错场景
❌ 反例一:用 == 比较字符串内容
这是小崔入职第一天就犯的错:
// ❌ 错误:== 比较的是地址
public boolean isBoss(String name) {
return name == "大翔"; // 如果传入的是 new String("大翔"),永远返回 false!
}
// 测试
System.out.println(isBoss("大翔")); // true(巧合,因为字面量)
System.out.println(isBoss(new String("大翔"))); // false!
System.out.println(isBoss(scanner.nextLine())); // false!用户输入不在常量池
纠正:
// ✅ 正确:使用 equals() 比较内容
public boolean isBoss(String name) {
return "大翔".equals(name); // 常量在前,防止 NPE
}
❌ 反例二:循环中用 + 拼接字符串
// ❌ 错误:每次循环创建新的 StringBuilder 和 String
public String buildEmployeeList(List<String> names) {
String result = "";
for (String name : names) {
result += name + ","; // 10000 个名字 = 10000 个 StringBuilder + 10000 个 String
}
return result;
}
纠正:
// ✅ 正确:使用 StringBuilder
public String buildEmployeeList(List<String> names) {
StringBuilder sb = new StringBuilder();
for (String name : names) {
sb.append(name).append(",");
}
return sb.toString();
}
❌ 反例三:substring 索引越界
// ❌ 错误:substring 的 endIndex 是 exclusive
String name = "大翔";
String sub = name.substring(0, 3); // StringIndexOutOfBoundsException! 长度只有2
纠正:
// ✅ 正确:endIndex 不超过 length()
String name = "大翔";
String sub = name.substring(0, name.length()); // "大翔"(完整复制)
提醒:
substring(begin, end)的 end 索引不包含,有效范围是[begin, end)。
面试考点
Q1:String 为什么被设计为不可变?
四个方面:① 线程安全——多线程共享无需同步;② Hash 缓存——hashCode 只需计算一次并缓存,快速用于 HashMap;③ 字符串常量池——不可变才能安全复用;④ 安全考虑——类加载器、数据库连接 URL 等不会被意外篡改。此外,不可变性也符合 String 作为"值类型"的语义直觉。
Q2:String s = new String("abc") 创建了几个对象?
1~2 个。首先 JVM 检查常量池中是否有 "abc":如果没有,先在常量池创建一个;然后在堆上用
new创建一个新的 String 对象(其内部的value[]指向常量池中的字符数组)。因此可能是 1 个(池中已有 + 堆对象)或 2 个(先在池中创建 + 再在堆中创建)。
Q3:String、StringBuilder、StringBuffer 的区别?
String 不可变,每次修改产生新对象;StringBuilder 可变,线程不安全,JDK 5 引入,性能最好;StringBuffer 可变,线程安全(synchronized),JDK 1.0 即有。单线程环境优先用 StringBuilder,多线程字符串操作才用 StringBuffer。
Q4:intern() 方法的作用是什么?JDK 7 前后有何变化?
intern()返回字符串在常量池中的引用。JDK 6 及以前常量池在方法区(永久代),intern()会把字符串复制到常量池;JDK 7+ 常量池移到堆中,如果常量池中没有,intern()只在常量池记录堆中对象的引用而不再复制,节省了内存。
Q5:String 的 "+" 拼接底层是如何实现的?
编译器将其优化为
StringBuilder.append()链式调用。对于编译时常量(如"a" + "b"),编译器直接做常量折叠产生"ab";对于变量参与的情况,反编译后可见new StringBuilder().append(...).toString()。因此单条语句中的多个+只创建一个 StringBuilder,但循环中的+每次迭代都会创建新的 StringBuilder。
白歌总结:"String 是面试官试探候选人基础深度的第一道门槛。能把常量池、intern()、拼接字节码这三个问题讲透,基本就算过了 String 这一关。"
JDK 11 String 增强(详见第17章「String 新方法」)
JDK 11 为 String 新增了六个实用方法,建议在学习完本章后进一步阅读:
isBlank()— 判断字符串为空或仅含空白字符(支持 Unicode 全角空格)strip()/stripLeading()/stripTrailing()— Unicode 安全去空白,替代trim()lines()— 按行分割返回Stream<String>,替代split("\\R")repeat(int n)— 将字符串重复 n 次拼接这些方法在日常开发中使用频率极高,尤其是
isBlank()和strip(),建议全面替换trim()和isEmpty()的旧用法。
JDK 12-17 String 增强(详见第18章「HelpfulNPE与String增强」及「文本块」)
JDK 12-15 进一步丰富了 String 的工具方法链,建议在掌握本章和 JDK 11 增强后进一步阅读:
indent(int n)— 调整每行缩进(JDK 12),配合多行文本处理transform(Function)— 自定义链式转换(JDK 12),将字符串传递给任意函数stripIndent()— 去除公共前导空格(JDK 15),文本块的核心配套方法translateEscapes()— 翻译转义序列(JDK 15),处理配置文件中的\n\t字面量formatted(Object...)— 实例方法版String.format()(JDK 15),支持链式调用- 文本块
"""..."""(JDK 15)— 多行字符串所见即所得这些方法让 String 的链式处理能力达到了新高度,推荐配合使用。