字符串 String:不可变的文字
本教程共 100 篇 · 第 55 篇 · 更新于 2026-08-05 · 约 7 分钟阅读
55. 字符串 String
本节目标:弄懂 String 为什么一旦创建就不能改、常量池如何省内存,以及判断相等和截取替换该怎么用。
字符串大概是 Java 里出场最多的类型。你写的每一个 "hello",背后都是一个 String 对象。它最反直觉的特点是:不可变。
这三个字很多人一带而过,结果在比较相等、循环拼接、拿字符串当缓存键这些地方接连翻车。这一章把它讲透。
String 为什么不可变
不可变到底指什么
字符串对象一旦创建,它内部保存的那串字符就锁死了。你调用的 toUpperCase()、replace() 看着像在改,其实都是造了一个新字符串还给你,原来那个一动没动。
String s = "hello";
String big = s.toUpperCase();
System.out.println(s); // hello,原串没变
System.out.println(big); // HELLO,这是新串
```bash
第 2 行的返回值必须接住。不少新手写成孤零零一句 `s.toUpperCase();`,然后奇怪为什么打印出来还是小写——结果被丢掉了。String 所有「修改类」方法都要接返回值,这是铁律。
### 锁死之后换来了什么
设计者不是为了给你添堵。内容固定下来,好处一串:
- **能放心共享**。多个变量指向同一个字符串对象也不怕,谁都改不了它,天生线程安全,不用加锁。
- **哈希值可以缓存**。字符串最常见的用途之一是当 Map 的键。内容不变,哈希码只算一次就存起来,之后查表直接复用,快很多。
- **系统更安全**。类名、文件路径、网络地址、数据库连接串在 Java 里都是字符串。如果这些值能在校验通过之后被中途篡改,就是实打实的漏洞。
> [!NOTE]
> 不可变不等于「引用不能变」。写 `s = "world";` 完全合法,那只是让变量改指向另一个对象,原来那个 "hello" 对象本身依旧没变。这两件事经常被混为一谈。
## 常量池:同一个字面量只存一份
### 字面量走池子
源码里直接写出来的字符串字面量,会被放进一块叫「字符串常量池」的内存区。内容相同的字面量只存一份,后来的直接复用。
```java
String a = "java";
String b = "java";
System.out.println(a == b); // true,指向池中同一个对象
一个中等规模的项目里,“utf-8”、“GET”、“id” 这类短串可能出现上千次。要是每次都造对象,内存全被同样的几个字符撑满了。池子就是为了省这份钱。
new 出来的不走池子
String c = new String("java");
System.out.println(a == c); // false,c 是堆里另开的新对象
System.out.println(a == c.intern()); // true,intern 换回池中那一份
```bash
`new String("java")` 其实弄出了两样东西:池里那份 "java",再加堆里一个把它复制一遍的新对象。纯属浪费。`intern()` 的作用是去池子里查一下,有就返回池中那份。
> [!TIP]
> 日常直接写字面量就好,别用 `new String(...)`。`intern()` 也别乱用,它只在需要把海量重复的运行时字符串合并成一份时才有价值,滥用反而拖慢速度。
## == 还是 equals,这是个问题
### 两个反例摆在一起看
`==` 比的是「是不是同一个对象」,不是内容。同样是拼出 "java",结果可能天差地别。
```java
String x = "ja" + "va"; // 两个字面量相加,编译期就合并好了
String y = "java";
System.out.println(x == y); // true
String p = "ja";
String q = p + "va"; // 变量参与拼接,运行时才算得出来
System.out.println(q == y); // false!内容一样,对象不同
System.out.println(q.equals(y)); // true,比内容永远靠谱
差别在编译器身上。第 1 行两边都是常量,编译器直接算好写死成 “java”,于是复用了池中那份。第 6 行左边是变量,只能等运行时拼,产物是堆里一个崭新对象,== 自然为 false。
结论很干脆:判断字符串内容,永远用 equals。== 有时对有时错,比一直错更可怕。
Warning判等要防空指针。写成
str.equals("yes")时,一旦 str 是 null 就抛NullPointerException。反过来写成"yes".equals(str)永远安全,因为字面量绝不为 null。这个技巧叫「常量在前」。
常用方法速查表
先给一张表压压惊,用法下面展开。
| 方法 | 作用 | 示例结果 |
|---|---|---|
length() | 字符个数 | ”hello” → 5 |
charAt(i) | 取下标 i 处的字符 | 下标 1 → ‘e’ |
isEmpty() | 长度是否为 0 | "" → true |
isBlank() | 是否全是空白(Java 11) | ” ” → true |
contains(s) | 是否包含子串 | true / false |
indexOf(s) | 首次出现下标,找不到返回 -1 | ”hello” 找 “l” → 2 |
substring(a,b) | 截取下标 a 到 b(不含 b) | “hello” 取 1~4 → “ell” |
replace(a,b) | 按普通文本全部替换 | ”a,b” → “a-b” |
split(re) | 按正则拆成数组 | ”a,b,c” → 三个元素 |
strip() | 去掉首尾空白(Java 11) | ” hi ” → “hi” |
repeat(n) | 重复 n 次(Java 11) | “ab” 重复 2 次 → “abab” |
equalsIgnoreCase(s) | 忽略大小写比内容 | true / false |
表里标了版本号的三个是 Java 11 才有的,老项目跑在 Java 8 上会编译不过。本教程基线是 Java 25,放心用。
查找与判断
判断是否包含、以什么开头结尾,一行就搞定:
String s = "hello world";
System.out.println(s.contains("world")); // true
System.out.println(s.startsWith("hello")); // true
System.out.println(s.endsWith(".txt")); // false
System.out.println(s.indexOf("o")); // 4,第一次出现的位置
System.out.println(s.lastIndexOf("o")); // 7,最后一次出现的位置
System.out.println(s.indexOf("xyz")); // -1,没找到
```bash
最后一行要记牢:`indexOf` 找不到时返回 -1,不是 0 也不抛异常。所以判断「有没有」常写成 `indexOf(...) >= 0`,不过直接用 `contains` 更直白。想从后往前找就用 `lastIndexOf`,解析文件扩展名、取路径最后一段时特别顺手。
## 判空、去空白与大小写
判空有三个容易混的方法,一次说清:
```java
String s = " ";
System.out.println(s.isEmpty()); // false,它长度是 3
System.out.println(s.isBlank()); // true,全是空白字符
System.out.println("".isEmpty()); // true
isEmpty() 只看长度是不是 0,一串空格它认为「不空」。真正想表达「没有有效内容」,用 isBlank()。
去首尾空白,老方法是 trim(),它只认 ASCII 空格及以下的控制字符,中文全角空格它删不掉。Java 11 起用 strip(),按 Unicode 规则处理,更可靠:
String raw = "\u3000 hi \u3000"; // 前后是全角空格
System.out.println(raw.trim().length()); // 3,全角空格没删掉
System.out.println(raw.strip().length()); // 2,干净了
```java
> [!NOTE]
> `trim()` 属于历史兼容,不推荐在新代码里用。处理用户输入、表单字段一律换成 `strip()`。
大小写转换同样返回新串,忽略大小写比较用 `equalsIgnoreCase`,比对邮箱、验证码时很好用:
```java
System.out.println("Hello".toLowerCase()); // hello
System.out.println("Hello".equalsIgnoreCase("HELLO")); // true
截取、替换与拆分
切一段用 substring,两个参数分别是起点和终点,终点不包含:
String s = "hello";
System.out.println(s.substring(1, 4)); // "ell",取下标 1、2、3
System.out.println(s.substring(2)); // "llo",一个参数表示切到结尾
```java
替换有两套。`replace` 按普通文本替换,`replaceAll` 的第一个参数是正则表达式:
```java
System.out.println("a.b.c".replace(".", "-")); // a-b-c,点就是点
System.out.println("a.b.c".replaceAll(".", "-")); // -----,点在正则里匹配任意字符
这两行结果完全不同,是新手常见的困惑来源。不涉及正则就用 replace,别图顺手写 replaceAll。
拆分用 split,参数也是正则;拼回去用静态方法 String.join:
String[] parts = "a,b,c".split(","); // 三个元素
String joined = String.join("-", "a", "b", "c"); // a-b-c
```bash
> [!WARNING]
> `substring` 的结束下标不包含在内,很多人写 0 到 5 以为取到第 5 个字符,其实取的是下标 0~4。下标越界会抛 `StringIndexOutOfBoundsException`,截取前先确认长度。
## 转数字、转字符数组与编码
字符串和数字互转非常常见:
```java
int n = Integer.parseInt("123"); // 字符串 → 数字
String s = String.valueOf(456); // 数字 → 字符串
parseInt 遇到非数字内容会抛 NumberFormatException,处理用户输入时要么先校验,要么用异常兜住。
要逐个处理字符,转成字符数组:
char[] cs = "hi".toCharArray();
System.out.println(cs[0]); // h
```java
涉及中文等多字节字符时,编码是重灾区。`getBytes()` 不带参数会用平台默认编码,同一份代码在不同机器上跑出不同字节,乱码就是这么来的。显式指定 UTF-8:
```java
import java.nio.charset.StandardCharsets;
byte[] bytes = "你好".getBytes(StandardCharsets.UTF_8);
String back = new String(bytes, StandardCharsets.UTF_8);
System.out.println(bytes.length); // 6,一个汉字占 3 字节
System.out.println(back.length()); // 2,字符个数还是 2
最后两行值得对照着看:字节数和字符数不是一回事,length() 数的是字符不是字节。
现代 Java 的便利方法
Java 11 往后,String 又添了几个贴心方法:
System.out.println("ab".repeat(3)); // ababab
System.out.println("a\nb\nc".lines().count()); // 3,按行拆成流
System.out.println(" x ".stripLeading()); // "x ",只去左边
```bash
`repeat` 画分隔线特别方便,`lines()` 逐行处理多行文本比自己按换行符切稳妥得多——它同时认 Windows 和 Unix 的换行符。
## 常见疑问
**问:字符串常量池到底放在哪里?**
Java 7 之前在方法区(永久代),Java 7 起搬到了堆里。搬家之后池子能被垃圾回收,也不再受永久代大小限制。日常写代码不用关心这个位置,但面试常问。
**问:既然不可变,为什么感觉字符串拼接也没多慢?**
单行里的 `+` 拼接,编译器会自动优化成 StringBuilder。真正慢的是循环里反复用 `+` 累加,那种场景每转一圈都造新对象。下一章专门讲怎么办。
**问:比较字符串大小该用什么?**
用 `compareTo`,按字典序返回负数、0、正数。忽略大小写排序用 `compareToIgnoreCase`。别拿 `>` 或 `<` 去比字符串,那根本编译不过。
## 小结
String 不可变,所有「修改」方法都返回新串,返回值必须接住。内容相同的字面量共享常量池里同一份对象。`==` 比对象、`equals` 比内容,判等永远用 `equals` 且把常量写在前面。查找、截取、替换、拆分是日常基本功;去空白用 `strip` 不用 `trim`;涉及中文一律显式指定 UTF-8 编码。