你有没有遇到过这样的“灵异事件”代码里明明两个 String 长得一模一样用比较却返回false而用equals()比较又返回true我在一次线上问题排查里就撞上过而且那一次让我彻底明白了一个道理——String 的和equals()不是“都能比较字符串”而是分别在做两类完全不同的事情。那次问题是某个接口把请求参数action判断成“不是发布”导致功能失效日志里看到actionpublish可我代码里偏偏就是if (action publish)。单测传字符串字面量时能过线上走反序列化对象时永远匹配不上。排查到深夜才反应过来我比的是“对象的地址”不是“字符串的内容”。这篇博客我想把这个话题讲透到底比的是什么equals()到底比的是什么字符串常量池和intern()又在里面扮演什么角色以及实际开发里哪些场景最容易踩坑。还会附带可运行代码和几个来自其他语言的横向对比适合所有写 Java 的人尤其是刚从其他语言转过来或者被“String 是基本类型吗”这类问题困扰过的朋友。1. 一个把 当内容比较的线上事故我差点怀疑是神仙 bug1.1 事件还原action 明明是 “publish”判断却不成立我印象很深当时是一个运营配置接口调用方会传一个action参数取值可能是publish、draft、delete。代码里有一段分支判断if (action publish) { // 触发发布流程 }单测是这么写的String action publish; // 断言进入发布逻辑测试全绿看起来一切正常。可一到线上无论调用方传什么action都进不了发布分支。日志打印出来的参数字符串明明就是publish但判断就是false。当时真的有人开始怀疑是不是框架序列化出了问题甚至有人提议上intern()试试。1.2 悲剧根源我把“对象是否同一个”当成了“值是否一样”问题根本不在框架。单测里我写的publish是字符串字面量它指向 JVM 字符串常量池里的那个对象而线上接口经过 JSON 反序列化得到的action是运行时在堆上新创建出来的一个 String 对象。它们的值一样但并不是同一个对象。在 Java 里用来比较两个引用类型变量时比较的是“它们是不是指向同一个对象”也就是地址是否相同。一个来自池子一个来自堆上的新对象地址怎么可能一样所以这不是玄学是我把比较语义搞错了。我记得当时修好之后群里有人调侃这 bug 的祖传秘诀就一句话——用 比 String就是在跟 JVM 玩“你是不是同一个我”的游戏。1.3 先记住这句结论 比引用equals 比内容在 Java 的语境下这个结论对所有引用类型都成立只是把“对象”换成“String 对象”后特别容易让人迷糊a b比较的是变量中存的“引用地址”判断两个引用是否指向同一个对象a.equals(b)调用的是对象的方法String 重写了这个方法用来判断两个字符串对象的“字符序列”是否完全一致。后面所有深入内容都绕不开这两句话。我建议你先把它写在代码注释的最上面然后再往下看细节。2. 拆开 它在 Java 里到底比的是什么2.1 引用类型变量存放的是“指向对象的地址”很多初学者会把String和int放在同一类里去理解这是最危险的错觉。int是基本类型变量里直接存值String是引用类型变量里存的是一个“地址”或者叫“引用”它指向真正存储字符串数据的那块堆内存。可以这样类比基本类型变量像是你在纸上写了一个数字就是比“纸上这个数字是不是一样”引用类型变量像是你把一叠材料放在某个档案柜里变量本身是一张写有柜门编号的便签比的是“两张便签上的编号是不是完全一样”而不是比“档案柜里的材料内容是不是一样”。int x 10; int y 10; System.out.println(x y); // true单纯数值比较 String s1 new String(hello); String s2 new String(hello); System.out.println(s1 s2); // false两个 new 出来的对象地址不同s1和s2的字符序列都是hello但它们分别是两个独立对象住在不同的“档案柜”里自然不成立。2.2 同一个字符串为什么有时 是 true有时是 false这就是 String 让人迷惑的地方它不是用new创建时才产生对象字符串字面量在类加载阶段就会进入 JVM 的字符串常量池。只要一个字面量是第一次出现池里就会保存这个字符串对象之后再次出现同样的字面量会直接复用池里的对象。String a hello; String b hello; System.out.println(a b); // true因为 a、b 指向常量池里的同一个 helloa和b都没有new它们的值来自同一个字符串字面量JVM 不会傻到为两个一模一样的字面量创建两个对象而是让它们共用池子里那一个对象。所以为true并不代表它在比较内容它只是在告诉你这两个引用指向同一个对象。只要换一种创建方式结果立马反转String c new String(hello); System.out.println(a c); // false常量池对象 vs 堆上新对象你从字面和从new得到的两个 String就算内容的每一个字符都一样对象却不是同一个。“内容相同”和“引用相同”是两件独立的事这也是全文最核心的认知。2.3 枚举、null 与基本类型别把 一竿子打死当你掌握了“ 比较引用”之后很容易矫枉过正觉得凡是对象都不能用。其实有例外而且很重要基本类型比如int、char、boolean必须用比较数值它们没有equals()可用枚举类型JVM 保证每个枚举常量全局只有一个实例所以是比较枚举值最推荐的方式既正确又高效判断对象是否为 nullx null本身就是引用比较的合法用法。真正的问题是很多新人把“判断 String 内容相等”写成了还恰好碰上测试阶段用字面量初始化导致隐患一直藏着。等到字段从外部传入就瞬间暴露底线。3. equals() 被 String 重写之后才真正承担起“内容比较”3.1 Object 的默认 equals 也是 String 对此做了重写很多人以为equals()天生就是比较内容的这也是误解。Object类中equals()的默认实现就是也就是比较引用。换句话说如果你写一个自定义类不去重写equals()那equals()和的行为没有任何区别。String 之所以能用equals()比较内容是因为String 重写了Object.equals()。这是几乎所有 Java 开发者都熟悉却又很少去想的“幕后改动”。JDK 源码里 String 的equals()判断逻辑很清晰先判断是不是同一个对象如果是直接返回true再判断传入对象是否是String类型如果不是返回false然后比较长度长度不同直接false最后逐个比较字符JDK 9 之后内部用 byte[] 存储会按编码情况逐字节处理全部相同才返回true。你可以简单想象成只看了“档案柜编号”String.equals()会把两份材料拿出来一页一页翻着对内容。String x new String(abc); String y new String(abc); System.out.println(x y); // false System.out.println(x.equals(y)); // true3.2 String.equals 的实现逻辑顺序、长度与逐字符如果你想更具体的感受可以把String.equals()的关键流程简化成这样public boolean equals(Object anObject) { // 1. 同一引用直接相等 if (this anObject) { return true; } // 2. 类型不对直接不等 if (anObject instanceof String) { String anotherString (String) anObject; int n value.length; // 3. 长度都不等后面不用比 if (n anotherString.value.length) { // 4. 逐位比较 } } return false; }注意String 的equals()不会先比较 hashCode。真正执行时长度检查是最前面的有效筛选因为两个长度不同的字符串不可能是同一内容。之后才逐字符比较所以内容越长比较越耗时——这是“内容比较”应有的成本。3.3 与 hashCode 的约定为什么 HashMap 用 String 当 key 是安全的按 Java 的约定重写equals()时必须重写hashCode()保证“equals()为true的两个对象hashCode()必须相等”。String 严格遵守了这条约定所以 String 才成为 HashMap 里最安全的 key。反过来如果某个自定义类只重写了equals()却不重写hashCode()那么两个内容上相等、逻辑上应该是同一个 key 的对象会被散列到 HashMap 的不同桶里。你用其中一个对象存进去再用另一个对象取可能就取不到了——这就是“equals 重写但 hashCode 不重写”的经典翻车现场。String 本身没有这个问题但也提醒我们另一件事equals 是有“契约”的不是随便写的。你在自己类里重写 equals 之前先想想会不会破坏 hashCode 契约否则连 HashMap 这种常用组件都会坑你。4. 常量池、 拼接和 intern()那些让你摸不着头脑的“特例”4.1 字面量为什么经常“共用同一个对象”JVM 维护了一张字符串常量表通常在堆上由 JVM 具体实现决定专门用来登记编译期能确定的字符串字面量。当你写abc时JVM 会先查这张表有就返回表中已有对象没有就创建一个放进去。这意味着只要你用字面量同一个内容的字符串就只有一个对象引用常常就是true。它确实在帮你省内存但也在制造“用 有时是对的”的假象。等到new String(abc)出现事情就变了。这一步会先在常量池里确认字面量对象再在堆上 new 一个全新的 String 对象。所以String a abc; String b new String(abc); System.out.println(a b); // falsea指向池子里的对象b指向堆上独立对象。它们值相同但地址不同。4.2 字符串拼接编译期折叠与运行期异同字符串拼接是另一个高频翻车点而且得分成两种情况看。第一种拼接内容全部是编译期常量字面量或 final 修饰的常量变量编译器会在编译阶段直接把结果算出来final String base a; String result base b; // 编译期直接变成 ab进入常量池 System.out.println(result ab); // true因为base是 final 的编译期常量base b在编译时就被折叠成了ab字面量自然复用常量池对象。第二种只要变量不是编译期常量拼接就会在运行期生成新对象String base a; String result base b; // 运行时拼接 System.out.println(result ab); // falsebase不是 finalbase b就只能到运行期再拼最终生成一个全新的 String 对象。它内容上是ab但不会是常量池里最初的那个ab。JDK 8 及以前这种运行期通常由编译器改写为StringBuilder.append()JDK 9 之后又改成了基于invokedynamic的字符串连接策略。不管底层怎么优化结论都一样只要拼接发生在运行期结果就是新对象别拿它与字面量用 比较。4.3 intern() 的用途与误用边界String.intern()可以把一个运行期创建出来的字符串登记到常量池里如果池里已经有相同内容的字符串就直接返回池中的引用如果没有就把内容插入池中并返回引用。String a new String(abc); String b a.intern(); System.out.println(b abc); // true看到这个结果有些人的第一反应是那我以后就a.intern() b.intern()呗能这么写但不建议把intern()当日常标配。原因有三intern()会对常量池产生影响池里 String 长期存活过度使用可能增大内存压力在某些 JVM 实现中intern()涉及哈希表操作和市场分配调用成本不低业务代码里引入intern()往往只是为了“让 成立”属于为了错误目标设计复杂手段不如直接把比较改成equals()。我看过不少老项目里写str.intern() xxx的代码问就是“网上说 intern 后能用 ”其实那只是绕了个弯收益极小风险却不少。除非你在做大量重复字符串去重的内存优化否则请远离 intern()。5. 工程中的比较范式什么时候用 什么时候用 equals5.1 常见场景对照表我一直觉得经验不是记住多少源码而是面对选择时能快速给出方向的判断力。字符串比较这件事我在代码 review 时基本按这张表来把关场景推荐写法理由基本类型数值比较没有引用语义String 内容是否相等equals()/Objects.equals()比的是字符序列两个 String 是否是同一个对象明确要比较引用枚举比较枚举单例受 JVM 保证判空x null合法引用比较传入参数来自外部HTTP/DB/RPCequals()外部数据一定是运行时对象从 Map/Set 里判断 key 相等交给集合底层别自己写集合已经用 hash equals这里有个容易被忽略的场景参数校验。很多框架的反射注入、JSON 反序列化、数据库驱动读出来的 String 基本上都是“新鲜对象”。从这些路径拿到的字符串千万别跟字面量做比较这是最容易埋雷的一类代码。5.2 一段可运行的验证代码与输出与其背结论不如自己跑一遍。我写了一段可以直接复制运行的验证代码覆盖上面讲到的各类场景public class StringCompareDemo { public static void main(String[] args) { // 字面量与 new String s1 abc; String s2 abc; String s3 new String(abc); String s4 new String(abc); System.out.println(s1 s2: (s1 s2)); System.out.println(s3 s4: (s3 s4)); System.out.println(s1 s3: (s1 s3)); System.out.println(s1.equals(s3): s1.equals(s3)); // 运行期拼接 String base ab; String result1 base c; System.out.println((base c) \abc\: (result1 abc)); // 编译期常量折叠 final String finalBase ab; String result2 finalBase c; System.out.println((finalBase c) \abc\: (result2 abc)); // StringBuffer 转 String StringBuffer sb new StringBuffer(abc); String sbStr sb.toString(); System.out.println(sb.toString() \abc\: (sbStr abc)); System.out.println(sb.toString().equals(\abc\): sbStr.equals(abc)); // intern System.out.println(new String(\abc\).intern() \abc\: (new String(abc).intern() abc)); } }运行结果如下s1 s2: true s3 s4: false s1 s3: false s1.equals(s3): true (base c) abc: false (finalBase c) abc: true sb.toString() abc: false sb.toString().equals(abc): true new String(abc).intern() abc: true你看看这个输出s1 s2为trues3 s4却为false。同样是“长得一模一样的字符串”只因为创建方式不同的结果就完全不同。而equals()在整个验证里始终保持着“内容比较”的稳定性这就是它能成为默认方案的原因。5.3 StringBuffer / StringBuilder 转 String 后的比较误区很多人的项目里都会有 StringBuffer 或 StringBuilder 拼完再toString()的代码。需要注意的是StringBuilder.toString()和StringBuffer.toString()每次都会生成一个新的 String 对象它既不是字面量池里的那个也不是某个缓存复用对象。StringBuilder builder new StringBuilder(abc); String built builder.toString(); System.out.println(built abc); // false System.out.println(built.equals(abc)); // true网上有个相关热词叫“stringbuffer转换为string”我猜很多人问的就是“转出来的 String 能不能用 和字面量比较”。答案很明确不能请用equals()。无论你拼接了多少次只要是运行期拼出来的它就和字面量不在同一个引用世界里。5.4 null 安全与 Objects.equals用equals()比较时还有一个常见的 NPE 陷阱。如果你写成String input null; input.equals(abc); // NullPointerException一旦被比较的变量是 null整个调用直接崩。更稳的写法有两种abc.equals(input); // 不抛异常input 为 null 时返回 false Objects.equals(input, abc); // 不抛异常null 与 null 比较时返回 true我个人的习惯是如果希望“两边都是 null 也算相同”用Objects.equals()如果只是简单判断一个非空字符串是否等于某个固定值把字面量放在前面调用equals()。别小看这个细节它能让参数校验代码少很多隐藏炸点。6. 横向看其他语言的字符串比较C、C#、Golang、Arduino聊完 Java 本身我还想做个横向对比。因为相关热词里出现了不少其他语言的身影很多人从 C、C#、Golang 转过来写 Java 时最容易犯“惯性思维”的错。6.1 C 的 std::string 与 char* 两种比较C 里有两类字符串操作对象。std::string是值语义它重载了operator所以a b比较的是内容可如果你手头是const char*那么比较的是指针地址与 Java 里用比 String 的坑非常相似。const char* p1 abc; const char* p2 abc; // p1 p2 在某些编译器优化下可能 true也可能 false绝不能依赖 std::string str1 abc; std::string str2 abc; // str1 str2 永远比较内容安全说白了C 比 Java 更强调“你要先搞清楚自己拿的是对象还是指针”。Java 的 String 引用则把这一层包装得更隐蔽让你更容易误以为自己在跟值打交道。6.2 C# 的 string 是引用类型但 被重载成内容比较C# 和 Java 很像string也是引用类型但 C# 对做了重载字符串用时默认比较内容而不是比较引用。初看比 Java 友好却也带来一个新迷惑如果你想判断两个字符串是不是同一个引用反而要专门用ReferenceEquals(a, b)。从 Java 转 C# 的人刚开始总觉得 C# 里的行为“太智能”写起来很爽但一旦涉及字符串的拼接、池化、驻留仍然需要回到“内容相等”与“引用相同”这两个维度去思考只是语言帮你把常用路径做顺了而已。6.3 Golang、Arduino 的字符串比较习惯Golang 的string是基本类型直接按字节序列比较内容所以 Go 里写字符串相等判断非常直观。相关热词里有一条“golang 判断 map[string]interface{} 中值类型”典型场景是从 map 取出值并断言成 string 再比较val, ok : m[key].(string) if ok val expected { // ... }这里val expected就是内容比较因为 Go 的 string 本质上就是字节序列没有 Java 那种“引用对象 vs 常量池对象”的分裂感。但你必须先做类型断言否则取出来的只是interface{}直接比较会受类型影响行为不一样。Arduino 平台则更分裂一点。它自带的String类提供了equals()方法同时也重载了内容比较是安全的但如果你下意识用了 C 风格的char*去比较那就又退回指针比较的老路上。很多 Arduino 新手在if (received start)上卡住本质就是没搞清当时变量到底是 String 对象还是 char 数组。把这些语言放在一起看Java 的 String差异之所以出名不是因为 Java 设计得差而是因为它把“引用”这个概念藏在了看似普通的字符串 API 后面。理解这一点你以后碰到任何语言的字符串比较都会先问一句这个东西是值类型还是引用类型如果是引用类型它有没有重写比较运算符或 equals最后想分享的小习惯排查过那次线上问题后我给自己定了一条纪律凡是看到有人在 Java 里用比较 String我都会先停下来问一句“你是真的想判断它们是不是同一个对象吗”绝大多数时候答案都是“不我只是想判断内容一样”那代码里的就是一颗定时炸弹。后来我在写代码时还会顺手在关键比较处加一行注释“这里比较的是内容不要改成 ”。别嫌注释多余三个月后的你以及接手你代码的同事都会感谢这行字的。如果你现在还在被 String 的和equals()困扰不用慌。看完这篇文章我建议你今天就在编辑器里跑一遍那段验证代码亲手把true和false的结果印在脑子里。下次再看到if (a xxx)你会比曾经的自己敏锐得多。