这是《JVM 零基础实战指南》的独立章节版。本章从概念、实操和生产排查三个视角展开,代码块保留了原书可直接运行的版本。 字符串是业务系统里最常见的对象之一。理解 String 不可变性、字符串常量池、intern、编码和拼接成本,可以避免一类典型内存和性能问题。
6.1 String 不可变
String a = "java";
String b = a.concat("-17");
System.out.println(a);
System.out.println(b);
不可变的收益:
- 字符串常量池可复用;
- hashCode 可缓存;
- 并发读写安全;
- 作为 Map key 稳定;
- 安全边界清晰。
代价:
- 修改会创建新对象;
- 高频拼接需要中间对象;
- 大字符串转换成本高。
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();
可能收益:
- 大量重复字符串减少堆占用;
- 相同字面量复用;
- 某些比较场景减少比较成本。
风险:
- 常量池占用堆;
- 高频 intern 增加竞争;
- 生命周期难以控制;
- 动态字符串大量进入池;
- 泄漏排查复杂。
生产建议:优先使用 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
优化:
- 流式解析大 JSON;
- 避免
SELECT *; - 分页返回;
- 复用解析器;
- 不要把大响应完整转字符串;
- 控制日志字符串长度。
Jackson 流式:
JsonFactory factory = new JsonFactory();
try (JsonParser parser = factory.createParser(new File("events.json"))) {
while (parser.nextToken() != null) {
// 处理 token
}
}
6.7 字符串去重
G1 字符串去重:
-XX:+UseStringDeduplication
适用条件:
- 使用 G1;
- 大量重复字符数组;
- 存活时间长;
- 用 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
-> 查看重复字符串和大字符串
关注:
- String 数量;
- value 数组大小;
- 重复值;
- retained heap;
- 引用来源;
- 是否来自日志、缓存、反序列化。
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
处理:
- 修复 DTO 重复转换;
- 增加缓存上限;
- 使用字典编码;
- 评估 G1 String Deduplication;
- 控制响应字段。
本章小结
String 不可变带来安全和复用收益,也意味着高频修改会产生大量中间对象。常量池和 intern 应谨慎使用,业务比较始终以 equals 为准。大 JSON、日志拼接和重复字符串是生产内存问题高发点。
思考题
- 字面量字符串和 new String 有什么区别?
- 为什么循环拼接推荐 StringBuilder?
String.length()返回什么长度?- intern 有哪些风险?
- 如何分析 String 占用过高?