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 类,其内部存储字段 value 和 coder 均为 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 调用。