Java String 详解
String 是 Java 里最常用的类之一。请求参数、JSON 字段、SQL、日志、缓存 Key、消息体、文件内容都大量使用字符串。String 学不好,会遇到性能差、空指针、比较错误、乱码、内存占用高等问题。
为什么 String 是不可变的
String 一旦创建,内容不能被修改。所谓修改字符串,本质上是创建新的字符串对象。
String name = "Tom";
name = name + " Lee";第二行不是把原来的 "Tom" 改掉,而是创建了新的 "Tom Lee",然后让 name 指向新对象。
不可变的好处:
| 好处 | 说明 |
|---|---|
| 安全 | 字符串可作为类名、文件路径、网络地址等敏感参数 |
| 可缓存 | 字符串常量池可以复用对象 |
| 线程安全 | 多线程共享同一个字符串不用担心内容被改 |
| 适合做 Map Key | 哈希值可稳定使用 |
String 内存关系
flowchart TD
A["执行 String 字面量代码"] --> B["常量池中保存 abc 对象"]
B --> C["s1 和 s2 指向同一个常量池对象"]
C --> D["执行 new String abc"]
D --> E["堆内存中创建新的 String 对象"]
E --> F["堆对象内容也是 abc"]
F --> G["equals 为 true 但 == 为 false"]示例:
public class StringPoolDemo {
public static void main(String[] args) {
String a = "abc";
String b = "abc";
String c = new String("abc");
System.out.println(a == b);
System.out.println(a == c);
System.out.println(a.equals(c));
}
}结论:
==比较引用地址。equals比较字符串内容。- 字符串内容比较必须用
equals。
字符串常量池到底是什么
字符串常量池可以理解为 JVM 对字符串字面量做的一块复用区域。代码里多次出现相同字面量时,JVM 尽量让它们指向同一个字符串对象,减少重复对象。
String a = "order";
String b = "order";
System.out.println(a == b); // true这里 a 和 b 都指向常量池里同一个 "order"。但下面不一样:
String a = "order";
String b = new String("order");
System.out.println(a == b); // false
System.out.println(a.equals(b)); // truenew String("order") 会创建新的 String 对象,所以引用地址不同。面试里一定要说清楚:常量池解决的是字面量复用,不代表所有内容相同的 String 都是同一个对象。
JDK 7、JDK 8、JDK 9 相关差异
| 版本 | 重点差异 |
|---|---|
| JDK 6 及以前 | 字符串常量池主要在永久代中,永久代空间较小,频繁 intern 容易有风险 |
| JDK 7 | 字符串常量池移动到堆中,intern 行为也和 JDK 6 有明显差异 |
| JDK 8 | 永久代被移除,类元数据进入元空间,字符串常量池仍在堆中 |
| JDK 9+ | String 内部从 char[] 优化为 byte[] + coder,也叫 Compact Strings,Latin-1 字符可以更省内存 |
企业项目最常见的是 JDK 8,所以要重点记住:字符串常量池在堆里,元空间不是用来存字符串对象内容的。
intern 原理
intern() 的作用是返回字符串在常量池中的引用。
public class InternDemo {
public static void main(String[] args) {
String a = new String("order");
String b = a.intern();
String c = "order";
System.out.println(a == b); // false
System.out.println(b == c); // true
}
}流程:
flowchart TD
A["调用 str.intern"] --> B{"常量池是否已有相同内容"}
B -- "有" --> C["返回常量池中的引用"]
B -- "没有" --> D["把该内容放入常量池"]
D --> E["返回常量池引用"]intern 不是性能魔法。它适合少量、高重复度、生命周期长的字符串,比如状态码、枚举式编码。不要对海量用户输入、订单号、日志内容盲目 intern,否则会让堆里保留大量字符串,增加 GC 压力。
字符串拼接的版本差异
字符串拼接不是所有版本都完全一样:
| 场景 | JDK 8 常见理解 | JDK 9+ 常见理解 |
|---|---|---|
| 编译期常量拼接 | 编译器直接折叠成一个常量 | 同样会常量折叠 |
运行期 + 拼接 | 通常编译成 StringBuilder append | 使用 invokedynamic 调用拼接工厂优化 |
| 循环大量拼接 | 仍不建议用 + | 仍不建议用 + |
示例:
String a = "hello" + "world"; // 编译期可确定,直接变成 "helloworld"
String name = "Tom";
String b = "hello" + name; // 运行期才知道 name,不能简单当常量循环里大量拼接为什么不好:
flowchart TD
A["循环第 1 次拼接"] --> B["创建临时字符串"]
B --> C["循环第 2 次拼接"]
C --> D["继续创建临时字符串"]
D --> E["对象数量增加"]
E --> F["GC 压力上升"]StringBuilder 的思路是内部维护一个可扩容数组,多次 append 尽量复用同一个 builder,最后一次性 toString。
equals、Objects.equals 和常量写左边
字符串内容比较必须用 equals,但要注意空指针。
不安全:
if (status.equals("PAID")) {
System.out.println("已支付");
}如果 status 是 null,会抛空指针。更安全:
if ("PAID".equals(status)) {
System.out.println("已支付");
}或者:
import java.util.Objects;
if (Objects.equals(status, "PAID")) {
System.out.println("已支付");
}商业代码里,状态、类型、渠道这类字段经常来自数据库、MQ 或外部接口,不能默认一定非空。
split、replaceAll 和正则陷阱
split 和 replaceAll 使用的是正则表达式,不是普通字符串。
错误示例:
String value = "a.b.c";
String[] parts = value.split("."); // 错误,. 在正则中表示任意字符
System.out.println(parts.length);正确写法:
String[] parts = value.split("\\.");如果只是普通替换,优先用 replace,不要误用 replaceAll:
String text = "a.b.c";
System.out.println(text.replace(".", "-")); // 普通字符串替换
System.out.println(text.replaceAll("\\.", "-")); // 正则替换生产里解析配置、文件名、版本号、IP、路径时,经常因为正则元字符导致切分结果不符合预期。
字符串拼接原理
少量拼接可以直接用 +:
String message = "订单 " + orderId + " 创建成功";但在循环中大量拼接不要直接用 +:
public class BadStringConcatDemo {
public static String buildSqlBad(int count) {
String sql = "";
for (int i = 0; i < count; i++) {
sql += "?,"; // 每次都可能创建新字符串
}
return sql;
}
}更好的写法是 StringBuilder:
public class GoodStringConcatDemo {
public static String buildPlaceholders(int count) {
StringBuilder builder = new StringBuilder();
for (int i = 0; i < count; i++) {
if (i > 0) {
builder.append(",");
}
builder.append("?");
}
return builder.toString();
}
}StringBuilder 和 StringBuffer
| 类型 | 是否线程安全 | 适合场景 |
|---|---|---|
StringBuilder | 否 | 单线程内字符串拼接,业务最常用 |
StringBuffer | 是 | 多线程共享同一个拼接对象,较少使用 |
多数业务方法里的局部变量不会被多线程共享,所以优先使用 StringBuilder。
常用 API
public class StringApiDemo {
public static void main(String[] args) {
String mobile = " 13800138000 ";
String value = mobile.trim();
System.out.println(value.length());
System.out.println(value.startsWith("138"));
System.out.println(value.substring(0, 3));
System.out.println(value.contains("0013"));
System.out.println(value.replace("138", "139"));
}
}业务开发里常见用法:
| API | 场景 |
|---|---|
trim | 去掉用户输入前后空格 |
isEmpty | 判断空字符串 |
startsWith | 判断文件前缀、URL 前缀 |
substring | 截取编码、手机号片段 |
split | 解析逗号分隔配置 |
replace | 脱敏、模板替换 |
空字符串和 null
null 表示没有对象,"" 表示有字符串对象但内容为空。
String a = null;
String b = "";
System.out.println(b.length());
// System.out.println(a.length()); // NullPointerException安全判断:
public class StringCheckDemo {
public static boolean hasText(String value) {
return value != null && value.trim().length() > 0;
}
}业务系统中,外部请求参数必须先判断 null,再做长度、格式、正则校验。
字符编码为什么重要
字符串在 Java 内部是字符数据,但网络传输、文件保存都要变成字节。字符和字节之间转换需要编码。
flowchart TD
A["Java String 字符"] --> B["按照 UTF-8 编码"]
B --> C["字节数组"]
C --> D["网络或文件"]
D --> E["按照 UTF-8 解码"]
E --> F["Java String 字符"]Demo:
import java.nio.charset.StandardCharsets;
public class CharsetDemo {
public static void main(String[] args) {
String text = "中文";
byte[] bytes = text.getBytes(StandardCharsets.UTF_8);
String restored = new String(bytes, StandardCharsets.UTF_8);
System.out.println(restored);
}
}如果编码和解码使用的字符集不一致,就会乱码。
String 内部结构和内存占用
JDK 8 里可以简单理解为:String 对象内部主要持有一个 char[],每个 char 占 2 字节。
JDK 9 之后引入 Compact Strings,内部变成 byte[] value 加一个 coder 标识:
- 如果字符串只包含 Latin-1 能表示的字符,例如英文、数字、常见符号,可以用 1 字节保存一个字符。
- 如果包含中文等字符,就使用 UTF-16 方式保存。
这对日志、缓存 key、编码、英文状态值很多的服务有内存收益。但学习和面试时不要把它理解成“Java String 统一是 UTF-8 存储”。Java 源码和运行时的编码、文件网络传输编码、String 内部存储优化是三个层面。
商业常用 Demo:接口签名原文拼接
很多支付、开放平台、数据采集接口都需要把参数按固定规则拼成签名原文。这个场景能体现 String 的稳定拼接、排序、空值处理和编码意识。
import java.nio.charset.StandardCharsets;
import java.security.MessageDigest;
import java.util.Map;
import java.util.TreeMap;
public class SignTextDemo {
public static void main(String[] args) throws Exception {
Map<String, String> params = new TreeMap<String, String>();
params.put("appId", "medical-platform");
params.put("timestamp", "1783296000");
params.put("orderId", "90001");
params.put("empty", "");
String text = buildSignText(params, "secret-key");
System.out.println(text);
System.out.println(sha256(text));
}
public static String buildSignText(Map<String, String> params, String secret) {
StringBuilder builder = new StringBuilder();
for (Map.Entry<String, String> entry : params.entrySet()) {
String value = entry.getValue();
if (value == null || value.length() == 0) {
continue;
}
if (builder.length() > 0) {
builder.append("&");
}
builder.append(entry.getKey()).append("=").append(value);
}
builder.append("&secret=").append(secret);
return builder.toString();
}
public static String sha256(String text) throws Exception {
MessageDigest digest = MessageDigest.getInstance("SHA-256");
byte[] bytes = digest.digest(text.getBytes(StandardCharsets.UTF_8));
StringBuilder hex = new StringBuilder();
for (byte b : bytes) {
String value = Integer.toHexString(b & 0xff);
if (value.length() == 1) {
hex.append("0");
}
hex.append(value);
}
return hex.toString();
}
}为什么这样写:
TreeMap保证参数按 key 排序,不同机器拼接顺序一致。StringBuilder避免循环拼接产生大量临时字符串。- 空值规则统一,否则调用方和服务端签名原文不一致。
- 摘要前显式使用 UTF-8,避免不同环境默认编码不同。
如果这些规则散落在业务代码里,线上会出现“同样参数本地签名正确,生产验签失败”的问题。
线上排查:乱码、内存和比较错误
flowchart TD
A["String 相关线上问题"] --> B{"表现是什么"}
B -- "中文乱码" --> C["检查请求编码、响应编码、文件编码、数据库连接编码"]
B -- "缓存查不到" --> D["检查 key 拼接规则、空格、大小写、分隔符"]
B -- "条件判断异常" --> E["检查是否用 == 比较内容或空值未处理"]
B -- "GC 压力大" --> F["检查循环拼接、大量 substring、日志大字符串"]
B -- "签名失败" --> G["检查排序、空值、编码、换行和大小写"]排查口诀:
- 先打印字符串长度和十六进制字节,确认是不是肉眼看起来一样但实际不同。
- 文件、网络、数据库交互都显式指定编码。
- 比较内容用
equals或Objects.equals。 - 大量拼接用
StringBuilder,大文本处理考虑流式处理。 - 缓存 key、签名原文、消息幂等 key 必须统一封装。
商业常用 Demo:生成缓存 Key
public class CacheKeyBuilder {
public static String userOrderKey(long userId, long orderId) {
return new StringBuilder("order")
.append(":")
.append(userId)
.append(":")
.append(orderId)
.toString();
}
public static void main(String[] args) {
System.out.println(userOrderKey(1001L, 90001L));
}
}缓存 Key 要稳定、可读、避免拼错。复杂场景建议统一封装,不要散落在各处手写。
常见风险
| 问题 | 后果 | 正确做法 |
|---|---|---|
用 == 比较字符串 | 内容相同也可能返回 false | 使用 equals |
循环里大量 + 拼接 | 创建大量临时对象 | 使用 StringBuilder |
| 不指定编码 | 不同机器结果不一致 | 显式使用 UTF-8 |
| 不判空直接调用方法 | 空指针 | 先判空 |
| 随处手写缓存 Key | Key 不一致导致缓存失效 | 统一封装 |
面试常问
String 为什么不可变?
因为不可变可以保证安全、复用、线程安全和 hash 稳定。类名、文件路径、URL、数据库连接信息等大量关键数据都用 String,如果内容能被随意修改,会破坏安全边界。不可变也让字符串常量池和 HashMap key 更可靠。
String a = "abc" 和 new String("abc") 区别?
字面量会优先使用字符串常量池里的对象;new String("abc") 会创建新的 String 对象。两者内容相同,所以 equals 为 true,但引用地址可能不同,所以 == 为 false。
intern() 做什么?intern() 返回字符串在常量池中的引用。池里已有相同内容就返回已有引用;没有则把对应内容放入池中并返回池中引用。它适合少量高重复字符串,不适合对海量动态字符串盲目使用。
StringBuilder 和 StringBuffer 区别?
StringBuilder 非线程安全,性能更好,适合方法内部局部拼接,是业务最常用选择;StringBuffer 方法带同步,线程安全但开销更大,只有多个线程共享同一个拼接对象时才考虑。
为什么会乱码?
因为字符转字节和字节转字符使用的编码不一致。Java String、文件字节、HTTP 请求响应、数据库连接都有编码环节,任何一处不一致都可能乱码。生产建议统一 UTF-8,并在 IO、HTTP、数据库连接配置中显式声明。
本章小结
String 的核心是不可变、常量池、内容比较、拼接性能和编码转换。业务开发中要习惯使用 equals 比较内容,用 StringBuilder 做大量拼接,外部输入先判空,文件和网络传输统一使用 UTF-8。
