你有没有遇到过这样的“灵异事件”:代码里明明两个 String 长得一模一样,用==比较却返回false,而用equals()比较又返回true?我在一次线上问题排查里就撞上过,而且那一次让我彻底明白了一个道理——String 的==和equals()不是“都能比较字符串”,而是分别在做两类完全不同的事情。
那次问题是某个接口把请求参数action判断成“不是发布”导致功能失效,日志里看到action=publish,可我代码里偏偏就是if (action == "publish")。单测传字符串字面量时能过,线上走反序列化对象时永远匹配不上。排查到深夜才反应过来:我比的是“对象的地址”,不是“字符串的内容”。
这篇博客,我想把这个话题讲透:==到底比的是什么,equals()到底比的是什么,字符串常量池和intern()又在里面扮演什么角色,以及实际开发里哪些场景最容易踩坑。还会附带可运行代码和几个来自其他语言的横向对比,适合所有写 Java 的人,尤其是刚从其他语言转过来,或者被“String 是基本类型吗”这类问题困扰过的朋友。
1. 一个把 == 当内容比较的线上事故:我差点怀疑是神仙 bug
1.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 指向常量池里的同一个 "hello"a和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 保证每个枚举常量全局只有一个实例,所以
==是比较枚举值最推荐的方式,既正确又高效; - 判断对象是否为 null:
x == 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不是 final,base + "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. 工程中的比较范式:什么时候用 ==,什么时候用 equals
5.1 常见场景对照表
我一直觉得,经验不是记住多少源码,而是面对选择时能快速给出方向的判断力。字符串比较这件事,我在代码 review 时基本按这张表来把关:
| 场景 | 推荐写法 | 理由 |
|---|---|---|
| 基本类型数值比较 | == | 没有引用语义 |
| String 内容是否相等 | equals()/Objects.equals() | 比的是字符序列 |
| 两个 String 是否是同一个对象 | == | 明确要比较引用 |
| 枚举比较 | == | 枚举单例受 JVM 保证 |
| 判空 | x == null | 合法引用比较 |
| 传入参数来自外部(HTTP/DB/RPC) | equals() | 外部数据一定是运行时对象 |
| 从 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为true,s3 == 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"),你会比曾经的自己敏锐得多。