字符串处理


String 不可变性 ⭐

Java 中的 String 是最常用的引用类型之一,而它最核心的设计哲学就是 不可变性(Immutability)。理解这一点,是掌握整个字符串体系的基石。

所谓不可变,是指一个 String 对象一旦被创建,它内部存储的字符序列就永远不会被改变。任何看似"修改"字符串的操作(如拼接、替换、截取),实际上都是创建了一个全新的 String 对象,原始对象纹丝不动。

从源码看不可变的实现

要理解 为什么 String 是不可变的,最直接的方式就是看它的源码结构。以 Java 8 为例:

Java
public final class String                    // final 修饰类:禁止任何子类继承,防止子类破坏不可变契约
    implements java.io.Serializable,         // 支持序列化
               Comparable<String>,           // 支持字符串自然排序比较
               CharSequence {               // 字符序列的通用接口
 
    /** The value is used for character storage. */
    private final char value[];              // private:外部无法直接访问该数组
                                             // final:引用一旦指向某个数组对象,就不能再指向别的数组
                                             // 注意:final 只保证引用不变,不保证数组内容不变
                                             // 真正保证内容不变的是:String 类没有暴露任何修改此数组的方法
 
    /** Cache the hash code for the string */
    private int hash;                        // 哈希值缓存,默认为 0,延迟计算(lazy evaluation)
}

从 Java 9 开始,内部存储从 char[] 变成了 byte[],这是 Compact Strings(紧凑字符串) 优化。Latin-1 字符只占 1 字节而非 2 字节,内存占用几乎减半:

Java
// Java 9+ 的内部实现
public final class String implements java.io.Serializable,
                                     Comparable<String>,
                                     CharSequence {
 
    @Stable
    private final byte[] value;              // 改用 byte 数组存储,节省内存
 
    private final byte coder;                // 编码标识:LATIN1 = 0, UTF16 = 1
                                             // JVM 根据字符串实际内容自动选择编码方式
 
    private int hash;                        // 哈希缓存,与 Java 8 一致
}

不可变性由三层防线共同保障,缺一不可:

这里有一个常见的误解需要澄清:很多人以为 final 关键字就是不可变的全部原因,这是不准确的。final 修饰数组引用,只能保证 value 这个变量不会指向另一个数组对象,但数组元素本身是可以被修改的。真正让内容不可变的关键在于 —— String 类的 API 设计中,没有任何一个公开方法会去修改 value 数组中的元素

我们可以用一段代码来证明 final 数组的内容其实是可以改的:

Java
public class FinalArrayDemo {
    public static void main(String[] args) {
        final char[] chars = {'H', 'e', 'l', 'l', 'o'};  // final 修饰数组引用
        // chars = new char[]{'W', 'o', 'r', 'l', 'd'};   // 编译错误!不能重新赋值引用
        chars[0] = 'h';                                     // 完全合法!可以修改数组内容
        System.out.println(chars);                           // 输出 "hello",内容已被修改
    }
}

所以 String 的不可变性是一个 整体设计决策,而不是某个单一关键字的功劳。

不可变性带来的核心优势

String 被设计为不可变并非偶然,这是 Java 设计者深思熟虑的结果。不可变性在安全性、性能和并发三个维度上都带来了巨大的好处。

安全性(Security)

字符串在 Java 中承担着大量安全敏感的职责:数据库连接 URL、文件路径、网络地址、类加载路径……如果字符串是可变的,后果不堪设想。

考虑这样一个场景:

Java
public class SecurityDemo {
    // 模拟一个安全校验方法
    public static boolean validatePath(String path) {
        // 第一步:安全检查,确认路径合法
        if (path.startsWith("/safe/directory/")) {       // 校验通过
            // 假设 String 是可变的,攻击者可以在校验通过后
            // 在另一个线程中将 path 修改为 "/etc/passwd"
            // 那么下面这行代码就会操作一个非法路径!
 
            openFile(path);                               // 此时 path 可能已被篡改
            return true;
        }
        return false;
    }
 
    private static void openFile(String path) {
        System.out.println("Opening: " + path);          // 实际操作文件
    }
}

因为 String 不可变,所以一旦校验通过,后续使用的一定还是同一个值,不存在 TOCTOU(Time-of-check to time-of-use)攻击的可能。

类加载机制同样依赖这一特性:

Java
// JVM 通过全限定类名(字符串)来加载类
Class<?> clazz = Class.forName("com.example.MyService");
// 如果这个字符串在传递过程中被篡改为恶意类名
// 整个类加载安全模型就会崩塌

哈希缓存(Hash Caching)

这是不可变性带来的一个极其实用的性能优化。StringhashCode() 方法实现了 延迟计算 + 一次缓存 的策略:

Java
// String.hashCode() 源码(简化版)
public int hashCode() {
    int h = hash;                            // 读取缓存值,初始为 0
    if (h == 0 && value.length > 0) {        // 如果还没计算过(且字符串非空)
        char val[] = value;                  // 获取内部字符数组
        for (int i = 0; i < value.length; i++) {
            h = 31 * h + val[i];             // 经典的 31 系数哈希算法
                                             // 31 是奇素数,JIT 可优化为 (h << 5) - h
        }
        hash = h;                            // 计算结果写入缓存,以后直接返回
    }
    return h;                                // 返回哈希值
}

因为字符串内容永远不会变,所以哈希值只需要计算一次就可以反复使用。这对 HashMapHashSet 的性能影响是巨大的:

Java
public class HashCacheDemo {
    public static void main(String[] args) {
        String key = "a]very_long_string_used_as_map_key";
 
        // 第一次调用:遍历整个字符数组计算哈希,O(n) 时间复杂度
        int hash1 = key.hashCode();
 
        // 第二次及以后:直接返回缓存值,O(1) 时间复杂度
        int hash2 = key.hashCode();
 
        System.out.println(hash1 == hash2);  // true,值完全一致
 
        // 在 HashMap 中,每次 get/put 都需要计算 key 的哈希值
        // 如果 key 是 String,哈希缓存让这些操作快了一个数量级
        Map<String, Integer> map = new HashMap<>();
        map.put(key, 42);                    // 内部调用 key.hashCode(),走缓存
        map.get(key);                        // 再次调用 key.hashCode(),依然走缓存
    }
}

我们可以用一张图来直观展示哈希缓存的工作流程:

线程安全(Thread Safety)

不可变对象天生就是线程安全的(Inherently thread-safe)。因为没有任何线程能修改 String 的内部状态,所以多个线程可以自由地共享同一个 String 实例,完全不需要加锁或同步:

Java
public class ThreadSafetyDemo {
    // 这个字符串可以被任意数量的线程安全地读取
    private static final String SHARED_CONFIG = "jdbc:mysql://localhost:3306/mydb";
 
    public static void main(String[] args) {
        // 启动多个线程同时读取同一个字符串
        for (int i = 0; i < 10; i++) {
            new Thread(() -> {
                // 完全安全,无需 synchronized,无需 volatile
                String local = SHARED_CONFIG;            // 读取共享字符串
                System.out.println(local.toUpperCase()); // toUpperCase 返回新对象,不影响原始值
            }).start();
        }
        // 如果 String 是可变的,上面的代码就需要加锁保护
        // 否则一个线程的修改可能导致另一个线程读到不一致的数据
    }
}

字符串常量池的基础

不可变性还是字符串常量池(String Pool)能够存在的前提条件。因为字符串不可变,JVM 才敢让多个引用指向池中的同一个对象。如果字符串可变,一个引用修改了内容,所有指向同一对象的引用都会受到影响,常量池机制就彻底失效了:

Java
public class PoolDemo {
    public static void main(String[] args) {
        String s1 = "Hello";                 // 字面量,进入常量池
        String s2 = "Hello";                 // 复用常量池中的同一个对象
        System.out.println(s1 == s2);        // true,指向同一个内存地址
 
        // 正因为 String 不可变,s1 和 s2 共享同一个对象才是安全的
        // 如果 s1 能修改内容为 "World",那 s2 也会变成 "World"
        // 这显然是灾难性的
    }
}

不可变性的"代价"与应对

不可变性并非没有代价。最直接的问题就是:频繁的字符串操作会产生大量临时对象,给 GC 带来压力。

Java
public class ImmutableCostDemo {
    public static void main(String[] args) {
        String result = "";                  // 初始空字符串
        for (int i = 0; i < 10000; i++) {
            result = result + i;             // 每次拼接都创建一个新的 String 对象!
                                             // 循环 10000 次 = 创建约 10000 个临时对象
                                             // 时间复杂度 O(n^2),因为每次都要复制已有内容
        }
        // 正确做法:使用 StringBuilder
        StringBuilder sb = new StringBuilder();  // 内部维护可变的 char/byte 数组
        for (int i = 0; i < 10000; i++) {
            sb.append(i);                        // 在原数组上追加,必要时扩容
                                                 // 不创建新对象,时间复杂度 O(n)
        }
        String finalResult = sb.toString();      // 最后一次性转为不可变的 String
    }
}

这也引出了后续章节要讨论的 StringBuilderStringBuffer

通过反射"破坏"不可变性

虽然 String 在正常使用中是不可变的,但 Java 的反射机制可以绕过访问控制,强行修改内部数组。这在面试中是一个经典考点:

Java
import java.lang.reflect.Field;
 
public class ReflectionHackDemo {
    public static void main(String[] args) throws Exception {
        String original = "Hello";                       // 创建一个字符串
        System.out.println("修改前: " + original);       // 输出 "Hello"
 
        // 通过反射获取 String 内部的 value 字段
        Field valueField = String.class.getDeclaredField("value");
        valueField.setAccessible(true);                  // 暴力破解 private 访问限制
 
        // Java 8 环境下:value 是 char[]
        char[] value = (char[]) valueField.get(original);
        value[0] = 'J';                                  // 直接修改数组第一个元素
 
        System.out.println("修改后: " + original);       // 输出 "Jello"!内容被篡改了
 
        // 更严重的问题:如果这个字符串在常量池中
        String s1 = "Hello";                             // 本应从常量池获取 "Hello"
        System.out.println(s1);                          // 输出 "Jello"!常量池中的对象也被污染了
    }
}

这段代码在 Java 8 中可以正常运行。但从 Java 9 开始,模块系统(JPMS)对反射访问做了更严格的限制,运行时会抛出 InaccessibleObjectException 或需要添加 --add-opens 参数。到了 Java 17+,这类操作会产生强烈的警告甚至直接失败。

这恰恰说明了一个重要观点:不可变性是一种设计契约(Design Contract),而非物理上的绝对保证。正常的代码应该尊重这个契约,而不是试图用反射去破坏它。

小结:不可变性的全景视图

📝 练习题

以下关于 String 不可变性的说法,哪一项是错误的?

A. String 类被 final 修饰,是为了防止子类通过重写方法来破坏不可变性

B. private final char[] value 中的 final 保证了数组内容不可被修改

C. String 的不可变性使得它可以安全地在多线程环境中共享,无需同步

D. StringhashCode() 利用了不可变性进行缓存,避免重复计算

【答案】 B

【解析】 final 修饰数组引用,只能保证 value 这个引用变量不会被重新赋值指向另一个数组对象,但它并不能阻止通过下标(如 value[0] = 'x')修改数组中的元素内容。真正保证数组内容不被修改的是 String 类的封装设计:valueprivate 的,外部无法访问;同时 String 没有暴露任何可以修改该数组的公开方法。这三层防护(final class + private final 字段 + 无修改方法)共同构成了不可变性,而非 final 关键字单独的功劳。


字符串常量池(String Constant Pool)

Java 中的字符串常量池(String Constant Pool / String Intern Pool)是 JVM 为了优化字符串存储和复用而设计的一块特殊内存区域。它的核心思想非常朴素:既然字符串是程序中使用频率最高的对象之一,而且 String 又是不可变的(immutable),那为什么不把相同内容的字符串只保留一份,让大家共享呢?这就是常量池存在的根本原因。

理解字符串常量池,需要先搞清楚它在 JVM 内存中的位置、字符串对象的创建方式,以及 intern() 方法的工作机制。这些知识点不仅是面试高频考点,更是写出高性能 Java 代码的基础。

常量池在 JVM 中的位置演变

字符串常量池的物理位置在不同 JDK 版本中发生过重大变化,这是一个非常重要的知识点。

在 JDK 6 及之前,字符串常量池存放在方法区的永久代(PermGen)中。永久代的大小是固定的,默认比较小(通常 64MB ~ 128MB),如果程序中大量使用 String.intern() 或者动态生成字符串,很容易触发 java.lang.OutOfMemoryError: PermGen space。这是早期 Java 应用中一个非常经典的 OOM 场景。

从 JDK 7 开始,Oracle 将字符串常量池从永久代迁移到了 Java 堆(Heap)中。这个改动意义重大:堆的空间远大于永久代,而且堆中的对象可以被垃圾回收器正常回收。这意味着不再被任何引用指向的池中字符串,是可以被 GC 回收的。

到了 JDK 8,永久代被彻底移除,取而代之的是 Metaspace(元空间),它使用本地内存(Native Memory)。但字符串常量池仍然留在堆中,这个位置从 JDK 7 之后就没有再变过。

这段历史演变带来一个实际影响:同样的代码在 JDK 6 和 JDK 7+ 上运行,intern() 方法的行为可能产生不同的结果,后面我们会用代码详细演示。

字面量入池机制

当你在代码中写下一个字符串字面量(String Literal),比如 "Hello",编译器和 JVM 会协同完成一套精密的入池流程。

Java
// 这行代码背后发生了什么?
String s = "Hello";

这看起来简单,但背后的过程值得深入了解:

  1. 编译阶段:javac 编译器将字符串字面量 "Hello" 写入 .class 文件的常量池表(Constant Pool Table)中,类型标记为 CONSTANT_String_info
  2. 类加载阶段:当 JVM 加载这个类时,会解析(resolve)常量池中的符号引用。对于字符串常量,JVM 会检查运行时字符串常量池中是否已经存在内容相同的字符串。
  3. 如果池中已存在相同内容的字符串,直接返回池中对象的引用。
  4. 如果池中不存在,则在池中创建一个新的字符串对象,并返回其引用。

我们可以用代码来验证这个机制:

Java
public class StringPoolDemo {
    public static void main(String[] args) {
        // s1 和 s2 都是字面量,编译期就确定了
        String s1 = "Hello";  // 第一次遇到 "Hello",在池中创建对象,s1 指向池中对象
        String s2 = "Hello";  // 池中已有 "Hello",s2 直接指向同一个池中对象
 
        // == 比较的是引用地址(reference identity)
        System.out.println(s1 == s2);  // true —— 同一个对象
 
        // 编译期常量折叠(Compile-time Constant Folding)
        String s3 = "Hel" + "lo";  // 编译器直接优化为 "Hello"
        System.out.println(s1 == s3);  // true —— 编译器已经拼好了,等价于 "Hello"
 
        // 涉及变量的拼接,编译器无法优化
        String part = "lo";           // part 是变量,非编译期常量
        String s4 = "Hel" + part;     // 运行时通过 StringBuilder 拼接,产生新的堆对象
        System.out.println(s1 == s4);  // false —— s4 指向堆中的新对象,不在池中
 
        // 用 final 修饰后,part2 成为编译期常量
        final String part2 = "lo";    // final + 字面量赋值 = 编译期常量
        String s5 = "Hel" + part2;    // 编译器可以确定结果,常量折叠为 "Hello"
        System.out.println(s1 == s5);  // true —— 又是同一个池中对象
    }
}

这段代码揭示了一个关键规则:只有编译期能完全确定值的字符串表达式,才会触发常量折叠(Constant Folding),其结果才会自动入池。一旦表达式中包含非 final 的变量,编译器就无法在编译期确定最终值,只能在运行时通过 StringBuilder(JDK 5+)进行拼接,产生的结果是堆上的普通对象,不会自动进入常量池。

用一张内存图来直观理解:

Java
// ========== 内存布局示意 ==========
//
//  栈 (Stack)                  堆 (Heap)
//  ┌──────────┐               ┌─────────────────────────────────┐
//  │ s1  ─────┼───┐           │                                 │
//  │ s2  ─────┼───┤           │   String Constant Pool          │
//  │ s3  ─────┼───┼────────►  │   ┌───────────────────┐         │
//  │ s5  ─────┼───┘           │   │  "Hello" (0x100)  │         │
//  │          │               │   └───────────────────┘         │
//  │          │               │                                 │
//  │ s4  ─────┼────────────►  │   ┌───────────────────┐         │
//  │          │               │   │  "Hello" (0x200)  │ ← 堆对象│
//  └──────────┘               │   └───────────────────┘         │
//                             └─────────────────────────────────┘
//
//  s1, s2, s3, s5 → 都指向池中同一个 "Hello" (0x100)
//  s4             → 指向堆中另一个 "Hello" (0x200),内容相同但地址不同

new String() 到底创建了几个对象

这是面试中出现频率极高的问题。我们来彻底搞清楚。

Java
public class NewStringDemo {
    public static void main(String[] args) {
        // 问题:这行代码创建了几个对象?
        String s = new String("ABC");
    }
}

答案是:最多 2 个,最少 1 个。

具体分析如下:

第一个对象(可能创建):字面量 "ABC" 对应的池中对象。当 JVM 第一次遇到字面量 "ABC" 时,会在字符串常量池中创建一个内容为 "ABC"String 对象。如果池中已经存在 "ABC"(比如之前的代码已经用过这个字面量),则不会重复创建。

第二个对象(一定创建):new String(...) 一定会在堆上创建一个新的 String 对象。这个新对象的内部 char[](JDK 8)或 byte[](JDK 9+)会指向与池中对象相同的底层数组(因为 String 是不可变的,共享底层数组是安全的)。

Java
public class HowManyObjects {
    public static void main(String[] args) {
        // 场景一:池中还没有 "XYZ"
        String a = new String("XYZ");
        // 创建了 2 个对象:
        //   1. 常量池中的 "XYZ"
        //   2. 堆上 new 出来的 String 对象
 
        // 场景二:池中已经有 "XYZ" 了
        String b = new String("XYZ");
        // 只创建了 1 个对象:
        //   堆上 new 出来的 String 对象(池中的 "XYZ" 已存在,复用)
 
        System.out.println(a == b);           // false —— 两个不同的堆对象
        System.out.println(a.equals(b));      // true  —— 内容相同
    }
}

再来看一个更复杂的变体:

Java
// new String("A") + new String("B") 创建了几个对象?
String s = new String("A") + new String("B");

这行代码涉及的对象数量(假设池中还没有 "A" 和 "B"):

Java
// 对象清单:
// 1. 常量池中的 "A"
// 2. 堆上 new String("A")
// 3. 常量池中的 "B"
// 4. 堆上 new String("B")
// 5. 编译器为 + 操作生成的 StringBuilder 对象
// 6. StringBuilder.toString() 产生的新 String 对象 "AB"(在堆上)
//
// 注意:"AB" 这个结果并不会自动进入常量池!
// 总计:最多 6 个对象

intern() 方法深度解析

String.intern() 是手动将字符串放入常量池的唯一途径。它的方法签名很简单:

Java
public native String intern();  // 这是一个 native 方法,由 JVM 底层实现

intern() 的语义是:如果常量池中已经存在一个与当前字符串内容相等(equals() 返回 true)的字符串,则返回池中那个字符串的引用;如果不存在,则将当前字符串添加到池中,并返回其引用。

但这里有一个关键的版本差异,直接影响 intern() 的行为:

JDK 6 中,intern() 会在永久代的常量池中创建一个全新的字符串副本(deep copy)。JDK 7+ 中,由于常量池已经在堆中,intern() 不再复制,而是直接将堆对象的引用存入池中。这个差异导致了经典面试题的不同答案。

来看这道经典题目:

Java
public class InternDemo {
    public static void main(String[] args) {
        // 第一组实验
        // new String("1") 会在堆上创建对象,同时 "1" 作为字面量已经在池中
        String s1 = new String("1");   // s1 → 堆对象; 池中已有 "1"
        s1.intern();                    // 池中已有 "1",intern() 什么都不做,返回池中引用(被丢弃)
        String s2 = "1";               // s2 → 池中的 "1"
        System.out.println(s1 == s2);   // false —— s1 指向堆对象,s2 指向池中对象
        // JDK 6: false    JDK 7+: false(两个版本结果一致)
 
        System.out.println("----------");
 
        // 第二组实验(重点!)
        // new String("1") + new String("1") 在堆上拼接出 "11"
        // 注意:拼接结果 "11" 不会自动入池
        String s3 = new String("1") + new String("1");  // s3 → 堆上的 "11"
        s3.intern();  // 池中没有 "11",执行入池操作
        String s4 = "11";  // s4 → 池中的 "11"
        System.out.println(s3 == s4);
        // JDK 6: false(池中是 "11" 的副本,s3 仍指向堆上原对象)
        // JDK 7+: true(池中存的就是 s3 的引用,s4 拿到的也是 s3 的引用)
    }
}

第二组实验的结果差异是理解 intern() 版本行为的关键。我们用内存图来对比:

Java
// ========== JDK 6 内存布局(第二组实验)==========
//
//  栈 (Stack)          堆 (Heap)                  PermGen (永久代)
//  ┌──────────┐       ┌──────────────┐            ┌──────────────────┐
//  │ s3  ─────┼──────►│ "11" (0x300) │            │ String Pool      │
//  │          │       └──────────────┘            │ ┌──────────────┐ │
//  │ s4  ─────┼──────────────────────────────────►│ │"11" (0x500)  │ │
//  └──────────┘                                   │ │ (副本/copy)  │ │
//                                                 │ └──────────────┘ │
//  s3.intern() 在 PermGen 中创建了 "11" 的副本     └──────────────────┘
//  s3 ≠ s4 → false
//
//
// ========== JDK 7+ 内存布局(第二组实验)==========
//
//  栈 (Stack)          堆 (Heap)
//  ┌──────────┐       ┌─────────────────────────────────────┐
//  │ s3  ─────┼───┐   │                                     │
//  │          │   │   │   ┌──────────────┐                  │
//  │ s4  ─────┼───┼──►│   │ "11" (0x300) │                  │
//  │          │   │   │   └──────┬───────┘                  │
//  └──────────┘   │   │          │                          │
//                 │   │   String Pool                       │
//                 │   │   ┌──────┴───────┐                  │
//                 └──►│   │ ref → 0x300  │ ← 存的是引用!    │
//                     │   └──────────────┘                  │
//                     └─────────────────────────────────────┘
//  s3.intern() 直接把 s3 的引用 (0x300) 存入池中
//  s4 从池中拿到的也是 0x300
//  s3 == s4 → true

intern() 的实际应用与性能考量

intern() 在实际开发中并非随意使用的。它有明确的适用场景和需要注意的性能陷阱。

适用场景:当程序中存在大量重复的字符串(比如从数据库读取的城市名、状态码、配置项 key 等),使用 intern() 可以显著减少内存占用,因为相同内容只保留一份。

Java
public class InternPractice {
    public static void main(String[] args) {
        // 模拟从数据库读取大量重复的城市名
        // 不使用 intern:每次 new String() 都会在堆上创建新对象
        // 使用 intern:相同城市名共享同一个池中对象
 
        String city1 = new String("Beijing").intern();  // 入池并返回池中引用
        String city2 = new String("Beijing").intern();  // 池中已有,直接返回
        String city3 = new String("Beijing").intern();  // 同上
 
        // 三个变量指向同一个对象,节省了 2 个对象的内存
        System.out.println(city1 == city2);  // true
        System.out.println(city2 == city3);  // true
    }
}

intern() 也有性能代价。在 JDK 6 中,常量池底层使用的是一个固定大小的 HashTable,默认桶数量为 1009。当池中字符串数量增多时,哈希冲突加剧,intern() 的时间复杂度从 O(1) 退化到 O(n)。JDK 7+ 中可以通过 JVM 参数 -XX:StringTableSize=N 来调整桶的数量:

Java
// JVM 启动参数示例
// -XX:StringTableSize=60013   设置字符串表大小为 60013(建议用素数)
// -XX:+PrintStringTableStatistics   打印字符串表统计信息(调试用)

JDK 7u40 之后,StringTableSize 的默认值从 1009 提升到了 60013,大幅改善了 intern() 的性能。但即便如此,在高并发场景下频繁调用 intern() 仍然可能成为瓶颈,因为字符串表的操作需要加锁。

字符串常量池与类的常量池的区别

很多初学者容易混淆这两个概念,这里做一个清晰的区分:

简单来说:Class 文件常量池是每个 .class 文件私有的编译产物;运行时常量池是类加载后的内存表示,每个类一份;而字符串常量池是全局唯一的,所有类共享,专门用于存储和复用字符串。

字面量入池的完整时机总结

Java
public class PoolTimingDemo {
    // 1. 类中的字符串字面量 —— 类加载时入池
    static final String CONSTANT = "STATIC_FINAL";  // 类加载阶段入池
 
    public static void main(String[] args) {
        // 2. 代码中的字面量 —— 首次执行到该行时入池(lazy resolve)
        String a = "runtime_literal";  // 运行到这行时,检查池并入池
 
        // 3. 编译期常量表达式 —— 编译器折叠后,等同于字面量
        String b = "run" + "time" + "_literal";  // 编译为 "runtime_literal",与 a 共享
 
        // 4. 手动 intern —— 调用时入池
        String c = new String("manual").intern();  // "manual" 字面量入池 + intern 返回池引用
 
        // 5. 不会入池的情况
        String d = new String("heap_only");  // "heap_only" 入池,但 d 指向堆上的新对象
        String e = a + "suffix";             // 运行时拼接,结果不入池
        String f = args.length + "test";     // 涉及变量,结果不入池
 
        System.out.println(a == b);  // true  —— 常量折叠
        System.out.println(a == e);  // false —— 运行时拼接
    }
}

关于 "lazy resolve" 值得多说一句:JVM 规范并没有强制要求在类加载时就解析所有字符串常量。实际上,HotSpot JVM 采用的是延迟解析(lazy resolution)策略——字符串字面量在第一次被实际使用时才会被解析并放入常量池。这意味着如果一个类中定义了 100 个字符串字面量,但只用到了 10 个,那么只有这 10 个会被实际放入池中。


📝 练习题

以下代码在 JDK 8 环境下的输出是什么?

Java
String s1 = new String("ja") + new String("va");
String s2 = s1.intern();
String s3 = "java";
System.out.println(s1 == s2);
System.out.println(s2 == s3);

A. true true

B. false true

C. true false

D. false false

【答案】 B

【解析】 这道题的关键在于 "java" 这个字符串的特殊性。JVM 在启动过程中,java.lang.Version(或其他核心类)已经使用过字符串 "java",因此在程序执行到 s1.intern() 之前,常量池中已经存在 "java" 了。

逐行分析:

  • String s1 = new String("ja") + new String("va"); —— 在堆上创建了内容为 "java" 的新对象,s1 指向这个堆对象。
  • String s2 = s1.intern(); —— 检查池中是否有 "java",发现已经有了(JVM 启动时放入的),所以返回池中已有对象的引用。s2 指向池中的 "java",而不是 s1。
  • String s3 = "java"; —— 字面量,直接指向池中的 "java"
  • s1 == s2:s1 是堆对象,s2 是池中对象,地址不同,结果为 false
  • s2 == s3:两者都指向池中同一个 "java" 对象,结果为 true

如果把 "java" 换成一个 JVM 启动时不会预加载的字符串(比如 "javakiro"),那么 s1.intern() 会将 s1 的引用直接存入池中(JDK 7+ 行为),此时 s1 == s2 就会是 true,答案就变成了 A。这个细节体现了对 JVM 内部机制的深入理解。


StringBuilder vs StringBuffer(性能、线程安全)

在 Java 中,String 是不可变的(immutable),每次拼接都会产生新的对象。为了解决频繁拼接带来的性能问题,JDK 提供了两个可变字符序列类:StringBuilderStringBuffer。它们的 API 几乎完全一致,核心区别在于 线程安全性 和由此带来的 性能差异。理解它们的内部机制,是写出高性能 Java 代码的基本功。

继承体系与共同抽象

StringBuilderStringBuffer 都继承自同一个抽象父类 AbstractStringBuilder,这个父类持有真正的可变字符数组,并实现了绝大部分的拼接、插入、删除逻辑。两个子类本质上只是在父类基础上做了不同的"包装"。

从图中可以看出,StringBuilderStringBuffer 是"平级兄弟",共享父类 AbstractStringBuilder 的核心实现。CharSequence 接口让它们可以与 String 在很多 API 中互换使用,Appendable 接口则让它们支持 append 操作。

我们先看一下 AbstractStringBuilder 中最关键的字段:

Java
// AbstractStringBuilder 源码(简化)
abstract class AbstractStringBuilder implements Appendable, CharSequence {
    // 存储字符的内部数组,注意:没有 final 修饰,可以扩容替换
    char[] value;
    // 当前实际使用的字符数量(不等于 value.length)
    int count;
}

这里有一个非常重要的认知:value.length 是数组的 容量(capacity),而 count 是实际存储的 字符数量(length)。两者的区别是理解扩容机制的前提。

StringBuilder 详解:为速度而生

StringBuilder 是 JDK 1.5 引入的,设计目标非常纯粹——在 单线程环境 下提供最快的字符串拼接能力。它的所有方法都 没有 synchronized 关键字,因此不存在锁的获取与释放开销。

基本用法

Java
public class StringBuilderDemo {
    public static void main(String[] args) {
        // 1. 创建:默认初始容量为 16
        StringBuilder sb = new StringBuilder();
 
        // 2. 链式调用 append —— 这是最常用的操作
        sb.append("Hello")       // 追加字符串
          .append(", ")          // 追加字符串
          .append("World")       // 追加字符串
          .append("!")           // 追加字符串
          .append(2025);         // 追加整数,自动转为字符串
 
        // 3. 转为不可变的 String
        String result = sb.toString(); // "Hello, World!2025"
        System.out.println(result);
 
        // 4. 插入操作:在指定索引处插入内容
        sb.insert(13, " Java");  // 在索引 13 处插入 " Java"
        System.out.println(sb);  // "Hello, World Java!2025"
 
        // 5. 删除操作:删除 [start, end) 范围的字符
        sb.delete(17, 22);       // 删除索引 17 到 21 的字符
        System.out.println(sb);  // "Hello, World Java!"
 
        // 6. 反转操作
        sb.reverse();            // 整个字符序列反转
        System.out.println(sb);  // "!avaJ dlroW ,olleH"
 
        // 7. 替换操作:将 [start, end) 范围替换为新字符串
        sb.reverse();            // 先反转回来
        sb.replace(0, 5, "Hi"); // 将 "Hello" 替换为 "Hi"
        System.out.println(sb);  // "Hi, World Java!"
    }
}

链式调用之所以能实现,是因为每个 append 方法都 return this,返回的是同一个 StringBuilder 对象本身,而不是创建新对象。

指定初始容量

Java
// 如果你预估最终字符串大约 200 个字符
// 提前指定容量可以避免多次扩容,提升性能
StringBuilder sb = new StringBuilder(200);
 
// 也可以用已有字符串初始化
// 此时容量 = 字符串长度 + 16
StringBuilder sb2 = new StringBuilder("Hello"); // 容量 = 5 + 16 = 21

这是一个经常被忽略的性能优化点。在循环拼接大量内容时,合理预估容量可以显著减少数组拷贝次数。

StringBuffer 详解:线程安全的代价

StringBuffer 从 JDK 1.0 就存在了,比 StringBuilder 早得多。它的 API 与 StringBuilder 几乎一模一样,唯一的区别是:每个公开方法都加了 synchronized 关键字

Java
// StringBuffer 源码(简化)
public final class StringBuffer extends AbstractStringBuilder {
 
    // 用于 toString() 缓存的字段
    private transient char[] toStringCache;
 
    @Override
    public synchronized StringBuffer append(String str) {
        // toString 缓存失效
        toStringCache = null;
        // 调用父类的实际拼接逻辑
        super.append(str);
        // 返回自身,支持链式调用
        return this;
    }
 
    @Override
    public synchronized StringBuffer insert(int offset, String str) {
        toStringCache = null;
        super.insert(offset, str);
        return this;
    }
 
    @Override
    public synchronized StringBuffer delete(int start, int end) {
        toStringCache = null;
        super.delete(start, end);
        return this;
    }
 
    @Override
    public synchronized String toString() {
        // 如果缓存为空,才创建新的 String
        if (toStringCache == null) {
            toStringCache = Arrays.copyOfRange(value, 0, count);
        }
        // 直接用缓存数组构造 String
        return new String(toStringCache, 0, count);
    }
}

注意 toStringCache 这个字段——这是 StringBuffer 独有的优化。当你连续多次调用 toString() 而中间没有修改内容时,它会复用上一次的字符数组副本,避免重复拷贝。但只要调用了任何修改方法(appendinsertdelete 等),缓存就会被置为 null

StringBuilder 没有这个缓存机制,它的 toString() 每次都直接创建新的 String

扩容机制:两者完全一致

扩容逻辑写在父类 AbstractStringBuilder 中,StringBuilderStringBuffer 共享同一套规则。这是面试中的高频考点。

Java
// AbstractStringBuilder 扩容核心逻辑(简化)
private void ensureCapacityInternal(int minimumCapacity) {
    // 只有当所需容量超过当前数组长度时,才触发扩容
    if (minimumCapacity - value.length > 0) {
        // 创建新数组并拷贝旧数据
        value = Arrays.copyOf(value, newCapacity(minimumCapacity));
    }
}
 
private int newCapacity(int minCapacity) {
    // 核心公式:新容量 = 旧容量 * 2 + 2
    int newCapacity = (value.length << 1) + 2;
 
    // 如果翻倍后仍然不够,就直接用所需的最小容量
    if (newCapacity - minCapacity < 0) {
        newCapacity = minCapacity;
    }
 
    // 边界检查:不能为负数,不能超过 Integer.MAX_VALUE - 8
    return (newCapacity <= 0 || MAX_ARRAY_SIZE - newCapacity < 0)
        ? hugeCapacity(minCapacity)
        : newCapacity;
}

用一个具体例子来走一遍扩容流程:

Java
// 默认容量 16
StringBuilder sb = new StringBuilder();       // capacity = 16, count = 0
 
sb.append("Hello");                           // capacity = 16, count = 5
// 16 >= 5,不需要扩容
 
sb.append(", World!12345678");                // 需要 count = 5 + 15 = 20
// 16 < 20,触发扩容
// newCapacity = 16 * 2 + 2 = 34
// 34 >= 20,使用 34
// capacity = 34, count = 20
 
sb.append("...(假设追加大量内容共 70 字符)"); // 需要 count = 20 + 70 = 90
// 34 < 90,触发扩容
// newCapacity = 34 * 2 + 2 = 70
// 70 < 90,翻倍不够!直接使用 minCapacity = 90
// capacity = 90, count = 90

扩容的关键点总结:

  • 默认初始容量 16(无参构造)
  • 扩容公式:旧容量 × 2 + 2
  • 如果翻倍后仍不够,直接使用所需的最小容量
  • 每次扩容都涉及 Arrays.copyOf,即 数组拷贝,这是有性能开销的

性能对比:到底差多少?

理论上,StringBuffer 因为每个方法都要获取和释放 monitor lock(synchronized 的底层实现),所以比 StringBuilder 慢。但实际差距有多大?我们用一个简单的基准测试来感受:

Java
public class PerformanceTest {
    public static void main(String[] args) {
        int iterations = 1_000_000; // 循环拼接一百万次
 
        // ========== StringBuilder 测试 ==========
        long start1 = System.nanoTime();              // 记录开始时间
        StringBuilder sb = new StringBuilder(iterations * 6); // 预估容量
        for (int i = 0; i < iterations; i++) {
            sb.append("Hello ");                      // 无锁追加
        }
        long time1 = System.nanoTime() - start1;      // 计算耗时
        System.out.println("StringBuilder: " + time1 / 1_000_000 + " ms");
 
        // ========== StringBuffer 测试 ==========
        long start2 = System.nanoTime();              // 记录开始时间
        StringBuffer sbf = new StringBuffer(iterations * 6); // 预估容量
        for (int i = 0; i < iterations; i++) {
            sbf.append("Hello ");                     // 每次获取/释放锁
        }
        long time2 = System.nanoTime() - start2;      // 计算耗时
        System.out.println("StringBuffer:  " + time2 / 1_000_000 + " ms");
 
        // ========== String += 测试(对照组)==========
        long start3 = System.nanoTime();
        String s = "";
        for (int i = 0; i < 100_000; i++) {           // 只跑 10 万次,否则太慢
            s += "Hello ";                             // 每次创建新 String 对象
        }
        long time3 = System.nanoTime() - start3;
        System.out.println("String +=:     " + time3 / 1_000_000 + " ms (仅 10 万次)");
    }
}

典型的运行结果(JDK 17, 不同机器会有差异,关注量级即可):

Code
StringBuilder: ~12 ms     (100 万次)
StringBuffer:  ~18 ms     (100 万次)
String +=:     ~3200 ms   (仅 10 万次!)

几个关键观察:

  1. StringBuilderStringBuffer 快约 30%-50%,在单线程场景下优势明显。
  2. JVM 的 锁消除(Lock Elision) 优化有时会让 StringBuffer 在单线程下接近 StringBuilder 的速度,但这不是可以依赖的行为。
  3. String += 慢了 两个数量级以上,因为每次拼接都要创建新的 String 对象和新的 char[] 数组。

线程安全:StringBuffer 真的安全吗?

StringBuffer 的每个方法确实是原子性的(atomic),但这并不意味着 复合操作 是线程安全的。这是一个非常容易踩的坑。

Java
public class ThreadSafetyPitfall {
 
    // ========== 场景一:单方法调用 —— StringBuffer 是安全的 ==========
    static StringBuffer buffer = new StringBuffer();
 
    // 多个线程同时调用 append,不会出现数据损坏
    // 因为每次 append 内部都有 synchronized 保护
    static Runnable safeTask = () -> {
        for (int i = 0; i < 1000; i++) {
            buffer.append("a");  // 原子操作,线程安全
        }
    };
 
    // ========== 场景二:复合操作 —— StringBuffer 不安全! ==========
    // "先检查再操作"(check-then-act)模式天然不是原子的
    static void unsafeCompoundOperation(StringBuffer sb) {
        // 线程 A 执行到这里,读到 length = 5
        if (sb.length() > 0) {
            // 线程 B 此时可能已经 delete 了所有内容
            // 线程 A 再执行 charAt(0) 就会抛出 IndexOutOfBoundsException!
            char c = sb.charAt(sb.length() - 1);
        }
    }
 
    // ========== 正确做法:对复合操作手动加锁 ==========
    static void safeCompoundOperation(StringBuffer sb) {
        synchronized (sb) {                    // 手动对 sb 对象加锁
            if (sb.length() > 0) {             // 检查
                char c = sb.charAt(sb.length() - 1); // 操作
                // 在同一个 synchronized 块内,不会被其他线程干扰
            }
        }
    }
}

这就引出了一个实际开发中的重要结论:如果你需要在多线程环境下做复合操作,StringBuffer 的方法级同步是不够的,你仍然需要手动加锁。既然如此,还不如直接用 StringBuilder + 外部同步,性能更好,语义也更清晰。

编译器优化:你写的 + 不一定慢

从 JDK 5 开始,Java 编译器会自动将字符串的 + 拼接优化为 StringBuilder。到了 JDK 9,引入了更先进的 invokedynamicStringConcatFactory)机制。

Java
// 你写的代码
String name = "World";
String greeting = "Hello, " + name + "!";
 
// JDK 5-8 编译器实际生成的等价代码
String greeting = new StringBuilder()
    .append("Hello, ")
    .append(name)
    .append("!")
    .toString();
 
// JDK 9+ 使用 invokedynamic,由 JVM 在运行时选择最优策略
// 可能直接计算总长度,一次性分配数组,避免 StringBuilder 的扩容开销

但编译器优化有其局限性——循环内的拼接无法被有效优化

Java
// ❌ 编译器无法优化循环拼接
// 每次循环都会创建新的 StringBuilder(JDK 8)或触发 invokedynamic(JDK 9+)
String result = "";
for (int i = 0; i < 10000; i++) {
    result += i + ",";  // 每次迭代都产生临时对象
}
 
// ✅ 手动使用 StringBuilder,整个循环只用一个对象
StringBuilder sb = new StringBuilder(60000); // 预估容量
for (int i = 0; i < 10000; i++) {
    sb.append(i).append(",");               // 在同一个对象上追加
}
String result = sb.toString();              // 最后一次性转为 String

实际选型指南

把核心差异浓缩成一张表:

维度StringBuilderStringBuffer
引入版本JDK 1.5JDK 1.0
线程安全❌ 不安全✅ 方法级 synchronized
性能🚀 更快(无锁开销)🐢 较慢(锁获取/释放)
toString 缓存❌ 无✅ toStringCache
默认容量1616
扩容规则oldCap × 2 + 2oldCap × 2 + 2
推荐场景单线程、局部变量遗留代码、简单多线程

实际开发中的经验法则:

  • 99% 的场景用 StringBuilder。绝大多数字符串拼接发生在方法内部的局部变量上,天然不存在线程安全问题。
  • 简单的一次性拼接用 +。编译器会帮你优化,代码可读性更好。
  • 循环拼接必须手动用 StringBuilder,并尽量预估初始容量。
  • StringBuffer 几乎只在维护老代码时遇到。新代码中,如果真的需要多线程安全的可变字符串,更推荐 StringBuilder + 显式锁,因为你通常需要的是复合操作的原子性,而不仅仅是单方法的原子性。

📝 练习题

以下代码的输出结果是什么?

Java
StringBuilder sb = new StringBuilder("Java");
sb.append(" is").insert(4, "17").append(" great");
System.out.println(sb);
System.out.println("capacity: " + sb.capacity());

A. Java is17 great,capacity: 20

B. Java17 is great,capacity: 36

C. Java17 is great,capacity: 20

D. Java is17 great,capacity: 36

【答案】 C

【解析】 逐步分析链式调用的执行顺序:

  1. new StringBuilder("Java") → 内容为 "Java",容量 = 4 + 16 = 20
  2. .append(" is") → 内容变为 "Java is"(count = 7)
  3. .insert(4, "17") → 在索引 4 处插入 "17",内容变为 "Java17 is"(count = 9)
  4. .append(" great") → 内容变为 "Java17 is great"(count = 15)

最终 count = 15,未超过初始容量 20,所以没有触发扩容,capacity 仍为 20。注意 insert(4, "17") 是在原来索引 4 的位置(即空格 ' ' 之前)插入,所以 "17" 出现在 "Java"" is" 之间。


正则表达式(Pattern、Matcher、常用正则)

正则表达式(Regular Expression, 简称 Regex)是一种用特定符号组成的"模式字符串",用来描述和匹配一系列符合某个规则的文本。它是文本处理领域最强大的工具之一,几乎所有现代编程语言都内置了对正则的支持。Java 从 JDK 1.4 开始,在 java.util.regex 包中提供了完整的正则表达式引擎,核心类就是 PatternMatcher

理解正则表达式,需要掌握三个层面:一是正则语法本身(那些看起来像"天书"的符号到底什么意思);二是 Java 如何封装和调用正则引擎(API 层面);三是在实际开发中如何高效、正确地使用正则(性能与陷阱)。我们逐一展开。

正则表达式基础语法

正则表达式的本质是一套"模式描述语言"。你用一串特殊字符来定义一个规则,引擎会拿着这个规则去目标字符串里逐字符扫描、匹配。

先从最基础的元字符(Metacharacters)说起。元字符是正则中具有特殊含义的字符,它们不代表自身的字面值,而是表达某种匹配逻辑:

Java
// ===== 常用元字符速查 =====
 
// 1. 字符类 (Character Classes)
.        // 匹配任意单个字符(除换行符外)
\d       // 匹配任意数字,等价于 [0-9]
\D       // 匹配任意非数字,等价于 [^0-9]
\w       // 匹配单词字符:字母、数字、下划线,等价于 [a-zA-Z0-9_]
\W       // 匹配非单词字符,等价于 [^\w]
\s       // 匹配空白字符:空格、制表符、换行等
\S       // 匹配非空白字符
 
// 2. 量词 (Quantifiers)
*        // 前面的元素出现 0 次或多次(贪婪)
+        // 前面的元素出现 1 次或多次(贪婪)
?        // 前面的元素出现 0 次或 1 次(贪婪)
{n}      // 恰好出现 n 次
{n,}     // 至少出现 n 次
{n,m}    // 出现 n 到 m 次
 
// 3. 边界匹配 (Boundary Matchers)
^        // 匹配行的开头
$        // 匹配行的结尾
\b       // 匹配单词边界
 
// 4. 逻辑与分组
|        // 或运算,如 cat|dog 匹配 "cat" 或 "dog"
()       // 分组捕获,将括号内的内容作为一个整体
[]       // 字符集合,如 [abc] 匹配 a、b 或 c 中的任意一个
[^]      // 否定字符集,如 [^abc] 匹配除 a、b、c 外的任意字符

这里有一个 Java 特有的"坑"需要特别注意:Java 字符串中反斜杠 \ 本身是转义字符,所以正则里的 \d 在 Java 代码中必须写成 \\d,双反斜杠。这是初学者最常犯的错误之一。

Java
// 正则表达式:\d+  (匹配一个或多个数字)
// Java 字符串写法:必须双反斜杠
String regex = "\\d+";  // 正确 ✓
// String regex = "\d+"; // 编译错误 ✗,\d 不是合法的 Java 转义序列

字符集合 [] 是非常灵活的构造,可以用范围表示法和组合:

Java
[a-z]       // 匹配任意小写字母
[A-Z]       // 匹配任意大写字母
[a-zA-Z]    // 匹配任意英文字母
[0-9]       // 匹配任意数字,等价于 \d
[a-zA-Z0-9] // 匹配字母或数字
[^0-9]      // 匹配非数字字符

量词默认是"贪婪"(Greedy)模式,即尽可能多地匹配字符。在量词后面加 ? 可以切换为"懒惰"(Lazy/Reluctant)模式,尽可能少地匹配:

Java
// 目标字符串
String text = "<div>hello</div>";
 
// 贪婪模式:<.*> 会匹配整个 "<div>hello</div>"
// 因为 .* 会尽可能多地吃掉字符,直到最后一个 > 才停下
 
// 懒惰模式:<.*?> 会匹配 "<div>",然后是 "</div>"
// 因为 .*? 遇到第一个 > 就停下了

这个贪婪 vs 懒惰的区别在实际开发中极其重要,尤其是解析 HTML/XML 标签时,用错模式会导致匹配范围远超预期。

Java 还支持第三种模式——独占模式(Possessive),在量词后加 +(如 .*+)。独占模式和贪婪一样尽可能多匹配,但它不会回溯。如果后续条件不满足,直接宣告失败。这在某些场景下能显著提升性能,避免"灾难性回溯"(Catastrophic Backtracking)。

Pattern 类详解

Pattern 是正则表达式的编译表示(compiled representation)。正则字符串本身只是一段文本,需要经过编译才能被引擎使用。Pattern.compile() 就是这个编译过程,它将正则字符串解析为内部的状态机结构。

Java
import java.util.regex.Pattern;
import java.util.regex.Matcher;
 
public class PatternDemo {
    public static void main(String[] args) {
        // 1. 编译正则表达式,得到 Pattern 对象
        //    这一步会解析正则语法,构建内部匹配引擎
        Pattern pattern = Pattern.compile("\\d{3}-\\d{4}-\\d{4}");
 
        // 2. 用 Pattern 创建 Matcher,绑定到目标字符串
        Matcher matcher = pattern.matcher("我的电话是 138-1234-5678,请联系我");
 
        // 3. 执行匹配操作
        if (matcher.find()) {                    // find() 在字符串中搜索下一个匹配
            System.out.println("找到号码: " + matcher.group()); // 输出: 138-1234-5678
        }
    }
}

Pattern.compile() 的第二个参数可以传入标志位(flags),用来控制匹配行为:

Java
// 常用 Pattern 标志位
 
// CASE_INSENSITIVE:忽略大小写
//   默认情况下正则是大小写敏感的
Pattern p1 = Pattern.compile("hello", Pattern.CASE_INSENSITIVE);
// 此时 "Hello", "HELLO", "hElLo" 都能匹配
 
// MULTILINE:多行模式
//   默认 ^ 和 $ 只匹配整个字符串的开头和结尾
//   开启后,^ 和 $ 匹配每一行的开头和结尾
Pattern p2 = Pattern.compile("^\\d+", Pattern.MULTILINE);
 
// DOTALL:单行模式(让 . 也能匹配换行符)
//   默认 . 不匹配 \n
Pattern p3 = Pattern.compile(".*", Pattern.DOTALL);
 
// COMMENTS:允许在正则中写注释和空白(提高可读性)
Pattern p4 = Pattern.compile(
    "\\d{3}   # 区号\n" +
    "-        # 分隔符\n" +
    "\\d{4}   # 前四位\n" +
    "-        # 分隔符\n" +
    "\\d{4}   # 后四位",
    Pattern.COMMENTS                             // 开启注释模式,空白和 # 后的内容被忽略
);
 
// 多个标志可以用 | 组合
Pattern p5 = Pattern.compile("hello",
    Pattern.CASE_INSENSITIVE | Pattern.MULTILINE // 同时忽略大小写 + 多行模式
);

Pattern 还提供了几个实用的静态方法:

Java
// Pattern.matches() —— 快捷方式,一行代码完成匹配
//   注意:它是全量匹配(相当于 matches()),不是部分查找
boolean isMatch = Pattern.matches("\\d+", "12345"); // true
boolean notMatch = Pattern.matches("\\d+", "abc123"); // false,因为整个字符串不全是数字
 
// Pattern.quote() —— 将字符串转义为字面量
//   当你想匹配的文本本身包含正则元字符时非常有用
String literal = Pattern.quote("price is $10.00"); // 返回 \Qprice is $10.00\E
// \Q 和 \E 之间的所有字符都被当作字面量,$ 和 . 不再有特殊含义
 
// pattern.split() —— 按正则分割字符串
Pattern splitPattern = Pattern.compile("[,;\\s]+"); // 按逗号、分号、空白分割
String[] parts = splitPattern.split("Java,Python; C++ Rust");
// 结果: ["Java", "Python", "C++", "Rust"]

一个重要的性能建议:Pattern.compile() 是有开销的操作,它需要解析正则语法并构建内部状态机。如果同一个正则要反复使用(比如在循环中),应该将 Pattern 对象提取为常量,避免重复编译:

Java
public class PhoneValidator {
    // ✓ 正确做法:编译一次,复用多次
    //   Pattern 是线程安全的不可变对象,可以放心作为 static final 常量
    private static final Pattern PHONE_PATTERN =
        Pattern.compile("^1[3-9]\\d{9}$");      // 中国大陆手机号正则
 
    public static boolean isValidPhone(String phone) {
        return PHONE_PATTERN.matcher(phone).matches();
    }
 
    // ✗ 错误做法:每次调用都重新编译
    public static boolean isValidPhoneBad(String phone) {
        return Pattern.matches("^1[3-9]\\d{9}$", phone); // 内部每次都 compile
    }
}

Pattern 对象本身是不可变的(immutable)且线程安全的(thread-safe),这意味着你可以安全地在多线程环境中共享同一个 Pattern 实例。但 Matcher 不是线程安全的,每个线程需要创建自己的 Matcher

Matcher 类详解

Matcher 是真正执行匹配操作的"工作马"。它由 Pattern.matcher(CharSequence) 创建,绑定到一个具体的目标字符串上。Matcher 维护着匹配状态(当前扫描到哪个位置、上一次匹配的结果等),所以它是有状态的、非线程安全的。

Matcher 提供三种核心匹配方法,它们的语义完全不同:

Java
import java.util.regex.Pattern;
import java.util.regex.Matcher;
 
public class MatcherMethodsDemo {
    public static void main(String[] args) {
        Pattern pattern = Pattern.compile("\\d+");       // 匹配一个或多个数字
        String input = "abc123def456";
 
        Matcher matcher = pattern.matcher(input);
 
        // 1. matches() —— 全量匹配
        //    要求整个字符串完全符合正则
        System.out.println(matcher.matches());           // false,因为字符串不全是数字
 
        // 注意:matches() 调用后会改变 Matcher 内部状态,需要 reset
        matcher.reset();                                 // 重置状态
 
        // 2. lookingAt() —— 从头部开始匹配
        //    要求字符串开头符合正则,但不要求整个字符串都匹配
        System.out.println(matcher.lookingAt());         // false,因为开头是 "abc" 不是数字
 
        matcher.reset();
 
        // 3. find() —— 查找下一个匹配
        //    在字符串中搜索,找到任意位置的匹配即返回 true
        //    可以反复调用,每次返回下一个匹配
        while (matcher.find()) {                         // 第一次找到 "123",第二次找到 "456"
            System.out.println("找到: " + matcher.group()
                + " 位置: " + matcher.start()            // 匹配起始索引
                + "-" + matcher.end());                  // 匹配结束索引(不含)
        }
        // 输出:
        // 找到: 123 位置: 3-6
        // 找到: 456 位置: 9-12
    }
}

分组捕获(Capturing Groups)是正则表达式最强大的特性之一。用圆括号 () 将正则的一部分括起来,就形成了一个捕获组。匹配成功后,可以通过组号或组名提取每个组捕获到的内容:

Java
public class GroupDemo {
    public static void main(String[] args) {
        // 正则:匹配日期格式 yyyy-MM-dd
        // 用三个括号分别捕获 年、月、日
        //       组1: (\\d{4})   年
        //       组2: (\\d{2})   月
        //       组3: (\\d{2})   日
        Pattern datePattern = Pattern.compile("(\\d{4})-(\\d{2})-(\\d{2})");
 
        Matcher m = datePattern.matcher("今天是 2025-01-15,明天是 2025-01-16");
 
        while (m.find()) {
            System.out.println("完整匹配: " + m.group(0)); // group(0) 或 group() 是整个匹配
            System.out.println("年: " + m.group(1));        // 第 1 个括号捕获的内容
            System.out.println("月: " + m.group(2));        // 第 2 个括号
            System.out.println("日: " + m.group(3));        // 第 3 个括号
            System.out.println("---");
        }
        // 输出:
        // 完整匹配: 2025-01-15
        // 年: 2025  月: 01  日: 15
        // 完整匹配: 2025-01-16
        // 年: 2025  月: 01  日: 16
    }
}

用数字编号引用分组容易出错(尤其正则复杂时),Java 7 引入了命名捕获组(Named Capturing Groups),用 (?<name>...) 语法给组起名字:

Java
public class NamedGroupDemo {
    public static void main(String[] args) {
        // 命名捕获组:用 (?<name>...) 语法
        Pattern pattern = Pattern.compile(
            "(?<year>\\d{4})-(?<month>\\d{2})-(?<day>\\d{2})"
        );
 
        Matcher m = pattern.matcher("发布日期: 2025-07-20");
 
        if (m.find()) {
            // 通过名字引用,代码可读性大幅提升
            String year  = m.group("year");              // "2025"
            String month = m.group("month");             // "07"
            String day   = m.group("day");               // "20"
            System.out.println(year + "年" + month + "月" + day + "日");
        }
    }
}

Matcher 还提供了强大的替换功能:

Java
public class ReplaceDemo {
    public static void main(String[] args) {
        Pattern pattern = Pattern.compile("\\b\\d{4}\\b");   // 匹配独立的4位数字
        Matcher m = pattern.matcher("年份 2024 和 2025 都很重要");
 
        // replaceAll —— 替换所有匹配
        String result1 = m.replaceAll("XXXX");               // "年份 XXXX 和 XXXX 都很重要"
 
        m.reset();                                            // 重置 Matcher 状态
 
        // replaceFirst —— 只替换第一个匹配
        String result2 = m.replaceFirst("XXXX");              // "年份 XXXX 和 2025 都很重要"
 
        // 高级替换:appendReplacement + appendTail
        //   可以在替换时执行自定义逻辑
        m.reset();
        StringBuffer sb = new StringBuffer();                 // 注意:这里必须用 StringBuffer
        while (m.find()) {
            int year = Integer.parseInt(m.group());           // 取出匹配到的年份
            m.appendReplacement(sb, String.valueOf(year + 1));// 每个年份 +1
        }
        m.appendTail(sb);                                     // 追加剩余未匹配的部分
        System.out.println(sb.toString());                    // "年份 2025 和 2026 都很重要"
    }
}

从 Java 9 开始,Matcher 新增了 results() 方法,返回 Stream<MatchResult>,可以与 Stream API 无缝结合:

Java
import java.util.List;
import java.util.regex.MatchResult;
import java.util.regex.Pattern;
import java.util.stream.Collectors;
 
public class StreamRegexDemo {
    public static void main(String[] args) {
        Pattern pattern = Pattern.compile("\\b[A-Z][a-z]+\\b"); // 匹配首字母大写的单词
 
        // Java 9+:用 results() 获取 Stream
        List<String> words = pattern.matcher("Hello World From Java Regex")
            .results()                                           // 返回 Stream<MatchResult>
            .map(MatchResult::group)                             // 提取每个匹配的文本
            .collect(Collectors.toList());                       // 收集为 List
 
        System.out.println(words);                               // [Hello, World, From, Java, Regex]
    }
}

常用正则表达式实战

理论讲完了,来看实际开发中最常用的正则模式。这些是每个 Java 开发者都应该熟悉的"工具箱":

Java
public class CommonRegexPatterns {
 
    // ========== 1. 邮箱验证 ==========
    // 基本格式:用户名@域名.顶级域名
    // 用户名:字母、数字、点、下划线、连字符
    // 域名:字母、数字、连字符,至少一个点分隔
    // 顶级域名:2-6个字母
    private static final Pattern EMAIL = Pattern.compile(
        "^[\\w.-]+@[a-zA-Z\\d.-]+\\.[a-zA-Z]{2,6}$"
    );
 
    // ========== 2. 中国大陆手机号 ==========
    // 1 开头,第二位 3-9,后面 9 位数字,共 11 位
    private static final Pattern PHONE_CN = Pattern.compile(
        "^1[3-9]\\d{9}$"
    );
 
    // ========== 3. URL 匹配 ==========
    // 协议(http/https)+ 域名 + 可选端口 + 可选路径
    private static final Pattern URL = Pattern.compile(
        "^https?://[\\w.-]+(:\\d+)?(/[\\w./?%&=+-]*)?$"
    );
 
    // ========== 4. IP 地址(IPv4)==========
    // 四组 0-255 的数字,用点分隔
    private static final Pattern IPV4 = Pattern.compile(
        "^((25[0-5]|2[0-4]\\d|[01]?\\d\\d?)\\.){3}(25[0-5]|2[0-4]\\d|[01]?\\d\\d?)$"
    );
 
    // ========== 5. 身份证号(18位)==========
    // 6位地区码 + 8位出生日期 + 3位顺序码 + 1位校验码(数字或X)
    private static final Pattern ID_CARD = Pattern.compile(
        "^\\d{6}(19|20)\\d{2}(0[1-9]|1[0-2])(0[1-9]|[12]\\d|3[01])\\d{3}[\\dXx]$"
    );
 
    // ========== 6. 强密码验证 ==========
    // 至少8位,包含大写、小写、数字、特殊字符各至少一个
    // 使用前瞻断言 (Lookahead) 实现多条件同时检查
    private static final Pattern STRONG_PASSWORD = Pattern.compile(
        "^(?=.*[a-z])"     +                    // 前瞻:至少一个小写字母
        "(?=.*[A-Z])"      +                    // 前瞻:至少一个大写字母
        "(?=.*\\d)"         +                    // 前瞻:至少一个数字
        "(?=.*[@#$%^&+=!])" +                   // 前瞻:至少一个特殊字符
        ".{8,}$"                                 // 总长度至少 8
    );
 
    // ========== 7. 中文字符匹配 ==========
    // Unicode 范围 \u4e00-\u9fa5 覆盖常用汉字
    private static final Pattern CHINESE = Pattern.compile(
        "^[\\u4e00-\\u9fa5]+$"
    );
 
    // 验证方法
    public static boolean validate(Pattern pattern, String input) {
        return pattern.matcher(input).matches();         // matches() 全量匹配
    }
 
    public static void main(String[] args) {
        // 测试各种验证
        System.out.println(validate(EMAIL, "user@example.com"));     // true
        System.out.println(validate(PHONE_CN, "13812345678"));       // true
        System.out.println(validate(IPV4, "192.168.1.1"));           // true
        System.out.println(validate(CHINESE, "你好世界"));            // true
        System.out.println(validate(STRONG_PASSWORD, "Abc@1234"));   // true
    }
}

上面密码验证中用到的前瞻断言(Lookahead)值得单独说一下。断言是一种"零宽度"匹配——它检查某个位置前后是否满足条件,但不消耗字符。Java 支持四种断言:

Java
// ===== 四种断言 (Assertions / Zero-width Assertions) =====
 
// 1. 正向前瞻 Positive Lookahead: (?=...)
//    当前位置之后必须能匹配 ... 中的模式
"foo(?=bar)"     // 匹配 "foo",但仅当后面紧跟 "bar" 时
                 // "foobar" 中匹配 "foo","foobaz" 中不匹配
 
// 2. 负向前瞻 Negative Lookahead: (?!...)
//    当前位置之后不能匹配 ... 中的模式
"foo(?!bar)"     // 匹配 "foo",但后面不能紧跟 "bar"
                 // "foobaz" 中匹配 "foo","foobar" 中不匹配
 
// 3. 正向后顾 Positive Lookbehind: (?<=...)
//    当前位置之前必须能匹配 ... 中的模式
"(?<=\\$)\\d+"   // 匹配数字,但前面必须是 $ 符号
                 // "$100" 中匹配 "100","100" 中不匹配
 
// 4. 负向后顾 Negative Lookbehind: (?<!...)
//    当前位置之前不能匹配 ... 中的模式
"(?<!\\$)\\d+"   // 匹配数字,但前面不能是 $ 符号

断言在实际开发中非常有用,比如"提取价格数字但不要美元符号"、"匹配不在引号内的关键词"等场景。

String 类中的正则方法

Java 的 String 类本身也内置了几个基于正则的便捷方法,底层都是调用 PatternMatcher

Java
public class StringRegexDemo {
    public static void main(String[] args) {
 
        // ========== 1. String.matches() ==========
        // 底层等价于 Pattern.matches(regex, this)
        // 注意:是全量匹配,不是部分查找
        String phone = "13812345678";
        boolean valid = phone.matches("1[3-9]\\d{9}");  // true
        // 每次调用都会重新编译正则,性能不如预编译 Pattern
 
        // ========== 2. String.split() ==========
        // 按正则分割字符串,返回 String[]
        String csv = "Java,,Python, ,Rust";
        String[] langs = csv.split(",\\s*");             // 按逗号+可选空白分割
        // 结果: ["Java", "", "Python", "", "Rust"]
        // 注意:连续分隔符会产生空字符串元素
 
        // split 的第二个参数 limit 控制分割次数
        String log = "2025-01-15 ERROR NullPointerException: something went wrong";
        String[] parts = log.split("\\s+", 3);          // 最多分成 3 段
        // 结果: ["2025-01-15", "ERROR", "NullPointerException: something went wrong"]
        // 第三段保留了剩余所有内容,非常适合解析日志
 
        // ========== 3. String.replaceAll() ==========
        // 用正则匹配并替换所有出现
        String text = "我的电话是 138-1234-5678,备用 139-8765-4321";
        String masked = text.replaceAll("1[3-9]\\d-\\d{4}-\\d{4}", "***-****-****");
        // 结果: "我的电话是 ***-****-****,备用 ***-****-****"
 
        // 替换中可以用 $1, $2 引用捕获组
        String date = "2025/01/15";
        String formatted = date.replaceAll(
            "(\\d{4})/(\\d{2})/(\\d{2})",                // 捕获 年/月/日
            "$1年$2月$3日"                                // 用 $1 $2 $3 引用
        );
        // 结果: "2025年01月15日"
 
        // ========== 4. String.replaceFirst() ==========
        // 只替换第一个匹配
        String html = "<b>hello</b> and <b>world</b>";
        String result = html.replaceFirst("<b>(.*?)</b>", "[$1]");
        // 结果: "[hello] and <b>world</b>"
 
        System.out.println(valid);
        System.out.println(java.util.Arrays.toString(langs));
        System.out.println(masked);
        System.out.println(formatted);
        System.out.println(result);
    }
}

这些 String 方法用起来很方便,但有一个共同的性能隐患:每次调用都会在内部重新执行 Pattern.compile()。如果在循环中频繁调用,开销会非常可观。经验法则是——用一两次没问题,用在循环里就该提取 Pattern 常量了。

正则性能与常见陷阱

正则表达式虽然强大,但用不好会成为性能杀手。Java 的正则引擎采用 NFA(Non-deterministic Finite Automaton,非确定性有限自动机)实现,NFA 的特点是支持回溯(backtracking),这赋予了它强大的表达能力,但也带来了潜在的性能风险。

最臭名昭著的问题是灾难性回溯(Catastrophic Backtracking),也叫 ReDoS(Regular Expression Denial of Service):

Java
public class BacktrackingDemo {
    public static void main(String[] args) {
        // 危险正则:(a+)+ 对 "aaaaaaaaaaaaaaaaab" 的匹配
        // 这个正则看起来人畜无害,但会导致指数级回溯
        Pattern dangerous = Pattern.compile("(a+)+b");
 
        String input = "aaaaaaaaaaaaaaaaac";             // 注意末尾是 c 不是 b
        // 引擎会尝试所有可能的 a 分组方式,组合数随 a 的数量指数增长
        // 20 个 a 就可能需要数百万次回溯,30 个 a 可能需要数十亿次
 
        long start = System.currentTimeMillis();
        boolean result = dangerous.matcher(input).matches(); // 可能卡住很久!
        long elapsed = System.currentTimeMillis() - start;
        System.out.println("耗时: " + elapsed + "ms");      // 可能是几秒甚至几分钟
    }
}

避免灾难性回溯的核心原则:

Java
public class SafeRegexDemo {
    public static void main(String[] args) {
 
        // ✗ 危险:嵌套量词
        // "(a+)+" —— a 的分组方式有指数种可能
        // "(a|aa)+" —— 同理,每个位置都有两种选择
 
        // ✓ 安全:用独占量词消除回溯
        // "(a++)+" —— a++ 匹配后不回溯,直接失败
        Pattern safe1 = Pattern.compile("(a++)b");
 
        // ✓ 安全:用原子组 (Atomic Group) 消除回溯
        // "(?>a+)b" —— 原子组内匹配成功后锁定结果,不允许回溯
        Pattern safe2 = Pattern.compile("(?>a+)b");
 
        // ✓ 安全:让正则更具体,减少歧义
        // 不要用 .* 匹配一切,尽量用具体的字符类
        Pattern vague  = Pattern.compile("<.*>");        // 危险:.* 太贪婪
        Pattern precise = Pattern.compile("<[^>]*>");    // 安全:[^>]* 遇到 > 就停
 
        // 实际测试
        String input = "aaaaaaaaaaab";
        System.out.println(safe1.matcher(input).matches()); // true,瞬间完成
        System.out.println(safe2.matcher(input).matches()); // true,瞬间完成
    }
}

最后总结一下正则表达式在 Java 开发中的最佳实践:

Java
// ===== 正则表达式最佳实践清单 =====
 
// 1. 预编译为 static final 常量
private static final Pattern PATTERN = Pattern.compile("...");
 
// 2. 能用 String 简单方法就不用正则
//    contains(), startsWith(), endsWith(), indexOf()
//    这些方法比正则快一个数量级
if (str.contains("@")) { ... }           // ✓ 比 str.matches(".*@.*") 快得多
 
// 3. 正则越具体越好
"[a-zA-Z0-9]"                            // ✓ 明确指定字符范围
"."                                       // ✗ 太宽泛,容易误匹配
 
// 4. 避免在用户输入上直接使用未经验证的正则
//    如果正则来自用户输入,务必做超时保护或白名单校验
//    否则恶意正则可以发起 ReDoS 攻击
 
// 5. 复杂正则加注释(使用 COMMENTS 标志)
Pattern.compile(
    "(?<year>\\d{4})  # 年份\n" +
    "-(?<month>\\d{2}) # 月份\n" +
    "-(?<day>\\d{2})   # 日期",
    Pattern.COMMENTS
);
 
// 6. 优先使用命名捕获组
//    (?<name>...) 比 group(1) 可读性强得多
 
// 7. 用 Pattern.quote() 处理字面量
//    当匹配的文本包含 . * + ? 等元字符时
String userInput = "price is $10.00";
Pattern.compile(Pattern.quote(userInput)); // 安全地匹配字面文本

正则表达式是一把双刃剑。用得好,几行代码就能完成复杂的文本处理;用得不好,轻则匹配错误,重则拖垮系统。Jamie Zawinski 有句名言:"Some people, when confronted with a problem, think 'I know, I'll use regular expressions.' Now they have two problems."(有些人遇到问题时想"我知道了,用正则表达式"。现在他们有两个问题了。)这话虽然是调侃,但提醒我们:在动用正则之前,先想想有没有更简单的方案。


📝 练习题

以下 Java 代码的输出是什么?

Java
Pattern p = Pattern.compile("(\\d+)([a-z]+)", Pattern.CASE_INSENSITIVE);
Matcher m = p.matcher("123abc456DEF");
StringBuffer sb = new StringBuffer();
while (m.find()) {
    m.appendReplacement(sb, m.group(2) + m.group(1));
}
m.appendTail(sb);
System.out.println(sb.toString());

A. abc123DEF456

B. 123abc456DEF

C. abc123def456

D. ABC123DEF456

【答案】 A

【解析】 正则 (\d+)([a-z]+) 配合 CASE_INSENSITIVE 标志,[a-z] 也能匹配大写字母。所以第一次 find() 匹配到 123abc,其中 group(1)="123",group(2)="abc";第二次匹配到 456DEF,group(1)="456",group(2)="DEF"。appendReplacement 将每次匹配替换为 group(2) + group(1),即 "abc123" 和 "DEF456"。appendTail 追加剩余部分(本例中没有剩余)。最终结果是 abc123DEF456。注意 CASE_INSENSITIVE 只影响匹配行为,不改变捕获到的原始文本大小写,所以 "DEF" 保持大写。


本章小结

本章围绕 Java 字符串处理这一核心主题,从底层设计哲学到上层实用工具,进行了系统性的梳理。我们来回顾一下整条知识脉络。

知识脉络回顾

核心要点提炼

整章内容可以浓缩为四个关键认知:

第一,String 的不可变性是 Java 字符串体系的基石。final class 加上 private final 的内部数组,从类级别和字段级别双重锁死了修改的可能。这不是一个随意的设计选择,而是为了同时满足线程安全(thread safety)、哈希缓存(hash caching)和安全性(security)三大需求。理解了不可变性,就理解了为什么常量池能够存在——如果字符串可变,共享同一个引用将是灾难性的。

第二,字符串常量池是 JVM 基于不可变性做出的内存优化策略。字面量在编译期就会被规划入池,而 intern() 方法提供了运行时手动入池的能力。从 JDK 7 开始,常量池从永久代(PermGen)迁移到了堆(Heap),这意味着它受 GC 管理,不再容易引发 OutOfMemoryError: PermGen space。使用 == 还是 equals() 比较字符串,本质上就是在问"我关心的是引用相同还是内容相同"。

第三,当需要频繁拼接字符串时,StringBuilderStringBuffer 是正确的选择。两者 API 完全一致,唯一区别在于 StringBuffer 的方法带有 synchronized 关键字。在绝大多数场景下,字符串拼接发生在单线程的局部方法内,StringBuilder 是默认首选。只有在极少数需要多线程共享同一个可变字符序列的场景下,才需要 StringBuffer。现代 JVM 的编译器(JIT)会对简单的 + 拼接做优化,但在循环体内,手动使用 StringBuilder 仍然是必要的。

第四,正则表达式是字符串处理的高级武器。Pattern.compile() 将正则字符串编译为有限状态自动机(Finite State Automaton),Matcher 则驱动这个自动机在目标字符串上执行匹配。对于需要反复使用的正则,务必将 Pattern 对象缓存为 static final 常量,避免重复编译的开销。贪婪、懒惰、独占三种量词模式决定了回溯行为,直接影响匹配性能。

一张表看清选型

Java
// ┌──────────────────────────────────────────────────────────────────┐
// │                    字符串处理选型速查表                            │
// ├──────────────┬───────────────────────────────────────────────────┤
// │  场景         │  推荐方案                                        │
// ├──────────────┼───────────────────────────────────────────────────┤
// │  少量拼接     │  String + 运算符 (编译器自动优化)                  │
// │  循环内拼接   │  StringBuilder (单线程) / StringBuffer (多线程)    │
// │  字符串比较   │  equals() (内容比较) / == (引用比较,仅限池化场景)  │
// │  格式校验     │  预编译 Pattern + Matcher                         │
// │  简单替换     │  String.replace() (不涉及正则)                    │
// │  正则替换     │  String.replaceAll() 或 Matcher.replaceAll()      │
// │  高频 intern  │  谨慎使用,避免常量池膨胀                          │
// └──────────────┴───────────────────────────────────────────────────┘

易错点警示

在实际开发和面试中,本章有几个高频踩坑点值得特别标记:

  • new String("abc") 到底创建了几个对象?答案取决于常量池中是否已存在 "abc"。如果不存在,则是两个(池中一个 + 堆中一个);如果已存在,则只有堆中的一个。
  • StringBuilder 的默认容量是 16,当追加内容超出容量时会触发扩容(旧容量 * 2 + 2)。如果能预估最终长度,在构造时传入初始容量可以避免多次扩容和数组拷贝。
  • String.matches() 每次调用都会重新编译正则,在循环中使用是性能杀手。应该提取为 Pattern 常量。
  • 正则中的 \d 在 Java 字符串中需要写成 \\d,因为 Java 字符串本身会先消耗一次转义。这是初学者最常犯的错误之一。

📝 练习题

以下代码的输出结果是什么?

Java
String s1 = new StringBuilder("ja").append("va").toString();
System.out.println(s1.intern() == s1);
 
String s2 = new StringBuilder("hel").append("lo").toString();
System.out.println(s2.intern() == s2);

A. true, true

B. false, true

C. true, false

D. false, false

【答案】 C

【解析】 这道题考查的是 intern() 方法在 JDK 7+ 中的行为。

对于 s1new StringBuilder("ja").append("va").toString() 在堆中创建了内容为 "java" 的字符串对象。但 "java" 这个字符串在 JVM 启动时就已经被 java.lang.Version(或类似的系统类)加载到了常量池中。因此 s1.intern() 返回的是常量池中早已存在的那个引用,而 s1 是堆中新建的对象,两者不是同一个引用,按理应该返回 false——但实际上,在 JDK 7+ 中,"java" 确实已经在常量池中存在(由 sun.misc.Version 类引入),所以 s1.intern() 返回的是池中已有的引用,与 s1 不同。等等,让我们重新审视:实际上在很多 JVM 实现中 "java" 字符串确实被预加载了,所以 s1.intern() != s1,输出 false

但题目答案选 C(true, false),这对应的是另一种常见的面试版本。让我重新校准:

在标准的 HotSpot JVM(JDK 7+)中:

  • s2 = "hel" + "lo" 通过 StringBuilder 在堆中创建。调用 s2.intern() 时,常量池中没有 "hello",于是将堆中 s2 的引用直接记录到常量池(JDK 7+ 的优化,不再复制),所以 s2.intern() == s2true
  • "java" 这个字符串在 JVM 初始化阶段就已经被系统类加载进常量池了,所以 s1.intern() 返回的是系统预置的引用,不等于 s1,结果为 false

因此正确输出是 false, true,答案应为 B。

这道题的核心知识点:JDK 7+ 的 intern() 不再复制字符串到常量池,而是直接存储堆对象的引用——前提是常量池中尚不存在该字符串。而 "java" 因为被 JVM 内部预加载,属于"已存在"的情况,所以表现不同。