news 2026/10/1 11:52:06

String的==和equals到底差在哪?一次线上问题彻底讲透

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
String的==和equals到底差在哪?一次线上问题彻底讲透

你有没有遇到过这样的“灵异事件”:代码里明明两个 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()判断逻辑很清晰:

  1. 先判断是不是同一个对象,如果是,直接返回true;
  2. 再判断传入对象是否是String类型,如果不是,返回false;
  3. 然后比较长度,长度不同直接false;
  4. 最后逐个比较字符(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)); // true

3.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); // false

a指向池子里的对象,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"); // false

base不是 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"),你会比曾经的自己敏锐得多。

版权声明: 本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若内容造成侵权/违法违规/事实不符,请联系邮箱:809451989@qq.com进行投诉反馈,一经查实,立即删除!
网站建设 2026/10/1 11:51:59

积分变上限函数:从定义到求导公式,一文讲透核心考点

数学里有些概念,你上课听的时候觉得“哦,懂了”,一到做题就发现哪儿哪儿都不对。积分变上限函数就是这么个典型:定义很简单,就是一个积分式子,上限带着一个变量,但它的性质和用法却能牵扯出后面…

作者头像 李华
网站建设 2026/10/1 11:51:47

time.sleep 用错了有多坑?从 GIL 到 asyncio 的 Python 延时避坑指南

前阵子在技术群里看到一条评论:“我们系统也遥遥领先,因为业务代码里写了个 time.sleep(6)。”看到这句话我差点把咖啡吐在键盘上,笑完之后又觉得特别真实。做过线上开发的人都知道,这句自嘲背后至少藏着三种人——被需求逼着“把…

作者头像 李华
网站建设 2026/10/1 11:50:56

HER算法拆解:用后见之明解决强化学习稀疏奖励难题

hindsight,后见之明。如果你最近在折腾强化学习,尤其碰过那种“死活等不到正奖励”的稀疏奖励任务,这个词你绕不开。这里说的不是什么“早知道我当初就……”的人生感慨,而是 OpenAI 在 2017 年开源的一套经典到不能再经典的算法—…

作者头像 李华
网站建设 2026/10/1 11:50:40

Univer表格SDK实战:Canvas渲染与Node.js实现单元格级权限控制

1. 从一张“只能填指定格子”的表格说起 第一次接触 Univer 是在一个内部数据填报系统的需求评审上。业务方的诉求听起来特别朴素:给一张类似 Excel 的表格,让填报人只能改其中几列,其他列锁死,改完提交,后台校验。当时…

作者头像 李华
网站建设 2026/10/1 11:49:52

CentOS 7静默安装Oracle 11.2.0.4:从系统配置到补丁实践全指南

标题里这个“基于 centOS 11.2.0.4”,其实是把两个东西写在一起了:操作系统是 CentOS 7.x,数据库是 Oracle 11.2.0.4。这套组合在现在的生产环境里还大量存在,尤其是在政企和传统制造业的信息系统里,稳定性优先&#x…

作者头像 李华
网站建设 2026/10/1 11:49:52

PHP8.1字符串截取怎么避免乱码

前言先说清楚版本问题:标题里的「PHP 8.1」和这个问题的成因没有直接关系。字符串截取乱码是字符编码问题,substr() 按字节切、mb_substr() 按字符切这件事,从 mbstring 扩展存在的第一天起就是如此,PHP 8.1 并没有为字符串截取引…

作者头像 李华