不可变是刻意设计 — 常量池复用 / hash 只算一次 / 安全共享全是它换来的, 循环拼接是唯一能引爆它的入口
String 的不可变是一笔精明的交易: 放弃"原地修改"换来三样东西 — 同内容全局只留一份 (常量池复用)、hashCode 算一次就能缓存、当作参数和 key 传递时永远安全。看懂这笔交易, 后面的现象全是推论: "a" + "b" 编译期就被折叠成 "ab" 入池, 而 new String("abc") 刻意在堆里再开一个对象, intern() 则把引用换回池里那个。
唯一能引爆这套设计的地方是循环拼接: s += x 每轮都 new 一个 StringBuilder、把旧串整个复制进去、再 toString 复制出来, n 轮累计 O(n²) 的字符拷贝和双份垃圾 — 10 万次拼接从 2.4 秒降到 9 毫秒, 只需要换成预分配容量的 StringBuilder。JDK 9 的 compact strings 又送了一份礼: 内部 byte[] 按内容选单/双字节, 纯 ASCII 内存直接省一半。
hash 缓存 (算一次)、安全 (传引用不怕被改)、池复用 (可变内容没法全局共享一份)。String a = "abc"; Map<String, Integer> m = new HashMap<>(); m.put(a, 1); m.get(a); // hash 只在第一次算, 之后全走缓存 // 关键: 无修改方法 → 敢全局共享一份, 敢放心传引用
"abc" 在类加载阶段进常量池, 全局唯一; new String("abc") 一定在堆里新开对象。经典题: 最多产生 2 个对象 (池里字面量 + 堆里副本)。String s1 = "abc"; // 池里唯一实例 String s2 = new String("abc"); // 堆里再开一个副本 s1 == s2; // → false (不同对象) s1 == "abc"; // → true (同一池实例)
String s = new String("abc"); // 堆对象 s == "abc"; // → false s.intern() == "abc"; // → true: 换回池中规范实例的引用 // 关键: 动态串大量 intern 会挤爆 StringTable, 慎用
javap -constants 可验证); 只要有一个运行期变量, 就得等运行期。final String A = "a", B = "b"; String c = A + B; // 编译期折叠, javap 只见 ldc "ab" c == "ab"; // → true, 与池里同一实例 // 关键: 只要有运行期变量参与, 就得等运行期拼接
+ 编译成 invokedynamic 调 StringConcatFactory (JIT 可优化成更优的拼接策略), 但循环里每次执行仍是新建 builder — 循环拼接的灾难没有消失。String s = "a"; for (int i = 0; i < 5; i++) s += i; // → "a01234" // 每轮新建 builder: "a0"/"a01"/"a012" 全是尸体, 累计 O(n²) // 关键: JDK 9 的 invokedynamic 救不了"循环里每轮新建"
StringBuilder sb = new StringBuilder(1024); // 预分配容量 sb.append("a").append(1).append(true); // → "a1true" // 关键: StringBuffer 全方法 synchronized 是历史遗留, // 单线程一律 StringBuilder, 并发也不该靠共享 builder 解决
private int hash 惰性计算, 之后直接复用 — String 作 HashMap key 反复 hashCode 是零成本的, 这也是它当 key 最称职的原因之一。// String 源码: private int hash; 默认 0, 惰性计算 String k = "abc"; k.hashCode(); // 第一次才算 → 96354 k.hashCode(); // 之后直接复用缓存字段, 不再遍历字符 // 关键: 作 HashMap key 反复 hashCode 零成本
byte[] value + byte coder, 纯 Latin-1 单字节、含非 Latin-1 则整串 UTF-16 双字节; 纯 ASCII 内存省约一半, equals/hash 因缓存友好也更快。// JDK 9+: String { byte[] value; byte coder; } "abc" // coder=LATIN1, 占 3 字节 (JDK 8 的 char[] 要 6 字节) "中a" // 含非 Latin-1 → 整串 UTF16, 占 4 字节; length() → 2 // 关键: 纯 ASCII 堆内存省约一半, equals/hash 也更快
String a = "abc", b = "ab" + "c"; // 折叠后同指池实例 a == b; // → true, 但这是"碰巧", 别依赖 String c = new String("abc"); a == c; // → false; a.equals(c) → true
new String(big.substring(...)) 强制复制仍是救命写法。// JDK 6: substring 只记 offset/count, 共享原 char[] String tag = new String(hugeLog.substring(i, j)); // 老系统救命写法 // 关键: JDK 7 起 substring 改为复制, 大串截小串不再滞留原数组
codePointAt/codePoints(), 否则截断出乱码方块。String nick = "A😀"; // 😀 是代理对, 占 2 个 char nick.length(); // → 3 (code unit 数) nick.codePointCount(0, 3); // → 2: 真正的"字数" // 关键: 按"字"操作用 codePointAt / codePoints()
int r = switch (s) { // 编译成 hashCode 快筛 + equals 确认 case "a" -> 1; case "b" -> 2; default -> 0; }; // 关键: switch(null) 先调 hashCode → 直接 NPE
String.join / repeat / isBlank / strip / format。CharSequence cs = new StringBuilder("abc"); // 参数收它更通用 String.join(", ", "a", "b"); // → "a, b" " ".isBlank(); // → true " x ".strip(); // → "x" (JDK 11+)
""" 三引号多行字面量, 编译期处理缩进, 本质仍是 String — 多行 SQL/JSON 模板的正确写法, 配 .formatted() 填占位。String sql = """ SELECT id FROM t_order WHERE status = ?"""; // 关键: 编译期处理公共缩进, 本质仍是 String // 填占位: sql.formatted(status)
对账文件按字段循环拼接, 量一大 CPU 和 GC 同时报警 — s += 每轮复制整个旧串。
// 灾难写法: 每轮 new StringBuilder + 两次数组复制, 累计 O(n²) 字符拷贝 for (int i = 0; i < parts.size(); i++) { body += parts.get(i); // 看着像加法, 实际是全量重写 } // 压测: 100k 段 × 平均 20B → 等效拷贝近百 GB, 耗时 2.4s, young GC 41 次 // 正解: 预估容量一次分配, append 摊平成 O(n) StringBuilder sb = new StringBuilder(parts.size() * 24); // 略大于估值得留余量 for (String p : parts) { sb.append(p); } String fixed = sb.toString(); // 同场景 9ms, GC 0 次 — 两个数量级
生产 info 级别, debug 日志的字符串却在每次调用前就拼好了 — 纯粹的白干功。
// 错: 不管 debug 开不开, 拼接都先发生了 log.debug("order detail: " + order + " items=" + items); // 对: 占位符延迟格式化, 级别不够就什么都不做 log.debug("order detail: {} items={}", order, items); // 超过 2 个占位参数走 varargs, 热路径上也有数组分配 — // 极致场景先 isDebugEnabled() 拦一道 if (log.isDebugEnabled()) log.debug(detailSummary(order, items));
上报数据每天几亿条, city 字段取值只有 ~3000 种 — 不处理就是几亿个内容相同的堆串。
// 值域有限 + 高频重复 → 收敛回池里那一个实例, 堆占用暴跌 String city = row.get(3).trim().intern(); // 但 StringTable 默认 60013 桶, 高速 intern 会退化成长链, 启动参数扩桶: // java -XX:StringTableSize=1000003 -jar app.jar // 前提: 值域有界; uid/traceId 这类无界值绝对禁止 intern (挤爆池)
每秒 8 万次序列化, 每次新建 builder 都是多一次数组分配 — 线程内复用, 用完清空。
private static final ThreadLocal<StringBuilder> SB = ThreadLocal.withInitial(() -> new StringBuilder(256)); public String encode(Heartbeat hb) { StringBuilder sb = SB.get(); try { return sb.append("{\"seq\":").append(hb.seq()) .append(",\"ts\":").append(hb.ts()).append('}').toString(); } finally { sb.setLength(0); // 复用内部数组, 不释放容量 if (sb.capacity() > 8192) sb.trimToSize(); // 防偶发大报文滞留 } }
JDK 11 一行顶一个循环, 生成测试账号、对齐报表列都比手拼清晰。
// 2000 个压测账号 + 固定宽度分隔线 String line = "-".repeat(80); List<String> users = IntStream.rangeClosed(1, 2000) .mapToObj(i -> "stress_" + i) .toList(); // 补齐列宽: repeat 比 guava padEnd 更少一个依赖 String cell = value + " ".repeat(Math.max(0, 12 - value.length()));
昵称限 8 "字", substring 直接按 char 切会把 emoji 的代理对拦腰截断, 末尾出现乱码方块。
public static String truncate(String nick, int maxPoints) { int count = 0, end = 0; while (end < nick.length()) { int cp = nick.codePointAt(end); // 一个码点, 可能占 2 个 char if (count == maxPoints) break; count++; end += Character.charCount(cp); // 步进 1 或 2, 绝不拆半 } return nick.substring(0, end); }
校验长度同理: 判"几个字"用 codePointCount, 不用 length。
String 不可变 = 擦不掉: 内容躺到 GC 为止, 还可能被复制进池、进日志快照。
// Console.readPassword() 天然返回 char[] — 设计意图就是让你能抹掉 public boolean login(String user, char[] pwd) { try { return authenticator.verify(user, pwd); } finally { Arrays.fill(pwd, '\0'); // 用完立刻抹零 — char[] 可以改 } } // 别 new String(pwd) "转一下" — 又变回擦不掉的副本 // 文件里的密钥同理: 用 ByteBuffer (最好 direct) 持有, 用完批量覆写
Files.readString 一口气进内存直接 OOMKilled — 按行读, 常驻内存只有一行。
try (BufferedReader br = Files.newBufferedReader(path, StandardCharsets.UTF_8)) { String line; while ((line = br.readLine()) != null) { parser.consume(line); // 用完即弃, 不持有引用 } } // 二进制大文件: FileChannel + 固定 64KB DirectBuffer 分块读, 同样不整串加载 // Files.lines 流式 + try-with-resources, 别忘 close (它持有文件句柄)
多行 SQL 靠 "\n" + 缩进拼接, 又难读又易错 — JDK 15 文本块天然保缩进。
String sql = """ SELECT id, status, amount FROM t_order WHERE created_at >= ? AND status = ? ORDER BY id DESC LIMIT 100"""; // 编译期处理公共缩进; 参数照样 PreparedStatement — text block 只是字面量 // 要填值: sql.formatted(cutoff, status) — String.format 的多行版 (JDK 15+)
本地 macOS (UTF-8) 一切正常, 生产容器默认 locale 是 POSIX → US-ASCII, 中文全变问号。
// 事故代码: 依赖平台默认字符集 — 隐式的环境依赖 byte[] bad = value.getBytes(); // 修复: 两个方向都显式声明 byte[] ok = value.getBytes(StandardCharsets.UTF_8); String back = new String(ok, StandardCharsets.UTF_8); // JDK 18+ (JEP 400) 默认就是 UTF-8; 老版本/跨系统交互仍要写死 // 兜底: JVM 启动加 -Dfile.encoding=UTF-8, 容器设 ENV LANG=C.UTF-8
new StringBuilder(预估容量) 再 append。// 错: for (...) body += part; 每轮新建 builder + 两次全量复制 → O(n²) // 对: StringBuilder sb = new StringBuilder(parts.size() * 24); // 循环里只 sb.append(part); 最后一次性 toString
// 错: String s = new String("abc"); 池里有还开副本, == 也变 false // 对: String s = "abc"; (防御性复制等特殊场景才用 new)
equals。// 错: if (name == "admin") 测试碰巧 true, 来源换成 IO/substring 就 false // 对: if ("admin".equals(name)) 常量放左边还顺带防 NPE
-XX:StringTableSize。// 错: String k = traceId.intern(); 无界值 → StringTable 长链/PermGen OOM // 对: 值域有界+高频重复才 intern, 并扩桶: // java -XX:StringTableSize=1000003 -jar app.jar
MessageFormat 或 .formatted 于低频路径。// 错: 热路径每条都 String.format("u=%s", uid); 每次解析 pattern // 对: "u=" + uid; 重复模板预编译 MessageFormat, 低频路径才 .formatted
new String(big.substring(...)) 强制复制。// 错 (JDK 6): String k = hugeLog.substring(i, j); 共享 1GB 原数组 // 对: 升 JDK 7+; 老系统 new String(hugeLog.substring(i, j)); 强制复制
. 匹配任意字符。正解: split("\\.") 或 Pattern.quote("."); 同族坑: | ( ) [ + 都是元字符。// 错: "a.b.c".split("."); → 长度 0: . 是正则任意符 // 对: "a.b.c".split("\\."); → [a, b, c] // 或 Pattern.quote(".") 包成字面量; | ( ) [ + 同族坑
"a,b,,".split(",") 长度是 1, 尾部空串全被剥掉, 列数对不上。正解: split(",", -1) 保留尾部空字段。// 错: "a,b,,".split(","); → 长度 1, 尾部空串全被剥掉 // 对: "a,b,,".split(",", -1); → 长度 4, 保留尾部空字段, 列数对上
trim() 只删 ≤ U+0020 的字符, 全角空格/制表符家族删不干净。正解: JDK 11+ 用 strip()/isBlank() (按 Unicode 空白判定)。// 错: " x".trim(); 全角空格 U+3000 删不掉 (trim 只管 ≤ U+0020) // 对: " x".strip(); JDK 11+ 按 Unicode 空白判定 → "x"
// 错: switch (s) { case "a" -> ... } s 为 null → NullPointerException // 对: 入口先 Objects.requireNonNull(s, "status 不能为空");
FINAL_A + FINAL_B 折叠, 变量 + 变量 照样新建 builder。正解: 分清编译期/运行期, 循环里一律手写 builder。// 错: 以为"变量 + 变量"也会优化 — 只有 final 常量/字面量在编译期折叠 // 对: 循环里一律手写 StringBuilder append, 别赌编译器
replaceAll("a.b", "-") 里 . 是正则任意符, 替换结果面目全非。正解: 字面替换用 replace (它就是字面的), 正则语义才用 replaceAll。// 错: "a.b.c".replaceAll("a.b", "-"); . 匹配任意 → "-c" // 对: "a.b.c".replace("a.b", "-"); 字面替换 → "-.c"
codePointAt/codePointCount/codePoints() 按码点操作。// 错: "A😀".substring(0, 2); 把代理对拦腰截断 → 末尾乱码方块 // 对: codePointAt / codePointCount / codePoints() 按码点步进与截断
getBytes(StandardCharsets.UTF_8), 反向 new String(bytes, UTF_8) 同理。// 错: byte[] b = value.getBytes(); 依赖平台默认, POSIX locale → 中文变 ? // 对: value.getBytes(StandardCharsets.UTF_8); // 反向 new String(bytes, StandardCharsets.UTF_8) 同理
Objects.toString(o, "缺省") 或先判空拼接。// 错: String.valueOf(obj) + "!"; obj 为 null → 字面 "null!" 进日志 // 对: Objects.toString(obj, "缺省") + "!"; 或先判空再拼接
// 错: 反射改 String.value 做"热更新" — JDK 12+ 直接 InaccessibleObjectException // 对: 配置走配置中心 / Properties 重载, 别 hack 常量池
// 错: Map<String, V> 以 60 字符长串为 key × 千万条 → 堆被字符数组吃穿 // 对: 高重复先 intern; 超大键用 long 指纹做 key, 原文另存
MissingFormatArgumentException, 编译器不查。正解: 占位符数量用单测锁住; 或改用强类型的 MessageFormat。// 错: String.format("%s-%s", a); → 运行时 MissingFormatArgumentException // 对: 占位符数量用单测锁住; 或改用强类型的 MessageFormat
s.concat(x) 每次返回新串, 循环里同样是 O(n²) 复制。正解: 循环拼接只有 StringBuilder 一个答案, concat 只适合一次性双串。// 错: for (...) s = s.concat(part); 每次返回新串, 同样 O(n²) 复制 // 对: 循环拼接只有 StringBuilder 一个答案; concat 只适合一次性双串
Locale.ROOT/toLowerCase(Locale.ROOT) 后再比。// 错: 土耳其语默认 Locale 下 "TITLE".toLowerCase() → "tıtle" (无点 i) // 再拿去做 equals/contains 判等 → 多语言系统偶发失败 // 对: "TITLE".toLowerCase(Locale.ROOT); 判等用 equalsIgnoreCase