JAVA 合理使用 intern 可节约内存,那 String.intern() 究竟做了什么?

一、先理解"字符串常量池"是什么

JVM 里有一块特殊区域叫 String Pool(字符串常量池)

你可以把它理解成一个全局公告栏

复制代码
String Pool(字符串常量池)
┌─────────────────────────────────┐
│  "2025"                          │
│  "Q1"                            │
│  "AP"                            │
│  "M&S"                           │
│  "hello"                         │
│  ...                             │
└─────────────────────────────────┘

规则:
  同一个内容,在 Pool 里永远只存一份
  任何人想用这个字符串,直接拿 Pool 里那个的地址

Java 7 之前 :String Pool 在 PermGen(永久代),不受 GC 管理

Java 7 之后:String Pool 搬到了堆里,可以被 GC


二、字符串是怎么进 Pool 的?

方式 1:直接写字符串字面量(自动进 Pool)

java 复制代码
String a = "2025";
// JVM 在编译时就把 "2025" 放进 Pool
// a 直接指向 Pool 里的那个对象

String b = "2025";
// JVM 发现 Pool 里已经有 "2025" 了
// b 也指向同一个对象,不创建新的

System.out.println(a == b);  // true!同一个对象!
复制代码
Pool:
┌──────────────┐
│  "2025"@1001 │  ← a 和 b 都指向这里
└──────────────┘
堆里只有一个 "2025",不管你写多少次 "2025" 字面量

方式 2:new String() 或 运行时拼接(不进 Pool)

java 复制代码
String c = new String("2025");
// 强制在堆上 new 了一个新对象
// 不管 Pool 里有没有,都是全新的

String d = someVar + "suffix";
// 运行时拼接,结果是一个全新的堆对象

System.out.println("2025" == c);  // false!不是同一个对象!
复制代码
Pool:                    堆(普通区域):
┌──────────────┐          ┌──────────────┐
│  "2025"@1001 │          │  "2025"@5566 │  ← new 出来的,Pool 外面的副本
└──────────────┘          └──────────────┘

方式 3:JDBC/MyBatis 从数据库读出来的(不进 Pool)

java 复制代码
// rs.getString("fiscal_year") 内部:
// 从网络 Buffer 读字节流 → new String(bytes) → 堆上全新对象
// 和 Pool 没有任何关系

三、intern() 做了什么?

intern() 就是把一个在 Pool 外面的 String,拉进 Pool 里,然后返回 Pool 里那个的地址。

java 复制代码
String fromDb = rs.getString("fiscal_year");  // 堆上新对象,Pool 外
// fromDb → @5566 ("2025"),在 Pool 外

String interned = fromDb.intern();
// JVM 做了 3 步:
// 1. 去 Pool 里查:有没有内容等于 "2025" 的字符串?
// 2a. 有!→ 直接返回 Pool 里那个的引用(@1001)
// 2b. 没有!→ 把 fromDb 这个对象放进 Pool,返回它的引用
// 结果:interned → @1001(Pool 里的那个)
//       fromDb   → @5566(Pool 外那个,没人引用了,等 GC 清掉)

用流程图表示:

复制代码
调用 intern() 之前:

  Pool:                    堆(普通区域):
  ┌──────────────┐          ┌──────────────┐
  │  "2025"@1001 │          │  "2025"@5566 │ ← fromDb 指向这里
  └──────────────┘          └──────────────┘

调用 intern() 之后:

  Pool:                    堆(普通区域):
  ┌──────────────┐          ┌──────────────┐
  │  "2025"@1001 │          │  "2025"@5566 │ ← 没人引用了,等 GC
  └──────────────┘          └──────────────┘
         ↑
  interned(和之后存入 DO 的字段)都指向这里

四、节省内存的原理

没有 intern,10000 条数据都有 fiscalYear = "2025":

复制代码
DO_1.fiscalYear  → @1001 ("2025")
DO_2.fiscalYear  → @1002 ("2025")  ← 内容一样,但是不同对象
DO_3.fiscalYear  → @1003 ("2025")  ← 又一个
...
DO_10000.fiscalYear → @10000 ("2025")  ← 又一个

堆里有 10000 个内容相同的 "2025" String 对象
每个对象 ~72 字节
10000 × 72 = 720KB,全部在 Old Gen 占着

有了 intern,10000 条数据同样的情况:

复制代码
DO_1.fiscalYear  → @1001(Pool 里的 "2025")
DO_2.fiscalYear  → @1001(同一个!)
DO_3.fiscalYear  → @1001(同一个!)
...
DO_10000.fiscalYear → @1001(同一个!)

堆里只有 1 个 "2025" 对象,在 Pool 里
那 9999 个 rs.getString() 创建出来的临时对象全被 GC 快速清掉

内存:10000 × 72字节 → 1 × 72字节,节省 99.99%

五、回到代码里那个例子

java 复制代码
// KafkaReportListener.java 第 84 行:
StringUtils.equalsIgnoreCase("M&S".intern(), taskEntity.getReportCatalog())

这里的 "M&S".intern() 是多余的,完全没有意义!

java 复制代码
String s = "M&S";
// "M&S" 是字符串字面量
// 编译时 JVM 就已经把它放进 Pool 了
// 它本身就已经是 Pool 里的对象!

"M&S".intern()
// 等于:去 Pool 里查有没有 "M&S" → 有!就是你自己!→ 返回自己
// 相当于绕了一圈,什么都没做

intern() 真正有意义的用法:

java 复制代码
// ✅ 有意义:对运行时产生的字符串 intern
String fromDb = rs.getString("fiscal_year");  // 堆上全新对象
this.fiscalYear = fromDb.intern();             // 拉进 Pool,共享

// ✅ 有意义:对拼接或计算出来的字符串 intern
String key = year + quarter;
String cached = key.intern();

// ❌ 没意义:对字面量 intern(字面量本来就在 Pool 里)
"M&S".intern()       // 多此一举
"hello".intern()     // 多此一举

六、intern() 的工作原理(底层)

Pool 内部是一个哈希表(类似 HashMap):

复制代码
intern() 的执行步骤:

  1. 计算当前 String 的 hashCode
         │
         ▼
  2. 在 Pool 的哈希表里查找
         │
         ├── 找到了(内容相同的对象已存在)
         │       └── 返回 Pool 里那个对象的引用
         │           当前这个对象变孤儿,等 GC
         │
         └── 没找到(第一次出现这个内容)
                 └── 把当前对象加入 Pool 的哈希表
                     返回当前对象的引用

时间复杂度:O(1) 平均,和 HashMap.get() 一样快

七、intern() 的代价和注意事项

代价 1:查哈希表有开销

复制代码
每次调用 intern(),都要做哈希表查找
高频调用(百万次/秒)时,这个开销不可忽视
对于短命对象,不如直接让 Minor GC 清掉

代价 2:Pool 里的对象不会被 GC(Java 6)

复制代码
Java 6 及以前:
  Pool 在 PermGen,GC 不管
  intern 太多 → PermGen 撑满 → OOM

Java 7+:
  Pool 搬到堆里,可以被 GC
  但 GC 回收 Pool 里的对象条件较苛刻(需要没有任何引用)
  高基数字段 intern(如 userId、orderId)→ Pool 无限膨胀 → OOM

什么时候用 intern() 合适:

复制代码
✅ 适合:
  字段取值范围固定,重复率高(低基数)
  对象生命周期长,会晋升 Old Gen
  例:fiscalYear("2025"等)、geo("AP"等)、quarter("Q1"等)

❌ 不适合:
  字面量(已经在 Pool 里)
  短命对象(Minor GC 会清,不用 intern)
  高基数字段(orderId、userId,几乎不重复,intern 只会撑爆 Pool)
  用户输入的自由文本(不可预测,不适合 intern)

八、总结

复制代码
intern() = 把堆上的 String 拉进"全局公告栏"(String Pool)

Pool 的规则:同一内容只存一份

intern() 做的事:
  1. 去 Pool 查有没有内容相同的
  2. 有 → 返回 Pool 里的,当前对象成为垃圾
  3. 没有 → 把当前对象注册进 Pool,返回自己

节省内存的本质:
  N 个内容相同的 String → Pool 里只剩 1 个
  N-1 个临时对象被 GC 清掉

正确使用场景:
  低基数、长生命周期的字符串字段(如各种维度字段)

错误使用场景:
  字面量直接调用 intern()(多此一举)
  高基数字段 intern()(撑爆 Pool)
  短命对象 intern()(加了 ConcurrentHashMap 查找开销,得不偿失)