news 2026/9/30 8:36:58

Java字符串比较:==与equals()底层原理及实战避坑指南

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
Java字符串比较:==与equals()底层原理及实战避坑指南

写Java这么多年,几乎每个新人都问过我同一个问题:两个String字符串,打印出来明明一模一样,用==比较却是false,换.equals()就true了。这问题看似基础,但真到了线上排查问题的时候,很多人还是会栽在String、equals()、==这三者的微妙关系上。这篇不是简单给你背结论,而是把JVM底层怎么做字符串存储、==到底比的是什么、equals()为什么能"看内容",一层层拆开,再配合我自己踩过的坑和实测数据,让你以后再遇到字符串比较时心里有底,也能给同事讲明白。

1. 先搞清楚:==到底在比什么,equals()又在比什么

1.1 ==比较的真实含义:栈上的引用值

很多初学者容易把==理解成"比较长得像不像",但这个直觉在Java里是完全错误的。==操作符在Java中只做两件事:如果比较的两个操作数是基本类型(int、boolean、char这些),它比较的是数值本身;如果比较的是引用类型,它比较的是栈帧中局部变量表里存放的那个引用值,也就是对象在堆内存中的地址。

String在Java里是类,是引用类型,所以对两个String变量做==,本质上就是问一句"你们俩是不是指向同一个堆内存对象"。注意,它不会去看这个对象内部存了什么字符,不会去逐字比对内容。这就像你面前有两个杯子,==只关心"这俩是不是同一个杯子",至于杯子里装的是白开水还是矿泉水,它完全不管。

这里得把JVM内存模型稍微带一下。运行时候,局部变量存在虚拟机栈的局部变量表里,String变量本身是个引用,弹栈后指向堆里的String对象。两个变量做==,就是比这个引用值。这也是为什么很对人第一次遇到s1 == s2结果为true、换两个字符串变量又为false的时候会一脸懵——因为有些时候JVM确实会让两个字符串变量指向同一个String对象,有些时候又不会,这就要看字符串常量池的机制了。

1.2 equals()的默认行为与String的重写

equals()不是随便一个"方法",它定义在Object类里,是所有对象的祖先方法。但很多人不知道的是,Object.equals()的默认实现其实就是==,也就是说,如果不重写equals(),它和==没有任何区别,一样只比较引用地址。

String是个特例:它重写了equals(),重写后的逻辑变成了"先看是不是同一个对象,再看是不是String类型,最后逐字符比较内容"。正因为这个重写,String的equals()才让你感觉"诶,它会看内容"。这个重写不是一个高端魔法,它背后就是遍历底层字符数组逐字节比对。

顺带补充一个细节:从JDK 9开始,String内部不再是char[],而是byte[]加一个coder字段,通过LATIN1或UTF16两种编码来压缩存储。所以String重写equals()之后的实际逐字符比较,是调用StringLatin1.equals()或者StringUTF16.equals()去遍历byte数组,但对外语义完全一样:内容相同返回true,内容不同返回false。这个细节平时用不到,但面试环节能说清楚,是很加分的。

到这里你该明白最核心的一层了:==是身份比较,equals()经过String重写后是内容比较。但事情没这么简单,因为JVM有一个字符串常量池,它会让"长得一样的字符串"有时共享同一个对象,有时又分开存放。下一节就把这个池子讲透。

2. JVM常量池与字符串创建背后的小九九

2.1 字面量赋值的真实流程

先看最常用的写法:String s = "abc"。注意,"abc"这个字面量不是随便躺在堆里的,它会在类加载的解析阶段被查进/放入字符串常量池(String Constant Pool,JDK 7之后已经移到了堆空间)。

具体流程是这样的:编译期,class文件的常量池里会有一条CONSTANT_String_info记录;运行期,JVM执行到ldc指令时,会拿这个字符串的内容去字符串常量池里查找。如果池里已经有相同内容的String对象,就直接把那个对象的引用交给变量;如果没有,就在池里新建一个String对象,再把引用交出去。

这就是为什么写String s1 = "abc"; String s2 = "abc";时,s1 == s2返回true。因为两个变量拿到的实际上是同一个对象的引用。用生活里面的栗子说,这就相当于一个集体宿舍,你俩登记的床位号是同一个,那当然是"同一个人"。

这里要特别注意一点:字符串常量池只用来存放"字面量或intern进来的字符串对象",并不是所有String对象都会进池。new出来的对象、拼接运行期产生的对象,默认都不进池,所以它们之间的==就会false。

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

经典面试题来了:new String("abc")会创建几个对象?答案不是固定的"2个",要分情况。

先说分情况前的一个前提:new String("abc")的括号里也是个字符串字面量"abc",这个字面量在字节码层面仍然是ldc指令,它同样会触发常量池查找,池里没有就先创建,有就直接用。然后new关键字再在堆上分配一个新的String对象。

  • 如果执行这行代码之前,池里从来没有"abc",那么JVM会在池里先创建一个对象,再new一个普通堆对象,一共2个String对象。
  • 如果池里已经有了"abc"(比如前面已经写过String s = "abc"),那么池里不会再新建,只会new一个新的堆对象,一共1个。

注意一个底层实现细节:在JDK 8及以后,new String("abc")虽然创建了新对象,但是构造器内部通常直接把池中那个字符串的value数组引用拿过来共享,并不会把字符数组内容再复制一遍。也就是说,两个不同的String对象,底层chars/bytes数组可能指向同一份数据。但不管底层数组是否共享,new出来的String对象和池里的对象一定是两个对象,所以"abc" == new String("abc")永远是false。

搞清楚这一点,很多人的疑问就解掉了一半:字符串内容相同的对象可能有很多个,但它们不一定是一个身份。

2.3 字符串拼接的编译期优化与运行时行为

字符串拼接是重灾区,因为结果很多时候反直觉。先说一个最常见的:

String s1 = "a" + "b"; String s2 = "ab"; System.out.println(s1 == s2);

这段代码打印true。原因是编译期就完成了优化,"a" + "b"在编译成字节码的时候直接被折叠成了字面量"ab",压根不会在运行期做任何拼接操作。

但如果是这样:

String prefix = "a"; String s3 = prefix + "b"; String s4 = "ab"; System.out.println(s3 == s4);

结果就是false。因为prefix不是编译期常量,prefix + "b"在编译时无法折叠,运行时会走StringBuilder(JDK 9之后底层是invokedynamic配合StringConcatFactory,但结论一样)来创建新的字符串对象,这个新对象不会进常量池,跟池里的"ab"当然不是同一个对象。

再扩展一个特殊场景,也就是final变量带来的差异:

final String prefixFinal = "a"; String s5 = prefixFinal + "b"; String s6 = "ab"; System.out.println(s5 == s6);

这里打印true。因为final String被当成编译期常量,prefixFinal + "b"照样能在编译期折叠成"ab"。如果这种边界没掌握,写业务代码时很容易被同事的"为什么这俩一个true一个false"问住。

拼接的场景里我再补一句:如果是StringBuffer.toString()或StringBuilder.toString()拿到的字符串,本质上是运行时构建的新对象,它和直接用字面量赋值的字符串做==比较,几乎必然false。这到第五节再细说坑。

3. 现场实测:各种比较场景逐一跑一遍

3.1 一段代码跑完所有经典场景

理论讲多了容易飘,直接上一段实测代码,把这些场景集中跑一次,结果用表格放出来,方便你以后对照。

public class StringCompareDemo { public static void main(String[] args) { // 字面量赋值 String s1 = "hello"; String s2 = "hello"; // 显式new String s3 = new String("hello"); String s4 = new String("hello"); // intern String s5 = s3.intern(); // 编译期折叠 String s6 = "he" + "llo"; // 变量拼接 String prefix = "he"; String s7 = prefix + "llo"; // final常量叠加 final String prefixFinal = "he"; String s8 = prefixFinal + "llo"; System.out.println("s1 == s2 -> " + (s1 == s2)); System.out.println("s1 == s3 -> " + (s1 == s3)); System.out.println("s3 == s4 -> " + (s3 == s4)); System.out.println("s1 == s5 -> " + (s1 == s5)); System.out.println("s1.equals(s3) -> " + s1.equals(s3)); System.out.println("s6 == s1 -> " + (s6 == s1)); System.out.println("s7 == s1 -> " + (s7 == s1)); System.out.println("s8 == s1 -> " + (s8 == s1)); } }

运行结果我用表格整理一下:

比较表达式结果原因简析
s1 == s2true两个字面量指向同一个StringTable对象
s1 == s3falsenew出来的对象是普通堆对象,不在池里
s3 == s4false两个new对象,地址天然不同
s1 == s5trueintern()返回了池中的那个对象
s1.equals(s3)true内容相同,equals比较内容
s6 == s1true"he"+"llo"编译期折叠为"hello"
s7 == s1false变量拼接运行时创建新对象
s8 == s1truefinal常量参与拼接,编译期折叠

3.2 为什么"同一个字符串"在拼接后==变成了false

很多人在3.1的表格里最接受不了的就是s7 == s1是false:s7打印出来明明就是"hello",凭什么说它和s1不一样?

我们回到内存视角。s1指向的是StringTable里那个"hello",而s7是运行期通过StringBuilder.append()再toString()生成的,它被放在普通的Java堆里,是一个全新对象,内容虽然也是"hello",但身份完全独立。==比较的是身份,不是内容,所以一定是false。

换个生活化说法:s1是注册在案的正式员工工号001,s7是一模一样名字和能力的外聘临时工,==检查的是"你是不是工号001本人",不是"你是不是有同样的能力"。equals才是看简历和履历内容是不是一致的。

3.3 equals()和hashCode()的契约:为什么用String做key没事

既然String重写了equals()却不重写hashCode()的话,那你把String放进HashMap或者HashSet的时候就要出事。但实际开发中大家用String做key并没什么大问题,因为String重写equals()的同时也重写了hashCode(),严格遵守了"equals()相等的两个对象,hashCode()必须相等"这个契约。

String的hashCode算法是有讲究的:s[0]*31^(n-1) + s[1]*31^(n-2) + ... + s[n-1],其中31的选择我一直觉得很有韵味。31是奇素数,用它可以减少哈希碰撞概率;而且31 * i在JVM里可以优化成(i << 5) - i,既省乘法指令又不容易溢出导致信息全部丢失。

所以当字符串对象被放进HashMap时,流程是先根据hashCode快速定位桶,再在桶内用equals()精确比对。如果两个内容是"hello"的String对象,哪怕一个是池中的、一个是new出来的,它们的hashCode一定相同,桶也能找到,equals()最终判断内容相同,于是HashMap能够正常工作。这也给我们一个启示:所有需要做业务比较的实体类,equals()和hashCode()必须一起重写,否则会遇到"看着像同一个对象但集合里死活找不到"的灵异事件。

4. 实际开发中的五个高发坑位与排查思路

4.1 坑1:equals()调用方向没注意,空指针找上门

刚工作那会儿我犯过这个错:有个参数可能为null,我图省事直接param.equals("期望值"),结果param是null,直接NullPointerException。排查的时候还在想,明明就是判断一下它是不是某个字符串,怎么还崩了呢?后来才意识到equals()是实例方法,null调用任何一个实例方法都会炸,String的equals()也不例外。

正确的写法是把常量放在前面:"期望值".equals(param)。这样即使param为null也只是返回false,不会抛异常。更深一层的推荐做法是用java.util.Objects.equals(param, "期望值"),它在内部帮你做了安全判断,代码语义也清晰。

4.2 坑2:从Map或JSON反序列化出来的字符串直接用==

这个坑藏得很深。你从Map<String, Object>里get一个值,或者用Fastjson、Jackson把一个JSON字段反序列化成String,这些字符串对象绝大多数不是字面量,也不在常量池里。用==去比较两个"内容相同"的JSON解析字符串,得到的结果往往是false。

我之前写过一段单元测试,比较两个通过JSON解析出来的字符串,当时想当然用了assertTrue(a == b),结果测试失败,同样的内容居然不等于自己。后来把==改成equals()才通过。根本原因就是这些字符串在运行时是通过new String(...)或字节数组构建出来的新对象,不是池里的那一个。

4.3 坑3:StringBuilder/StringBuffer拼出来的结果忘掉身份差异

StringBuilder的toString()方法,源码写得很明白:每次调用都new String(value, 0, count),也就是说,toString()返回的永远是一个新对象。StringBuffer继承自AbstractStringBuilder,行为一样,只不过方法加了同步。所以类似new StringBuilder("hello").toString() == "hello"这种比较,必然false。

有一个我到现在还记得的线上例子:配置系统里动态拼出一个SQL的查询条件字符串,然后拿去和一个常量字符串做==比较来走不同分支。结果分支永远走不到,业务功能静默失效。排了半天,最后查出来就是拼接字符串不能拿==比。这种问题特别坑,因为不会报错,只会让业务行为异常。

4.4 坑4:intern()误用带来的性能隐患

intern()能把一个字符串放到常量池里并返回池中的对象,这看起来是个很香的"去重"手段。有些框架老代码里确实会拿它做缓存或锁。但我建议普通业务代码不要滥用intern(),尤其是把运行时字符串大规模intern。

为什么?StringTable本质是一个哈希表,默认桶的数量有限(JDK 8里大概是60013个左右),如果大量不同内容的字符串都往里塞,哈希冲突和扩容会变得很严重,最终导致大量字符串的查找、插入变慢,极端情况下会造成明显的GC停顿和CPU飙升。这就像是把所有杂物都塞进一个小小的鞋柜,越塞越满,找双鞋要翻半天。

如果你确实需要字符串对象去重,优先考虑用Map<String, String>来做显式缓存,或者用专门的interner库,可控性比直接调intern()好很多。再补充一个细节:JDK 7之前intern()的字符串放在永久代,可能会触发PermGen OOM,后来移到了堆区,由GC统一管理,这问题才算缓解,但别因此放松警惕。

4.5 坑5:字符串对象做锁,看着逻辑对但实际危险

synchronized ("某个字符串")这种写法在一些老代码里偶有出现。如果每次都写同一个字面量,因为常量池的存在,拿到的恰好是同一个对象,锁倒是能生效。但如果你拿的是运行时new出来的字符串,不同方法拿到的锁对象不是同一个,锁就形同虚设。

更稳妥的做法是定义一个专门的锁对象:private static final Object LOCK = new Object();,这样既能保证是同一个锁,又不会依赖字符串常量池的"沾光"行为。我在代码Review里遇到字符串做锁的,基本都要求改成独立锁对象。

5. 从一次线上问题到排查方法的沉淀

5.1 一眼判断"是值不同还是对象不同"

遇到字符串比较不符预期时,第一反应不要是改代码,先判断到底是"内容不同"还是"对象不同"。最快的排查手段是打印System.identityHashCode(str),它类似于给对象一个"身份证号":

String a = new String("hello"); String b = new String("hello"); System.out.println(System.identityHashCode(a)); System.out.println(System.identityHashCode(b)); System.out.println(System.identityHashCode("hello"));

如果两个identityHashCode不一样,说明一定是两个不同的对象,==为false就是理所当然的;如果一样,说明指向了同一个对象。配合断点观察变量值旁边的内部id也能看出来。

5.2 把比较规范固化成团队的纪律

我们团队做了几条硬性规定,后来线上这类问题明显少了:

  • 引用类型之间做值比较,一律用equals(),禁止用==。
  • 使用Objects.equals()处理可能为null的一方。
  • 业务实体类重写equals()时必须同时重写hashCode()。
  • 基本类型包装类(Integer、Long等)做值比较也用equals()或拆箱后比较,不要依赖默认缓存范围(-128到127)碰巧相等。

这几条不需要背什么原理,当成代码规范执行就能避免绝大多数低级事故。等出了问题再回来翻一遍本文,自然就理解为什么了。

5.3 给新人的最快上手练习

如果你或者你带的新人还没彻底转过来,我给一个半小时就能做完的练习:把上面3.1的Demo复制到本地,在IDE里断点调试,逐个变量查看它的内部value和对象id;再尝试把s3 = new String("hello")换成s3 = "hello"看看结果如何变化;最后把equals()改回Object的默认实现(自己随便写个没重写的类模拟),观察一下差异。

这套练习跑完,比死记十遍"String比较要用equals()"都管用。因为你不仅在背结论,而是亲手验证了==和equals()的底层差异。

5.4 我的个人体会

说实话,String的==和equals()这个问题,我到现在都不敢拍胸脯说每一行代码都能立刻断出结果,但只要记住一句话就够了:==问"是不是同一个对象",equals()问"内容是否一致"。其余所有的常量池、intern、new、拼接,都是围绕这句话在变花样。业务代码里老老实实用equals(),偶尔遇到性能敏感的极致场景再研究池和intern,这样才能既正确又高效。

最后送你一个我从几十次事故里换来的小技巧:改代码前先写一行Objects.equals(target, expected),不要急着优化成==。绝大多数场景下,这个"笨"写法反而是最稳的。

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

CTFHUB基础认证题详解:从401弹窗到Authorization头构造

CTFHUB技能树是很多Web安全入门选手的“第一个副本”&#xff0c;它第一站“Web前置技能-HTTP协议”里的基础认证题&#xff0c;就卡住了一大批人。你在浏览器里打开题目分配的环境地址&#xff0c;迎面弹出一个账号密码输入框&#xff0c;题目描述却什么都没说。这时候该输什么…

作者头像 李华
网站建设 2026/9/30 8:36:25

微信小程序电影院订票选座系统SSM:并发控制与订单超时释放实战

简介&#xff1a;这是一份面向高校计算机相关专业毕业设计场景的完整论文文档&#xff0c;主题为基于微信小程序的电影院订票选座系统&#xff0c;适合正在准备毕设选题、需要参考系统设计与论文写作框架的本科生及指导教师使用。资源包内仅含1个doc格式文件&#xff0c;压缩包…

作者头像 李华
网站建设 2026/9/30 8:35:10

乐鑫 ESP32 模组完整料号怎么读:N、R、H、U 分别代表什么

看到 ESP32-S3-WROOM-1-N16R8&#xff0c;最容易犯的错是把它拆成几段后就直接下单。这个名称确实能给出有用线索&#xff1a;它指向某个芯片系列、模组形态和内存组合&#xff1b;但它不能单独证明供应商交付的批次、天线形式、认证状态或能否替代现有料。采购动作应当把“读懂…

作者头像 李华
网站建设 2026/9/30 8:34:46

Paramics信号控制建模:从信号组、相位到配时方案全解析

做交通仿真这几年&#xff0c;我越来越觉得信号控制才是微观仿真里最考功力的环节。路网画得再漂亮&#xff0c;车道和连接器布置得再细致&#xff0c;只要信号灯的逻辑和现场对不上&#xff0c;整个仿真输出就是废纸。Paramics里把信号控制抽象成信号组、相位和配时计划这三层…

作者头像 李华
网站建设 2026/9/30 8:34:42

AI工程实践指南:从数据管线到LLM应用部署

1. AI工程不是调包&#xff1a;先想清楚它和软件工程、数据科学的边界如果你打开搜索引擎去搜"AI engineering"&#xff0c;大概率会看到两种截然不同的东西&#xff1a;一种是教你用现成大模型API做应用开发的&#xff0c;另一种是讲机器学习平台架构的。这两个都算…

作者头像 李华
网站建设 2026/9/30 8:34:37

用产品思维破解计算机专业的迷茫:从需求定位到作品集

1. 为什么计算机专业的学生越学越迷茫想先问一个问题&#xff1a;你有多久没有因为“写完一个功能”而兴奋了&#xff1f;我见过太多计算机专业的学生&#xff0c;大一踌躇满志&#xff0c;大二开始焦虑&#xff0c;大三陷入迷茫&#xff0c;大四干脆随波逐流。最典型的场景是—…

作者头像 李华