JVMNotes

第 06 章:字符串与常量池

zjc 于 2026-01-06 发布

这是《JVM 零基础实战指南》的独立章节版。本章从概念、实操和生产排查三个视角展开,代码块保留了原书可直接运行的版本。 字符串是业务系统里最常见的对象之一。理解 String 不可变性、字符串常量池、intern、编码和拼接成本,可以避免一类典型内存和性能问题。

6.1 String 不可变

String a = "java";
String b = a.concat("-17");
System.out.println(a);
System.out.println(b);

不可变的收益:

  1. 字符串常量池可复用;
  2. hashCode 可缓存;
  3. 并发读写安全;
  4. 作为 Map key 稳定;
  5. 安全边界清晰。

代价:

  1. 修改会创建新对象;
  2. 高频拼接需要中间对象;
  3. 大字符串转换成本高。

6.2 字符串常量池

String literal = "java";
String heap = new String("java");
String interned = heap.intern();

System.out.println(literal == heap);
System.out.println(literal == interned);

典型输出:

false
true

常量池位置随 JDK 版本变化。Java 7 起 HotSpot 将字符串常量池放在堆中,但这是实现细节,不应作为业务逻辑基础。

常量池复用:

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

运行时拼接:

String prefix = "or";
String value = prefix + "der";
System.out.println(value == "order");

典型输出为 false。编译期常量拼接可能被优化,具体以字节码和常量池为准。

6.3 intern 的使用与风险

String normalized = value.intern();

可能收益:

  1. 大量重复字符串减少堆占用;
  2. 相同字面量复用;
  3. 某些比较场景减少比较成本。

风险:

  1. 常量池占用堆;
  2. 高频 intern 增加竞争;
  3. 生命周期难以控制;
  4. 动态字符串大量进入池;
  5. 泄漏排查复杂。

生产建议:优先使用 equals、枚举、字典编码或外部缓存。不要把 intern 当作万能去重器。

6.4 String、StringBuilder、StringJoiner

StringBuilder builder = new StringBuilder();
for (int i = 0; i < 10; i++) {
    builder.append(i).append(',');
}
String result = builder.toString();

StringJoiner:

String result = new StringJoiner(",", "[", "]")
        .add("a")
        .add("b")
        .toString();

选择:

场景 工具
单次拼接 +
循环拼接 StringBuilder
指定分隔符 StringJoiner
多行文本 Text Block
高频模板 预编译模板

6.5 字符编码

Java String 内部以 UTF-16 表示,常见 API 会处理编码转换。

byte[] utf8 = value.getBytes(StandardCharsets.UTF_8);
String decoded = new String(utf8, StandardCharsets.UTF_8);

长度:

String value = "a\u00e9b";
System.out.println(value.length());
System.out.println(value.codePointCount(0, value.length()));

常见问题:

问题 原因
中文乱码 请求、响应、文件、数据库编码不一致
字节数超限 UTF-8 中文常占 3 字节,字符数不等于字节数
emoji 截断 UTF-16 代理对
排序异常 未使用 Collator

接口应统一 UTF-8,并显式指定 charset。

6.6 字符串与 JSON

常见内存放大:

HTTP response
  -> byte[]
     -> String
        -> JSON tree
           -> DTO

优化:

  1. 流式解析大 JSON;
  2. 避免 SELECT *
  3. 分页返回;
  4. 复用解析器;
  5. 不要把大响应完整转字符串;
  6. 控制日志字符串长度。

Jackson 流式:

JsonFactory factory = new JsonFactory();
try (JsonParser parser = factory.createParser(new File("events.json"))) {
    while (parser.nextToken() != null) {
        // 处理 token
    }
}

6.7 字符串去重

G1 字符串去重:

-XX:+UseStringDeduplication

适用条件:

  1. 使用 G1;
  2. 大量重复字符数组;
  3. 存活时间长;
  4. 用 CPU 换内存可接受。

业务层去重:

Map<String, String> canonical = new ConcurrentHashMap<>();
String shared = canonical.computeIfAbsent(value, k -> k);

必须设置上限和过期策略,否则会变成新的泄漏点。

6.8 equals 与 hashCode

String a = new String("order");
String b = new String("order");

System.out.println(a == b);
System.out.println(a.equals(b));

业务 key 应使用明确类型或 record:

record CacheKey(long tenantId, String scene) {}

6.9 内存分析

直方图:

jcmd <pid> GC.class_histogram | grep -E 'String|byte\[\]|char\[\]'

堆 dump:

MAT Histogram
  -> java.lang.String
     -> Group by value
        -> 查看重复字符串和大字符串

关注:

  1. String 数量;
  2. value 数组大小;
  3. 重复值;
  4. retained heap;
  5. 引用来源;
  6. 是否来自日志、缓存、反序列化。

6.10 生产案例

案例一:日志拼接导致 CPU 高

问题代码:

log.debug("order=" + hugeObject);

即使日志级别关闭,拼接也已执行。处理:

log.debug("order={}", () -> expensiveToString(hugeObject));

或前置判断:

if (log.isDebugEnabled()) {
    log.debug("order={}", expensiveToString(hugeObject));
}

案例二:重复字符串占用高

发现:

jcmd <pid> GC.class_histogram | head -20

处理:

  1. 修复 DTO 重复转换;
  2. 增加缓存上限;
  3. 使用字典编码;
  4. 评估 G1 String Deduplication;
  5. 控制响应字段。

本章小结

String 不可变带来安全和复用收益,也意味着高频修改会产生大量中间对象。常量池和 intern 应谨慎使用,业务比较始终以 equals 为准。大 JSON、日志拼接和重复字符串是生产内存问题高发点。

思考题

  1. 字面量字符串和 new String 有什么区别?
  2. 为什么循环拼接推荐 StringBuilder?
  3. String.length() 返回什么长度?
  4. intern 有哪些风险?
  5. 如何分析 String 占用过高?