写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 == s2 | true | 两个字面量指向同一个StringTable对象 |
| s1 == s3 | false | new出来的对象是普通堆对象,不在池里 |
| s3 == s4 | false | 两个new对象,地址天然不同 |
| s1 == s5 | true | intern()返回了池中的那个对象 |
| s1.equals(s3) | true | 内容相同,equals比较内容 |
| s6 == s1 | true | "he"+"llo"编译期折叠为"hello" |
| s7 == s1 | false | 变量拼接运行时创建新对象 |
| s8 == s1 | true | final常量参与拼接,编译期折叠 |
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),不要急着优化成==。绝大多数场景下,这个"笨"写法反而是最稳的。