在Java面试中,String
类绝对是“钉子户”——几乎每场面试都会被问到。但很多候选人吐槽:“背了那么多题,一到现场还是卡壳!”问题出在哪?其实,面试官真正想考察的,从来不是你记住了多少结论,而是你对String
底层逻辑的理解深度。
今天我们就撕开String
的“神秘面纱”,从源码细节、设计思想、常见误区三个维度,带你彻底搞懂这个“最熟悉的陌生人”。
一、String的“不变性”:为什么说它是Java的“设计典范”?
提到String
,第一个关键词一定是“不可变”(Immutable)。但“不可变”到底意味着什么?仅仅是因为final
修饰吗?
1. 源码里的“不可变”是如何实现的?
看String
的源码(JDK17),核心字段只有两个:
public final class String implements java.io.Serializable, Comparable<String>, CharSequence {
private final char value[]; // 存储字符的实际数组(JDK9前是char[],JDK9后改为byte[])
private int hash; // 缓存哈希值,默认0
}
-
final
修饰类:禁止被继承,避免子类修改行为; -
private final char[] value
:数组引用不可变(不能指向新数组),且数组内容本身也不可变(没有提供修改数组的方法); - 无直接修改
value
的方法:所有看似修改的操作(如concat
、substring
)都会返回新的String
对象。
举个栗子:
String s = "hello";
s = s + " world"; // 实际是创建了新的String对象"hello world",原"hello"未被修改
表面上是“修改”了s
,但本质是让s
指向了新的对象,原对象依然存在于内存中(如果没有被GC回收)。
2. 为什么Java坚持让String不可变?
这背后是Java设计者的“深思熟虑”:
- 安全性:字符串广泛用于类加载、网络连接、文件路径等场景。如果字符串可变,恶意代码可能通过修改字符串内容(如篡改URL)导致安全漏洞;
- 哈希缓存:
String
的hashCode()
方法会被频繁调用(如作为HashMap的键)。由于不可变,哈希值只需计算一次并缓存(hash
字段),避免重复计算; - 线程安全:不可变对象天生是线程安全的,无需额外同步;
- 字符串常量池:JVM通过“字符串常量池”缓存重复的字符串,减少内存占用。如果字符串可变,修改一个池中的字符串会影响所有引用它的地方,导致逻辑混乱。
二、源码深挖:那些你没注意过的“隐藏细节”
1. 字符串常量池:JVM的“字符串缓存库”
字符串常量池(String Pool)是JVM中的一块特殊内存区域,用于存储字面量字符串和显式调用intern()
的字符串。理解它的工作机制,能解决90%的String
面试题。
关键规则:
示例验证:
三、常见误区与避坑指南
误区1:“==
比较字符串内容,equals
比较引用”
这几乎是所有新手都会踩的坑。实际上:
- 字面量直接入池:
String s1 = "abc";
会在常量池中创建"abc"(若不存在); -
new String()
的两种情况:String s2 = new String("abc"); // JVM会先在常量池创建"abc",再在堆中创建一个新的String对象(指向常量池的"abc")
- (JDK6及之前,常量池在永久代;JDK7后移至堆中,避免永久代内存溢出)
-
intern()
方法:手动将字符串加入常量池(返回池中的引用)。例如:String s3 = new String("abc").intern(); // s3指向常量池中的"abc",与s1相同
面试题延伸:
String s1 = "hello"; String s2 = "hello"; String s3 = new String("hello"); String s4 = s3.intern(); System.out.println(s1 == s2); // true(同属常量池) System.out.println(s1 == s3); // false(s3在堆中) System.out.println(s1 == s4); // true(s4指向常量池的"hello")
2. 字符串拼接:JDK7的“革命性优化”
字符串拼接是高频操作,但很多人不知道,Java编译器对拼接做了大量优化。
JDK6及之前:
拼接操作会被编译为StringBuilder
的append()
和toString()
。例如:String s = "a" + "b" + "c"; // 编译后等价于: String s = new StringBuilder().append("a").append("b").append("c").toString();
但每次拼接都会生成新的
StringBuilder
和String
对象,频繁拼接时性能较差。JDK7及之后:
编译器优化了“字面量拼接”的场景。例如:String s = "a" + "b" + "c"; // 编译后直接合并为"abc",无需额外对象
但对于“变量拼接”(如
String a = "a"; String s = a + "b";
),仍然会生成StringBuilder
。更关键的优化:
concat
方法的字节码优化
对于短字符串拼接,Java编译器会直接使用String.concat()
方法,避免StringBuilder
的开销。例如:// 编译后直接调用concat方法 String s = "a".concat("b").concat("c");
但对于“变量拼接”(如
String a = "a"; String s = a + "b";
),仍然会生成StringBuilder
。更关键的优化:
concat
方法的字节码优化
对于短字符串拼接,Java编译器会直接使用String.concat()
方法,避免StringBuilder
的开销。例如:// 编译后直接调用concat方法 String s = "a".concat("b").concat("c");
concat
方法的源码(JDK17):public String concat(String str) { int otherLen = str.length(); if (otherLen == 0) return this; // 空字符串直接返回原对象 int len = value.length; char[] buf = Arrays.copyOf(value, len + otherLen); // 复制原数组并扩容 str.getChars(buf, len); // 将str的内容复制到新数组 return new String(buf, false); // 用新数组创建String(JDK9后false表示非Latin-1) }
可以看到,
concat
本质也是创建新对象,但比StringBuilder
更轻量(减少了中间对象的创建)。3.
substring
的“坑”:从内存泄漏到性能优化substring
方法用于截取子字符串,但它在不同JDK版本中的实现差异,曾导致过严重的性能问题。JDK6的实现:
substring
会复用原String
的value
数组,仅调整偏移量(offset
)和长度(count
)。例如:String s = "abcdefg"; String sub = s.substring(2, 5); // 结果"cde" // 此时sub的value数组和s是同一个,只是offset=2,count=3
这种设计的缺点是:即使
sub
不再使用,原大字符串abcdefg
也无法被GC回收(因为sub
持有它的引用),导致内存泄漏。JDK7的优化:
substring
不再复用原数组,而是创建一个新的char
数组(长度为子串长度),将原数组的内容复制进去。虽然增加了内存复制开销,但避免了内存泄漏。JDK9的进一步优化:
由于JDK9将value
数组从char[]
改为byte[]
(根据字符集选择Latin-1或UTF-16存储),substring
的复制逻辑更高效,但仍会创建新数组。面试题总结:
- JDK6中
substring
可能导致内存泄漏,JDK7+修复了这个问题; - 频繁截取大字符串时,JDK7+的性能可能略低于JDK6(但牺牲了内存换安全);
- 实际开发中,若需要保留大字符串的子串,建议显式调用
new String(substring)
(JDK6)或直接使用substring
(JDK7+)。 ==
比较的是对象的引用(内存地址);equals
是String
重写的方法,比较的是value
数组的内容(逐个字符对比)。
String s1 = "hello";
String s2 = new String("hello");
System.out.println(s1 == s2); // false(s1在常量池,s2在堆中)
System.out.println(s1.equals(s2));// true(内容相同)
误区2:“String s = new String("abc")
创建了1个对象”
实际上,这句话创建了2个对象:
- 一个在堆中(
new String()
的结果); - 另一个在常量池中(
"abc"
字面量,若不存在则创建)。
(JDK7后,常量池移至堆中,所以两个对象都在堆中,但属于不同的区域)
误区3:“StringBuilder
一定比+
拼接快”
这要看场景:
- 在循环中拼接字符串时,
StringBuilder
效率更高(避免重复创建对象); - 但在简单拼接(如
"a" + "b" + "c"
)时,编译器会优化为StringBuilder
,此时+
和StringBuilder
性能几乎无差异。
示例对比:
// 低效写法(循环中用+拼接)
String s = "";
for (int i = 0; i < 1000; i++) {
s += i; // 每次循环都创建新的StringBuilder和String
}
// 高效写法(显式用StringBuilder)
StringBuilder sb = new StringBuilder();
for (int i = 0; i < 1000; i++) {
sb.append(i);
}
String s = sb.toString();
四、总结:如何“吃透”String?
String
的设计体现了Java“简单高效”的哲学:通过不可变性保证安全和性能,通过常量池减少内存占用,通过编译器优化简化开发。
要真正掌握它,建议:
- 动手写Demo:用
javap
反编译字符串拼接代码,观察编译器优化过程; - 调试源码:在IDE中调试
substring
、intern
等方法,看底层如何操作数组; - 关注版本差异:对比JDK6、JDK7、JDK9中
String
的实现变化(如数组类型、substring
逻辑)。