大家好,我是程序员柏风,AI 最全的 Vibecoding 学习地图 http://coderoadmap.cn 作者。
字符串
这一课你将能做到
- 流畅使用 String 的常用 API(查找、截取、替换、拆分、判断),处理本站后端日常的报文、日志、参数清洗
- 用一句话讲清 String 不可变的设计动机,并永远正确地区分
==与equals - 在拼接、构建长文本时正确选择 String / StringBuilder / 文本块,包括生成 SQL、日志、JSON 时的安全习惯
为什么要学
字符串是后端世界出现频率最高的数据形态:HTTP 参数是字符串、MySQL 返回前要拼 JSON、日志是字符串、Redis 里存的值是字符串。本站后端有大量字符串操作场景:
- 校验请求参数:
StringUtils.isBlank(username)的原生写法 - 生成缓存 key:
"user:info:" + userId(Redis 键命名规范) - 解析 Excel/CSV 导入:
split(",") - 记录接口日志:占位符
{}风格
这一课还有一个面试百年老题:String s = new String("abc") 创建了几个对象?理解了它,字符串常量池你就通了。
先建立图像
String 是「不可变的字符序列」:
s = "hello" → 堆里的字符串对象 "hello"
│
└─ s.toUpperCase() 不会改 "hello",而是 new 一个 "HELLO" 返回
"hello" 本体永远原封不动
字符串常量池(专门放字符串字面量的缓存区):
String a = "hi";
String b = "hi"; → a 和 b 指向池中同一个 "hi"(省内存)
String c = new String("hi"); → 强制 new 堆中新对象,地址与 a 不同
跟着做
步骤 1:常用 API 全家福
新建 StringApiDemo.java:
public class StringApiDemo {
public static void main(String[] args) {
String s = " Hello, CodeRoadMap ";
// 去空白:trim 只去 ASCII 空白;strip(JDK 11+)支持全角空格等 Unicode 空白
System.out.println("[" + s.strip() + "]"); // [Hello, CodeRoadMap]
String t = s.strip();
System.out.println(t.length()); // 18
System.out.println(t.charAt(0)); // H(下标访问,越界抛异常)
System.out.println(t.indexOf("Code")); // 7(找不到返回 -1)
System.out.println(t.contains("Road")); // true
System.out.println(t.startsWith("Hello")); // true
System.out.println(t.toUpperCase()); // 全大写(返回新串)
System.out.println(t.substring(7)); // CodeRoadMap(从下标 7 到末尾)
System.out.println(t.substring(0, 5)); // Hello(含头不含尾)
System.out.println(t.replace("CodeRoadMap", "Java")); // Hello, Java
// 判空三兄弟(后端参数校验天天用)
System.out.println("".isEmpty()); // true:长度 0
System.out.println(" ".isBlank()); // true:只有空白
System.out.println(" a ".isBlank()); // false:有实际内容
// 拆分:正则语法,特殊字符要转义
String csv = "tom,20,beijing";
String[] parts = csv.split(",");
for (String p : parts) System.out.print(p + " | "); // tom | 20 | beijing |
System.out.println();
System.out.println("a.b.c".split("\\.")); // 点号是正则元字符,必须 \\.
}
}
步骤 2:== 与 equals 世纪之争
public class StringEqualsDemo {
public static void main(String[] args) {
String a = "hi"; // 字面量 → 常量池
String b = "hi"; // 复用池中同一对象
String c = new String("hi"); // 强制堆中新建
String d = "h" + "i"; // 编译期常量折叠 → 池中同一对象
System.out.println(a == b); // true:同一对象
System.out.println(a == c); // false:堆中对象地址不同
System.out.println(a.equals(c)); // true:内容相等
System.out.println(a == d); // true:编译期就合并成 "hi"
// 最危险的真实场景:运行期拼接的结果不在池里
String prefix = "h";
String e = prefix + "i"; // 运行期拼接 → 新对象
System.out.println(a == e); // false!
System.out.println(a.equals(e)); // true
// 规范写法:常量在左,防 NPE
String input = null;
// System.out.println(input.equals("admin")); // NPE!
System.out.println("admin".equals(input)); // false,安全
}
}
铁律:字符串比较永远用 equals。== 只在比「是不是同一个对象」,碰巧 true 的场景(小字面量)反而是最大的坑。
步骤 3:不可变性实证
public class ImmutableDemo {
public static void main(String[] args) {
String s = "hello";
s.toUpperCase(); // 返回值被丢弃!s 不变
System.out.println(s); // hello
s = s.toUpperCase(); // 必须接住返回的新对象
System.out.println(s); // HELLO
// String 的所有"修改"方法都是返回新串,原串永远不变
String t = "abc";
t.replace('a', 'x');
System.out.println(t); // abc
}
}
步骤 4:StringBuilder 高效拼接
public class StringBuilderDemo {
public static void main(String[] args) {
// 场景 1:循环内拼接 → 必须用 StringBuilder
long start = System.currentTimeMillis();
StringBuilder sb = new StringBuilder();
for (int i = 0; i < 100_000; i++) {
sb.append(i).append(",");
}
System.out.println("StringBuilder 耗时: " + (System.currentTimeMillis() - start) + "ms");
start = System.currentTimeMillis();
String s = "";
for (int i = 0; i < 20_000; i++) {
s += i; // 每次循环都 new 新对象 + 复制全部内容,O(n²)
}
System.out.println("String += 耗时: " + (System.currentTimeMillis() - start) + "ms");
// 常用方法
sb.setLength(0); // 清空复用
sb.append("redis").append(":").append("user").append(":").append(1001);
System.out.println(sb); // redis:user:1001
System.out.println(sb.reverse()); // 倒序(刷题常用)
// 场景 2:格式化输出(对照 C 的 printf)
String line = "%s 的订单 %d 总金额 %.2f".formatted("tom", 10086, 199.5);
System.out.println(line);
System.out.printf("ID=%05d%n", 42); // ID=00042(补零)
}
}
结论:单次拼接 + 随意(编译器自动优化);循环内拼接必须 StringBuilder;StringBuffer 是老线程安全版,单线程无脑用 Builder。
步骤 5:文本块(JDK 15+)
public class TextBlockDemo {
public static void main(String[] args) {
// 三引号多行字符串:缩进以 """ 的位置为基准
String json = """
{
"username": "tom",
"age": 20
}
""";
System.out.println(json);
// 对照传统写法(换行 + 转义引号,可读性灾难):
String old = "{\n \"username\": \"tom\",\n \"age\": 20\n}";
System.out.println(json.equals(old)); // true
// 本站后端场景:写测试 SQL、HTML 模板、内嵌配置
String sql = """
SELECT id, username, status
FROM user
WHERE status = 0
""";
System.out.println(sql);
}
}
步骤 6:后端实战——缓存 key 与参数清洗
public class BackendStringDemo {
public static void main(String[] args) {
// Redis 缓存 key 规范:业务:对象:id(冒号分层是社区惯例)
long userId = 1001;
String cacheKey = "roadmap:user:info:" + userId;
System.out.println(cacheKey); // roadmap:user:info:1001
// 用户输入清洗:trim + 判空 + 统一比较
java.util.Scanner sc = new java.util.Scanner(System.in);
System.out.print("输入用户名: ");
String input = sc.nextLine().strip();
if (input.isEmpty()) {
System.out.println("用户名不能为空");
} else if ("admin".equalsIgnoreCase(input)) {
System.out.println("管理员登录(忽略大小写比较)");
} else {
System.out.println("普通用户: " + input);
}
}
}
讲清楚要点
- String 不可变的三个理由:① 安全——作为 HashMap key、文件路径、类名不会被中途篡改;② 线程安全——只读对象天然可共享;③ 常量池复用成为可能——相同字面量全局一份。
- 常量池:字面量(
"hi")进池复用;new String强制堆中新对象;运行期拼接结果是新对象不进池(intern()可手动入池,少用)。 equals是唯一正确的比较;equalsIgnoreCase处理大小写不敏感场景(邮箱、验证码)。- 拼接选型:静态少量拼接用
+;循环拼接用 StringBuilder;formatted/printf管排版;多行文本用文本块。 - split 的正则本质:参数是正则表达式,
.|*等元字符必须转义\\.\\|;想按多个分隔符切:split("[,;]")。 - 安全红线:拼接 SQL 字符串 = SQL 注入漏洞(
"SELECT * FROM user WHERE name = '" + input + "'"),本站后端一律用 MyBatis/JDBC 的参数占位符?,字符串只用于日志和展示。
你可能会懵的地方
-
String s = new String("abc")创建几个对象? 最多 2 个:字面量"abc"若池中不存在则池中 1 个 + 堆中 new 的 1 个;池中已存在则只 new 1 个。这题考的是常量池机制,答出「1 或 2,取决于池中是否已有」才是满分。 -
String、StringBuilder、StringBuffer 怎么选? String:不可变,存值传值场景;StringBuilder:可变、非线程安全、单线程拼接首选;StringBuffer:可变、方法加 synchronized、多线程才需要(后端拼接场景几乎用不到它)。
-
字符串的
+到底怎么实现的? 编译器把a + b翻译成 StringBuilder 操作(JDK 9+ 用 invokedynamic 优化成 StringConcatFactory)。循环里每次+=都生成新对象,这就是性能差异的来源。 -
为什么
a == d("h" + "i")是 true?"h"和"i"都是编译期常量,编译器直接折叠成"hi"放进常量池。但只要有一方是变量(运行期才知道值),就变成运行期拼接 → 新对象 →==为 false。 -
intern() 是什么? 把堆中字符串放入常量池并返回池中引用。JDK 7 后池在堆里,intern 用得极少;数据库连接池、字符串高度重复的大数据处理偶尔用。
-
为什么推荐
"常量".equals(变量)而不是反过来? 变量为 null 时,null.equals(...)直接 NPE;常量在左则返回 false。Yoda 条件,参数校验的标准防御姿势。
错法对照
| 错法 | 问题 | 正解 |
|---|---|---|
if (name == "admin") | 堆中对象恒 false | if ("admin".equals(name)) |
s.toUpperCase(); 以为改了 s | 不可变,返回值丢了 | s = s.toUpperCase(); |
循环里 result += x | O(n²),10 万次明显卡顿 | 循环外建 StringBuilder,循环内 append |
"a.b.c".split(".") | 点号是正则元字符,得到空数组 | "a.b.c".split("\\.") |
if (input.equals("admin")) 且 input 可能为 null | NPE | "admin".equals(input) |
| 用字符串拼接拼 SQL | SQL 注入漏洞 | 预编译参数占位符 ? / MyBatis #{} |
拿 == 比 char 数组内容 | 比地址 | Arrays.equals(charArr1, charArr2) |
卡点急救
- split 得到的数组是空的:分隔符是正则元字符(
.、|、*、+),加双反斜杠转义;或分隔符在结尾时默认丢弃空串,加参数split(",", -1)保留。 - 改了字符串没生效:所有「修改类」方法都返回新串,必须接收返回值。
- substring 报 StringIndexOutOfBoundsException:范围是 [begin, end),end 超过 length 或 begin > end 都会炸;边界先打印
length()核对。 - 拼接性能差:确认拼接是否在循环内;超过万次量级换 StringBuilder,预知长度时给构造器传初始容量。
- 中文乱码:读写文件/网络流时统一指定 UTF-8:
new String(bytes, StandardCharsets.UTF_8)(下一课 IO 详讲)。
面试常问
-
String 为什么设计成不可变? 安全(key/路径/类加载名不可篡改)、线程安全(只读共享)、常量池复用、hashCode 可缓存(String 缓存 hash,作为 HashMap key 时重复计算成本低)。
-
== 与 equals 的区别?
==比地址(引用是否同一对象);equals 默认同==,String 重写为逐字符比内容。字符串比较必须 equals。 -
String、StringBuilder、StringBuffer 的区别? 可变性:不可变 / 可变 / 可变;线程安全:天然安全 / 不安全 / synchronized 安全;性能:循环拼接 String 最差;选择:默认 String,拼接用 Builder,多线程共享拼接才用 Buffer。
-
字符串常量池是什么? JVM 里缓存字符串字面量的区域(JDK 7+ 位于堆中)。相同字面量全局复用一份,
new String绕过复用强制建新对象。 -
"abc".substring(1)会复制数组吗?(内存细节题) JDK 6 会共享原数组(有内存泄漏隐患);JDK 7u6 起改为复制,substring 返回独立小对象。答出「新版是复制」即可。
自测三问
String a="hi"; String b=new String("hi");—a==b和a.equals(b)各是什么?为什么?- 循环 10 万次拼接用
+=和 StringBuilder 差多少量级?底层原因是什么? "a|b".split("|")的结果是什么?怎么改才对?
小结一句话
String 不可变、进常量池、比较永远 equals(常量在左防 NPE);循环拼接用 StringBuilder、正则元字符要转义、多行用文本块;拼 SQL 是红线——一律参数占位符。
延伸
- Oracle:字符与字符串
- 下一课:异常处理——try/catch/finally、受检与非受检的边界、自定义业务异常,别再让 500 直接打到用户脸上。