- 第一篇:Java基础概念四连问,==与equals、hashCode约定、接口vs抽象类、深拷贝vs浅拷贝
- 第二篇:String、StringBuilder、StringBuffer深度剖析
前言
在上一篇文章《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):
publicfinalclassStringimplementsjava.io.Serializable,Comparable<String>,CharSequence{// 存储字符数组(final修饰,不可变)privatefinalcharvalue[];// 哈希码缓存privateinthash;// 构造函数publicString(Stringoriginal){this.value=original.value;this.hash=original.hash;}// 替换操作返回新String对象publicStringreplace(charoldChar,charnewChar){if(oldChar!=newChar){intlen=value.length;inti=-1;char[]val=value;while(++i<len){if(val[i]==oldChar){break;}}if(i<len){charbuf[]=newchar[len];for(intj=0;j<i;j++){buf[j]=val[j];}while(i<len){charc=val[i];buf[i]=(c==oldChar)?newChar:c;i++;}returnnewString(buf,true);// 返回新对象}}returnthis;}}关键设计:
final class:不能被继承final char[] value:字符数组引用不可变(但数组内容可变?)- 所有修改操作(
replace、substring、toLowerCase等)都返回新String对象
1.2 为什么说String是不可变的?
虽然final char[] value只能保证引用地址不变,但数组内容理论上可以修改(通过反射)。然而,String类没有提供任何修改内部数组的方法,所有对外API都不会改变原字符串内容。
// 通过反射可以修改String内部值(证明不可变性是通过封装实现的)Stringstr="hello";Fieldfield=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 两种创建方式的区别
// 方式1:字面量创建Strings1="hello";Strings2="hello";// 方式2:new创建Strings3=newString("hello");Strings4=newString("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()方法详解
publicnativeStringintern();作用:将字符串对象放入常量池,如果常量池中已有相同内容的字符串,则返回常量池中的引用。
// intern()示例Strings1=newString("hello");// 堆中对象Strings2=s1.intern();// 常量池对象Strings3="hello";// 常量池对象System.out.println(s1==s2);// false(堆 vs 常量池)System.out.println(s2==s3);// true(都是常量池对象)2.4 JDK 6 vs JDK 7+ 的intern()差异
这是一个经典的面试陷阱:
// JDK 6Strings1=newString("a")+newString("b");// "ab"在堆中s1.intern();// 在永久代中创建"ab",并返回Strings2="ab";System.out.println(s1==s2);// false(堆 vs 永久代)// JDK 7+Strings1=newString("a")+newString("b");// "ab"在堆中s1.intern();// 常量池中直接存储堆中"ab"的引用Strings2="ab";System.out.println(s1==s2);// true!都指向堆中同一对象JDK 7+的变化:常量池移到堆中后,intern()不再复制字符串内容,而是将堆中对象的引用存入常量池。
三、字符串拼接的编译器优化
3.1 编译期优化
// 源码Strings="hello"+" "+"world";// 编译后(javap -c)Strings="hello world";// 编译期直接拼接!常量表达式(编译期可知的值)会在编译时直接拼接。
3.2 运行期优化
// 源码Strings1="hello";Strings2=s1+" world";// 编译后(JDK 5-8)Strings2=newStringBuilder().append(s1).append(" world").toString();// JDK 9+ 使用invokedynamic优化注意:循环中使用+拼接会创建多个StringBuilder对象:
// 错误写法Stringresult="";for(inti=0;i<1000;i++){result+=i;// 每次循环都new StringBuilder}// 编译后等价于for(inti=0;i<1000;i++){result=newStringBuilder().append(result).append(i).toString();}// 创建了1000个StringBuilder对象和1000个String对象3.3 正确写法
// 正确写法:显式使用StringBuilderStringBuildersb=newStringBuilder();for(inti=0;i<1000;i++){sb.append(i);}Stringresult=sb.toString();四、StringBuilder与StringBuffer源码对比
4.1 继承体系
┌─────────────────────────────────────────────────────────────────────┐ │ AbstractStringBuilder │ │ (可变字符序列的抽象父类) │ │ ├─ char[] value // 存储字符(非final,可修改) │ │ └─ int count // 已使用长度 │ └─────────────────────────────────────────────────────────────────────┘ ↑ ┌───────────────┴───────────────┐ │ │ ┌─────────────────┐ ┌─────────────────┐ │ StringBuilder │ │ StringBuffer │ │ (线程不安全) │ │ (线程安全) │ │ 无同步 │ │ 所有方法加锁 │ └─────────────────┘ └─────────────────┘4.2 StringBuilder源码
publicfinalclassStringBuilderextendsAbstractStringBuilderimplementsjava.io.Serializable,CharSequence{// 无锁,性能高publicStringBuilderappend(Stringstr){super.append(str);returnthis;}}4.3 StringBuffer源码
publicfinalclassStringBufferextendsAbstractStringBuilderimplementsjava.io.Serializable,CharSequence{// 所有public方法都加了synchronizedpublicsynchronizedStringBufferappend(Stringstr){toStringCache=null;super.append(str);returnthis;}publicsynchronizedStringtoString(){// 使用缓存优化toString性能if(toStringCache==null){toStringCache=Arrays.copyOfRange(value,0,count);}returnnewString(toStringCache,true);}}4.4 性能对比
// 性能测试publicclassStringVsBuilderBenchmark{publicstaticvoidmain(String[]args){intiterations=100000;// String拼接(最慢)longstart=System.nanoTime();Strings="";for(inti=0;i<iterations;i++){s+=i;}longtime1=System.nanoTime()-start;// StringBuilder(最快)start=System.nanoTime();StringBuildersb=newStringBuilder();for(inti=0;i<iterations;i++){sb.append(i);}longtime2=System.nanoTime()-start;// StringBuffer(中等)start=System.nanoTime();StringBuffersbf=newStringBuffer();for(inti=0;i<iterations;i++){sbf.append(i);}longtime3=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对象。编译后等价于:
Strings=newStringBuilder().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的源码,彻底搞懂哈希表的实现原理。
如果你觉得本文有帮助,欢迎点赞、评论、转发!