前言
在上一篇文章《Java基础概念四连问》中,我们学习了
==与equals()的区别、hashCode()与equals()的约定等基础概念。但有一个类在Java开发中使用频率最高,却也最容易被误解------String。
String a = "hello"和String b = new String("hello")有什么区别?字符串拼接到底用+还是StringBuilder?StringBuffer和StringBuilder谁更快?这些问题不仅是面试高频题,更直接影响着你的代码性能和内存使用。今天,我们就来彻底揭开String家族的神秘面纱。读完本文,你将能回答:
- 字符串常量池在JDK 7前后有什么变化?
intern()方法到底做了什么?- 为什么说
String是不可变的?StringBuilder和StringBuffer的源码差异是什么?下一篇,我们将进入集合框架的核心------HashMap源码深度剖析。
一、String的不可变性
1.1 源码验证
先看String类的源码(JDK 8):
java
public final class String
implements java.io.Serializable, Comparable<String>, CharSequence {
// 存储字符数组(final修饰,不可变)
private final char value[];
// 哈希码缓存
private int hash;
// 构造函数
public String(String original) {
this.value = original.value;
this.hash = original.hash;
}
// 替换操作返回新String对象
public String replace(char oldChar, char newChar) {
if (oldChar != newChar) {
int len = value.length;
int i = -1;
char[] val = value;
while (++i < len) {
if (val[i] == oldChar) {
break;
}
}
if (i < len) {
char buf[] = new char[len];
for (int j = 0; j < i; j++) {
buf[j] = val[j];
}
while (i < len) {
char c = val[i];
buf[i] = (c == oldChar) ? newChar : c;
i++;
}
return new String(buf, true); // 返回新对象
}
}
return this;
}
}
关键设计:
final class:不能被继承final char[] value:字符数组引用不可变(但数组内容可变?)- 所有修改操作(
replace、substring、toLowerCase等)都返回新String对象
1.2 为什么说String是不可变的?
虽然final char[] value只能保证引用地址不变,但数组内容理论上可以修改(通过反射)。然而,String类没有提供任何修改内部数组的方法,所有对外API都不会改变原字符串内容。
java
// 通过反射可以修改String内部值(证明不可变性是通过封装实现的)
String str = "hello";
Field field = String.class.getDeclaredField("value");
field.setAccessible(true);
char[] value = (char[]) field.get(str);
value[0] = 'H';
System.out.println(str); // "Hello"
结论 :String的不可变性是通过封装实现的,而非绝对的物理不可变。
1.3 为什么要设计成不可变?
| 原因 | 说明 |
|---|---|
| 字符串常量池 | 只有不可变才能安全地共享,否则一个引用修改会影响所有 |
| 线程安全 | 不可变对象天然线程安全,无需同步 |
| 哈希码缓存 | 哈希码只需计算一次,可作为HashMap的Key |
| 安全 | 避免被恶意修改(如文件路径、数据库URL等) |
二、字符串常量池
2.1 常量池的位置演进
字符串常量池是理解String内存行为的关键。
| JDK版本 | 常量池位置 | 原因 |
|---|---|---|
| JDK 6及之前 | 永久代(PermGen) | 默认空间小,容易OOM |
| JDK 7 | 堆(Heap) | 永久代空间不足,移到堆中 |
| JDK 8+ | 堆(Heap) | 元空间替代永久代,常量池仍在堆 |
2.2 两种创建方式的区别
java
// 方式1:字面量创建
String s1 = "hello";
String s2 = "hello";
// 方式2:new创建
String s3 = new String("hello");
String s4 = new String("hello");
System.out.println(s1 == s2); // true,指向常量池同一对象
System.out.println(s1 == s3); // false,s3在堆中
System.out.println(s3 == s4); // false,两个不同的堆对象
内存图解:
JDK 7+ 内存布局:
┌─────────────────────────────────────────────────────────────────────┐
│ 堆内存 │
├─────────────────────────────────────────────────────────────────────┤
│ ┌─────────────────────────────────────────────────────────────┐ │
│ │ 字符串常量池 │ │
│ │ ┌─────────────────────────────────────────────────────┐ │ │
│ │ │ "hello" │ │ │
│ │ └─────────────────────────────────────────────────────┘ │ │
│ └─────────────────────────────────────────────────────────────┘ │
│ │
│ ┌─────────────────────────────────────────────────────────────┐ │
│ │ 普通堆对象 │ │
│ │ ┌─────────┐ ┌─────────┐ │ │
│ │ │ String │ │ String │ │ │
│ │ │ s3 │ │ s4 │ │ │
│ │ │ value ─┼───→│ value ─┼───→ 都指向常量池的"hello" │ │
│ │ └─────────┘ └─────────┘ │ │
│ └─────────────────────────────────────────────────────────────┘ │
└─────────────────────────────────────────────────────────────────────┘
栈:
┌─────┐ ┌─────┐ ┌─────┐ ┌─────┐
│ s1 │ │ s2 │ │ s3 │ │ s4 │
│ ↓ │ │ ↓ │ │ ↓ │ │ ↓ │
└─────┘ └─────┘ └─────┘ └─────┘
│ │ │ │
↓ ↓ ↓ ↓
指向常量池 指向堆对象 指向堆对象
2.3 intern()方法详解
java
public native String intern();
作用:将字符串对象放入常量池,如果常量池中已有相同内容的字符串,则返回常量池中的引用。
java
// intern()示例
String s1 = new String("hello"); // 堆中对象
String s2 = s1.intern(); // 常量池对象
String s3 = "hello"; // 常量池对象
System.out.println(s1 == s2); // false(堆 vs 常量池)
System.out.println(s2 == s3); // true(都是常量池对象)
2.4 JDK 6 vs JDK 7+ 的intern()差异
这是一个经典的面试陷阱:
java
// JDK 6
String s1 = new String("a") + new String("b"); // "ab"在堆中
s1.intern(); // 在永久代中创建"ab",并返回
String s2 = "ab";
System.out.println(s1 == s2); // false(堆 vs 永久代)
// JDK 7+
String s1 = new String("a") + new String("b"); // "ab"在堆中
s1.intern(); // 常量池中直接存储堆中"ab"的引用
String s2 = "ab";
System.out.println(s1 == s2); // true!都指向堆中同一对象
JDK 7+的变化 :常量池移到堆中后,intern()不再复制字符串内容,而是将堆中对象的引用存入常量池。
三、字符串拼接的编译器优化
3.1 编译期优化
java
// 源码
String s = "hello" + " " + "world";
// 编译后(javap -c)
String s = "hello world"; // 编译期直接拼接!
常量表达式(编译期可知的值)会在编译时直接拼接。
3.2 运行期优化
java
// 源码
String s1 = "hello";
String s2 = s1 + " world";
// 编译后(JDK 5-8)
String s2 = new StringBuilder().append(s1).append(" world").toString();
// JDK 9+ 使用invokedynamic优化
注意 :循环中使用+拼接会创建多个StringBuilder对象:
java
// 错误写法
String result = "";
for (int i = 0; i < 1000; i++) {
result += i; // 每次循环都new StringBuilder
}
// 编译后等价于
for (int i = 0; i < 1000; i++) {
result = new StringBuilder().append(result).append(i).toString();
}
// 创建了1000个StringBuilder对象和1000个String对象
3.3 正确写法
java
// 正确写法:显式使用StringBuilder
StringBuilder sb = new StringBuilder();
for (int i = 0; i < 1000; i++) {
sb.append(i);
}
String result = sb.toString();
四、StringBuilder与StringBuffer源码对比
4.1 继承体系
┌─────────────────────────────────────────────────────────────────────┐
│ AbstractStringBuilder │
│ (可变字符序列的抽象父类) │
│ ├─ char[] value // 存储字符(非final,可修改) │
│ └─ int count // 已使用长度 │
└─────────────────────────────────────────────────────────────────────┘
↑
┌───────────────┴───────────────┐
│ │
┌─────────────────┐ ┌─────────────────┐
│ StringBuilder │ │ StringBuffer │
│ (线程不安全) │ │ (线程安全) │
│ 无同步 │ │ 所有方法加锁 │
└─────────────────┘ └─────────────────┘
4.2 StringBuilder源码
java
public final class StringBuilder
extends AbstractStringBuilder
implements java.io.Serializable, CharSequence {
// 无锁,性能高
public StringBuilder append(String str) {
super.append(str);
return this;
}
}
4.3 StringBuffer源码
java
public final class StringBuffer
extends AbstractStringBuilder
implements java.io.Serializable, CharSequence {
// 所有public方法都加了synchronized
public synchronized StringBuffer append(String str) {
toStringCache = null;
super.append(str);
return this;
}
public synchronized String toString() {
// 使用缓存优化toString性能
if (toStringCache == null) {
toStringCache = Arrays.copyOfRange(value, 0, count);
}
return new String(toStringCache, true);
}
}
4.4 性能对比
java
// 性能测试
public class StringVsBuilderBenchmark {
public static void main(String[] args) {
int iterations = 100000;
// String拼接(最慢)
long start = System.nanoTime();
String s = "";
for (int i = 0; i < iterations; i++) {
s += i;
}
long time1 = System.nanoTime() - start;
// StringBuilder(最快)
start = System.nanoTime();
StringBuilder sb = new StringBuilder();
for (int i = 0; i < iterations; i++) {
sb.append(i);
}
long time2 = System.nanoTime() - start;
// StringBuffer(中等)
start = System.nanoTime();
StringBuffer sbf = new StringBuffer();
for (int i = 0; i < iterations; i++) {
sbf.append(i);
}
long time3 = System.nanoTime() - start;
System.out.println("String: " + time1 / 1000000 + "ms");
System.out.println("StringBuilder: " + time2 / 1000000 + "ms");
System.out.println("StringBuffer: " + time3 / 1000000 + "ms");
}
}
典型输出(10万次拼接):
String: 28500ms (最慢,约28秒)
StringBuilder: 8ms (最快)
StringBuffer: 12ms (中等,略慢于StringBuilder)
4.5 使用场景总结
| 场景 | 推荐 | 原因 |
|---|---|---|
| 单线程字符串拼接 | StringBuilder |
性能最高,无锁开销 |
| 多线程共享可变字符串 | StringBuffer |
线程安全 |
| 简单的固定字符串拼接 | +(String) |
编译器会优化,代码简洁 |
| 循环中大量拼接 | StringBuilder |
避免创建大量临时对象 |
| 方法内局部变量拼接 | StringBuilder |
线程安全不需要同步 |
五、常见面试题
Q1:String为什么是不可变的?
答 :String类被声明为final,字符数组value被声明为final private,且没有提供任何修改内部状态的方法。这样设计的原因包括:字符串常量池可以安全共享、线程安全、哈希码可缓存、安全性(避免恶意修改)。
Q2:new String("hello")创建了几个对象?
答:可能创建1个或2个对象:
- 如果常量池中已有
"hello",则只在堆中创建1个String对象 - 如果常量池中没有
"hello",则在常量池创建1个,堆中创建1个,共2个
Q3:StringBuilder和StringBuffer的区别?
答:
StringBuilder:线程不安全,性能高,适合单线程场景StringBuffer:线程安全(方法加synchronized),性能略低,适合多线程场景- 两者都继承自
AbstractStringBuilder,底层都是可修改的char[]
Q4:String s = "a" + "b" + "c"创建了几个对象?
答 :只创建1个对象。编译器会优化为String s = "abc",常量池中创建"abc"。
Q5:String s = a + b + c(a、b、c是变量)创建了几个对象?
答 :创建1个StringBuilder和1个结果String对象。编译后等价于:
java
String s = new StringBuilder().append(a).append(b).append(c).toString();
Q6:intern()方法在JDK 6和JDK 7+有什么区别?
答:
- JDK 6 :常量池在永久代,
intern()会在永久代中复制一份字符串内容 - JDK 7+ :常量池在堆中,
intern()将堆中对象的引用存入常量池,不复制内容
六、总结
6.1 核心要点
| 类 | 是否可变 | 线程安全 | 底层存储 | 适用场景 |
|---|---|---|---|---|
| String | 不可变 | 安全 | final char[] |
字符串常量、作为Key |
| StringBuilder | 可变 | 不安全 | char[] |
单线程大量拼接 |
| StringBuffer | 可变 | 安全(同步) | char[] |
多线程共享拼接 |
6.2 字符串常量池演进速记
JDK 6:永久代 → 空间小,容易OOM
JDK 7:移到堆 → 空间更大,intern()存引用
JDK 8:仍在堆 → 元空间独立,常量池不变
6.3 性能最佳实践
单次拼接 → 用String(编译器优化)
循环拼接 → 用StringBuilder(显式创建)
多线程拼接 → 用StringBuffer(或用StringBuilder加外部锁)
6.4 面试金句
如果面试官问你"String、StringBuilder、StringBuffer的区别",你可以这样回答:
"String是不可变类,底层是
final char[],所有修改操作都返回新对象,适合作为常量或HashMap的Key。StringBuilder和StringBuffer都是可变字符序列,底层是可修改的char[]。StringBuilder线程不安全但性能最高,适合单线程大量拼接;StringBuffer所有public方法都加了synchronized,线程安全但性能略低,适合多线程共享。字符串常量池在JDK 7后移到堆中,intern()方法不再复制字符串内容,而是存储堆中对象的引用,这减少了内存开销。"
下篇预告
理解了String家族的底层原理,我们掌握了Java中最常用的数据结构。但Java集合框架中还有一个使用频率极高的类------HashMap。
它为什么能实现O(1)的查找?JDK 8为什么要引入红黑树?多线程下为什么不能用HashMap?
下一篇《HashMap源码深度剖析------从JDK 7到JDK 8的演进》将带你深入HashMap的源码,彻底搞懂哈希表的实现原理。
如果你觉得本文有帮助,欢迎点赞、评论、转发!