朋友,后台经常有人私信问我“Java 里字符串到底怎么学”“String、StringBuffer、StringBuilder 到底用哪个”,说实话,这几个问题我在刚工作那会儿也绕了很久。String 在 Java 里的出现频率极高,可以说是基础中的基础,但它背后的内存模型、不可变性设计、常量池机制,又牵扯到性能、线程安全、日常 Bug 排查,真要认真梳理一遍,内容量完全不亚于一个小框架。这篇专题我打算从原理到实操,再到踩坑实录,一次性把 String 相关的东西讲透。
不管你是刚学 JavaSE 的新手,还是写了两三年业务代码的老手,只要平时会和字符串打交道,这篇都能帮你把底层逻辑捋顺。我会尽量用平时聊天的方式讲,不堆术语,但该给的代码、该画的步骤、该列的参数,一样都不会少。
1. String 的整体设计思路与核心概念
1.1 String 的不可变性到底意味着什么
先从一个最朴素的问题说起:为什么 String 要被设计成不可变(immutable)?很多初学者会把“String 是引用类型”和“String 的内容可以随便改”混为一谈,实际上 String 对象一旦创建,它的字符序列就固定了。你看起来是在“修改”字符串,比如执行 str = str + "x",底层其实是创建了一个全新的 String 对象,然后让引用变量指向了新对象,原来的对象还在内存里待着,等着被垃圾回收。这个设计不是拍脑袋决定的,它带来几个实打实的好处:
- 缓存安全:字符串常量池可以放心缓存同一个字符串对象,因为内容不会变,多个变量共享同一个对象也不会互相影响。
- 线程安全:不可变对象天然线程安全,不需要加锁,多线程环境下随便读。
- 哈希缓存:String 重写了 hashCode(),因为内容不变,哈希值可以缓存起来,这直接提升了 HashMap 中 String 作为键的查询效率。
代价也很明显:每一次“修改”都会产生新对象,如果操作频率很高,内存开销和 GC 压力都会上来。这也是为什么后来要有 StringBuilder 和 StringBuffer,后面我会专门讲。
理解不可变性有个很经典的验证方式,你可以打印对象的地址或者用 System.identityHashCode() 来看,每次拼接后对象的哈希标识都不一样,说明确实换了对象。实战里最常见的坑就是“我以为我改了字符串,其实没改”,比如在方法里对 String 参数做拼接,方法结束后外部变量一点变化都没有,因为参数传递的是引用的副本,而拼接又是生成新对象,外部的引用还是指向老对象。
1.2 字符串常量池与 intern() 机制
Java 为了节省内存,维护了一个字符串常量池(String Pool)。在 JDK 7 之前,这个池子在方法区(永久代)里,JDK 7 之后挪到了堆内存中。它的工作逻辑大致是:当你用字面量创建字符串,比如 String s1 = "hello",JVM 会先去常量池里找有没有内容相同的字符串,有就直接复用,没有就在池子里创建一个新的。
这里就衍生出一个高频面试题:String s1 = "hello"; String s2 = new String("hello"); 到底有什么区别?s1 走的是常量池,s2 在堆上创建了一个新对象,即使内容和常量池里的一样,它也是独立的对象。所以 s1 == s2 是 false,s1.equals(s2) 才是 true。如果你想让堆上的字符串也进常量池,可以调用 intern() 方法,它会在常量池里查找相同内容的字符串,有就返回常量池里的引用,没有就把当前字符串加入池子并返回引用。
我在实际项目里见过有人滥用 intern(),导致常量池膨胀甚至出现性能问题,所以要提醒一句:intern() 不是免费的,它本身需要查找和存储,控制不好反而拖慢程序。正常情况下,用字面量声明字符串就够了,不要为了省内存硬上 intern()。另外,现在很多框架和 ORM 工具内部会自己处理字符串,你不需要在业务代码里手动 intern。
1.3 三种常用创建方式的对比
我整理了一张表,把日常开发中最常见的三种字符串创建方式放在一起对比,一眼就能看出差异:
| 创建方式 | 对象数量 | 存放位置 | 特点 |
|---|---|---|---|
String s = "abc" |
0 或 1 个 | 字符串常量池 | 先去池里查,有就直接引用;没有则创建后入池 |
String s = new String("abc") |
1 或 2 个 | 堆(常量池也可能有) | 堆上必有一个新对象,常量池里可能存在另一个 |
String s = new String("abc").intern() |
0 或 1 个 | 常量池 | 如果池里有相同内容,直接返回池中引用 |
这里要特别强调一点,字符串拼接产生的对象位置会随写法变化。如果是字面量直接拼接 "a" + "b",javac 编译器在编译期就会优化成 "ab",直接进常量池;如果是变量拼接 str1 + str2,运行时会通过 StringBuilder 来拼,最终生成的新字符串在堆上。很多新手用 == 比较两个拼接结果时发现结果不稳定,就是因为对编译期和运行期的区别理解不够。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. String 常用 API 与底层实现细节
2.1 字符串构建与拼接的底层逻辑
先看一段最常见的代码:
java复制String str = "";
for (int i = 0; i < 10000; i++) {
str += i;
}
这段代码在循环里用 += 拼接字符串。从语法上完全没错,但性能非常差。原因在于每一次 += 都会在底层创建一个 StringBuilder 对象,调用 append 方法拼完后再 toString 生成新 String。循环一万次就等于创建了一万个 StringBuilder 和一万个临时 String,垃圾收集器直接被拖垮。
正确的写法是显式使用 StringBuilder:
java复制StringBuilder sb = new StringBuilder();
for (int i = 0; i < 10000; i++) {
sb.append(i);
}
String result = sb.toString();
这个方案的原理很好理解,StringBuilder 内部维护了一个可变的 char 数组(JDK 9 之后是 byte 数组),append 操作只是往数组末尾写数据,数组不够了才扩容。一次性拼接大量字符串时,它的性能比 + 拼接高一个数量级。
不过也不是说业务里所有拼接都要改成 StringBuilder。如果是少数几个字符串拼接,比如 "id:" + userId,编译器会自动优化成 StringBuilder 的形式,写起来反而更简洁。真正要注意的是循环拼接、动态拼 SQL、生成大报文这类高频场景。
2.2 高频操作:比较、切割、替换、子串
String 类的 API 数量相当多,但真正日常高频使用的其实就那么几个,我用场景化的方式来讲:
第一个是内容比较。equals 比较的是内容,== 比较的是引用,这个必须刻在脑子里。遇到需要忽略大小写比较的场景,用 equalsIgnoreCase。还有一种情况是字符串可能是 null,直接调 equals 会抛空指针,建议用 Objects.equals(str1, str2) 或者把常量放在前面 "value".equals(str)。
第二个是切割。split 方法是基于正则的,所以切分 . 或 | 这种特殊字符时必须转义,"a.b.c".split("\\.")。如果你只是按普通字符切分,用 StringTokenizer 或者 Guava 的 Splitter 也行,但标准 JDK 里最常用的还是 split。要注意 split 可能会产生空字符串元素,比如 "a,,b".split(",") 会得到 ["a", "", "b"],了解这个特性有助于避免后面处理数据的时候掉坑。
第三个是替换。JDK 的 replace 和 replaceAll 容易混。replace(CharSequence, CharSequence) 是纯字面量替换,不涉及正则;replaceAll(String, String) 是正则替换。如果你只是想替换某个固定字符,用 replace 就好,用 replaceAll 反而要处理正则转义问题。
第四个是子串提取。substring(beginIndex, endIndex) 是左闭右开区间,比如 "hello".substring(1, 3) 是 "el"。JDK 6 时代有个著名的内存泄漏问题,substring 会保留原始字符数组的引用,导致大字符串无法被回收;JDK 7 之后改成创建新的字符数组,这个问题已经解决,但如果你是工作多年的老项目,还是值得留意一下历史版本差异。
2.3 字符串与基本类型、数组、集合的转换
这块是平时写代码最频繁的操作之一,我直接给一份可直接抄的清单:
java复制// 基本类型转 String
String s1 = String.valueOf(123);
String s2 = Integer.toString(456);
String s3 = "" + 789;
// String 转基本类型
int i = Integer.parseInt("123");
long l = Long.parseLong("123L");
double d = Double.parseDouble("3.14");
boolean b = Boolean.parseBoolean("true");
// String 转 char 数组
char[] chars = "hello".toCharArray();
// char 数组转 String
String s = new String(chars);
// 集合转 String
List<String> list = Arrays.asList("a", "b", "c");
String joinStr = String.join(",", list);
// String 转数组
String[] arr = "a,b,c".split(",");
有几个细节我想额外提一下。第一,valueOf 和 toString 的区别在于,valueOf 对 null 是安全的,返回字符串 "null";而直接调用对象的 toString 会抛空指针。第二,String.join 是 JDK 8 引入的,非常适合把集合元素用分隔符拼起来,不要再手动循环去拼了。第三,用 "" + 数字 的方式在代码简洁性上没问题,但底层也是走 StringBuilder,如果只是转换一个数字,直接 valueOf 更直接。
2.4 一个绕不开的话题:字符串与字节数组的编码问题
字符串在 JVM 内部是 UTF-16 编码的 char 数组,但和外部系统交互时,经常涉及 UTF-8、GBK、ISO-8859-1 等编码。最基础的两个方法就是 getBytes(charset) 和 new String(bytes, charset)。
我在项目中遇到过不少乱码问题,最后排查下来基本都是编码不一致导致的。比如对方发来的数据是 GBK 编码字节流,你用 UTF-8 去解码,出来的就是一堆乱码。处理这类问题有个标准步骤:先确认数据源是什么编码,再用对应的编码进行解码,输出的时候也确认目标编码。如果确认不了,优先用 UTF-8,它已经是事实上的通用标准。
还有一个小提示:getBytes() 无参版本会使用 JVM 默认编码,不同环境、不同操作系统可能不一样,这会给程序带来不确定性。生产环境涉及跨平台或者对外接口时,最好显式指定字符集,比如 getBytes(StandardCharsets.UTF_8),这样才不会出现“本地跑得好好的,部署到服务器就乱码”的问题。
3. String、StringBuffer、StringBuilder 三兄弟对比
3.1 存储模型与线程安全的差异
既然要对比三兄弟,先把它们各自的数据模型说清楚。String 是 final 类,内部用 byte[] 存字符,不可变;StringBuffer 和 StringBuilder 都继承自 AbstractStringBuilder,内部维护的是可变的 byte[],可以不断 append。
StringBuffer 和 StringBuilder 的核心区别只有一条:StringBuffer 的方法加了 synchronized 关键字,是线程安全的;StringBuilder 没有加,是非线程安全的。但别以为 StringBuffer 就一定好,线程安全是需要付出性能代价的,每次方法调用都要获取锁、释放锁。在单线程场景下,StringBuilder 的性能明显优于 StringBuffer。
我见过不少人为了“保险”不管什么场景都用 StringBuffer,其实没必要。方法内部的局部变量、单线程环境,直接用 StringBuilder 就好。只有当你确定同一个 StringBuilder/StringBuffer 对象会被多个线程共同访问和修改时,才需要 StringBuffer 或者自己加锁。日常后端接口里,如果每个请求的处理线程都有自己独立的对象,那 StringBuilder 完全够用。
3.2 StringBuilder 的扩容机制和初始容量设置
这块属于高级一点的细节。StringBuilder 内部的字符数组是有长度的,当你 append 的内容超过当前数组容量时,它会自动扩容。默认初始容量是 16,扩容策略在新版本 JDK 里是“新容量 = 旧容量 * 2 + 2”,然后还会和实际需要的长度做比较,取较大值。
扩容本身是件有成本的事,因为要新建一个更大的数组,再把旧数据拷贝过去。如果你一开始就能预估字符串的最终长度,最好在创建 StringBuilder 时指定容量。举一个实际场景,你要拼一个 XML 报文,预估最终长度大概 4096 字节,直接 new StringBuilder(4096),能避免多次扩容带来的性能损耗。对性能要求高的地方,这个细节能带来肉眼可见的提升。
java复制// 不指定容量,可能触发多次扩容
StringBuilder sb1 = new StringBuilder();
// 预估容量,一次性到位
StringBuilder sb2 = new StringBuilder(4096);
3.3 StringBuffer 转换为 String 的几种姿势
这里我要专门展开讲一下“StringBuffer 转 String”,因为相关热词里出现了这个,说明很多人被这个转换卡过。实际上方法的转换并不复杂,但不同方式有细微差别:
java复制StringBuffer buffer = new StringBuffer("hello");
// 方式一:直接调用 toString
String str1 = buffer.toString();
// 方式二:通过 substring 获取
String str2 = buffer.substring(0, buffer.length());
// 方式三:先转为 StringBuilder 再 toString(不推荐)
String str3 = new StringBuilder(buffer).toString();
// 方式四:利用构造方法
String str4 = new String(buffer);
最推荐的方式就是第一种 toString(),它本身就是 AbstractStringBuilder 提供的方法,效率最高,语义也最清楚。substring 方式能实现,但绕了一圈,还容易出错。new String(buffer) 在 JDK 8 之后也能正常工作,但需要额外创建一个 String 对象,不如 toString 来得自然。方式三等于是先复制了一遍再转,完全没必要。这里顺手说一下 StringBuilder 转 String 也是一样,直接 .toString()。
3.4 三兄弟和 String 的互相转换关系
在实际开发中,三兄弟之间的转换路径很固定:
- String 转 StringBuilder:
new StringBuilder(str) - StringBuilder 转 String:
sb.toString() - StringBuffer 和 StringBuilder 互转:可以先
toString()再构造,或者直接传入构造方法 - String 转 StringBuffer:
new StringBuffer(str) - StringBuffer 转 String:
buffer.toString()
我建议把这些转换方式当成肌肉记忆写进代码里,不要每次都去查。还有一个点,字符串常量池只对 String 有效,StringBuilder 和 StringBuffer 的对象不会进常量池,所以不存在“池化”的概念,这也说明三者的内存模型有本质区别。
4. 实战:几个典型场景的完整实现
4.1 高频拼接场景的性能优化示例
先看一个反面教材,再给优化方案。
java复制// 不推荐:循环中 += 拼接
String result = "";
for (int i = 0; i < 100000; i++) {
result += i;
}
// 推荐:指定容量的 StringBuilder
StringBuilder sb = new StringBuilder(600000);
for (int i = 0; i < 100000; i++) {
sb.append(i);
}
String result = sb.toString();
有人问,为什么不直接预估 500000?因为 100000 个整数转成字符串后,大部分是 5 位或 6 位数,再加上每个数字间的分隔符,600000 是个比较合理的估算。实际操作中你可以根据业务数据的平均长度来算。这种优化在数据量小的时候看不出差别,但当数据量达到十万、百万级别,性能差距就能拉开几十倍甚至更多。
4.2 字符串拼 XML 的关键实现
热词里有“java string转xml”,我讲一个业务里常见的 XML 报文拼接场景。很多老系统没有用 DOM 或 JAXB,而是直接拼字符串来生成 XML。这种方式简单直接,但也最容易出问题:特殊字符没转义、根节点漏闭合、编码不对导致解析失败。
一个相对稳妥的手动拼接模板如下:
java复制StringBuilder xml = new StringBuilder(1024);
xml.append("<?xml version=\"1.0\" encoding=\"UTF-8\"?>");
xml.append("<order>");
xml.append("<id>").append(orderId).append("</id>");
xml.append("<name>").append(escapeXml(name)).append("</name>");
xml.append("</order>");
String result = xml.toString();
这里最关键的是 escapeXml 方法。XML 里 <、>、&、"、' 都是特殊字符,业务数据里一旦出现,会直接破坏 XML 结构。所以任何用户输入的内容,拼接进 XML 前都要转义成 <、>、& 等实体。如果你用的是第三方 XML 库,这些它都会帮你处理;但用字符串手动拼,这步绝对不能省。
如果项目允许引入第三方库,我其实更推荐用 Jackson 或 dom4j 来构建 XML,字符串拼接只适合结构简单、数据可信的场景。毕竟手动拼字符串做 XML,本质上是在和不规范的转义规则作斗争。
4.3 集合统一转字符串的实用写法
业务开发里常有“把一个列表转成逗号分隔的字符串”的需求,最常见的写法有下面几种:
java复制// JDK 8:String.join
List<String> list = Arrays.asList("a", "b", "c");
String result1 = String.join(",", list);
// 使用 Collectors.joining
String result2 = list.stream().collect(Collectors.joining(","));
// 传统循环拼接
StringBuilder sb = new StringBuilder();
for (int i = 0; i < list.size(); i++) {
if (i > 0) {
sb.append(",");
}
sb.append(list.get(i));
}
String result3 = sb.toString();
第一种写法最简单,适合没有额外处理逻辑的场景;第二种适合你需要同时做过滤、映射的流式操作;第三种最灵活,适合需要自定义分隔规则、处理 null、去重等复杂业务。如果你要拼的是数据库里的字段 ID 列表,还要注意空集合的问题,空列表 join 出来是空字符串,不能直接拿去当 SQL 查询条件。
5. 常见问题与排查技巧实录
5.1 编译期优化带来的字符串相等判断困惑
我记得有一次帮同事排查 Bug,代码大概是这样的:
java复制String s1 = "a" + "b" + "c";
String s2 = "abc";
System.out.println(s1 == s2); // true
String a = "a";
String b = "b";
String s3 = a + b + "c";
System.out.println(s3 == s2); // false
第一段代码输出 true,是因为 javac 在编译期就把 "a" + "b" + "c" 合并成了字面量 "abc",所以 s1 和 s2 都指向常量池里的同一个对象。第二段代码里,a 和 b 是变量,编译期无法确定值,所以运行时通过 StringBuilder 拼接,s3 是堆上的新对象,和常量池里的 s2 不是同一个引用。这个例子非常经典,理解它你就能明白为什么“字符串是否相等”这个问题不能靠直觉。
5.2 字符串操作常见异常速查表
| 问题 | 典型场景 | 解决办法 |
|---|---|---|
| NullPointerException | 对 null 字符串调用 equals、length | 先判空,或用 Objects.equals |
| ArrayIndexOutOfBoundsException | substring 越界、charAt 越界 | 注意索引范围,左闭右开 |
| PatternSyntaxException | split/replaceAll 使用未转义的正则元字符 | 特殊字符前加 \\ 转义 |
| NumberFormatException | 把非数字字符串 parse 成数字 | 转换前校验,用正则或 try-catch |
| StringIndexOutOfBoundsException | 拼接时索引计算错误 | 建议用 StringBuilder 代替手动拼字符 |
遇到这些异常,排查的第一步永远是先打印或确认字符串的当前值。很多人排查半天下不去手,问题就出在“以为字符串是某个值,实际是另一个值”。建议在关键节点加日志,把字符串长度和内容都打出来。
5.3 内存视角下的字符串优化建议
我在微服务项目里做过一次字符串相关的性能调优,总结了几条对线上环境有实际帮助的经验:
- 大量重复字符串场景,尽量复用常量池里的对象,减少 new String 的频率。
- 大文本处理时避免 substring 保留大数组的旧问题,虽然 JDK 7 已经修复,但还是要留意代码逻辑。
- 集合存储大量字符串时,用
StringBuilder代替频繁的字符串相加。 - 不要用字符串拼接去构造 SQL 或日志,存在 SQL 注入或日志格式错乱的风险。
- 在需要高性能的二进制协议、网络传输场景,用字节数组配合
ByteBuffer更合适,String 适合表达和展示,不适合高频序列化。
关于最后一条,我想多说一句:很多人把 String 当作万能的“通用容器”,但它的不可变性和编码转换开销在极端高性能场景下反而是负担。认清 String 的适用边界,才能在架构设计时做出更合理的选择。
5.4 再补一个冷门但实用的小技巧
有时候你需要快速判断一个字符串是否是空白(空串、全空格、全 Tab),不要自己写循环,直接用 isBlank() 方法,JDK 11 提供,可以同时识别空格、Tab、换行符。如果项目还是 JDK 8,可以退而求其次用 trim().isEmpty() 来模拟,但 trim 只能去掉两端空格,对中间的全角空格无能为力。
还有一个我天天用的快捷键式写法:判断一个字符串不为空且长度大于 0,直接 str != null && !str.isEmpty(),如果还要跳过纯空白,就加一个 !str.isBlank()。这类判断非常频繁,我建议你封装成一个工具方法,避免每个地方都写这么长一串。
6. 最后一点个人体会
写字符串相关的代码其实写多了就会形成肌肉记忆,但真正拉开水平差距的,不是会背多少 API,而是遇到线上问题时能不能第一时间想到“这是字符串不可变性导致的”“这是编码不对”“这是拼接性能问题”。我在实际踩坑中最深的一点体会是:字符串相关的 Bug 往往不是单点问题,而是多个因素叠加,比如编码不一致加特殊字符没有转义加拼接方式选择不当,最后合成一个看起来莫名其妙的报错。排查时别急着改代码,先理清数据从哪来、到哪去、经过哪些处理,再动手。
如果你正在学 JavaSE,我建议你花一个下午专门做三件事:第一,把 String 的常用 API 从头到尾写一遍 demo;第二,用 == 和 equals 做几组对比实验,彻底搞明白引用比较和内容比较的区别;第三,把 String、StringBuilder、StringBuffer 在同样的循环拼接场景下跑一个性能对比,眼见为实。这三件事做完,你对字符串的理解会比看十篇博客都扎实。
最后分享一个编码习惯:公司规范里如果允许,尽量把字符串常量抽成 private static final,避免在代码里到处散落魔法字符串。这不仅能减少拼写错误,也让替换和排查更容易。字符串的专题内容到这里基本讲全了,大家写代码的时候多留个心眼,少踩一个坑就是赚到。
