Java String 深度解析(含高频面试题)

前言

在 Java 生态系统中,java.lang.String 无疑是使用频率最高、面试考察最密集、同时也是设计哲学体现最为淋漓尽致的类。它看似只是一个简单的文本容器,但其背后融合了不可变设计、内存优化、JVM 运行时支持、字符编码规范以及并发安全等多重考量。

一、String 的底层存储结构与历史演进

理解 String 的一切特性,都必须从其底层存储结构出发。String 的内部表示并非一成不变,而是随着 JDK 版本的迭代经历了两次重大变革,每一次变革都深刻反映了 Java 对"内存效率"与"兼容性"之间权衡的思考。

1.1 JDK 8 及以前:基于 char\[\] 的 UTF-16 固定编码

在 JDK 8 及之前的版本中,String 的核心字段定义如下:

java 复制代码
public final class String implements java.io.Serializable, Comparable<String>, CharSequence {
    /** The value is used for character storage. */
    private final char[] value;

    /** Cache the hash code for the string */
    private int hash; // Default to 0
    
    // ...
}

这种设计采用 UTF-16 编码,每个 char 占用固定的 2 个字节。其优点在于索引访问是 O(1) 的,逻辑简单;但缺点极其明显:对于主要由 ASCII/Latin-1 字符组成的字符串(这在英文为主的程序中占比极高),每个字符都浪费了 1 个字节的内存。在实际生产环境中,大量短字符串对象的存在导致堆内存被严重浪费,GC 压力随之增大。

1.2 JDK 9+:Compact Strings 紧凑字符串

为了解决上述内存浪费问题,JDK 9 引入了 Compact Strings 优化(JEP 254)。String 的内部存储发生了根本性变化:

java 复制代码
public final class String implements java.io.Serializable, Comparable<String>, CharSequence {
    @Stable
    private final byte[] value;

    /**
     * The identifier of the encoding used to encode the bytes in {@code value}.
     * The supported values in this implementation are:
     * LATIN1
     * UTF16
     */
    private final byte coder;

    static final byte LATIN1 = 0;
    static final byte UTF16 = 1;
    
    // ...
}
对比维度 JDK 8 及以前 JDK 9+ (Compact Strings)
底层存储 char[] value byte[] value + byte coder
编码方式 UTF-16 (固定2字节/字符) Latin-1 (1字节) 或 UTF-16 (2字节) 自适应
内存占用 较高,Latin-1字符也占2字节 Latin-1字符串节省约50%,平均节省30%-40%
索引访问 直接数组下标 需根据 coder 判断偏移量计算
API 兼容性 - 完全透明,所有公开API行为不变
JVM 支持 无特殊支持 JIT编译器对String操作有专门内联优化

核心工作原理 :当创建一个 String 时,JVM 会检测内容是否全部落在 Latin-1(ISO-8859-1,即 0x00~0xFF)范围内。如果是,则使用 LATIN1 编码,每个字符仅占 1 字节;如果包含超出 Latin-1 范围的字符,则自动回退到 UTF16 编码。这个判断和转换过程对开发者完全透明,所有公开 API 的行为与旧版本保持一致。

⚠️ 重要澄清 :Compact Strings 不是压缩算法,它是一种编码自适应策略 。它不会改变 String 的任何语义行为,也不会影响序列化格式(序列化仍然使用标准的 UTF-8/Modified UTF-8)。该优化可通过 -XX:-CompactStrings 关闭,但在绝大多数场景下应保持开启。

二、不可变性(Immutability):String 设计的基石

String 被声明为 final 类,其内部存储字段 valuecoder 均为 private final,且没有任何修改内部状态的公开方法。这种彻底的不可变性绝非偶然,而是经过深思熟虑的设计决策。

2.1 为什么必须是不可变的?

安全性保障:String 被广泛用作安全敏感场景的参数,包括网络连接 URL、文件路径、数据库连接字符串、反射中的类名、安全管理器中的权限标识等。如果 String 是可变的,攻击者可以在通过安全检查后篡改字符串内容,导致严重的安全漏洞。不可变性从根本上杜绝了此类 TOCTOU(Time-of-Check to Time-of-Use)攻击。

线程安全的天然保证:不可变对象天然是线程安全的。多个线程可以同时读取同一个 String 实例而无需任何同步措施。这使得 String 可以安全地作为 HashMap 的 Key、ConcurrentHashMap 的键值、以及在多线程间自由传递而无需防御性拷贝。

HashCode 缓存与性能:由于内容不可变,hashCode 只需计算一次即可永久缓存。这是 String 作为 HashMap/HashSet 键时高性能的关键前提。如果 String 可变,每次 hashCode 调用都需要重新遍历计算,或者需要在每次修改时失效缓存,两者都会带来显著的性能退化。

java 复制代码
// String.hashCode() 源码 ------ 懒计算 + 缓存
private int hash; // Default to 0

public int hashCode() {
    int h = hash;
    if (h == 0 && !value.isEmpty()) {
        h = isLatin1() ? StringLatin1.hashCode(value) 
                       : StringUTF16.hashCode(value);
        hash = h; // 写入缓存,后续调用直接返回
    }
    return h;
}

字符串常量池的前提条件:只有不可变对象才能被安全地共享。如果 String 可变,常量池中共享同一个引用的多个变量就会互相干扰,常量池机制将彻底崩溃。

类加载与安全框架的基础:Java 的类加载机制、安全沙箱、RMI 远程调用等都依赖 String 的不可变性来保证类型标识和权限描述的完整性。

2.2 final 修饰的多层含义

  • 类级别 final:禁止继承。防止子类通过重写方法破坏不可变契约或引入不安全行为。
  • 字段级别 final:配合私有访问控制,确保对象构造完成后内部状态永远不会被修改。
  • JMM 安全发布保证:根据 Java 内存模型规范,final 字段的初始化在构造函数返回前对其他线程可见。这意味着当一个 String 引用被发布到其他线程时,该线程一定能看到完整、正确初始化的 String 内容,不存在部分构造的问题。

2.3 不可变性的代价与应对

不可变性意味着每次"修改"操作(如拼接、替换、截取)都会产生新的 String 对象。这在频繁修改的场景下会导致大量的临时对象分配和 GC 压力。Java 通过以下机制缓解这一问题:

  • StringBuilder / StringBuffer:提供可变的字符序列构建器
  • 编译器优化 :JDK 8 的 invokespecial + StringBuilder 自动转换;JDK 9+ 的 invokedynamic + StringConcatFactory
  • 常量池折叠:编译期常量表达式直接在编译时求值
  • JIT 内联优化:热点代码中的 String 操作被 JIT 编译器深度优化

三、字符串常量池(String Pool)与 intern 机制

3.1 常量池的本质与作用

字符串常量池是 JVM 维护的一个全局唯一的字符串表(Intern Table),其核心目标是去重:相同内容的字符串在池中只保留一份实例,所有引用都指向这同一个对象。这大幅减少了堆内存中重复字符串的数量。

java 复制代码
String s1 = "hello";           // 字面量,编译期确定,直接从池中获取
String s2 = "hello";           // 复用池中同一对象
String s3 = new String("hello"); // 强制在堆上创建新对象
String s4 = s3.intern();       // 返回池中已有引用

System.out.println(s1 == s2);  // true  ✅ 同一池对象
System.out.println(s1 == s3);  // false ❌ 堆对象 vs 池对象
System.out.println(s1 == s4);  // true  ✅ intern返回池引用
System.out.println(s3 == s4);  // false ❌ 堆对象 ≠ 池对象

3.2 常量池的位置变迁

JDK 版本 常量池位置 特点
JDK 6 永久代 (PermGen) 大小由 -XX:MaxPermSize 控制,不随堆扩展,易 OOM
JDK 7 堆内存 (Heap) -Xmx 控制,可被 GC 回收
JDK 8+ 堆内存 (Heap) 元空间(Metaspace)仅存类元数据,字符串仍在堆中

这一变迁的意义重大:将字符串移出永久代后,字符串的生命周期完全由 GC 管理,不再存在因常量池满而导致 PermGen OOM 的风险。同时,G1 收集器可以对字符串进行专门的去重优化。

3.3 intern() 方法的正确使用

intern() 是一个 native 方法,其语义是:如果池中已存在内容相同的字符串,则返回池中引用;否则将当前字符串加入池中并返回其引用。

适用场景

  • 解析大量重复文本数据(日志、CSV、JSON 字段名)
  • ETL 数据清洗中枚举值的去重
  • 自定义协议中高频出现的标识符

禁忌场景

  • 不要对高基数(high-cardinality)字符串调用 intern,这会导致常量池无限膨胀
  • 不要在热路径上无条件调用 intern,其本身有哈希查找开销
  • 不要假设 intern 后的字符串永远不被 GC(JDK 7+ 中池对象可被回收)

JDK 8u20+ 的 G1 字符串去重 :G1 收集器提供了 -XX:+UseStringDeduplication 选项,可以在 GC 过程中自动检测堆中内容相同但引用不同的 String 对象,并将它们的底层 byte[]/char[] 指向同一数组。这是一种被动、自动、零代码侵入的去重机制,适合无法手动 intern 的场景。

四、字符串拼接的性能模型与编译器优化

字符串拼接是日常开发中最常见的操作,也是性能陷阱最集中的区域。不同 JDK 版本、不同拼接模式下的行为差异巨大。

4.1 编译期常量折叠

当拼接表达式中的所有操作数都是编译期常量时,编译器会在编译阶段直接完成拼接,运行时零开销:

java 复制代码
// 源码
String s = "Hello" + " " + "World";

// 编译后等价于
String s = "Hello World"; // 常量池中只有一个对象

这适用于字面量、final 基本类型常量、以及由它们组成的表达式。但一旦涉及变量(非 final)、方法调用、运行时计算,就无法在编译期折叠。

4.2 JDK 8 的 StringBuilder 自动转换

JDK 8 编译器会将运行时的 + 拼接转换为 StringBuilder 操作:

java 复制代码
// 源码
String result = a + b + c;

// JDK 8 编译产物(反编译)
String result = new StringBuilder()
    .append(a)
    .append(b)
    .append(c)
    .toString();

循环中的灾难 :如果在循环体内使用 + 拼接,每次迭代都会创建一个新的 StringBuilder 对象、执行 append、再 toString 生成新 String。时间复杂度退化为 O(n²),空间开销线性增长。

java 复制代码
// ❌ 错误示范:O(n²) 时间 + O(n²) 空间
String result = "";
for (String item : list) {
    result += item; // 每次循环都 new StringBuilder + toString
}

// ✅ 正确做法:O(n) 时间 + O(n) 空间
StringBuilder sb = new StringBuilder(list.size() * 16); // 预估容量
for (String item : list) {
    sb.append(item);
}
String result = sb.toString();

4.3 JDK 9+ 的 invokedynamic 与 StringConcatFactory

JDK 9 彻底重构了字符串拼接的编译策略。编译器不再生成 StringBuilder 代码,而是生成 invokedynamic 指令,由 StringConcatFactory 在运行时动态选择最优拼接策略:

java 复制代码
// JDK 9+ 编译产物(反编译)
String result = StringConcatFactory.makeConcatWithConstants(
    lookup, "makeConcatWithConstants", 
    MethodType.methodType(String.class, String.class, String.class, String.class),
    "\u0001\u0001\u0001",  // 模板:三个参数占位符
    ""                     // 常量数组
).invokeExact(a, b, c);

运行时策略选择StringConcatFactory 会根据参数数量、类型、总长度等因素,从多种策略中选择最优解:

  • BC_SB:使用 StringBuilder(传统方式)
  • BC_SB_SIZED:预计算大小的 StringBuilder
  • BC_SB_SIZED_EXACT:精确预计算大小
  • MH_SB_SIZED:MethodHandle + StringBuilder
  • MH_INLINE_SIZED_EXACT直接操作 byte\[\],零中间对象分配(最快)

对于大多数简单拼接(参数少、总长度可控),JDK 9+ 会选择 MH_INLINE_SIZED_EXACT 策略,直接在目标 byte\[\] 上填充数据,避免了 StringBuilder 对象的分配和 toString 时的数组拷贝。这意味着在 JDK 9+ 中,简单的非循环 + 拼接性能已经接近甚至超过手动 StringBuilder

💡 工程建议更新 :JDK 9+ 中,非循环的简单拼接可以直接使用 +,可读性更好且性能无损。但循环内拼接仍然必须使用 StringBuilder,因为 invokedynamic 优化不适用于累积式拼接。

五、关键方法的底层实现与历史教训

5.1 substring 的内存泄漏问题(已修复)

这是 Java 历史上最著名的 String 陷阱之一:

  • JDK 6 及以前substring() 不复制底层数组,而是共享原 char[],仅记录 offset 和 count。这导致一个从大字符串截取的短字符串仍然持有整个大数组的引用,造成严重的内存泄漏。
  • JDK 7+substring() 改为始终拷贝新数组。彻底解决了内存泄漏问题,但代价是增加了数组复制开销。对于需要从大字符串中提取大量子串的场景,需要意识到这个成本。

5.2 equals 方法的优化策略

String.equals() 并非简单的逐字符比较,而是包含了多层快速失败优化:

java 复制代码
public boolean equals(Object anObject) {
    // 1. 引用相等快速返回
    if (this == anObject) return true;
    
    // 2. 类型检查(instanceof 而非 getClass,允许子类参与比较)
    if (anObject instanceof String aString) {
        // 3. 编码不同时不可能相等(JDK 9+)
        if (coder() == aString.coder()) {
            // 4. 根据编码选择专用比较函数
            return isLatin1() 
                ? StringLatin1.equals(value, aString.value)
                : StringUTF16.equals(value, aString.value);
        }
    }
    return false;
}

在 JDK 9+ 中,如果两个 String 的 coder 不同(一个是 LATIN1,一个是 UTF16),可以直接返回 false,无需逐字符比较。这是一个非常有效的快速失败优化。

5.3 compareTo 与字典序

String 实现了 Comparable 接口,其 compareTo 方法按 Unicode 码点逐字符比较。需要注意的是:

  • 比较基于 char/codePoint 的数值,不是语言学的排序规则
  • 如果需要语言敏感的排序(如中文拼音排序、德语变音字母处理),应使用 Collator
  • TreeSet/TreeMap 中使用 String 作为键时,排序依据就是 compareTo

六、字符编码:char、CodePoint 与 Unicode 的正确理解

这是许多开发者忽视但极易出错的领域。

6.1 char 不等于"一个字符"

Java 的 char 是 UTF-16 编码单元(code unit),固定 16 位。而 Unicode 码点(code point)的范围是 U+0000 ~ U+10FFFF,超出了 16 位的表示能力。超出 BMP(Basic Multilingual Plane,U+0000~U+FFFF)的字符(如 Emoji、古文字、音乐符号等)需要用两个 char(代理对,Surrogate Pair) 来表示。

java 复制代码
String emoji = "😀"; // U+1F600
System.out.println(emoji.length());          // 2!不是1
System.out.println(emoji.charAt(0));         // 高代理 \uD83D
System.out.println(emoji.charAt(1));         // 低代理 \uDE00
System.out.println(emoji.codePointAt(0));    // 128512 (0x1F600)
System.out.println(emoji.codePointCount(0, emoji.length())); // 1 ✅

6.2 正确处理 Unicode 文本

操作 错误方式 正确方式
获取字符数 str.length() str.codePointCount(0, str.length())
遍历字符 for(i=0;i<str.length();i++) str.charAt(i) str.codePoints().forEach(...)
截取子串 str.substring(0, n) 按 codePoint 边界截取,避免截断代理对
反转字符串 new StringBuilder(str).reverse() ✅ reverse() 已正确处理代理对
正则匹配 . 匹配单个char 使用 (?s).\X 匹配完整码点

⚠️ 严重警告 :在处理用户输入(尤其是包含 Emoji 的消息、昵称、评论)时,使用 length() 做长度限制、使用 charAt() 遍历、使用 substring() 截取,都可能导致代理对被截断,产生乱码或数据损坏。务必使用 CodePoint 感知的 API。

七、String 相关类的定位与选型

可变性 线程安全 性能 适用场景
String 不可变 安全 读取极快,修改产生新对象 通用文本、Map Key、配置值
StringBuilder 可变 不安全 单线程拼接最快 循环拼接、格式化输出、模板渲染
StringBuffer 可变 synchronized 安全 比 StringBuilder 慢 30%+ 多线程共享可变文本(极少见)
CharSequence 接口 - - 方法参数类型,接受 String/SB/CS 等
StringJoiner 可变 不安全 封装分隔符逻辑 带分隔符的拼接(替代手动 append delimiter)
Formatter 可变 不安全 printf 风格格式化 复杂格式化输出
MessageFormat 可变 不安全 ICU 风格 国际化消息模板

关于 StringBuffer 的现代观点:在现代 Java 开发中,StringBuffer 几乎已经没有使用价值。如果真的需要多线程安全的字符串构建,更好的做法是使用 ThreadLocal 或在业务层面做好同步控制,而不是依赖 StringBuffer 的粗粒度 synchronized。

八、安全考量:为什么密码不应该用 String

这是一个经常被提及但很少被真正理解的安全最佳实践。

问题根源:String 是不可变的,一旦创建就无法主动清除其内容。密码字符串会一直存在于堆内存中,直到被 GC 回收。而在 GC 回收之前,这段内存可能被:

  • 堆转储(heap dump)捕获
  • 内存扫描工具读取
  • 交换到磁盘(swap)
  • 被 core dump 记录

正确做法 :使用 char[]byte[] 存储密码,使用后立即手动清零

java 复制代码
// ❌ 不安全
String password = getPasswordFromUser();
authenticate(password);
// password 在内存中残留,无法清除

// ✅ 安全
char[] password = getPasswordFromUserAsCharArray();
try {
    authenticate(password);
} finally {
    Arrays.fill(password, '\0'); // 立即清除敏感数据
}

注意:许多现代框架(如 Spring Security、Jakarta EE)的认证 API 已经迁移到 char[] 或专门的 Credential 类型。如果你的代码仍在使用 String 传递密码,应视为安全隐患并计划修复。

九、JVM 调优与 String 相关的关键参数

参数 作用 默认值/建议
-XX:+CompactStrings 启用紧凑字符串(JDK 9+) 默认开启,除非有特殊原因否则不要关闭
-XX:+UseStringDeduplication G1 GC 自动字符串去重 JDK 8u20+ 可用,默认关闭,高重复率场景建议开启
-XX:StringDeduplicationAgeThreshold 去重触发的对象年龄阈值 默认3,可根据实际情况调整
-Xmx / -Xms 堆大小(直接影响常量池容量) 根据实际字符串负载设置
-XX:+PrintStringTableStatistics 打印字符串表统计信息 诊断常量池使用情况
-XX:StringTableSize 字符串表桶数量(JDK 8) 默认为质数,大量 intern 时可增大以减少冲突

十、最佳实践

  1. 永远不要用 == 比较字符串内容,除非你明确知道两边都来自常量池或 intern。
  2. 循环内拼接必须使用 StringBuilder,并尽可能预估初始容量以避免扩容拷贝。
  3. JDK 9+ 的非循环简单拼接可直接使用 +,编译器会自动选择最优策略。
  4. 处理含 Emoji/多语言文本时,使用 CodePoint API,不要用 length()/charAt()。
  5. 敏感数据使用 char\[\] 并及时清零,避免使用 String 存储密码、密钥、Token。
  6. 谨慎使用 intern(),仅在高重复率、低基数场景下使用,避免常量池膨胀。
  7. 预编译正则表达式,Pattern.compile() 的结果应缓存复用,避免每次匹配都重新编译。
  8. JSON/XML 解析时注意 null 与空串的语义区别,不要盲目 trim() 或 isEmpty()。
  9. 国际化排序使用 Collator,不要依赖 String.compareTo() 的字典序。
  10. 日志拼接使用占位符log.info("user={}", user)),避免无条件字符串拼接造成的性能浪费。
  11. 理解 substring 在 JDK 7+ 会拷贝数组,从超大字符串中提取大量子串时考虑自定义视图或使用 ByteBuffer。
  12. 单元测试中覆盖 Unicode 边界情况,包括空字符串、纯 ASCII、混合脚本、Emoji、零宽字符等。

附录:Java String 高频面试题

Q1:String 为什么被设计成不可变的?

A: 这是多重设计目标权衡的结果,核心原因有五点:

  1. 安全性:String 被用作类加载器中的类名、网络连接 URL、文件路径、安全权限标识等关键参数。如果可变,攻击者可在通过安全检查后篡改内容,引发 TOCTOU 漏洞。
  2. 线程安全:不可变对象天然线程安全,可被多线程自由共享而无需同步,这是其作为 HashMap Key 和并发容器键值的前提。
  3. HashCode 缓存:不可变性保证 hashCode 计算一次后即可永久缓存,使 String 作为 Map Key 时具备 O(1) 的查找性能。
  4. 字符串常量池基础:只有不可变对象才能被安全地放入常量池共享引用。若可变,多个变量指向同一池对象时会互相干扰,常量池机制将彻底失效。
  5. JMM 安全发布:final 字段保证构造函数返回前,其他线程一定能看到完整初始化的对象状态,不存在部分构造问题。
Q2:new String("abc") 到底创建了几个对象?

A: 最多 2 个,最少 1 个,取决于常量池中是否已存在 "abc"

  • 若常量池中已有 "abc":仅在堆上创建 1 个新的 String 对象(其内部 byte\[\] 指向池中已有内容的拷贝或共享,取决于 JDK 版本),字面量 "abc" 直接复用池中实例。
  • 若常量池中没有 "abc":先在常量池中创建 1 个 "abc" 实例,再在堆上创建 1 个新 String 对象,共 2 个。

⚠️ 常见错误说法是"总是创建 2 个"。实际上,在大多数运行时场景中,字面量 "abc" 在类加载阶段就已进入常量池,new String("abc") 通常只创建 1 个堆对象。

Q3:==equals() 的区别是什么?

A:

  • == 比较的是引用地址(即两个变量是否指向堆中同一个对象)。
  • equals() 比较的是内容相等性 。String 重写了 equals(),逐字符比较内容,并包含多层快速失败优化:先比引用(this == anObject)、再比 coder 编码类型、最后才逐字节比较。

⚠️ 永远不要用 == 判断字符串内容相等,除非你明确知道两边都来自编译期常量折叠或 intern()。

Q4:String.intern() 的作用是什么?什么时候该用?

A: intern() 是一个 native 方法,语义为:若常量池中已存在内容相同的字符串,返回池中引用;否则将当前字符串加入池中并返回其引用。

适用场景:解析大量高重复率、低基数的文本数据(如日志字段、CSV 枚举值、JSON key),通过去重显著降低堆内存占用。

禁忌 :不要对高基数数据调用 intern(会导致常量池膨胀);不要在热路径无条件调用(有哈希查找开销);JDK 7+ 中池对象可被 GC,但频繁 intern 仍会增加 GC 扫描负担。生产环境中优先考虑 G1 的 -XX:+UseStringDeduplication 作为零侵入替代方案。

Q5:JDK 9 对 String 做了什么优化?有什么影响?

A: JDK 9 引入 Compact Strings(JEP 254),将底层存储从 char[] 改为 byte[] + coder。当字符串内容全部落在 Latin-1 范围时,使用单字节编码;否则回退 UTF-16。

影响 :Latin-1 字符串内存占用减少约 50%,整体平均节省 30%-40%;API 完全兼容,行为不变;JIT 编译器对 String 操作有专门内联优化。该优化默认开启,可通过 -XX:-CompactStrings 关闭,但绝大多数场景应保持开启。

Q6:循环中拼接字符串为什么不能用 +?JDK 9+ 还需要注意吗?

A: JDK 8 及以前,循环中的 + 每次迭代都会 new StringBuilder → append → toString,时间复杂度 O(n²),空间开销线性增长。

JDK 9+ 虽然对非循环 简单拼接做了 invokedynamic 优化(可直接操作 byte\[\],零中间对象),但循环累积式拼接仍然不适用此优化。因此,循环内拼接必须显式使用 StringBuilder,并预估初始容量避免扩容拷贝。

Q7:substring() 在不同 JDK 版本中有何区别?

A:

  • JDK 6 及以前:substring 共享原 char\[\],仅记录 offset/count。优点是零拷贝,缺点是短字符串持有大数组引用导致内存泄漏。
  • JDK 7+:substring 始终拷贝新数组。彻底解决内存泄漏,但增加了复制成本。从超大字符串提取大量子串时需意识到此开销,必要时可使用自定义视图或 ByteBuffer。
Q8:String 是值传递还是引用传递?

A: Java 只有值传递。对于 String,传递的是引用的副本(即地址值的拷贝)。由于 String 不可变,即使方法内对形参重新赋值,也不会影响实参;且无法通过形参修改原对象内容。这使得 String 在行为上类似值类型,但本质仍是引用类型的值传递。

Q9:为什么密码不应该用 String 存储?

A: String 不可变,一旦创建就无法主动清除内容。密码会一直驻留在堆内存中直到 GC 回收,期间可能被堆转储、内存扫描、swap 或 core dump 捕获。正确做法是使用 char[]byte[],使用后立即手动清零Arrays.fill(password, '\0'))。现代安全框架(Spring Security、Jakarta EE)已迁移至 char\[\] 或专用 Credential 类型。

Q10:str.length() 和实际字符数有什么区别?如何正确处理 Unicode?

A: length() 返回 UTF-16 code unit 数量,不等于实际字符数。Emoji 等 BMP 外字符由代理对表示,占 2 个 char。

正确做法 :获取真实字符数用 codePointCount(0, str.length());遍历用 codePoints() Stream API;截取需按 codePoint 边界操作,避免截断代理对产生乱码;正则匹配使用 \X 或 Unicode 感知 Pattern。处理用户输入(昵称、消息、评论)时必须使用 CodePoint 感知 API。

Q11:StringBuilder 和 StringBuffer 怎么选?

A: 优先选 StringBuilder。StringBuffer 的 synchronized 是粗粒度锁,性能比 StringBuilder 慢 30% 以上,且在现代并发编程中几乎无用武之地。若真需多线程安全构建,应使用 ThreadLocal\ 或在业务层做细粒度同步,而非依赖 StringBuffer。

Q12:字符串拼接时,+、StringBuilder、String.format 的性能排序是怎样的?

A: 在 JDK 9+ 环境下:

  • 非循环简单拼接+ ≈ StringBuilder > String.format(invokedynamic 优化使 + 与 SB 持平)
  • 循环拼接 :StringBuilder >> + >> String.format
  • 复杂格式化:String.format 可读性最佳,但性能最差(涉及反射、Locale 解析、正则匹配);若性能敏感,应使用 StringBuilder + 手动拼接或 MessageFormat

⚠️ String.format 的性能开销通常是 StringBuilder 的 10-50 倍,仅在非热点路径或对可读性要求极高时使用。

Q13:常量池在 JVM 中的位置经历了哪些变化?为什么?

A:

  • JDK 6 :永久代(PermGen),大小固定(-XX:MaxPermSize),不随堆扩展,易因字符串过多导致 PermGen OOM。
  • JDK 7+ :移至堆内存,受 -Xmx 控制,可被 GC 回收。

这一变迁使字符串生命周期完全由 GC 管理,消除了 PermGen OOM 风险,并为 G1 字符串去重等 GC 级优化提供了基础。元空间(Metaspace)仅存类元数据,不再存放字符串。

Q14:如何诊断和优化字符串相关的内存问题?

A:

  1. 使用 -XX:+PrintStringTableStatistics 查看常量池桶分布、负载因子、冲突情况。
  2. 堆转储分析工具(Eclipse MAT、JProfiler)检查 String 对象数量、大小分布及引用链。
  3. 高重复率场景启用 G1 字符串去重(-XX:+UseStringDeduplication)。
  4. 大量 intern 场景调整 -XX:StringTableSize(JDK 8)增大桶数以减少哈希冲突。
  5. 确认 Compact Strings 已启用(JDK 9+ 默认开启)。
  6. 审查代码中是否存在循环拼接、未预分配容量的 StringBuilder、以及对高基数数据的 intern 调用。