Skip to content

Java String 详解

String 是 Java 里最常用的类之一。请求参数、JSON 字段、SQL、日志、缓存 Key、消息体、文件内容都大量使用字符串。String 学不好,会遇到性能差、空指针、比较错误、乱码、内存占用高等问题。

为什么 String 是不可变的

String 一旦创建,内容不能被修改。所谓修改字符串,本质上是创建新的字符串对象。

java
String name = "Tom";
name = name + " Lee";

第二行不是把原来的 "Tom" 改掉,而是创建了新的 "Tom Lee",然后让 name 指向新对象。

不可变的好处:

好处说明
安全字符串可作为类名、文件路径、网络地址等敏感参数
可缓存字符串常量池可以复用对象
线程安全多线程共享同一个字符串不用担心内容被改
适合做 Map Key哈希值可稳定使用

String 内存关系

mermaid
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"]

示例:

java
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));
    }
}

结论:

  1. == 比较引用地址。
  2. equals 比较字符串内容。
  3. 字符串内容比较必须用 equals

字符串常量池到底是什么

字符串常量池可以理解为 JVM 对字符串字面量做的一块复用区域。代码里多次出现相同字面量时,JVM 尽量让它们指向同一个字符串对象,减少重复对象。

java
String a = "order";
String b = "order";
System.out.println(a == b); // true

这里 ab 都指向常量池里同一个 "order"。但下面不一样:

java
String a = "order";
String b = new String("order");
System.out.println(a == b);      // false
System.out.println(a.equals(b)); // true

new 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() 的作用是返回字符串在常量池中的引用。

java
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
    }
}

流程:

mermaid
flowchart TD
    A["调用 str.intern"] --> B{"常量池是否已有相同内容"}
    B -- "有" --> C["返回常量池中的引用"]
    B -- "没有" --> D["把该内容放入常量池"]
    D --> E["返回常量池引用"]

intern 不是性能魔法。它适合少量、高重复度、生命周期长的字符串,比如状态码、枚举式编码。不要对海量用户输入、订单号、日志内容盲目 intern,否则会让堆里保留大量字符串,增加 GC 压力。

字符串拼接的版本差异

字符串拼接不是所有版本都完全一样:

场景JDK 8 常见理解JDK 9+ 常见理解
编译期常量拼接编译器直接折叠成一个常量同样会常量折叠
运行期 + 拼接通常编译成 StringBuilder append使用 invokedynamic 调用拼接工厂优化
循环大量拼接仍不建议用 +仍不建议用 +

示例:

java
String a = "hello" + "world"; // 编译期可确定,直接变成 "helloworld"

String name = "Tom";
String b = "hello" + name;   // 运行期才知道 name,不能简单当常量

循环里大量拼接为什么不好:

mermaid
flowchart TD
    A["循环第 1 次拼接"] --> B["创建临时字符串"]
    B --> C["循环第 2 次拼接"]
    C --> D["继续创建临时字符串"]
    D --> E["对象数量增加"]
    E --> F["GC 压力上升"]

StringBuilder 的思路是内部维护一个可扩容数组,多次 append 尽量复用同一个 builder,最后一次性 toString

equals、Objects.equals 和常量写左边

字符串内容比较必须用 equals,但要注意空指针。

不安全:

java
if (status.equals("PAID")) {
    System.out.println("已支付");
}

如果 statusnull,会抛空指针。更安全:

java
if ("PAID".equals(status)) {
    System.out.println("已支付");
}

或者:

java
import java.util.Objects;

if (Objects.equals(status, "PAID")) {
    System.out.println("已支付");
}

商业代码里,状态、类型、渠道这类字段经常来自数据库、MQ 或外部接口,不能默认一定非空。

split、replaceAll 和正则陷阱

splitreplaceAll 使用的是正则表达式,不是普通字符串。

错误示例:

java
String value = "a.b.c";
String[] parts = value.split("."); // 错误,. 在正则中表示任意字符
System.out.println(parts.length);

正确写法:

java
String[] parts = value.split("\\.");

如果只是普通替换,优先用 replace,不要误用 replaceAll

java
String text = "a.b.c";
System.out.println(text.replace(".", "-"));    // 普通字符串替换
System.out.println(text.replaceAll("\\.", "-")); // 正则替换

生产里解析配置、文件名、版本号、IP、路径时,经常因为正则元字符导致切分结果不符合预期。

字符串拼接原理

少量拼接可以直接用 +

java
String message = "订单 " + orderId + " 创建成功";

但在循环中大量拼接不要直接用 +

java
public class BadStringConcatDemo {
    public static String buildSqlBad(int count) {
        String sql = "";
        for (int i = 0; i < count; i++) {
            sql += "?,"; // 每次都可能创建新字符串
        }
        return sql;
    }
}

更好的写法是 StringBuilder

java
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

java
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 表示没有对象,"" 表示有字符串对象但内容为空。

java
String a = null;
String b = "";

System.out.println(b.length());
// System.out.println(a.length()); // NullPointerException

安全判断:

java
public class StringCheckDemo {
    public static boolean hasText(String value) {
        return value != null && value.trim().length() > 0;
    }
}

业务系统中,外部请求参数必须先判断 null,再做长度、格式、正则校验。

字符编码为什么重要

字符串在 Java 内部是字符数据,但网络传输、文件保存都要变成字节。字符和字节之间转换需要编码。

mermaid
flowchart TD
    A["Java String 字符"] --> B["按照 UTF-8 编码"]
    B --> C["字节数组"]
    C --> D["网络或文件"]
    D --> E["按照 UTF-8 解码"]
    E --> F["Java String 字符"]

Demo:

java
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 标识:

  1. 如果字符串只包含 Latin-1 能表示的字符,例如英文、数字、常见符号,可以用 1 字节保存一个字符。
  2. 如果包含中文等字符,就使用 UTF-16 方式保存。

这对日志、缓存 key、编码、英文状态值很多的服务有内存收益。但学习和面试时不要把它理解成“Java String 统一是 UTF-8 存储”。Java 源码和运行时的编码、文件网络传输编码、String 内部存储优化是三个层面。

商业常用 Demo:接口签名原文拼接

很多支付、开放平台、数据采集接口都需要把参数按固定规则拼成签名原文。这个场景能体现 String 的稳定拼接、排序、空值处理和编码意识。

java
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();
    }
}

为什么这样写:

  1. TreeMap 保证参数按 key 排序,不同机器拼接顺序一致。
  2. StringBuilder 避免循环拼接产生大量临时字符串。
  3. 空值规则统一,否则调用方和服务端签名原文不一致。
  4. 摘要前显式使用 UTF-8,避免不同环境默认编码不同。

如果这些规则散落在业务代码里,线上会出现“同样参数本地签名正确,生产验签失败”的问题。

线上排查:乱码、内存和比较错误

mermaid
flowchart TD
    A["String 相关线上问题"] --> B{"表现是什么"}
    B -- "中文乱码" --> C["检查请求编码、响应编码、文件编码、数据库连接编码"]
    B -- "缓存查不到" --> D["检查 key 拼接规则、空格、大小写、分隔符"]
    B -- "条件判断异常" --> E["检查是否用 == 比较内容或空值未处理"]
    B -- "GC 压力大" --> F["检查循环拼接、大量 substring、日志大字符串"]
    B -- "签名失败" --> G["检查排序、空值、编码、换行和大小写"]

排查口诀:

  1. 先打印字符串长度和十六进制字节,确认是不是肉眼看起来一样但实际不同。
  2. 文件、网络、数据库交互都显式指定编码。
  3. 比较内容用 equalsObjects.equals
  4. 大量拼接用 StringBuilder,大文本处理考虑流式处理。
  5. 缓存 key、签名原文、消息幂等 key 必须统一封装。

商业常用 Demo:生成缓存 Key

java
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
不判空直接调用方法空指针先判空
随处手写缓存 KeyKey 不一致导致缓存失效统一封装

面试常问

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。