一、开篇:字符串的底层本质 —— 为什么它不是 “简单文本”?
你是不是也在 Java 字符串上踩过这些坑?明明两个字符串内容一样,用==
判断却返回false
;循环拼接字符串时,代码没报错但执行速度慢到离谱;打印字符串数组时,输出的不是内容而是一串奇怪的地址…… 其实这些问题,都藏在字符串的底层存储逻辑里 —— 它不像int
那样直接存值,而是涉及栈区、堆区和字符串常量池的协同,甚至还关联着内存页分配的效率秘密。今天这篇博客,就从最基础的定义方式讲起,把内存原理、相等判断、常用方法、可变与不可变的差异,还有实战中的效率选型全拆透,让你看完不仅能解决当下的 bug,更能搞懂每一个操作背后的 “为什么”,真正把字符串知识学扎实。
补充:对于int类型的数据,在main()定义后直接在当下开辟空间,因为基本数据类型的大小是可以确定的,如在main()中定义int a = 10:
二、字符串的两种定义方式 —— 内存差异决定 “相等性”
字符串有两种核心定义方式,不同方式对应不同的内存分配逻辑,直接影响"==
"的判断断结果。
1. 方式一:直接赋值(String b = "demo"
)—— 字符串常量池复用
-
内存逻辑:
-
优先在字符串常量池(方法区的一部分)中检查是否存在 “demo”;
-
若不存在,在常量池中开辟空间存储 “demo” 对应的
char
数组(底层char[] = {'d','e','m','o','\0'}
),并将常量池地址(如0x11
)存入栈区的变量b
;
-
-
若已存在(如后续定义
String c = "demo"
),直接让c
指向该地址,不重复创建空间。
-
相等判断:
b == c
结果为true
(二者指向同一内存地址)。
2. 方式二:new
关键字创建(String c = new String("demo")
)—— 堆区新空间
-
内存逻辑:
-
无论字符串常量池中是否存在 “demo”,都会在堆区新开辟一块空间(存储 “demo” 的
char
数组副本); -
栈区变量
c
存储的是堆区新空间的地址(如0xaa
),而非常量池地址; -
特殊情况:首次用
new
创建时,会先在常量池创建 “demo”(若不存在),后续再new
时,仅堆区新开辟空间,常量池不再重复创建。
-
-
相等判断:
b == c
结果为false
(二者指向堆区不同空间)。
3. 关键结论:==
vs equals()
—— 字符串相等判断的 “正确姿势”
-
==
的局限性:仅判断两个变量是否指向同一内存地址,无法判断字符串内容是否一致(如new String("demo")
和"demo"
,==
结果为false
,但内容相同)。 -
equals()
的逻辑:String
类重写了Object
的equals()
,专门判断字符串内容,步骤如下:
-
快速判断:若
this == anObject
(引用相同),直接返回true
; -
类型判断:若
anObject
不是String
类型,返回false
; -
内容判断:比较二者
char
数组的长度和每一位字符,全部一致则返回true
。
-
示例:
new String("demo").equals("demo")
结果为true
(内容一致)。
三、String 常用方法 —— 从替换、拆分到打印技巧
String 类提供了大量实用方法,以下结合文档示例和底层逻辑详解核心方法:
1. 替换方法:replace(旧字符/字符串, 新字符/字符串)
-
功能:将字符串中所有匹配的 “旧内容” 替换为 “新内容”,返回新字符串(原字符串不变,因 String 不可变)。
String b = "demoeplecjdh";
String newB = b.replace("o", "xxx"); // 把所有"o"换成"xxx"
System.out.println(newB); // 输出:demxxxeplecjdh
2. 拆分方法:split(分隔符)
-
功能:按指定分隔符拆分字符串,返回
String[]
数组(拆分后空字符串也会保留,需注意边界)。
String b = "demoeplecjdh";
String[] arr = b.split("e"); // 按"e"拆分
// 直接打印arr会输出地址(如[Ljava.lang.String;@15db9742),因数组toString()调用Object类方法
System.out.println(Arrays.toString(arr)); // 正确打印:[d, mo, pl, cjdh]
-
打印技巧:数组需用
Arrays.toString(数组)
打印,该方法会遍历数组元素,用[元素1, 元素2, ...]
格式输出;而 String 直接打印会调用自身toString()
(返回this
,即字符串内容),无需额外处理。
3. 其他常用 String 方法
方法 | 功能描述 | 示例代码 | 输出结果 |
---|---|---|---|
length() | 返回字符串长度(字符个数,不含\0 ) | String b = "demo"; b.length(); | 4 |
charAt(int index) | 返回指定索引处的字符(索引从 0 开始) | String b = "demo"; b.charAt(2); | 'm' |
substring(int beginIndex) | 从 beginIndex 开始截取子串,到末尾结束 | String b = "demo"; b.substring(1); | "emo" |
toLowerCase() | 将字符串转为小写 | String b = "Demo"; b.toLowerCase(); | "demo" |
toUpperCase() | 将字符串转为大写 | String b = "demo"; b.toUpperCase(); | "DEMO" |
isEmpty() | 判断字符串是否为空(长度为 0) | String b = ""; b.isEmpty(); | true |
四、String 的不可变性 —— 为什么 “修改” 会创建新对象?
1. 不可变的定义:原地址无法直接修改
String 的底层是final
修饰的char
数组(JDK 9 后改为byte
数组),数组大小固定,且 String 类无提供修改数组内容的方法。因此,无法在原内存地址上直接修改字符串内容,任何 “修改” 操作(如拼接、替换)都会创建新字符串对象。
2. 矛盾解析:字符串加法(a + b
)的底层逻辑
String a = "demo";
String b = "456";
String d = "demo456";
String c = a + b;
System.out.println(c); // 输出:demo456
System.out.println(c == d); // 输出:false
矛盾 1:为什么内存图中b也是存的地址,为什么b打印出来不是地址值,因为打印语句中,隐含了toString方法:
按住CTRL键单击进去会看到具体方法,进入String.class(不管打印语句写不写都会使用这个方法),但对于数组arr来说,它的toString方法调用的是object库:
矛盾 2:明明 String 不可变,为何a + b
能得到 “demo456”?
反编译揭示真相:a + b
并非直接拼接,而是底层创建StringBuilder
对象,调用append(a)
、append(b)
,最后用toString()
生成新 String(即c
指向的新对象):
// 底层逻辑(反编译后)
StringBuilder sb = new StringBuilder();
sb.append(a);
sb.append(b);
String c = sb.toString(); // 新对象,地址与d不同
矛盾 3:c
和d
内容相同,为何==
结果为false
?
d
指向字符串常量池中的 “demo456”,而c
指向StringBuilder.toString()
创建的堆区新对象,二者地址不同,故==
为false
。
五、可变字符串 ——StringBuffer 与 StringBuilder(解决频繁修改效率问题)
当需要频繁修改字符串(如循环拼接)时,String 的不可变性会导致频繁创建新对象,效率极低。此时需用可变字符串类:StringBuffer 和 StringBuilder。
1. 核心区别:线程安全 vs 效率
特性 | StringBuffer | StringBuilder |
---|---|---|
线程安全 | 安全(方法加synchronized 锁) | 不安全(无锁) |
执行效率 | 较慢(锁竞争耗时) | 较快(无锁 overhead) |
适用场景 | 多线程环境(如服务器日志拼接) | 单线程环境(如普通业务逻辑拼接) |
底层实现 | 可变char 数组(JDK 9 后为byte 数组) | 同 StringBuffer |
2. 核心方法:append(内容)
—— 原地址修改
-
功能:在原字符串末尾直接追加内容,无需创建新对象(修改的是底层数组,数组满时会自动扩容)。
StringBuilder a = new StringBuilder("demo");
StringBuilder b = a.append("456"); // 直接在a的底层数组追加"456"
System.out.println(a); // 输出:demo456
System.out.println(a == b); // 输出:true(b指向a的同一对象)
-
toString () 逻辑:调用
toString()
时,会创建新 String 对象(new String(value, 0, count)
),但原 StringBuilder 对象地址不变。
3. 其他常用可变字符串方法
方法 |
功能描述 |
示例代码(StringBuilder) |
输出结果 |
---|---|---|---|
|
在指定索引处插入内容 |
|
"dexxmo" |
|
删除 [start, end) 区间的字符 |
|
"do" |
|
反转字符串 |
|
"omed" |
|
修改指定索引处的字符 |
|
"dxmo" |
|
返回底层数组的容量(初始 16,扩容翻倍) |
|
16 |
六、效率对比 ——String vs 可变字符串(用时间戳验证)
1. 时间戳工具:System.currentTimeMillis()
通过记录代码执行前后的时间戳(毫秒级),计算执行耗时,公式:耗时 = 结束时间戳 - 开始时间戳
。
2. 效率对比实验
-
实验 1:String 循环拼接(10 万次):
long start = System.currentTimeMillis();
String a = "";
for (int i = 0; i < 100000; i++) {
a = a + "1"; // 每次拼接都创建新String
}
long end = System.currentTimeMillis();
System.out.println(end - start); // 输出:约8918毫秒(文档示例)
实验 2:StringBuilder 循环拼接(10 万次):
long start1 = System.currentTimeMillis();
StringBuilder a1 = new StringBuilder("");
for (int i = 0; i < 100000; i++) {
a1.append("2"); // 原地址修改,无新对象
}
long end1 = System.currentTimeMillis();
System.out.println(end1 - start1); // 输出:约1毫秒(文档示例)
3. 效率差异的底层原因:内存页的 “创建与复用”
在分析原因之前,我们先来了解一下计算机存储数据的知识,因为计算机是按字节存储数据的,但若存储10000B的信息,需要传输10000次地址,也很庞大,所以操作系统对这些数据进行了分页,比如把左边的数据分成三页,只用传输三次地址就行了:
我们也可以查看自己计算机上具体多少字节是一页,页的大小也可以进行修改设置,默认是4KB:
但是这样传也有问题:每页有一个地址,数据只能从页的开始进行读,比如只存int数据10,那么整个4KB的空间都属于这个数据10,即使会剩余很多空间,但之后的空间也不能再用了,就算是存数据,也无法读取到,因为整页的空间地址都属于数据10,如果再想存int数据类型的20、30,只能在新的页里面存储,会造成内存空间的浪费。内存页大,读取速度快,浪费的空间也多。操作系统为了平衡各方,空间浪费尽量少,且保证性能,选择一个稍为居中的值4KB,当数据较小是没有过大的浪费;当数据量太大时也能使得几个内存页协同合作来进行存储。
如果是存数组:int[] = {10,20,30},可以把整个4KB的空间每32位进行截取存储数据:
这也解释了为什么javascript效率比 java低,哪怕是几种数据类型放到一个数组中,如{10,50,“ddd”},整个数组也不能放在一个内存页中,因为不能确定根据多大的长度来截取片段进行存储,所以10、50、ddd会被分到3个内存页中进行存储。根据补充的知识,我们继续分析原因:
-
String 拼接的问题:
每次拼接都创建新 String,需在堆区新开辟内存页(默认 4KB / 页)。10 万次拼接约需 25 个内存页(100000 ÷ 4096 ≈ 25
),频繁创建和回收内存页,耗时极高。 -
StringBuilder 的优势:
底层数组初始容量 16,满时自动翻倍扩容(16→32→64→...),所有修改都在同一内存页中完成,无需频繁申请新页,效率远超 String。
七、核心总结:字符串选型与避坑指南
1.定义方式选型
-
仅使用固定字符串(不修改):用直接赋值(
String s = "xxx"
),复用常量池,节省空间; -
需创建独立对象(如不同对象但内容相同):用
new String("xxx")
(谨慎使用,避免空间浪费)。
2.相等判断避坑
-
判断内容是否一致:用
equals()
(推荐加null
判断,如Objects.equals(s1, s2)
); -
判断引用是否相同:才用
==
(仅特殊场景,如单例对象判断)。
3.修改场景选型
-
不频繁修改:用 String(简单直观);
-
单线程频繁修改:用 StringBuilder(效率优先);
-
多线程频繁修改:用 StringBuffer(安全优先)。
4.打印技巧
-
String 直接打印:
System.out.println(s)
(调用自身toString()
); -
数组打印:
System.out.println(Arrays.toString(arr))
(避免打印地址)。
八、结尾:字符串的 “选择与权衡”—— 技术背后的设计哲学
从字符串常量池的复用逻辑,到==
与equals()
的判断差异;从 String 不可变的底层约束,到 StringBuilder/StringBuffer 的可变优化;再到内存页分配与效率的深层关联,我们终于把 Java 字符串从 “表面文本” 拆解得明明白白。
其实字符串的每一个特性,都是 Java 在 “空间复用”“操作安全”“执行效率” 之间的精妙权衡:常量池让重复字符串不再浪费空间,不可变性保障了多线程下的安全,而可变字符串则解决了频繁修改的效率痛点。下次写代码时,当你纠结用String
还是StringBuilder
,当你困惑为什么new String("a")
和"a"
不相等,不妨回头想想这些底层逻辑 —— 你笔下的每一次字符串操作,都是对 “原理” 的实际践行。如果还想深挖更多 Java 底层细节,关注我,后续我们继续拆解更多核心知识点,让技术学习从 “知其然” 到 “知其所以然”~