CodeRoad Map 程序员的学习成长路线图。内容免费开放,登录后可同步路线图与课时进度。
由 程序员柏风 独立维护 · 关于作者
学习 更多
字符串
这一课你将能做到
流畅使用 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 可能为 nullNPE "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 直接打到用户脸上。