内容会包含:String 的底层原理 + 内存分配、StringBuilder/StringBuffer 底层原理、StringJoiner 底层本质、三者的内存效率对比、还有你之前遇到的字符串拼接、常量池、new String () 的坑,全部讲透,零基础易懂
一、核心铺垫:Java 的两大内存区域(必须先懂,重中之重)
所有字符串相关类的内存分配,都围绕这两块核心区域,记住这两点,后面所有内存问题迎刃而解:
✅ 1. 堆内存(Heap)
- 存储内容:所有通过
new关键字创建的对象(new String()、new StringBuilder()、数组、自定义对象如 CarDriver/MyMath)、字符串的真实字符数据; - 核心特点:内存空间大、创建的对象都是独一无二的、对象用完后由 GC 垃圾回收器回收、同一个内容的对象,在堆中会存多份。
✅ 2. 方法区(Method Area)- 字符串常量池(String Pool)【核心核心】
- 字符串常量池是 方法区的一块专属小区域,JDK8 后移到堆内存的一块独立空间中;
- 存储内容:字符串字面量 / 常量(比如
"张三"、"abc"、"123"这种写死在代码里的字符串)、通过String.intern()手动入池的字符串; - 核心特点:常量池的核心规则 → 【字符串内容唯一,不会存重复值】 ;比如代码里写了 100 个
"abc",常量池中只会存 1 份"abc",所有地方引用的都是这一份,目的是节省内存。
二、String 字符串 底层原理 + 内存分配(最核心,必考)
✅ 1. String 类的底层源码核心(Java8)
java
运行
public final class String {
// 1. 底层存储的核心:一个【被final修饰的char[]字符数组】
private final char value[];
// 2. 字符串的哈希值缓存
private int hash;
}
✅ 2. String 核心底层特性(为什么不可变?根源!)
从源码能直接得出 2 个决定性特性,也是所有 String 特性的根源:
✔ 特性①:String被final修饰 → String 类不能被继承,没有子类,保证了字符串的安全性;
✔ 特性②:存储字符的数组char[] value被private final修饰 → String 是不可变字符串
private:外部类无法直接操作这个字符数组;final:数组的引用地址一旦确定,永远不能改变(不能指向新的数组);- 👉 最终结果:一个 String 对象一旦创建,它的字符内容永远不能修改!
✅ 3. String 最关键的【内存分配规则】(分 3 种场景,全是考点,必须背)
String 的内存分配分 3 种场景,对应 3 种写法,内存完全不同,也是新手最容易踩坑的点,结合你的代码场景举例:
✔ 场景 1:直接赋值字符串常量 【推荐写法】String str = "张三";
java
运行
String str1 = "张三";
String str2 = "张三";
👉 内存分配过程:
- JVM 会先去 字符串常量池 中查找,有没有
"张三"这个字符串; - 如果没有,就在常量池中创建
"张三"这个字符串对象,存入常量池; - 如果有(比如 str2),直接让 str2 指向常量池中已有的
"张三",不会创建新对象;👉 内存图结论:str1和str2指向常量池中的同一个对象,内存地址相同 →str1 == str2结果为true。
✔ 场景 2:通过 new 关键字创建 【不推荐,浪费内存】String str = new String("张三");
java
运行
String str3 = new String("张三");
👉 内存分配过程:一定会在内存中创建【2 个对象】,内存浪费!
- JVM 先去字符串常量池查找
"张三",没有则创建常量池的"张三"对象; - 执行
new关键字,在堆内存中创建一个新的 String 对象; - 堆中的这个 String 对象,内部的 char [] 数组,会复制常量池里的字符数据;
- 变量 str3 最终指向的是 堆内存中的这个新对象;👉 内存图结论:
str1 == str3结果为false(一个指向常量池,一个指向堆),str1.equals(str3)为 true(内容相同)。
✔ 场景 3:字符串拼接 String str = "张" + "三"; / String str = a + b;
这是你之前写代码经常用到的场景,分两种子情况,内存完全不同:
✨ 子情况 A:常量拼接(两边都是字面量)String str = "张" + "三";
- JVM 在编译阶段就会做优化,直接把拼接结果变成
"张三"; - 内存分配和场景 1 完全一样,指向常量池的
"张三",无内存浪费,效率高。
✨ 子情况 B:变量拼接(有变量参与)String a = "张"; String b = "三"; String str = a + b;
-
这是String 的性能大坑!JVM 编译时无法确定变量的值,会做如下操作:
- 底层自动创建一个
StringBuilder对象; - 调用
append(a)和append(b)拼接; - 调用
toString()方法,把拼接后的内容在堆内存中创建一个新的 String 对象; - 变量 str 指向堆中的这个新对象;
- 底层自动创建一个
-
👉 核心问题:每一次变量拼接,都会创建新的 StringBuilder 和 String 对象,循环拼接时(比如循环 100 次),会创建 100 个对象,内存爆炸,效率极低 → 这就是为什么需要 StringBuilder!
✅ 4. String 不可变的优缺点(面试必问)
✔ 优点:
- 节省内存:常量池复用相同内容的字符串;
- 线程安全:内容不可变,多线程环境下不会出现并发修改问题;
- 哈希值缓存:hash 值缓存,作为 HashMap 的键时效率高。
✔ 缺点:
字符串的任何修改(拼接、替换、截取)都会创建新对象,频繁修改时效率极低、内存浪费 → 这就是StringBuilder诞生的唯一原因!
三、StringBuilder 底层原理 + 内存分配(你的作业高频用,核心)
✅ 1. StringBuilder 类的底层源码核心(Java8)
java
运行
public final class StringBuilder extends AbstractStringBuilder {
// 无参构造
public StringBuilder() {
super(16); // 调用父类构造,默认初始容量16个字符
}
// 有参构造,传入初始字符串
public StringBuilder(String str) {
super(str.length() + 16); // 初始容量=字符串长度+16
append(str); // 追加字符串
}
}
// 父类 AbstractStringBuilder 核心源码
abstract class AbstractStringBuilder {
// 核心:一个【没有被final修饰的char[]字符数组】
char[] value;
// 记录数组中实际存储的字符个数
int count;
}
✅ 2. StringBuilder 核心底层特性(为什么可变?根源!)
和 String 的不可变形成鲜明对比,根源就在父类的数组:
存储字符的数组
char[] value是普通的数组,没有 final 修饰,数组的引用地址可以改变,数组的内容可以直接修改!
👉 最终结果:StringBuilder 是可变字符串缓冲区,所有的追加、插入、删除操作,都是直接修改这个 char [] 数组的内容,不会创建新对象,这就是效率高的根源!
✅ 3. StringBuilder 内存分配规则(核心 + 简单,只有 1 条)
java
运行
StringBuilder sb = new StringBuilder();
sb.append("张三").append(20).append("男");
👉 内存分配过程:永远只在【堆内存】中创建 1 个对象!
- 执行
new StringBuilder(),JVM 在堆内存中创建一个 StringBuilder 对象; - 这个对象内部的 char [] 数组,默认初始容量是 16 个字符;
- 调用
append()方法时,直接把字符写入这个 char [] 数组,没有任何新对象创建; - 直到数组装满了(count >= 数组长度),才会触发【数组扩容】;
- 扩容完成后,还是在原来的对象中操作,只是数组变大了,对象地址不变。
✅ 4. StringBuilder 核心机制:数组扩容(为什么效率还高?)
扩容是 StringBuilder 的核心优化,也是为什么它比 String 拼接快的关键:
✔ 扩容触发条件:当追加的字符数量,超过了当前 char [] 数组的容量时;
✔ 扩容规则:默认扩容为 原容量 * 2 + 2(比如初始 16 → 扩容后 34 → 再扩容 70);
✔ 扩容本质:创建一个新的更大的 char [] 数组,把原数组的内容复制过去,然后让 value 指向新数组;
✔ 关键:扩容只改变数组的引用地址,StringBuilder 对象的地址永远不变,而且扩容的频率极低(比如拼接 100 个字符,只需要扩容 2-3 次),所以效率依然极高!
✅ 5. StringBuilder 的 toString () 方法内存分配(必懂)
你每次调用sb.toString()时,底层源码是:
java
运行
public String toString() {
return new String(value, 0, count);
}
👉 内存规则:会在堆内存中创建一个新的 String 对象,把 char [] 数组的内容复制过去,返回这个 String 对象。✅ 注意:这是 StringBuilder 唯一创建新对象的地方,但是只创建 1 次,不管你 append 多少次,toString 只调用 1 次,这就是为什么循环拼接用 StringBuilder 效率天差地别!
✅ 6. StringBuilder vs StringBuffer 底层区别(面试必问)
两者的底层原理、内存分配、所有方法完全一样,唯一的区别只有一个:
StringBuilder:所有方法都没有加 synchronized 锁 → 线程不安全,效率极高 → 单线程环境(作业 / 练习 / 开发 99% 场景)必用;StringBuffer:所有方法都加了 synchronized 锁 → 线程安全,效率稍低 → 只有多线程开发时才用;
一句话总结:日常写代码,无脑用
StringBuilder就对了!
四、StringJoiner 底层原理 + 内存分配(最简单,必懂)
✅ 1. StringJoiner 核心底层本质(一句话讲透)
StringJoiner 就是 Java8 为我们封装的一个「带分隔符的 StringBuilder 工具类」,底层完全依赖 StringBuilder 实现,没有任何新的底层逻辑!
✅ 2. StringJoiner 底层源码核心(简化版)
java
运行
public final class StringJoiner {
private final String delimiter; // 你传入的分隔符
private final String prefix; // 前缀
private final String suffix; // 后缀
private StringBuilder value; // 底层核心:就是一个StringBuilder对象!
private String emptyValue;
// 构造方法,传入分隔符
public StringJoiner(CharSequence delimiter) {
this(delimiter, "", "");
}
// 构造方法,传入分隔符+前缀+后缀
public StringJoiner(CharSequence delimiter, CharSequence prefix, CharSequence suffix) {
this.delimiter = delimiter.toString();
this.prefix = prefix.toString();
this.suffix = suffix.toString();
}
// add方法本质:调用StringBuilder的append方法,先加分隔符再加内容
public StringJoiner add(CharSequence newElement) {
prepareBuilder().append(newElement);
return this;
}
// toString方法本质:调用StringBuilder的toString()
public String toString() {
// ... 拼接前缀后缀
return value.toString();
}
}
✅ 3. StringJoiner 内存分配规则
因为底层是 StringBuilder,所以内存分配和 StringBuilder完全一样:
new StringJoiner(",")→ 底层创建一个 StringBuilder 对象,存在堆内存中;add()方法 → 调用 StringBuilder 的 append,直接修改 char [] 数组,无新对象;toString()→ 调用 StringBuilder 的 toString,在堆中创建一个 String 对象返回;
✅ 4. StringJoiner 的核心价值
它没有任何性能优势,也没有任何新的底层逻辑,核心价值只有一个:帮我们封装了「分隔符、前缀、后缀」的拼接逻辑,简化代码,避免手动写判断。比如你之前拼接数组时,要手动判断是不是最后一个元素,避免多一个逗号,用 StringJoiner 就不用了,它帮你做好了所有判断。
五、String.join () 底层原理(极简补充)
String.join(",", str1, str2, str3) 是 String 的静态方法,底层就是调用了 StringJoiner,源码如下:
java
运行
public static String join(CharSequence delimiter, CharSequence... elements) {
Objects.requireNonNull(delimiter);
Objects.requireNonNull(elements);
// 底层创建一个StringJoiner对象,调用add方法拼接
StringJoiner joiner = new StringJoiner(delimiter);
for (CharSequence cs : elements) {
joiner.add(cs);
}
return joiner.toString();
}
👉 结论:String.join() → StringJoiner → StringBuilder → char[],一层套一层的封装,本质都是同一个东西,只是为了简化开发。
六、三者底层 + 内存 核心对比总结表(必背,必考,精华)
| 类名 | 底层存储 | 可变性 | 内存分配位置 | 核心特点 | 内存效率 | 使用场景 |
|---|---|---|---|---|---|---|
| String | final char[] | 不可变 | 常量池 + 堆 | 内容不能改,拼接创建新对象 | 极低(频繁拼接内存爆炸) | 字符串内容固定不变、少量拼接、常量定义 |
| StringBuilder | char[] | 可变 | 仅堆内存 | 内容可改,所有操作在一个对象完成 | 极高(只创建 1 个对象,扩容少) | 所有字符串修改场景:循环拼接、追加、插入、删除、toString 重写 |
| StringJoiner | 封装 StringBuilder 的char[] | 可变 | 仅堆内存 | 带分隔符拼接,自动处理前缀后缀 | 和 StringBuilder 一致 | 带格式的拼接:数组打印、有序列表、带分隔符的字符串拼接 |
七、开发 / 作业 避坑指南(结合你的代码,必看)
✅ 坑 1:循环拼接字符串用 String,不用 StringBuilder
java
运行
// 错误 ❌ 效率极低,创建大量对象
String str = "";
for(int i=0;i<100;i++){str += i;}
// 正确 ✅ 效率极高,只创建1个对象
StringBuilder sb = new StringBuilder();
for(int i=0;i<100;i++){sb.append(i);}
String str = sb.toString();
✅ 坑 2:用 new String ("张三") 创建字符串,浪费内存
java
运行
// 错误 ❌ 创建2个对象,浪费内存
String str = new String("张三");
// 正确 ✅ 只创建1个对象,常量池复用
String str = "张三";
✅ 坑 3:重写 toString 方法时,用 String 拼接不用 StringBuilder
java
运行
// 错误 ❌ 拼接多了效率低
@Override
public String toString() {
return "姓名:"+name+"性别:"+gender+"年龄:"+age;
}
// 正确 ✅ 效率高,推荐写法
@Override
public String toString() {
StringBuilder sb = new StringBuilder();
sb.append("姓名:").append(name).append("性别:").append(gender).append("年龄:").append(age);
return sb.toString();
}
八、最终总结(所有知识点精华,背下来 = 掌握所有考点)
- String 的底层是
final char[],不可变,内存分配在常量池 + 堆,拼接创建新对象,效率低; - StringBuilder 的底层是
char[],可变,内存只在堆中,所有操作在一个对象完成,效率极高,是字符串修改的首选; - StringJoiner 是 StringBuilder 的封装,底层一样,只是简化了带分隔符的拼接,代码更简洁;
- String.join () 是 StringJoiner 的封装,极简写法,适合简单的分隔拼接;
- 所有字符串相关类的底层,最终都是 char [] 字符数组,只是封装了不同的功能;
- 核心选型原则:内容不变用 String,内容要改 / StringBuilder,带分隔符拼接用 StringJoiner/String.join () 。