编程进阶网 编程进阶网
首页
  • 计算机原理
  • 操作系统
  • 网络协议
  • 数据库原理
  • 面向对象
  • 设计原则
  • 设计模式
  • 系统架构
  • 性能优化
  • 编程原理
  • 方案设计
  • 稳定可靠
  • 工程运维
  • 基础认知
  • 线性结构
  • 树与哈希
  • 工业级实现
  • 算法思想
  • 实战与综合
  • 算法题考核
  • C语言入门
  • C综合案例
  • C专栏博客
  • C标准集库
  • C++入门教程
  • C++综合案例
  • C++专栏博客
  • C++开发技巧
  • Java入门教程
  • Java综合案例
  • Java专栏博客
  • Go入门教程
  • Go综合案例
  • Go专栏博客
  • Go开发技巧
  • JavaScript入门
  • JavaScript高级
  • Android库解读
  • Android专栏
  • Android智能硬件
  • iOS ObjC入门
  • iOS Swift入门
  • iOS入门精通
  • Web之Html手册
  • Web之TypeScript
  • Web之Vue高级进阶
  • Linux之QML入门
  • Linux之QT核心库
  • Linux实践开发
  • Python教程
  • Shell&Bash教程
  • 工具脚本
  • 自动化脚本
  • 质量保障
  • 产品思考
  • 软实力
  • 开发流程
  • Git应用
  • 技术模版
  • 技术规范
  • Markdown
  • Mermaid
  • 开源协议
  • JSON工具
  • 文本工具
  • 图片处理
  • 文档转化
  • 代码压缩
  • 关于我
  • 自我精进
  • 职场管理
  • 职场面试
  • 心情杂货
  • 友情链接

杨充

专注编程 · 终身学习者
首页
  • 计算机原理
  • 操作系统
  • 网络协议
  • 数据库原理
  • 面向对象
  • 设计原则
  • 设计模式
  • 系统架构
  • 性能优化
  • 编程原理
  • 方案设计
  • 稳定可靠
  • 工程运维
  • 基础认知
  • 线性结构
  • 树与哈希
  • 工业级实现
  • 算法思想
  • 实战与综合
  • 算法题考核
  • C语言入门
  • C综合案例
  • C专栏博客
  • C标准集库
  • C++入门教程
  • C++综合案例
  • C++专栏博客
  • C++开发技巧
  • Java入门教程
  • Java综合案例
  • Java专栏博客
  • Go入门教程
  • Go综合案例
  • Go专栏博客
  • Go开发技巧
  • JavaScript入门
  • JavaScript高级
  • Android库解读
  • Android专栏
  • Android智能硬件
  • iOS ObjC入门
  • iOS Swift入门
  • iOS入门精通
  • Web之Html手册
  • Web之TypeScript
  • Web之Vue高级进阶
  • Linux之QML入门
  • Linux之QT核心库
  • Linux实践开发
  • Python教程
  • Shell&Bash教程
  • 工具脚本
  • 自动化脚本
  • 质量保障
  • 产品思考
  • 软实力
  • 开发流程
  • Git应用
  • 技术模版
  • 技术规范
  • Markdown
  • Mermaid
  • 开源协议
  • JSON工具
  • 文本工具
  • 图片处理
  • 文档转化
  • 代码压缩
  • 关于我
  • 自我精进
  • 职场管理
  • 职场面试
  • 心情杂货
  • 友情链接
  • README
  • C语言入门精通

  • Cpp入门到精通

  • Java入门精通

    • README
    • 入门教程

    • 综合案例

    • 专栏博客

      • README
      • JVM内存模型与对象
      • 类加载与双亲委派
      • 垃圾回收与GC调优
      • 异常体系与JVM机制
      • 字节码指令集javap实战
      • JIT编译与去优化机制
      • JVM性能诊断工具链
      • OOM八大现场全景剖析
      • JVM参数调优全景图
      • GraalVM与AOT编译原理
      • HashMap底层哈希设计
      • String不可变与常量池
        • 5.1 开篇疑问
        • 5.2 String底层实现演进
          • 5.2.1 JDK8的char数组
          • 5.2.2 JDK9的byte数组紧凑设计
          • 5.2.3 String对象的内存占用分析
        • 5.3 不可变性设计原理
          • 5.3.1 三重保护机制
          • 5.3.2 为什么设计成不可变
          • 5.3.3 反射能否破坏不可变性
          • 5.3.4 不可变对象的设计模式
        • 5.4 字符串常量池深入分析
          • 5.4.1 常量池的本质与实现
          • 5.4.2 常量池位置变迁的技术背景
          • 5.4.3 intern方法底层原理
          • 5.4.4 JDK6与JDK7的intern差异详解
          • 5.4.5 intern的实战应用与性能
        • 5.5 经典面试题深度剖析
          • 5.5.1 new String创建几个对象
          • 5.5.2 字符串相等性比较全解析
          • 5.5.3 拼接场景的对象创建分析
        • 5.6 String拼接底层真相
          • 5.6.1 常量拼接的编译期优化
          • 5.6.2 变量拼接的StringBuilder转换
          • 5.6.3 JDK9的invokedynamic拼接优化
          • 5.6.4 循环拼接的性能陷阱与解决
        • 5.7 StringBuilder与StringBuffer深度对比
          • 5.7.1 底层数据结构与扩容机制
          • 5.7.2 线程安全实现原理
          • 5.7.3 性能基准测试对比
        • 5.8 String相关的JVM优化
          • 5.8.1 字符串去重优化
          • 5.8.2 hashCode缓存机制
          • 5.8.3 编译器对String的特殊处理
        • 5.9 生产环境中的String最佳实践
          • 5.9.1 避免内存泄漏的注意事项
          • 5.9.2 大规模字符串处理策略
          • 5.9.3 String与字符编码问题
        • 5.10 总结与核心要点
      • ArrayList与LinkedList源码
      • ConcurrentHashMap并发
      • TreeMap与红黑树原理
      • LinkedHashMap与LRU实现
      • Java数字类型原理
      • Object通用方法的契约
      • 泛型擦除与类型系统
      • 枚举原理与最佳实践
      • 注解原理与编译期处理
      • Lambda与引用底层原理
      • Stream原理与流水线设计
      • Optional设计原理
      • Record密封类与模式
      • 反射机制与动态代理
      • MethodHandle与VarHandle
      • 三大字节码框架对比
      • JavaAgent与Instrumentation机制
      • AOP三种实现路线对比
      • synchronized与锁升级
      • volatile与JMM内存模型
      • 线程池核心源码设计
      • Thread线程生命周期
      • AQS同步框架源码
      • 并发锁三剑客
      • CAS和Atomic深入分析
      • 五大同步器对比
      • CompletableFuture异步
      • IO模型演进BIO到AIO
      • ByteBuffer与堆外内存
      • 序列化原理与替代方案
      • 文件IO与NIO.2
      • 面向对象的真意
      • JDK设计模式上
      • JDK设计模式下
      • SPI与模块化设计
  • Go入门到精通

  • JavaScript入门

  • CodeX
  • Java入门精通
  • 专栏博客
杨充
2026-06-02
目录

String不可变与常量池

# 12.String不可变与常量池

# 目录介绍

  • 5.1 开篇疑问
  • 5.2 String底层实现演进
    • 5.2.1 JDK8的char数组
    • 5.2.2 JDK9的byte数组紧凑设计
    • 5.2.3 String对象的内存占用分析
  • 5.3 不可变性设计原理
    • 5.3.1 三重保护机制
    • 5.3.2 为什么设计成不可变
    • 5.3.3 反射能否破坏不可变性
    • 5.3.4 不可变对象的设计模式
  • 5.4 字符串常量池深入分析
    • 5.4.1 常量池的本质与实现
    • 5.4.2 常量池位置变迁的技术背景
    • 5.4.3 intern方法底层原理
    • 5.4.4 JDK6与JDK7的intern差异详解
    • 5.4.5 intern的实战应用与性能
  • 5.5 经典面试题深度剖析
    • 5.5.1 new String创建几个对象
    • 5.5.2 字符串相等性比较全解析
    • 5.5.3 拼接场景的对象创建分析
  • 5.6 String拼接底层真相
    • 5.6.1 常量拼接的编译期优化
    • 5.6.2 变量拼接的StringBuilder转换
    • 5.6.3 JDK9的invokedynamic拼接优化
    • 5.6.4 循环拼接的性能陷阱与解决
  • 5.7 StringBuilder与StringBuffer深度对比
    • 5.7.1 底层数据结构与扩容机制
    • 5.7.2 线程安全实现原理
    • 5.7.3 性能基准测试对比
  • 5.8 String相关的JVM优化
    • 5.8.1 字符串去重优化
    • 5.8.2 hashCode缓存机制
    • 5.8.3 编译器对String的特殊处理
  • 5.9 生产环境中的String最佳实践
    • 5.9.1 避免内存泄漏的注意事项
    • 5.9.2 大规模字符串处理策略
    • 5.9.3 String与字符编码问题
  • 5.10 总结与核心要点

# 5.1 开篇疑问

疑惑:String s = new String("abc") 创建了几个对象?String 为什么是不可变的?intern() 方法有什么用?字符串拼接用 + 号效率到底低不低?JDK 9 为什么要把 char[] 改成 byte[]?反射能打破 String 的不可变性吗?

答疑:String 是 Java 中使用频率最高的类,据统计在典型 Java 应用中,String 对象占据了 25%~40% 的堆内存。理解它的底层实现和设计思想,不仅能回答面试题,更能帮你写出更高效的代码,理解 JVM 内存优化策略。

本篇将从 String 的底层数据结构演进出发,深入分析不可变性的三重保护机制、常量池的位置变迁与 intern 原理、拼接操作的编译优化,以及生产环境中的最佳实践。

# 5.2 String底层实现演进

# 5.2.1 JDK8的char数组

在 JDK 8 及之前,String 底层使用 char[] 数组存储字符数据:

// JDK 8 的 String 核心字段
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
    
    /** use serialVersionUID from JDK 1.0.2 for interoperability */
    private static final long serialVersionUID = -6849794470754667710L;
}
1
2
3
4
5
6
7
8
9
10
11

关键点:

  • 每个 char 占 2 字节(UTF-16 编码),无论存储的是 ASCII 字符还是中文字符
  • 对于大量 Latin-1 字符(英文、数字、标点等),每个字符浪费了 1 字节
  • 在英文为主的应用中(如大部分服务端应用),这种浪费相当可观

# 5.2.2 JDK9的byte数组紧凑设计

JDK 9 引入了紧凑字符串(Compact Strings, JEP 254),将底层改为 byte[]:

// JDK 9+ 的 String 核心字段
public final class String implements java.io.Serializable, Comparable<String>, CharSequence {
    @Stable
    private final byte[] value;      // 底层存储从 char[] 变为 byte[]
    
    private final byte coder;        // 编码标识:LATIN1=0, UTF16=1
    
    private int hash;
    
    private boolean hashIsZero;      // JDK 13+ 新增,区分hash=0和未计算
    
    @Native static final byte LATIN1 = 0;
    @Native static final byte UTF16  = 1;
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14

设计原理:

  • 如果字符串中所有字符都是 Latin-1(0~255),使用 LATIN1 编码,每个字符 1 字节
  • 如果包含非 Latin-1 字符(如中文),使用 UTF16 编码,每个字符 2 字节
  • coder 字段标识当前使用的编码方式
// 紧凑字符串的编码选择
String ascii = "Hello";    // coder = LATIN1, value 长度 = 5 字节
String chinese = "你好";    // coder = UTF16,  value 长度 = 4 字节(2字符×2字节)
String mixed = "Hi你好";   // coder = UTF16,  value 长度 = 8 字节(4字符×2字节)
1
2
3
4

疑惑:混合字符串为什么全部用 UTF16,不能部分用 LATIN1 吗?

论证:如果混合编码,charAt(index) 就无法通过简单的偏移计算定位字符,需要遍历前面所有字符才能确定偏移量,时间复杂度从 O(1) 变为 O(n)。统一编码保证了随机访问的 O(1) 性能。

实际效果:Oracle 官方测试显示,紧凑字符串减少了约 10%~15% 的堆内存使用,GC 停顿也有所下降,因为需要扫描的对象体积变小了。

# 5.2.3 String对象的内存占用分析

一个 String 对象的实际内存占用(64 位 JVM,开启指针压缩):

JDK 8 中 "Hello" 的内存占用:
┌────────────────────────────────────┐
│ String 对象头:         12 字节       │  (Mark Word 8 + Klass Pointer 4)
│ char[] value 引用:      4 字节       │
│ int hash:               4 字节       │
│ 对齐填充:                4 字节       │
│                        ──────       │
│ String 对象合计:        24 字节       │
├────────────────────────────────────┤
│ char[] 数组对象头:      16 字节       │  (对象头12 + 数组长度4)
│ char[5] 数据:           10 字节       │  (5 × 2字节)
│ 对齐填充:                6 字节       │
│                        ──────       │
│ char[] 合计:            32 字节       │
├────────────────────────────────────┤
│ 总计:                   56 字节       │
└────────────────────────────────────┘

JDK 9+ 中 "Hello" 的内存占用:
┌────────────────────────────────────┐
│ String 对象头:         12 字节       │
│ byte[] value 引用:      4 字节       │
│ byte coder:             1 字节       │
│ int hash:               4 字节       │
│ 对齐填充:                3 字节       │
│                        ──────       │
│ String 对象合计:        24 字节       │
├────────────────────────────────────┤
│ byte[] 数组对象头:      16 字节       │
│ byte[5] 数据:            5 字节       │  (5 × 1字节,LATIN1编码)
│ 对齐填充:                3 字节       │
│                        ──────       │
│ byte[] 合计:            24 字节       │
├────────────────────────────────────┤
│ 总计:                   48 字节       │  (节省了 8 字节,14%)
└────────────────────────────────────┘
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36

关键洞察:对于短字符串,对象头和元数据的开销占比很大。"Hello" 存储 5 个字符实际数据只需 5~10 字节,但总开销是 48~56 字节。这也是为什么大量短字符串会给内存带来巨大压力。

# 5.3 不可变性设计原理

# 5.3.1 三重保护机制

String 通过三重保护实现不可变:

// 第一重:final 类——不可被继承
public final class String {
    // 第二重:final 数组引用——引用不能指向其他数组
    private final char value[];   // JDK 8
    // private final byte[] value; // JDK 9+
    
    // 第三重:不暴露修改方法
    // replace、substring、concat 等都返回新 String 对象,不修改原对象
    
    public String substring(int beginIndex) {
        // 不修改 this.value,而是创建新的 String
        return (beginIndex == 0) ? this : new String(value, beginIndex, subLen);
    }
    
    public String replace(char oldChar, char newChar) {
        // 如果没有找到 oldChar,返回 this(原对象)
        // 如果找到了,创建新数组,构建新 String 返回
    }
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19

疑惑:final char value[] 中的 final 修饰的是数组引用还是数组内容?

论证:final 只保证引用不能重新指向其他数组对象,但数组内容理论上是可以修改的:

final char[] arr = {'a', 'b', 'c'};
// arr = new char[]{'x', 'y', 'z'};  // 编译错误!引用不可变
arr[0] = 'x';                        // 完全合法!内容可变
1
2
3

所以 String 的不可变性不仅仅靠 final,还需要类不暴露任何修改 value 数组的公开方法。这三重保护缺一不可。

# 5.3.2 为什么设计成不可变

1. 字符串常量池的前提条件

字符串常量池允许多个变量引用同一个 String 对象。如果 String 可变,一处修改会影响所有引用方:

String s1 = "hello";
String s2 = "hello";
// s1 和 s2 指向常量池中的同一个对象

// 如果 String 可变且 s1 修改了内容:
// s1.modify("world");  // 假设有这个方法
// s2 也变成 "world" 了!这显然是灾难性的
1
2
3
4
5
6
7

2. 天然线程安全

不可变对象可以在多线程之间自由共享,无需任何同步措施:

// 以下代码在多线程环境下完全安全
public class Config {
    private final String dbUrl;     // 不可变,无需 volatile 或 synchronized
    private final String username;
    
    public Config(String dbUrl, String username) {
        this.dbUrl = dbUrl;
        this.username = username;
    }
    
    public String getDbUrl() { return dbUrl; }
}
1
2
3
4
5
6
7
8
9
10
11
12

3. hashCode 缓存优化

String 重写了 hashCode(),首次计算后缓存结果。如果内容可变,缓存的 hashCode 就可能过期:

public int hashCode() {
    int h = hash;           // 读取缓存
    if (h == 0 && value.length > 0) {
        char val[] = value;
        for (int i = 0; i < value.length; i++) {
            h = 31 * h + val[i];
        }
        hash = h;           // 缓存计算结果
    }
    return h;
}
1
2
3
4
5
6
7
8
9
10
11

如果 String 可变:

HashMap<String, Integer> map = new HashMap<>();
String key = "hello";
map.put(key, 1);
// 如果 key 的内容被修改为 "world"
// hashCode 变了,但 HashMap 中存储的是旧的 hashCode
// map.get("hello") → 找不到
// map.get("world") → 也找不到(因为 hash 桶位置不对)
// 整个 HashMap 崩溃
1
2
3
4
5
6
7
8

4. 安全性保证

Java 的很多安全敏感操作使用 String 传递参数:

// 类加载器根据类名加载类
Class.forName("com.example.MyClass");

// 数据库连接
DriverManager.getConnection(url, user, password);

// 文件路径
new File(path);

// 如果 String 可变,攻击者可以在验证后修改字符串内容
// 验证时是 "safe/path",实际使用时变成了 "/etc/shadow"
1
2
3
4
5
6
7
8
9
10
11

5. 作为 HashMap 的 key 更高效

由于 hashCode 被缓存,String 作为 HashMap 的 key 只需要在第一次计算 hash,后续查找都是 O(1) 的 hash 获取 + O(1) 的比较。

# 5.3.3 反射能否破坏不可变性

疑惑:都说 String 不可变,但 Java 反射可以突破 private 限制,能修改 value 数组吗?

论证:

// JDK 8 中可以通过反射修改(但强烈不推荐)
String s = "hello";
System.out.println(s); // hello

Field valueField = String.class.getDeclaredField("value");
valueField.setAccessible(true);     // 突破 private
char[] value = (char[]) valueField.get(s);
value[0] = 'H';

System.out.println(s); // Hello —— 内容确实被修改了!

// 更可怕的是:
String s2 = "hello";
System.out.println(s2); // Hello —— 常量池中的 "hello" 也被修改了!
1
2
3
4
5
6
7
8
9
10
11
12
13
14

JDK 9+ 的限制:

// JDK 9+ 引入了模块系统(Jigsaw)
// 默认不允许反射访问 java.base 模块的内部字段
// 会抛出 InaccessibleObjectException
// 除非启动参数加 --add-opens java.base/java.lang=ALL-UNNAMED
1
2
3
4

结论:反射在旧版本中可以破坏不可变性,但这是"用枪指着自己的脚"。JDK 9+ 的模块系统封堵了这个漏洞。正常编程应该信任 String 的不可变性保证。

# 5.3.4 不可变对象的设计模式

String 的不可变设计是一种通用的设计模式。如果你需要创建自己的不可变类:

public final class Money {  // 1. final 类
    private final int amount;       // 2. final 字段
    private final String currency;  // 3. 所有字段都是不可变类型
    
    public Money(int amount, String currency) {
        this.amount = amount;
        this.currency = currency;
    }
    
    public int getAmount() { return amount; }          // 4. 只有 getter
    public String getCurrency() { return currency; }
    
    // 5. "修改"操作返回新对象
    public Money add(Money other) {
        if (!this.currency.equals(other.currency)) {
            throw new IllegalArgumentException("Currency mismatch");
        }
        return new Money(this.amount + other.amount, this.currency);
    }
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20

不可变类的检查清单:

  1. 类声明为 final(防止子类重写行为)
  2. 所有字段声明为 private final
  3. 不提供 setter 方法
  4. 如果字段是可变对象(如数组、集合),构造函数中做防御性拷贝
  5. getter 方法也需要返回防御性拷贝(对于可变字段)
// 防御性拷贝示例
public final class Period {
    private final Date start;
    private final Date end;
    
    public Period(Date start, Date end) {
        // 防御性拷贝——防止外部修改传入的 Date 对象
        this.start = new Date(start.getTime());
        this.end = new Date(end.getTime());
        
        if (this.start.after(this.end)) {
            throw new IllegalArgumentException("start after end");
        }
    }
    
    public Date getStart() {
        return new Date(start.getTime());  // 返回副本,防止外部修改
    }
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19

# 5.4 字符串常量池深入分析

# 5.4.1 常量池的本质与实现

字符串常量池在 JVM 中是一个哈希表(StringTable),底层实现类似 HashMap。

StringTable(JVM 内部 C++ 实现)
┌─────────────────────────────────┐
│ bucket[0] → null                │
│ bucket[1] → "hello" → "abc"    │
│ bucket[2] → null                │
│ bucket[3] → "world"            │
│ ...                             │
│ bucket[n] → ...                 │
└─────────────────────────────────┘
1
2
3
4
5
6
7
8
9

关键参数:

  • -XX:StringTableSize=N:设置 StringTable 的桶数量(默认 JDK 8 为 60013)
  • JDK 7u40 之前默认值很小(1009),容易导致哈希冲突,intern() 性能退化
# 查看 StringTable 统计信息
-XX:+PrintStringTableStatistics

# 示例输出:
# StringTable statistics:
# Number of buckets       :     60013 =    480104 bytes
# Number of entries       :     24136 =    579264 bytes
# Number of literals      :     24136 =   1738112 bytes
# Total footprint         :           =   2797480 bytes
# Average bucket size     :     0.402
# Variance of bucket size :     0.405
# Std. dev. of bucket size:     0.636
# Maximum bucket size     :         5
1
2
3
4
5
6
7
8
9
10
11
12
13

# 5.4.2 常量池位置变迁的技术背景

JDK 版本 常量池位置 原因
JDK 1.6 永久代(PermGen) 属于方法区的一部分
JDK 1.7 堆内存(Heap) 永久代空间有限,GC 效率低
JDK 1.8+ 堆内存(Heap) 永久代被元空间替代

疑惑:为什么要把常量池从永久代搬到堆中?

论证:

问题一——PermGen 空间有限:

# JDK 6 中默认 PermGen 大小只有 64MB~82MB
-XX:MaxPermSize=82m

# 大量使用 intern() 或动态生成字符串(如 Spring、MyBatis 的 XML 解析)
# 很容易导致 OutOfMemoryError: PermGen space
1
2
3
4
5

问题二——PermGen 的 GC 效率低:

  • 永久代只在 Full GC 时才会被回收
  • Full GC 代价高昂,而且默认条件苛刻
  • 字符串常量池中可能有大量临时字符串需要回收

问题三——分代假说不适用:

  • 字符串常量池中的对象生命周期多样化
  • 有些是永久存活的字面量,有些是临时 intern 的
  • 放在堆中可以被 Minor GC 和 Major GC 正常管理

搬迁后的效果:

// JDK 6 中这段代码会导致 PermGen OOM
// JDK 7+ 中只会导致普通的 Heap OOM(可以被正常管理)
List<String> list = new ArrayList<>();
int i = 0;
while (true) {
    list.add(String.valueOf(i++).intern());
}
1
2
3
4
5
6
7

# 5.4.3 intern方法底层原理

intern() 的作用:检查常量池中是否已有内容相同的字符串。如果有,返回常量池中的引用;如果没有,将该字符串加入常量池。

底层流程(JDK 7+ HotSpot):

String s = new String("hello").intern()

1. 计算 "hello" 的 hashCode
2. 在 StringTable 中找到对应的 bucket
3. 遍历 bucket 中的链表,逐个比较内容
   → 找到内容相同的:返回已有的引用
   → 没找到:
     a. JDK 6: 在永久代复制一份字符串,加入 StringTable
     b. JDK 7+: 直接将堆中对象的引用加入 StringTable(不复制!)
4. 返回 StringTable 中的引用
1
2
3
4
5
6
7
8
9
10

这个"不复制"的改变是 JDK 7 intern 行为变化的根源。

# 5.4.4 JDK6与JDK7的intern差异详解

这是面试高频考点,我们用图解的方式彻底搞清楚:

场景一:简单字面量

String s1 = "abc";
String s2 = new String("abc");
String s3 = s2.intern();

// 不管 JDK 6 还是 JDK 7+,结果一致:
System.out.println(s1 == s2);  // false(堆对象 vs 常量池)
System.out.println(s1 == s3);  // true(都是常量池引用)
1
2
3
4
5
6
7

场景二:拼接后 intern(关键差异场景)

String s = new String("1") + new String("1");  // 堆上创建 "11"
// 注意:此时常量池中有 "1",但没有 "11"
s.intern();       // 将 "11" 放入常量池
String s2 = "11"; // 从常量池获取 "11"

System.out.println(s == s2);
1
2
3
4
5
6

JDK 6 的执行过程:

堆:                              永久代常量池:
┌──────────┐                    ┌──────────┐
│ s → "11" │                    │ "1"      │
└──────────┘                    └──────────┘

s.intern():
堆:                              永久代常量池:
┌──────────┐                    ┌──────────┐
│ s → "11" │                    │ "1"      │
└──────────┘                    │ "11"(副本)│ ← 复制了一份!
                                └──────────┘

s2 = "11":
s2 → 常量池中的 "11"(副本)
s → 堆中的 "11"(原件)
s == s2 → false(不同对象)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16

JDK 7+ 的执行过程:

堆:                              常量池(在堆中):
┌──────────┐                    ┌──────────┐
│ s → "11" │                    │ "1"      │
└──────────┘                    └──────────┘

s.intern():
堆:                              常量池(在堆中):
┌──────────┐                    ┌──────────────┐
│ s → "11" │ ←─────────────────── │ "11" → s的引用 │ ← 不复制,直接存引用!
└──────────┘                    └──────────────┘

s2 = "11":
s2 → 常量池中的引用 → s 指向的同一个堆对象
s == s2 → true(同一个对象!)
1
2
3
4
5
6
7
8
9
10
11
12
13
14

再看一个变种:

String s = new String("1") + new String("1");  // 堆上的 "11"
String s2 = "11";    // 先于 intern() 在常量池创建 "11"
s.intern();          // 常量池已有 "11",intern 无效

System.out.println(s == s2);  // false(JDK 6 和 JDK 7+ 都是 false)
1
2
3
4
5

关键结论:顺序很重要!如果常量池中已经有了该字符串,intern() 返回已有的引用。JDK 7+ 的"不复制"优化只在常量池中没有该字符串时生效。

# 5.4.5 intern的实战应用与性能

适用场景:大量重复字符串时,intern() 可以大幅减少内存:

// 不使用 intern:10万个相同字符串 = 10万个对象
List<String> list = new ArrayList<>();
for (int i = 0; i < 100000; i++) {
    list.add(new String("status_active"));  // 10万个不同的堆对象
}

// 使用 intern:10万个引用 → 1个常量池对象
List<String> list = new ArrayList<>();
for (int i = 0; i < 100000; i++) {
    list.add(new String("status_active").intern());  // 所有引用指向同一个对象
}
1
2
3
4
5
6
7
8
9
10
11

Twitter 的经典案例:Twitter 曾通过 intern() 优化用户状态字符串,节省了数 GB 内存。

注意事项:

  • StringTable 是有大小限制的哈希表,intern 过多字符串会导致哈希冲突增加
  • intern() 有原生方法调用开销,不适合短期生存的临时字符串
  • JDK 7+ 可以通过 -XX:StringTableSize 调大桶数来优化 intern 性能
// 性能测试:intern 的时间开销
long start = System.nanoTime();
for (int i = 0; i < 1000000; i++) {
    String.valueOf(i % 100).intern();  // 只有100个不同值
}
long cost = System.nanoTime() - start;
// 约 50-100ms(取决于 StringTableSize)
1
2
3
4
5
6
7

# 5.5 经典面试题深度剖析

# 5.5.1 new String创建几个对象

String s = new String("abc") 创建了几个对象?

严格回答:最多 2 个,最少 1 个。

场景一:常量池中没有 "abc"
1. 编译期确定字面量 "abc",类加载时在常量池创建 String 对象(对象1)
2. 运行时 new 在堆上创建 String 对象(对象2),内部 value 指向同一个字符数组

场景二:常量池中已有 "abc"
1. 只在堆上 new 一个对象(对象1),常量池的已存在,不再创建
1
2
3
4
5
6

更复杂的场景:

// 这行代码创建了几个对象?
String s = new String("a") + new String("b");
1
2

逐步分析:

  1. 常量池中创建 "a"(如果没有的话)
  2. 堆上 new String("a")
  3. 常量池中创建 "b"(如果没有的话)
  4. 堆上 new String("b")
  5. + 操作实际通过 StringBuilder 实现
  6. new StringBuilder()
  7. sb.toString() → 内部 new String(char[], 0, count)

最多创建 6 个对象(2 常量池 + 2 堆上 String + 1 StringBuilder + 1 最终结果 String)。

# 5.5.2 字符串相等性比较全解析

String s1 = "abc";
String s2 = "abc";
String s3 = new String("abc");
String s4 = new String("abc");
String s5 = "ab" + "c";          // 编译期优化
String s6 = "ab" + new String("c"); // 运行时拼接

System.out.println(s1 == s2);     // true  — 都指向常量池同一对象
System.out.println(s1 == s3);     // false — 常量池 vs 堆
System.out.println(s3 == s4);     // false — 堆上两个不同对象
System.out.println(s1 == s5);     // true  — "ab"+"c" 编译期变为 "abc"
System.out.println(s1 == s6);     // false — 含变量/new 的拼接在运行时创建新对象

// equals() 比较内容
System.out.println(s1.equals(s3)); // true  — 内容相同
System.out.println(s3.equals(s4)); // true  — 内容相同
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16

编译期常量折叠的条件:

final String a = "ab";
String b = "ab";

String s1 = a + "c";        // a 是 final,编译期可确定 → "abc"
String s2 = b + "c";        // b 不是 final,编译期无法确定 → StringBuilder

System.out.println("abc" == s1);  // true  — 常量折叠
System.out.println("abc" == s2);  // false — 运行时拼接
1
2
3
4
5
6
7
8

关键规则:final 修饰的 String 变量在编译期视为常量,可以参与常量折叠。

# 5.5.3 拼接场景的对象创建分析

// 场景1:纯字面量拼接
String s1 = "a" + "b" + "c";
// 编译后:String s1 = "abc";  只有1个常量池对象

// 场景2:含变量拼接
String a = "a";
String s2 = a + "b" + "c";
// 编译后:new StringBuilder().append(a).append("b").append("c").toString()
// 创建对象:1个StringBuilder + 1个String + 内部的char数组

// 场景3:final变量拼接
final String fa = "a";
String s3 = fa + "b" + "c";
// 编译后:String s3 = "abc";  fa 是编译期常量

// 场景4:方法返回值拼接
String s4 = getA() + "b";
// 即使 getA() 总是返回 "a",编译器也无法确定
// 编译后:new StringBuilder().append(getA()).append("b").toString()
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19

# 5.6 String拼接底层真相

# 5.6.1 常量拼接的编译期优化

编译器对纯字面量拼接做常量折叠(Constant Folding)优化:

// 源代码
String s = "Hello" + ", " + "World" + "!";

// javac 编译后的字节码等价于
String s = "Hello, World!";

// 可以通过 javap -c 验证
// ldc "Hello, World!"   ← 直接加载完整字符串,没有任何拼接操作
1
2
3
4
5
6
7
8

常量折叠也适用于 final 变量和基本类型:

final int x = 10;
final String prefix = "ID_";
String s = prefix + x;
// 编译后等价于:String s = "ID_10";
1
2
3
4

# 5.6.2 变量拼接的StringBuilder转换

JDK 8 中,包含变量的 + 拼接会被编译器转换为 StringBuilder:

// 源代码
String name = "World";
String s = "Hello, " + name + "!";

// 编译后等价于
String name = "World";
String s = new StringBuilder()
    .append("Hello, ")
    .append(name)
    .append("!")
    .toString();
1
2
3
4
5
6
7
8
9
10
11

查看字节码验证:

javap -c StringConcat.class
1
  0: ldc           #2   // String World
  2: astore_1
  3: new           #3   // class java/lang/StringBuilder
  6: dup
  7: invokespecial #4   // Method StringBuilder."<init>"
 10: ldc           #5   // String Hello,
 12: invokevirtual #6   // Method StringBuilder.append
 15: aload_1
 16: invokevirtual #6   // Method StringBuilder.append
 19: ldc           #7   // String !
 21: invokevirtual #6   // Method StringBuilder.append
 24: invokevirtual #8   // Method StringBuilder.toString
 27: astore_2
1
2
3
4
5
6
7
8
9
10
11
12
13

# 5.6.3 JDK9的invokedynamic拼接优化

JDK 9 引入了 invokedynamic 指令(JEP 280)替代 StringBuilder:

// JDK 9+ 编译后,字节码使用 invokedynamic
// 0: invokedynamic #2, 0  // InvokeDynamic #0:makeConcatWithConstants
1
2

优势:

  1. 延迟策略选择:JVM 在运行时根据实际情况选择最优的拼接策略
  2. 减少字节码大小:一条 invokedynamic 替代了多条 new/append/toString
  3. 未来优化空间:JVM 可以在不修改字节码的情况下改进拼接算法

六种运行时策略(通过 -Djava.lang.invoke.stringConcat 控制):

策略 说明
BC_SB 使用 StringBuilder(默认兼容模式)
BC_SB_SIZED 预计算 StringBuilder 容量
BC_SB_SIZED_EXACT 精确计算容量
MH_SB_SIZED 使用 MethodHandle 创建 StringBuilder
MH_SB_SIZED_EXACT MethodHandle + 精确容量
MH_INLINE_SIZED_EXACT 直接操作 byte[],跳过 StringBuilder

JDK 9 默认使用 MH_INLINE_SIZED_EXACT,直接拼接 byte 数组,性能最优。

# 5.6.4 循环拼接的性能陷阱与解决

反面教材:循环中使用 + 拼接

// 每次循环都创建新的 StringBuilder 和 String 对象
String result = "";
for (int i = 0; i < 10000; i++) {
    result += i;
    // 等价于:result = new StringBuilder(result).append(i).toString();
    // 10000 次循环 = 10000 个 StringBuilder + 10000 个 String
    // 且每次都要复制之前的内容,时间复杂度 O(n²)
}
1
2
3
4
5
6
7
8

正确做法一:循环外创建 StringBuilder

StringBuilder sb = new StringBuilder(64);  // 预估容量
for (int i = 0; i < 10000; i++) {
    sb.append(i);
}
String result = sb.toString();
// 时间复杂度 O(n),只创建 1 个 StringBuilder
1
2
3
4
5
6

正确做法二:使用 StringJoiner(JDK 8+)

StringJoiner sj = new StringJoiner(", ", "[", "]");
for (int i = 0; i < 10; i++) {
    sj.add(String.valueOf(i));
}
String result = sj.toString();  // [0, 1, 2, 3, 4, 5, 6, 7, 8, 9]
1
2
3
4
5

正确做法三:使用 Stream(JDK 8+)

String result = IntStream.range(0, 10)
    .mapToObj(String::valueOf)
    .collect(Collectors.joining(", ", "[", "]"));
1
2
3

性能对比(拼接 10000 个数字):

方式 耗时 对象创建数
+ 循环拼接 ~300ms ~30000
StringBuilder ~1ms 1
StringJoiner ~2ms 1
String.join ~2ms 1

# 5.7 StringBuilder与StringBuffer深度对比

# 5.7.1 底层数据结构与扩容机制

两者都继承自 AbstractStringBuilder,底层是可变的字符数组:

abstract class AbstractStringBuilder {
    // JDK 8
    char[] value;     // 可变数组(不是 final!)
    int count;        // 实际使用的字符数
    
    // JDK 9+
    byte[] value;
    byte coder;
    int count;
}
1
2
3
4
5
6
7
8
9
10

扩容策略(关键源码):

// AbstractStringBuilder.ensureCapacityInternal
private void ensureCapacityInternal(int minimumCapacity) {
    int oldCapacity = value.length >> coder;
    if (minimumCapacity - oldCapacity > 0) {
        // 新容量 = 旧容量 × 2 + 2
        int newCapacity = (oldCapacity << 1) + 2;
        
        if (newCapacity - minimumCapacity < 0)
            newCapacity = minimumCapacity;  // 仍不够则直接用需要的容量
        if (newCapacity < 0) {
            if (minimumCapacity < 0) throw new OutOfMemoryError();
            newCapacity = Integer.MAX_VALUE;
        }
        
        // 创建新数组并复制数据
        value = Arrays.copyOf(value, newCapacity << coder);
    }
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18

扩容过程示例:

初始容量:16(默认无参构造)
第1次扩容:16 × 2 + 2 = 34
第2次扩容:34 × 2 + 2 = 70
第3次扩容:70 × 2 + 2 = 142
...
1
2
3
4
5

优化建议:如果能预估字符串长度,在构造时指定初始容量,避免扩容:

// 不推荐:默认容量16,可能多次扩容
StringBuilder sb = new StringBuilder();

// 推荐:预估容量
StringBuilder sb = new StringBuilder(256);

// 带初始字符串:容量 = str.length() + 16
StringBuilder sb = new StringBuilder("Hello");  // 容量 = 5 + 16 = 21
1
2
3
4
5
6
7
8

# 5.7.2 线程安全实现原理

StringBuffer 通过 synchronized 关键字保证线程安全:

// StringBuffer 的 append 方法
@Override
public synchronized StringBuffer append(String str) {
    toStringCache = null;                      // 清除 toString 缓存
    super.append(str);                          // 调用父类 AbstractStringBuilder
    return this;
}

// StringBuilder 的 append 方法(无 synchronized)
@Override
public StringBuilder append(String str) {
    super.append(str);
    return this;
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14

StringBuffer 额外的 toString 缓存优化:

public class StringBuffer {
    private transient String toStringCache;  // 缓存上次 toString 的结果
    
    @Override
    public synchronized String toString() {
        if (toStringCache == null) {
            return toStringCache = isLatin1()
                ? StringLatin1.newString(value, 0, count)
                : StringUTF16.newString(value, 0, count);
        }
        return new String(toStringCache);
    }
}
1
2
3
4
5
6
7
8
9
10
11
12
13

每次 append 操作都会清除缓存,只有连续调用 toString() 才会命中缓存。

# 5.7.3 性能基准测试对比

// JMH 基准测试结果(单线程,append 10000 次)
// StringBuilder:   约 0.2ms
// StringBuffer:    约 0.3ms   (慢约 50%,因为 synchronized 即使无竞争也有轻量级锁开销)

// 多线程共享同一对象(4线程,各 append 2500 次)
// StringBuilder:   结果不确定(数据错乱、ArrayIndexOutOfBoundsException)
// StringBuffer:    正常运行,结果正确
1
2
3
4
5
6
7

选择建议:

  • 99% 的场景使用 StringBuilder:字符串拼接几乎都在单个方法内完成,不存在多线程共享
  • StringBuffer 适用场景极少:只有多个线程共享同一个 StringBuilder 对象时才需要
  • 实际上更好的多线程方案是每个线程用自己的 StringBuilder,最后合并结果

# 5.8 String相关的JVM优化

# 5.8.1 字符串去重优化

G1 收集器从 JDK 8u20 开始支持字符串自动去重(String Deduplication, JEP 192):

# 开启字符串去重(默认关闭)
-XX:+UseStringDeduplication
-XX:+UseG1GC

# 触发去重的年龄阈值(对象经过多少次GC后参与去重)
-XX:StringDeduplicationAgeThreshold=3
1
2
3
4
5
6

原理:GC 时扫描堆中的 String 对象,如果两个 String 的 value 数组内容相同,让它们共享同一个 value 数组。

去重前:
String s1 → value1 = {'H','e','l','l','o'}
String s2 → value2 = {'H','e','l','l','o'}  // 内容相同但不同的数组对象

去重后:
String s1 → value1 = {'H','e','l','l','o'}
String s2 ↗  (s2 的 value 指向了 s1 的 value)
// value2 被回收
1
2
3
4
5
6
7
8

注意:去重只作用于 value 数组,不合并 String 对象本身。这是因为 String 对象的引用可能被 == 比较。

适用场景:数据库查询结果中大量重复字符串(如状态值、城市名等)。

# 5.8.2 hashCode缓存机制

String 的 hashCode 计算使用经典的 31 进制公式:

// s[0]*31^(n-1) + s[1]*31^(n-2) + ... + s[n-1]
public int hashCode() {
    int h = hash;
    if (h == 0 && value.length > 0) {
        for (int i = 0; i < value.length; i++) {
            h = 31 * h + value[i];
        }
        hash = h;  // 缓存结果
    }
    return h;
}
1
2
3
4
5
6
7
8
9
10
11

疑惑:为什么选择 31 作为乘法因子?

论证:

  1. 31 是奇素数:素数能减少哈希冲突概率
  2. 31 = 2^5 - 1:JIT 编译器可以将 31 * h 优化为 (h << 5) - h,位运算比乘法更快
  3. 经验选择:过小的素数(如 2、3)冲突率高,过大的素数溢出频繁。31 在实践中表现最佳

JDK 9+ 的 hashIsZero 优化:

// 问题:如果字符串的 hashCode 恰好计算出 0 怎么办?
// hash 字段初始值也是 0,无法区分"未计算"和"计算结果为0"
// JDK 13+ 引入了 hashIsZero 字段解决这个问题

private boolean hashIsZero;

public int hashCode() {
    int h = hash;
    if (h == 0 && !hashIsZero) {
        h = isLatin1() ? StringLatin1.hashCode(value)
                       : StringUTF16.hashCode(value);
        if (h == 0) {
            hashIsZero = true;
        } else {
            hash = h;
        }
    }
    return h;
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19

# 5.8.3 编译器对String的特殊处理

Java 编译器(javac)和 JIT 编译器对 String 有很多特殊优化:

1. 编译期常量折叠(前面已详述)

2. switch-case 对 String 的支持(JDK 7+):

// 源代码
switch (str) {
    case "hello": doA(); break;
    case "world": doB(); break;
}

// 编译后等价于(先比较 hashCode,再用 equals 确认)
int h = str.hashCode();
switch (h) {
    case 99162322:  // "hello".hashCode()
        if (str.equals("hello")) doA();
        break;
    case 113318802: // "world".hashCode()
        if (str.equals("world")) doB();
        break;
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16

3. JIT 编译器的 intrinsic 优化:

HotSpot JVM 对 String 的核心方法(如 equals、indexOf、compareTo)有 intrinsic 实现——用手写的机器码替代 Java 字节码,充分利用 CPU 的 SIMD 指令:

// String.equals() 的 intrinsic 优化:
// 普通 Java 实现:逐字符比较,一次比较 2 字节
// Intrinsic 实现:使用 SSE/AVX 指令,一次比较 16~32 字节
// 对于长字符串比较,性能可提升 4~8 倍
1
2
3
4

# 5.9 生产环境中的String最佳实践

# 5.9.1 避免内存泄漏的注意事项

JDK 6 的 substring 内存泄漏(已在 JDK 7 修复):

// JDK 6 的 substring 实现:共享原字符串的 char[]
// 仅通过 offset 和 count 截取视图
String huge = loadHugeString();  // 10MB 字符串
String small = huge.substring(0, 5);
huge = null;
// small 仍然持有 10MB 的 char[] 引用!内存泄漏

// JDK 7+ 修复:substring 创建新的 char[] 数组
// 只复制需要的部分,原 char[] 可以被 GC 回收
1
2
3
4
5
6
7
8
9

toCharArray 的防御性拷贝:

String s = "hello";
char[] chars = s.toCharArray();  // 返回副本,修改不影响原 String
chars[0] = 'H';
System.out.println(s);  // 仍然是 "hello"
1
2
3
4

# 5.9.2 大规模字符串处理策略

1. 使用 CharSequence 接口提高灵活性:

// 方法参数使用 CharSequence 而非 String
// 可以接受 String、StringBuilder、StringBuffer、CharBuffer 等
public boolean containsKeyword(CharSequence text) {
    return text.toString().contains("keyword");
}
1
2
3
4
5

2. 大文件处理避免一次性加载:

// 错误:一次性读取整个文件到 String
String content = new String(Files.readAllBytes(path));  // 可能 OOM

// 正确:流式读取
try (BufferedReader reader = Files.newBufferedReader(path)) {
    String line;
    while ((line = reader.readLine()) != null) {
        processLine(line);
    }
}
1
2
3
4
5
6
7
8
9
10

3. 字符串池化(自定义池):

// 对于有限枚举值的字符串,使用 Map 池化比 intern() 更可控
private static final Map<String, String> POOL = new ConcurrentHashMap<>();

public static String pool(String s) {
    String existing = POOL.putIfAbsent(s, s);
    return existing != null ? existing : s;
}
1
2
3
4
5
6
7

# 5.9.3 String与字符编码问题

常见编码陷阱:

// 陷阱1:默认编码不可靠
byte[] bytes = "你好".getBytes();  // 使用系统默认编码,不可移植!
// 正确做法
byte[] bytes = "你好".getBytes(StandardCharsets.UTF_8);

// 陷阱2:字符长度 ≠ 字节长度
String s = "你好World";
System.out.println(s.length());                          // 7(字符数)
System.out.println(s.getBytes(StandardCharsets.UTF_8).length); // 11(UTF-8字节数)

// 陷阱3:supplementary characters(增补字符)
String emoji = "😀";
System.out.println(emoji.length());     // 2(UTF-16 用两个 char 表示)
System.out.println(emoji.codePointCount(0, emoji.length())); // 1(实际1个字符)

// 正确遍历所有 Unicode 字符
"Hello😀World".codePoints().forEach(cp -> {
    System.out.println(new String(Character.toChars(cp)));
});
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19

# 5.10 总结与核心要点

String 设计哲学:

  1. 不可变性是基石:字符串常量池、线程安全、hashCode 缓存都建立在不可变之上
  2. 池化思想:字符串常量池通过复用减少内存消耗,是"享元模式"的经典实践
  3. 持续优化:从 char[] 到 byte[] 紧凑存储,从 StringBuilder 到 invokedynamic 拼接,每一版都在优化

技术演变总结:

版本 关键变化 影响
JDK 1.0 String 诞生,char[] 存储 奠定基础
JDK 1.2 hashCode 缓存 HashMap 性能提升
JDK 1.7 常量池移入堆,substring 不再共享数组 解决 PermGen OOM 和内存泄漏
JDK 1.7u40 StringTable 默认大小增至 60013 intern() 性能提升
JDK 8u20 G1 字符串去重 减少重复字符串内存
JDK 9 byte[] + Compact Strings 减少 ~15% 堆内存
JDK 9 invokedynamic 字符串拼接 更灵活的拼接优化
JDK 13 hashIsZero 字段 修复 hashCode=0 的重复计算问题

核心要点速记:

问题 答案
String 为什么不可变 final 类 + final 数组引用 + 不暴露修改方法
不可变的好处 常量池前提、线程安全、hashCode 缓存、安全性
常量池在哪 JDK 7+ 在堆内存中
new String("abc") 几个对象 最多 2 个:常量池 1 个 + 堆 1 个
JDK 6 vs JDK 7 intern 区别 JDK 6 复制到永久代,JDK 7+ 直接存引用
循环拼接用什么 StringBuilder,避免循环内反复创建
JDK 9 底层改了什么 char[] → byte[] + coder,节省内存
为什么 hashCode 用 31 奇素数 + 可优化为位运算 (h<<5)-h

掌握 String 的原理,是理解 Java 内存模型、集合框架、并发编程的基础。

上次更新: 2026/06/10, 11:13:41
HashMap底层哈希设计
ArrayList与LinkedList源码

← HashMap底层哈希设计 ArrayList与LinkedList源码→

最近更新
01
信号崩溃快速排查
06-15
02
CoreDump破案
06-15
03
perf火焰图实战
06-15
更多文章>
Theme by Vdoing | Copyright © 2019-2026 杨充 | MIT License | 桂ICP备2024034950号 | 桂公网安备45142202000030
  • 跟随系统
  • 浅色模式
  • 深色模式
  • 阅读模式