String字符串
String 是 Java 中使用频率最高的类之一。它既是日常业务开发里的文本类型,也是理解对象不可变性、常量池、编码、性能优化和面试题的入口。
掌握 String 时,不能只停留在 length()、substring()、replace() 这些 API。更重要的是理解:
- 字符串为什么不可变。
- 字符串字面量和
new String()有什么区别。 - 字符串常量池如何减少重复对象。
==和equals为什么结果不同。- 拼接、循环拼接、
StringBuilder的性能差异。 - 字符编码和乱码问题如何产生。
# 1. String 的基本特性
| 特性 | 说明 |
|---|---|
| 引用类型 | String 是类,不是基本类型 |
| 不可变 | 创建后内容不能修改 |
| 可共享 | 字符串字面量会进入字符串常量池 |
实现了 Comparable | 可以按字典序比较 |
实现了 CharSequence | 可作为字符序列使用 |
final 类 | 不能被继承 |
示例:
String name = "Java";
name 是引用变量,"Java" 是字符串对象。
# 2. String 为什么不可变
不可变指字符串对象内部保存的字符内容不能被改变。
String s = "Java";
s = s + "!";
这段代码不是修改原来的 "Java",而是创建了新字符串 "Java!",再让变量 s 指向新对象。
初始:
s ───────────▶ "Java"
拼接后:
"Java"
s ───────────▶ "Java!"
不可变设计的价值:
| 价值 | 说明 |
|---|---|
| 安全 | 文件路径、类名、网络地址等字符串不容易被篡改 |
| 线程安全 | 多线程共享同一个字符串不需要加锁 |
| 支持常量池 | 相同字面量可以共享对象 |
| 支持 hash 缓存 | String 可安全作为 HashMap key |
| 简化设计 | 调用方不必担心传入字符串被方法内部修改 |
如果字符串可变,那么下面代码就会很危险:
Map<String, String> map = new HashMap<>();
String key = "user:1";
map.put(key, "Tom");
// 如果 key 内容能变,HashMap 就可能再也找不到原来的键
# 3. String 内部结构
不同 JDK 版本中 String 的内部存储有所变化:
| 版本 | 典型存储方式 | 说明 |
|---|---|---|
| JDK 8 | char[] | 每个字符通常占 2 字节 |
| JDK 9+ | byte[] + coder | Compact Strings,Latin-1 字符可节省空间 |
可以概念化理解为:
String 对象
┌────────────────────┐
│ hash │
│ coder │ JDK 9+
│ value ─────────┐ │
└────────────────┼───┘
▼
byte[] / char[]
┌──────────────┐
│ 字符内容 │
└──────────────┘
注意:内部字段是实现细节,业务代码不应该依赖它。理解内部结构主要是为了知道字符串不可变、内存占用和编码转换的成本。
# 4. 字符串创建方式
# 4.1 字面量
String a = "hello";
String b = "hello";
System.out.println(a == b); // true
字面量 "hello" 会放入字符串常量池,a 和 b 指向同一个池中对象。
a ─────┐
▼
"hello" 字符串常量池
▲
b ─────┘
# 4.2 new String
String a = "hello";
String b = new String("hello");
System.out.println(a == b); // false
System.out.println(a.equals(b)); // true
new String("hello") 会创建新的堆对象,变量 b 指向这个新对象。
字符串常量池
┌──────────┐
│ "hello" │◀──────── a
└──────────┘
堆内存
┌──────────┐
│ "hello" │◀──────── b
└──────────┘
开发中通常不应该写 new String("xxx"),它会制造不必要的对象。
# 5. 字符串常量池
字符串常量池用于复用相同内容的字符串字面量。
String a = "java";
String b = "ja" + "va";
System.out.println(a == b); // true
因为 "ja" + "va" 是编译期常量,编译器会直接优化成 "java"。
但如果有变量参与:
String part = "ja";
String a = "java";
String b = part + "va";
System.out.println(a == b); // false
part + "va" 是运行期拼接,默认会创建新对象。
执行链路:
编译期常量拼接
"ja" + "va"
│
▼
"java" 直接进入常量池
运行期变量拼接
part + "va"
│
▼
运行时创建新字符串对象
# 6. intern 方法
intern() 会尝试把字符串内容放入常量池,并返回池中对应字符串的引用。
String a = new String("hello");
String b = a.intern();
String c = "hello";
System.out.println(b == c); // true
实际开发中不建议随意使用 intern():
- 它会增加常量池管理成本。
- 大量动态字符串进入池中可能增加内存压力。
- 通常业务代码通过普通字符串复用和缓存就足够了。
intern() 更适合对高重复率、生命周期较长的字符串做有意识的优化。
# 7. 字符串比较
# 7.1 ==
== 比较两个引用是否指向同一个对象。
String a = "java";
String b = new String("java");
System.out.println(a == b); // false
# 7.2 equals
equals() 比较字符串内容。
System.out.println(a.equals(b)); // true
为了避免空指针,可以把常量放左边:
if ("ACTIVE".equals(status)) {
// ...
}
或者使用:
Objects.equals(a, b);
# 7.3 compareTo
compareTo() 按字典序比较:
"abc".compareTo("abd"); // 小于 0
"abc".compareTo("abc"); // 等于 0
"abd".compareTo("abc"); // 大于 0
它常用于排序:
List<String> names = new ArrayList<>(List.of("Tom", "Alice"));
Collections.sort(names);
# 8. 常用 API
| 方法 | 作用 |
|---|---|
length() | 字符串长度 |
isEmpty() | 长度是否为 0 |
isBlank() | 是否为空或全空白,Java 11+ |
charAt(int) | 获取指定位置字符 |
substring | 截取子串 |
contains | 是否包含子串 |
startsWith | 是否以某内容开头 |
endsWith | 是否以某内容结尾 |
indexOf | 查找位置 |
replace | 普通替换 |
replaceAll | 正则替换 |
split | 正则切分 |
trim | 去除首尾小于等于空格的字符 |
strip | Unicode 感知的首尾空白去除,Java 11+ |
示例:
String text = " Java Basic ";
System.out.println(text.trim()); // "Java Basic"
System.out.println(text.contains("Java")); // true
System.out.println(text.substring(2, 6)); // "Java"
substring(begin, end) 是左闭右开区间:
"abcdef"
012345
substring(1, 4) => "bcd"
# 9. replace、replaceAll 与 split
replace 是普通字符或字符串替换:
"a.b.c".replace(".", "-"); // "a-b-c"
replaceAll 使用正则表达式:
"a.b.c".replaceAll(".", "-"); // "-----"
因为正则里的 . 表示任意字符。若要替换普通点号:
"a.b.c".replaceAll("\\.", "-"); // "a-b-c"
split 也使用正则:
"a.b.c".split("."); // 结果可能不是你想要的
"a.b.c".split("\\."); // ["a", "b", "c"]
开发中如果只是普通替换,优先使用 replace,避免无意引入正则语义。
# 10. 字符串拼接
# 10.1 少量拼接
String message = "Hello " + name + "!";
少量拼接用 + 可读性好,编译器和运行时会做优化。
# 10.2 循环拼接
不推荐:
String result = "";
for (int i = 0; i < 10000; i++) {
result += i;
}
推荐:
StringBuilder builder = new StringBuilder();
for (int i = 0; i < 10000; i++) {
builder.append(i);
}
String result = builder.toString();
循环拼接问题:
第 1 次:"" + "1" -> 新字符串
第 2 次:"1" + "2" -> 新字符串
第 3 次:"12" + "3" -> 新字符串
...
中间对象多,性能和内存都不理想。
# 11. String、StringBuilder、StringBuffer
| 类 | 是否可变 | 线程安全 | 适用场景 |
|---|---|---|---|
String | 不可变 | 安全 | 少量文本、常量、Map key |
StringBuilder | 可变 | 不安全 | 单线程频繁拼接 |
StringBuffer | 可变 | 安全 | 老代码或确实需要同步的拼接 |
大多数业务代码中,循环拼接优先使用 StringBuilder。
构造 StringBuilder 时可以预估容量:
StringBuilder builder = new StringBuilder(1024);
如果能预估最终字符串长度,提前设置容量可以减少扩容复制。
# 12. 字符编码与乱码
字符串本身表示 Unicode 字符序列。只有当字符串和字节数组互相转换时,才涉及字符编码。
String text = "你好";
byte[] bytes = text.getBytes(StandardCharsets.UTF_8);
String decoded = new String(bytes, StandardCharsets.UTF_8);
编码转换流程:
String 字符
│ encode UTF-8
▼
byte[]
│ decode UTF-8
▼
String 字符
乱码通常来自编码和解码使用了不同字符集:
原始字符串 "你好"
│ UTF-8 编码
▼
字节数组
│ GBK 解码
▼
乱码
开发建议:
- 明确指定字符集,不依赖平台默认编码。
- 文件、网络、数据库、HTTP 响应统一使用 UTF-8。
- 使用
StandardCharsets.UTF_8,避免手写"UTF-8"。
# 13. 文本块
文本块适合多行字符串:
String sql = """
SELECT id, name
FROM user
WHERE status = 'ACTIVE'
""";
适用场景:
- 多行 SQL。
- JSON 示例。
- HTML 模板片段。
- 测试用大段文本。
注意:文本块也会处理缩进和换行,写测试断言时要注意实际字符串内容。
# 14. 常见开发问题
# 14.1 空字符串和 null
| 值 | 含义 |
|---|---|
null | 没有引用任何字符串对象 |
"" | 长度为 0 的字符串对象 |
" " | 长度为 1,内容是空格 |
判断示例:
if (text == null || text.isBlank()) {
// 空或空白
}
# 14.2 脱敏和截断
处理用户输入时,要注意字符串长度可能不是视觉字符数量。Emoji、组合字符、多字节字符都可能让“字符数量”问题变复杂。
普通业务校验可以用 length();涉及短信长度、数据库字节长度、前端显示宽度时,要按具体规则处理。
# 14.3 String 作为锁对象
不要使用字符串字面量作为锁:
synchronized ("LOCK") {
// ...
}
字符串字面量可能被全局共享,容易和其他代码意外使用同一个锁。
推荐:
private final Object lock = new Object();
# Tips 快问快答
Q:String 是基本类型吗?
A:不是。String 是 java.lang.String 类,是引用类型。
Q:字符串不可变是什么意思? A:字符串对象内容不能改变。变量可以重新指向新字符串,但原对象不变。
Q:String a = "x" 和 new String("x") 有什么区别?
A:字面量优先使用常量池对象,new String 会额外创建堆对象。
Q:字符串常量池在 JDK 8 后位于元空间吗? A:不是。字符串对象本身在堆中,常量池相关结构不要简单理解成“字符串都在元空间”。
Q:为什么字符串比较要用 equals?
A:== 比较引用地址,equals 比较字符内容。
Q:"a" + "b" == "ab" 为什么是 true?
A:因为纯字面量拼接会在编译期优化成 "ab"。
Q:循环拼接为什么推荐 StringBuilder?
A:它在同一个可变缓冲区中追加内容,减少中间字符串对象。
Q:StringBuffer 是不是一定比 StringBuilder 好?
A:不是。StringBuffer 方法带同步开销,单线程拼接通常选 StringBuilder。
Q:replaceAll(".", "-") 为什么把所有字符都替换了?
A:replaceAll 使用正则表达式,. 在正则里表示任意字符。
Q:乱码怎么排查? A:沿着“字符串 -> 字节 -> 字符串”的链路检查编码和解码字符集是否一致。