Java · String 与常量池

不可变是刻意设计 — 常量池复用 / hash 只算一次 / 安全共享全是它换来的, 循环拼接是唯一能引爆它的入口

拼接真相 — 编译期 vs 运行期 编译期: 常量折叠 String s = "a" + "b"; javap -c 看不到 StringBuilder: 编译器直接折叠成 ldc "ab" "ab" 直接进常量池 (final 常量同理) static final String A 也会被折叠进池 运行期: 循环里 s += x for (int i = 0; i < 5; i++) s += i; 每轮: new StringBuilder → append(整个旧串) → toString 副本 "a0" ✕ 第1轮尸体 "a01" ✕ 第2轮尸体 "a012" ✕ 第3轮尸体 "a0123" ✕ 第4轮尸体 "a01234" 最终串 GC 压力随轮数暴涨 旧串整个复制进 builder, toString 再复制 一次 → 每轮制造两份垃圾 n 轮累计字符拷贝 O(n²) 正解: StringBuilder + 预分配容量 10 万次拼接: 2.4s/41 次 GC → 9ms/0 次 字符串常量池 (JDK 7+ 移入堆) StringTable — 固定桶数的开放链表 "open" "abc" "main" "xyz" "java" · · · String s1 = "abc"; String s2 = new String("abc"); 堆对象 s2 内容是 "abc" 的副本 new 一定新开堆内存 s2.intern() == s1 → true (换回池里那个实例的引用) s1 == s2 → false s1.equals(s2) → true 经典题: new String("abc") 最多 2 个对象 常量池的真实身份 HotSpot 用 StringTable 存"引用"而非字符 JDK 7 前在 PermGen: intern 过猛直接 OOM 动态串大量入池 → 长链退化 + 挤爆 可调桶数: -XX:StringTableSize=1000003 hash 惰性缓存 private int hash; — 第一次才算, 之后复用 作 HashMap key 反复 hashCode 零成本 编译器视角 (javap -constants) final String A = "a", B = "b"; String c = A + B; // 折叠成 "ab" String d = a + b; // 变量 → 运行期拼接 JDK 9+: 运行期拼接是 invokedynamic (StringConcatFactory), 循环里仍是每轮新建 compact strings (JDK 9+, JEP 254) String { byte[] value; byte coder; } 不再固定 char[] (2B/字符), 按内容选编码 coder: LATIN1=0 单字节 / UTF16=1 双字节 "abc" → coder=LATIN1 a (1B) b (1B) c (1B) = 3 B Java 8 的 char[] 要 6 B "中a" → coder=UTF16 中 (2B) a (2B) = 6 B 含任一非 Latin-1 字符, 整串转 UTF16 纯 ASCII 堆内存省 ~50% 日志/JSON/key 绝大多数是纯 ASCII 引用图变小, GC 扫描也跟着变快 两个必须知道的细节 length() 仍按 UTF-16 code unit 计数: "emoji 昵称".length() 会把表情算 2 charAt 会拆出半个字符 → 用 codePointAt getBytes(UTF_8) 时再展开, 不亏 开关与收益 JDK 9+ 默认开启, -XX:-CompactStrings 关 JMH 实测 equals/hash 也变快 (缓存友好) 升级 JDK 顺手就有的"免费午餐" 与常量池正交: 池里存的也是 compact 布局 Legend 常量池/复用 堆新对象/废弃 正确做法 内部结构 UTF16/要点

不可变是特性不是缺陷

  • • hash 只算一次并缓存, 作 HashMap key 零成本
  • • 可放心当参数传/做锁粒度, 不怕背后被改
  • • 常量池复用的前提 — 可变就没人敢共享

拼接的真相

  • • 常量 + 常量: 编译期折叠, 连池都不出
  • • 循环里 +=: 每轮新建 builder + 副本, O(n²)
  • • StringBuilder 预分配容量, 摊平成 O(n)

该用工具就用

  • • join/repeat/isBlank/strip 一行顶十行
  • • text block 写多行 SQL/JSON 不再拼 \n
  • • emoji/多语言文本一律按 codePoint 处理

💡 一句话理解

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 内存直接省一半。

🧠 必知必会 必考 & 必会

为什么不可变
final 类 + 私有 final 数组 + 不提供修改方法。三大回报: hash 缓存 (算一次)、安全 (传引用不怕被改)、池复用 (可变内容没法全局共享一份)。
String a = "abc";
Map<String, Integer> m = new HashMap<>();
m.put(a, 1); m.get(a);  // hash 只在第一次算, 之后全走缓存
// 关键: 无修改方法 → 敢全局共享一份, 敢放心传引用
字面量 vs new
"abc" 在类加载阶段进常量池, 全局唯一; new String("abc") 一定在堆里新开对象。经典题: 最多产生 2 个对象 (池里字面量 + 堆里副本)。
String s1 = "abc";              // 池里唯一实例
String s2 = new String("abc");    // 堆里再开一个副本
s1 == s2;      // → false (不同对象)
s1 == "abc";   // → true (同一池实例)
intern()
返回池中的"规范实例", 没有就把自己放进去。JDK 7 起池在堆里 (不再 PermGen)。用途只剩"值域有限且高频重复"的去重, 动态串慎用。
String s = new String("abc");  // 堆对象
s == "abc";               // → false
s.intern() == "abc";    // → true: 换回池中规范实例的引用
// 关键: 动态串大量 intern 会挤爆 StringTable, 慎用
编译期常量折叠
纯字面量与 static final 常量参与的拼接, 编译器直接算成结果入池 (javap -constants 可验证); 只要有一个运行期变量, 就得等运行期。
final String A = "a", B = "b";
String c = A + B;         // 编译期折叠, javap 只见 ldc "ab"
c == "ab";            // → true, 与池里同一实例
// 关键: 只要有运行期变量参与, 就得等运行期拼接
运行期拼接的真相
JDK 9 起 + 编译成 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 vs StringBuffer
StringBuffer 全方法 synchronized, 是历史遗留; 单线程一律 StringBuilder, 并发本来也不该靠共享 builder 解决。构造时给预估容量是基本功。
StringBuilder sb = new StringBuilder(1024); // 预分配容量
sb.append("a").append(1).append(true);   // → "a1true"
// 关键: StringBuffer 全方法 synchronized 是历史遗留,
// 单线程一律 StringBuilder, 并发也不该靠共享 builder 解决
hash 缓存字段
private int hash 惰性计算, 之后直接复用 — String 作 HashMap key 反复 hashCode 是零成本的, 这也是它当 key 最称职的原因之一。
// String 源码: private int hash;  默认 0, 惰性计算
String k = "abc";
k.hashCode();   // 第一次才算 → 96354
k.hashCode();   // 之后直接复用缓存字段, 不再遍历字符
// 关键: 作 HashMap key 反复 hashCode 零成本
compact strings
JDK 9 (JEP 254): 内部改 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 也更快
equals 与 ==
equals 比内容, == 比引用。字面量赋值会命中池里同一实例, 让 == "碰巧"为 true — 测试通过不代表写法正确, 内容比较永远 equals。
String a = "abc", b = "ab" + "c"; // 折叠后同指池实例
a == b;                  // → true, 但这是"碰巧", 别依赖
String c = new String("abc");
a == c;                  // → false; a.equals(c) → true
substring 的历史
JDK 6 及以前 substring 共享原 char[] (只记 offset/count), 大串截小串导致内存滞留; JDK 7 起改为复制。老系统里 new String(big.substring(...)) 强制复制仍是救命写法。
// JDK 6: substring 只记 offset/count, 共享原 char[]
String tag = new String(hugeLog.substring(i, j)); // 老系统救命写法
// 关键: JDK 7 起 substring 改为复制, 大串截小串不再滞留原数组
char 与 code point
char 是 16 位 UTF-16 code unit; emoji 等增补字符是代理对 (两个 char)。按"字"处理用 codePointAt/codePoints(), 否则截断出乱码方块。
String nick = "A😀";        // 😀 是代理对, 占 2 个 char
nick.length();              // → 3 (code unit 数)
nick.codePointCount(0, 3);  // → 2: 真正的"字数"
// 关键: 按"字"操作用 codePointAt / codePoints()
switch(String) 的实现
编译成"先 hashCode switch 快筛, 再 equals 确认"两段 — 所以 case 顺序不影响正确性, 但 switch(null) 直接 NPE。
int r = switch (s) {          // 编译成 hashCode 快筛 + equals 确认
    case "a" -> 1;
    case "b" -> 2;
    default -> 0;
};
// 关键: switch(null) 先调 hashCode → 直接 NPE
CharSequence
String/StringBuilder/CharBuffer 的公共抽象; API 参数收 CharSequence 比 String 更通用, 避免无谓 toString。常用工具: String.join / repeat / isBlank / strip / format。
CharSequence cs = new StringBuilder("abc"); // 参数收它更通用
String.join(", ", "a", "b");   // → "a, b"
"  ".isBlank();             // → true
" x ".strip();               // → "x" (JDK 11+)
text block
JDK 15+: """ 三引号多行字面量, 编译期处理缩进, 本质仍是 String — 多行 SQL/JSON 模板的正确写法, 配 .formatted() 填占位。
String sql = """
        SELECT id FROM t_order
        WHERE status = ?""";
// 关键: 编译期处理公共缩进, 本质仍是 String
// 填占位: sql.formatted(status)

🏭 生产实战 real world

场景 1 · 报文组装 10 万次拼接: 从 2.4s 到 9ms

对账文件按字段循环拼接, 量一大 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 次 — 两个数量级

场景 2 · 日志参数化: 级别没开就别白拼

生产 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));

场景 3 · 海量重复串去重: intern 慎用 + StringTable 调桶

上报数据每天几亿条, city 字段取值只有 ~3000 种 — 不处理就是几亿个内容相同的堆串。

// 值域有限 + 高频重复 → 收敛回池里那一个实例, 堆占用暴跌
String city = row.get(3).trim().intern();

// 但 StringTable 默认 60013 桶, 高速 intern 会退化成长链, 启动参数扩桶:
//   java -XX:StringTableSize=1000003 -jar app.jar
// 前提: 值域有界; uid/traceId 这类无界值绝对禁止 intern (挤爆池)

场景 4 · 报文引擎复用 StringBuilder (ThreadLocal)

每秒 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();  // 防偶发大报文滞留
    }
}

场景 5 · String.repeat 生成分隔线/压测数据

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()));

场景 6 · emoji 昵称按码点截断, 不拆代理对

昵称限 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。

场景 7 · 密码不用 String: 擦不掉的敏感信息

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) 持有, 用完批量覆写

场景 8 · 10GB 日志流式读取, 512MB 容器跑通

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 (它持有文件句柄)

场景 9 · text block 写多行 SQL 模板

多行 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+)

场景 10 · getBytes 显式 UTF-8 修跨环境乱码

本地 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

⚠️ 编码注意与常见坑 pitfalls

坑 1 · 循环 + 拼接 O(n²) — 大拼接 CPU 飙高/young GC 风暴: 每轮 new StringBuilder + 两次全量复制。正解: 循环外 new StringBuilder(预估容量) 再 append。
// 错: for (...) body += part;  每轮新建 builder + 两次全量复制 → O(n²)
// 对: StringBuilder sb = new StringBuilder(parts.size() * 24);
//     循环里只 sb.append(part);  最后一次性 toString
坑 2 · new String("x") 无谓堆对象 — 明明池里有, 又开一个副本占内存还让 == 变 false。正解: 直接字面量; 需要防御性副本的场景 (如截断老串) 才有意义。
// 错: String s = new String("abc");  池里有还开副本, == 也变 false
// 对: String s = "abc";  (防御性复制等特殊场景才用 new)
坑 3 · == 比较内容"偶发为 true" — 字面量命中池里同一实例, 测试环境全绿, 换个构造来源 (substring/new/输入流) 就 false。正解: 内容比较永远 equals。
// 错: if (name == "admin")  测试碰巧 true, 来源换成 IO/substring 就 false
// 对: if ("admin".equals(name))  常量放左边还顺带防 NPE
坑 4 · intern 动态串挤爆 StringTable — 无界值 (uid/url) 大量 intern, 表退化为长链, 老版本直接 PermGen OOM。正解: 只有"值域有界 + 高频重复"才 intern, 并调 -XX:StringTableSize。
// 错: String k = traceId.intern();  无界值 → StringTable 长链/PermGen OOM
// 对: 值域有界+高频重复才 intern, 并扩桶:
//     java -XX:StringTableSize=1000003 -jar app.jar
坑 5 · String.format 热路径性能差 — 每次调用都要解析 pattern, 比拼接慢一个量级。正解: 简单拼接用 +/builder; 重复模板用预编译的 MessageFormat 或 .formatted 于低频路径。
// 错: 热路径每条都 String.format("u=%s", uid);  每次解析 pattern
// 对: "u=" + uid;  重复模板预编译 MessageFormat, 低频路径才 .formatted
坑 6 · 老 JDK substring 内存滞留 — JDK 6 的 substring 共享原 char[]: 1GB 日志截 20B 关键词, 1GB 活不下去。正解: 升 JDK 7+; 老系统用 new String(big.substring(...)) 强制复制。
// 错 (JDK 6): String k = hugeLog.substring(i, j);  共享 1GB 原数组
// 对: 升 JDK 7+; 老系统 new String(hugeLog.substring(i, j)); 强制复制
坑 7 · split(".") 切出空数组 — 参数是正则, . 匹配任意字符。正解: split("\\.") 或 Pattern.quote("."); 同族坑: | ( ) [ + 都是元字符。
// 错: "a.b.c".split(".");     → 长度 0: . 是正则任意符
// 对: "a.b.c".split("\\.");  → [a, b, c]
//     或 Pattern.quote(".") 包成字面量; | ( ) [ + 同族坑
坑 8 · split 丢尾部空串 — "a,b,,".split(",") 长度是 1, 尾部空串全被剥掉, 列数对不上。正解: split(",", -1) 保留尾部空字段。
// 错: "a,b,,".split(",");      → 长度 1, 尾部空串全被剥掉
// 对: "a,b,,".split(",", -1); → 长度 4, 保留尾部空字段, 列数对上
坑 9 · trim 与 isBlank 判定范围 — trim() 只删 ≤ U+0020 的字符, 全角空格/制表符家族删不干净。正解: JDK 11+ 用 strip()/isBlank() (按 Unicode 空白判定)。
// 错: " x".trim();   全角空格 U+3000 删不掉 (trim 只管 ≤ U+0020)
// 对: " x".strip();  JDK 11+ 按 Unicode 空白判定 → "x"
坑 10 · switch(null) NPE — switch(String) 先调 hashCode, null 直接抛 NullPointerException。正解: switch 前判空, 或 JDK 14+ 的 switch 表达式也在收到 null 时抛 NPE — 入口先拦截。
// 错: switch (s) { case "a" -> ... }  s 为 null → NullPointerException
// 对: 入口先 Objects.requireNonNull(s, "status 不能为空");
坑 11 · 拼接时机误区 — "反正 + 会优化"只对编译期常量成立: FINAL_A + FINAL_B 折叠, 变量 + 变量 照样新建 builder。正解: 分清编译期/运行期, 循环里一律手写 builder。
// 错: 以为"变量 + 变量"也会优化 — 只有 final 常量/字面量在编译期折叠
// 对: 循环里一律手写 StringBuilder append, 别赌编译器
坑 12 · replaceAll 当字面替换用 — replaceAll("a.b", "-") 里 . 是正则任意符, 替换结果面目全非。正解: 字面替换用 replace (它就是字面的), 正则语义才用 replaceAll。
// 错: "a.b.c".replaceAll("a.b", "-");  . 匹配任意 → "-c"
// 对: "a.b.c".replace("a.b", "-");    字面替换 → "-.c"
坑 13 · char 处理 emoji 拆成乱码 — substring/charAt 把代理对拆半, 末尾出现 "?" 方块, 数据库里还落了半个字符。正解: codePointAt/codePointCount/codePoints() 按码点操作。
// 错: "A😀".substring(0, 2);  把代理对拦腰截断 → 末尾乱码方块
// 对: codePointAt / codePointCount / codePoints() 按码点步进与截断
坑 14 · getBytes 不传字符集 — 依赖平台默认: 本地正常, 容器里 (POSIX locale) 中文变问号。正解: 永远 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) 同理
坑 15 · String.valueOf(null) 返回 "null" — null 对象被拼成字面 "null" 进日志/SQL, 排查两小时发现是"假 null"。正解: Objects.toString(o, "缺省") 或先判空拼接。
// 错: String.valueOf(obj) + "!";  obj 为 null → 字面 "null!" 进日志
// 对: Objects.toString(obj, "缺省") + "!";  或先判空再拼接
坑 16 · 反射改常量 String 失效 — 老代码用反射改 String.value 做"热更新", JDK 12+ 字段不可反射修改, 升级直接崩。正解: 配置走正常配置中心/Properties, 别 hack 常量池。
// 错: 反射改 String.value 做"热更新" — JDK 12+ 直接 InaccessibleObjectException
// 对: 配置走配置中心 / Properties 重载, 别 hack 常量池
坑 17 · 海量长串当 Map key — 60 字符的 key × 千万级条目, 堆被字符数组吃穿。正解: 高重复先 intern/规范化; 超大键改哈希指纹 (int/long) 做 key, 原文另存。
// 错: Map<String, V> 以 60 字符长串为 key × 千万条 → 堆被字符数组吃穿
// 对: 高重复先 intern; 超大键用 long 指纹做 key, 原文另存
坑 18 · format 占位符与参数不匹配 — 运行时才抛 MissingFormatArgumentException, 编译器不查。正解: 占位符数量用单测锁住; 或改用强类型的 MessageFormat。
// 错: String.format("%s-%s", a);  → 运行时 MissingFormatArgumentException
// 对: 占位符数量用单测锁住; 或改用强类型的 MessageFormat
坑 19 · concat 在循环里与 + 一样灾难 — s.concat(x) 每次返回新串, 循环里同样是 O(n²) 复制。正解: 循环拼接只有 StringBuilder 一个答案, concat 只适合一次性双串。
// 错: for (...) s = s.concat(part);  每次返回新串, 同样 O(n²) 复制
// 对: 循环拼接只有 StringBuilder 一个答案; concat 只适合一次性双串
坑 20 · equalsIgnoreCase 的土耳其 i — 土耳其语 Locale 下 "TITLE".equalsIgnoreCase("title") 可能为 false (点DOTLESS I 问题), 多语言系统偶发判等失败。正解: 显式 Locale.ROOT/toLowerCase(Locale.ROOT) 后再比。
// 错: 土耳其语默认 Locale 下 "TITLE".toLowerCase() → "tıtle" (无点 i)
//     再拿去做 equals/contains 判等 → 多语言系统偶发失败
// 对: "TITLE".toLowerCase(Locale.ROOT);  判等用 equalsIgnoreCase