好的!我们从数据类型→变量类型→修饰符→运算符→流程控制,现在来到了 java 中最常用的引用类型之一:string,以及它的两位“兄弟”——stringbuilder 和 stringbuffer。
从不可变性到线程安全,从常量池到 gc,从 + 拼接到底层优化,一篇全通关
string 是 java 中最常用的类,没有之一。但正因为太常用,很多开发者对它的理解止步于“用双引号包起来的一段文本”。
面试时,面试官最爱的连环三问是:
string、stringbuilder、stringbuffer有什么区别?string为什么是不可变的?- 字符串拼接用
+还是stringbuilder?
今天这一篇,我们从底层存储、内存布局、性能测试、字节码分析四个维度,彻底把这“三兄弟”扒个底朝天。
一、先上结论(一张表看懂三兄弟)
| 对比维度 | string | stringbuilder | stringbuffer |
|---|---|---|---|
| 是否可变 | ❌ 不可变(immutable) | ✅ 可变(mutable) | ✅ 可变(mutable) |
| 线程安全 | ✅ 安全(不可变天然安全) | ❌ 不安全(非同步) | ✅ 安全(方法用 synchronized 修饰) |
| 性能 | 拼接时最慢(创建大量中间对象) | 最快(无同步开销) | 较快(有同步开销) |
| 适用场景 | 定义常量、少量拼接、作为 key | 单线程下大量拼接 | 多线程下大量拼接 |
| 底层存储(java 8) | char[] | char[] | char[] |
| 底层存储(java 9+) | byte[](紧凑布局) | byte[](紧凑布局) | byte[](紧凑布局) |
| 默认初始容量 | n/a(不可变) | 16 | 16 |
黄金选型原则:
- 少量拼接:用
string的+(编译器会优化) - 单线程大量拼接:用
stringbuilder(性能之王) - 多线程大量拼接:用
stringbuffer(安全第一)
二、string的“不可变性”深度剖析
1. 什么是不可变?
string s = "hello"; s = s + " world"; // 看起来 s 变了,实际上创建了一个新对象!
底层原理:
string类被final修饰,不能被继承。- 底层存储数组(
char[]或byte[])被private和final修饰,且不提供任何修改方法。 - 任何“修改”操作(
concat、replace、substring、+)都会创建全新的string对象。
// jdk 8 源码
public final class string implements java.io.serializable {
private final char[] value; // final + private,不可修改
// ...
}
2. 为什么设计成不可变?
| 原因 | 说明 |
|---|---|
| 字符串常量池 | 只有不可变才能安全地共享同一个字符串对象(如 "hello" 被多处引用) |
| 线程安全 | 不可变对象天然线程安全,无需同步 |
| 安全(security) | classloader 加载类时使用字符串,如果可变可能被篡改 |
| hashcode 缓存 | string 的 hashcode 只计算一次并缓存,作为 hashmap 的 key 性能极高 |
3.string真的是“完全不可变”吗?(反射破解)
严格来说,通过反射可以暴力修改 string 的内部数组,但这违背了设计初衷,且极不推荐:
string s = "hello";
field field = string.class.getdeclaredfield("value");
field.setaccessible(true);
char[] value = (char[]) field.get(s);
value[0] = 'h';
system.out.println(s); // 输出:hello(反射修改了内部数组!)
注意:这是反射 hack,实际生产中永远不要这么干!它破坏了封装性,且在不同 jdk 版本中可能失效(java 9+ 的 compact strings 结构变了)。
三、string的存储位置:常量池 vs 堆
这是面试中的高频考点,也是很多混淆的根源。
string s1 = "hello"; // 字面量 → 常量池
string s2 = "hello"; // 复用常量池中的同一对象
string s3 = new string("hello"); // new → 强制在堆中创建新对象
string s4 = s3.intern(); // intern() → 强制放入常量池
system.out.println(s1 == s2); // true(同一常量池对象)
system.out.println(s1 == s3); // false(堆 vs 常量池)
system.out.println(s1 == s4); // true(intern 返回常量池对象)
内存布局图解
┌─────────────────────────────────────────────────────────────┐
│ 堆内存 (heap) │
│ ┌─────────────────────────────────────────────────────┐ │
│ │ 字符串常量池 (string pool) │ │
│ │ ┌───────┐ ┌───────┐ ┌───────┐ │ │
│ │ │"hello"│ │"world"│ │"java" │ │ │
│ │ └───────┘ └───────┘ └───────┘ │ │
│ └─────────────────────────────────────────────────────┘ │
│ │
│ ┌─────────────────────────────────────────────────────┐ │
│ │ 普通对象区 │ │
│ │ ┌──────────────────────────┐ │ │
│ │ │ new string("hello") │ ← 堆中的独立对象 │ │
│ │ │ (内部的 char[] 引用常量池) │ │ │
│ │ └──────────────────────────┘ │ │
│ └─────────────────────────────────────────────────────┘ │
└─────────────────────────────────────────────────────────────┘
关键结论:
string s = "hello";→ 0 或 1 个对象(常量池中已有则 0 个)string s = new string("hello");→ 1 或 2 个对象(堆中 1 个新对象 + 常量池中可能 1 个)
四、字符串拼接:+vsconcat()vsstringbuilder
1. 直接用+拼接(编译器优化)
string s = "a" + "b" + "c";
编译后(字节码)→ 直接变成 "abc",编译期常量折叠。
string s = "a"; s = s + "b"; s = s + "c";
编译后 → 等价于使用 stringbuilder(每次拼接都 new 一个 stringbuilder)。
2.concat()方法
string s = "a".concat("b").concat("c");
每次 concat 都会创建一个新的 string 对象,性能比 + 还差(因为 + 至少会优化)。
3.stringbuilder手动拼接(最佳实践)
stringbuilder sb = new stringbuilder();
sb.append("a");
sb.append("b");
sb.append("c");
string s = sb.tostring();
只创建一个 stringbuilder 对象和一个最终的 string 对象,无中间对象。
性能对比(循环 10 万次拼接)
// ❌ 极差:每次循环 new 一个 stringbuilder + 一个 string 对象
string s = "";
for (int i = 0; i < 100000; i++) {
s += i; // 编译后等价于 new stringbuilder().append(s).append(i).tostring()
}
// ✅ 优秀:只创建 1 个 stringbuilder
stringbuilder sb = new stringbuilder();
for (int i = 0; i < 100000; i++) {
sb.append(i);
}
string s = sb.tostring();
| 拼接方式 | 耗时(10 万次) | 创建对象数 |
|---|---|---|
s += i(循环内) | ~5000ms | 10 万+ |
stringbuilder.append() | ~5ms | 1 个 |
| 性能差距 | 1000 倍! | — |
铁律:循环内拼接字符串,永远用 stringbuilder!
五、stringbuilder和stringbuffer的底层细节
1. 扩容机制
stringbuilder 和 stringbuffer 底层是 char[](java 8)或 byte[](java 9+),默认初始容量 16。
stringbuilder sb = new stringbuilder(); // 容量 16
stringbuilder sb2 = new stringbuilder(100); // 指定初始容量 100
stringbuilder sb3 = new stringbuilder("abc"); // 容量 16 + "abc".length() = 19
扩容策略:当容量不足时,新容量 = oldcapacity * 2 + 2。扩容涉及 arrays.copyof() 复制数组,性能开销较大。
优化建议:如果预知拼接后的长度,请指定初始容量,避免频繁扩容:
// 预估最终长度 1000 stringbuilder sb = new stringbuilder(1000);
2.stringbuffer的线程安全实现
// jdk 源码
public synchronized stringbuffer append(string str) {
super.append(str);
return this;
}
每个方法都用 synchronized 修饰,保证了多线程环境下的安全性,但也带来了性能损耗。
3. 面试高频:stringbuilder和stringbuffer哪个更快?
- 单线程:
stringbuilder快约 10%~30%(无锁开销) - 多线程:必须用
stringbuffer(否则数据错乱)
实际开发中:99% 的场景是单线程,所以 stringbuilder 是默认选择。
六、java 9+ 的 compact strings(紧凑字符串)优化
这是 string 家族的一个重要性能升级:
| jdk 版本 | 底层存储 | 英文/数字内存 |
|---|---|---|
| java 8 | char[](每个字符 2 字节) | 每个字符 2 字节 |
| java 9+ | byte[] + coder 标识 | 每个字符 1 字节(latin-1) |
// java 9+ 源码
public final class string {
private final byte[] value; // 改为 byte[]
private final byte coder; // 0 = latin-1, 1 = utf-16
}
效果:纯英文/数字/标点组成的字符串,内存占用直接减半!百万级字符串场景下,gc 压力大幅降低。
七、实战选型决策树
需要拼接字符串?
│
├─ 是少数几次拼接(如 3~5 个固定字符串)
│ └─ 直接用 +(编译器会优化)
│
├─ 是循环内拼接(或大量动态拼接)
│ ├─ 单线程环境 → stringbuilder
│ └─ 多线程环境 → stringbuffer
│
└─ 只是定义常量、作为 map key、传递参数
└─ 用 string(不可变的好处)
代码示例
// ✅ 场景1:固定少量拼接 → 用 +
string msg = "用户:" + name + ",年龄:" + age;
// ✅ 场景2:循环内大量拼接 → stringbuilder
stringbuilder sb = new stringbuilder(1024); // 指定容量防扩容
for (string item : list) {
sb.append(item).append(",");
}
string result = sb.tostring();
// ✅ 场景3:多线程共享拼接 → stringbuffer
stringbuffer buffer = new stringbuffer();
// 多个线程同时调用 buffer.append()
八、思考题(检验是否真的懂了)
// 问题1:下面这段代码创建了几个 string 对象?
string s = new string("abc") + new string("def");
// 问题2:下面两段代码,性能差距有多大?为什么?
// 代码a
string s = "";
for (int i = 0; i < 10000; i++) {
s += i;
}
// 代码b
stringbuilder sb = new stringbuilder();
for (int i = 0; i < 10000; i++) {
sb.append(i);
}
string s = sb.tostring();
// 问题3:下面代码输出什么?
string s1 = "hello";
string s2 = "he" + "llo";
string s3 = new string("hello");
system.out.println(s1 == s2);
system.out.println(s1 == s3);
system.out.println(s1 == s3.intern());
答案(选中下方空白区域查看):
- 常量池中
"abc"、"def"各 1 个,堆中new string("abc")、new string("def")、new string("abcdef")各 1 个,共 5 个(如果常量池中已有则减少)。 - 代码 a 慢 1000 倍以上,因为每次循环都创建新的
stringbuilder和string对象,产生大量 gc。 true(编译期常量折叠)、false(堆 vs 常量池)、true(intern返回常量池对象)。
总结
| 知识点 | 一句话记忆 |
|---|---|
string | 不可变,线程安全,少量拼接用 + |
stringbuilder | 可变,线程不安全,单线程大量拼接首选 |
stringbuffer | 可变,线程安全,多线程大量拼接必选 |
| 循环拼接 | 永远不要用 +=,用 stringbuilder |
| 常量池 | 字面量存常量池,new 强制存堆 |
| java 9+ | 底层改成 byte[],纯英文内存减半 |
到此这篇关于一文彻底搞懂java中字符串三兄弟string、stringbuilder和stringbuffer的文章就介绍到这了,更多相关java string、stringbuilder和stringbuffer内容请搜索代码网以前的文章或继续浏览下面的相关文章希望大家以后多多支持代码网!
发表评论