1. 先把 String 的内存机制说透:双等号到底在比什么
1.1 一次典型的线上事故现场
先讲一个我自己踩过的坑。几年前维护一个老订单系统,用户反馈“订单状态显示异常”,查日志发现代码里有一段状态判断:
String status = getOrderStatus(); // 从接口或者缓存里返回 if (status == "PAID") { // 执行发货逻辑 }本地测试怎么都是正常的,因为本地跑的时候 status 恰好是从代码常量直接赋值的;一旦联调环境从接口返回,同样的状态就死活进不去 if 分支。排查老半天,最后定位就是==把两个值相等的字符串当成了不相等。这种问题在 Java 开发里真的不算罕见,几乎每个团队都会有人在这上面跌一次。
很多人第一反应是“背结论:字符串比较要用 equals,不要用 ==”。结论没错,但如果只背结论而不理解背后机制,遇到变形场景还是会懵。比如为什么有时候==又是 true?为什么 StringBuffer 转成 String 之后连 equals 都不好使?为什么 HashMap 的 key 查找非要依赖 equals?这些问题的根源,全在 String 在 JVM 里的存储方式。
1.2 字符串常量池和新对象的两条路
Java 里字符串有两类来源,存储位置完全不同。
第一类,直接写字面量:
String a = "PAID"; String b = "PAID";这里a和b指向的是同一个字符串对象,这个对象被缓存在 JVM 的字符串常量池(String Pool)里。字面量出现时,JVM 先从常量池中查找是否有相同内容的字符串,有就直接复用,没有就创建一个放进去。所以用==去比较两个字面量是 true。
第二类,通过new创建:
String c = new String("PAID");这句话干了两件事:先确保字符串常量池中有一个"PAID"的字面量对象,再在堆内存(Heap)中 new 一个全新的字符串对象。c指向的是堆里的新对象,跟常量池里的对象内容一样,但不是同一个对象。拿c == a去比,结果是 false。
第三类,通过运行时拼接或转换得到:
String d = getFromRemote(); // 实际内容也是 "PAID"这个d到底在常量池里还是堆里,取决于它内部怎么构造的。如果是远程反序列化、IO 读取、new String(byte[])转出来,基本都是堆里的新对象。这时候用==去和字面量比较,大概率是 false。
所以==在 Java 里对于引用类型来说,比的从来就不是“内容”,而是“引用地址”,也就是两个变量是不是指向同一个对象。内容相同但地址不同,结果就是 false。这个“地址”的概念,可以用快递柜来类比:两个快递柜里都放了一本一模一样的书,但你要说“这两个柜子是同一个柜子”,显然不对;==就是比较柜子是不是同一个。
1.3 为什么 JDK 要搞一个字符串常量池
有人会问:既然==容易把人坑到,为什么不把 String 做成像 int 那样的值类型,直接比较内容不就好了?
这里涉及 String 的设计初衷。String 是引用类型,但它被设计成不可变的,一旦创建,内容就不能修改。不可变带来一个巨大优势:可以放心缓存、共享,不用担心一个地方改了导致别处跟着变。字符串在程序里出现频率极高,如果每个字面量都 new 一个独立对象,内存压力相当大。常量池就是基于“不可变+共享”的前提做的优化,让相同内容的字面量只存一份。
JDK 7 之前的字符串常量池在永久代(PermGen)里,JDK 7 之后挪到了堆内存中。这个挪动对实际使用影响不小,比如常量池里的字符串也可以被垃圾回收了,不会因为类加载过多字符串导致永久代溢出。但这些细节不影响我们理解==和equals,只是提醒你,别把“常量池里的字符串一定存活到老”当作永远不变的认知。
看一个综合实验,建议直接跑一跑:
String s1 = "HELLO"; String s2 = "HELLO"; String s3 = new String("HELLO"); String s4 = s3.intern(); String s5 = "HE" + "LLO"; // 编译期常量折叠,等价于字面量 System.out.println(s1 == s2); // true:常量池复用 System.out.println(s1 == s3); // false:堆对象 vs 常量池对象 System.out.println(s1 == s4); // true:intern() 返回常量池对象 System.out.println(s1 == s5); // true:编译期就已经折叠成 "HELLO" System.out.println(s1.equals(s3)); // true:内容相同"HE" + "LLO"里两个都是编译期常量,拼接结果在编译阶段就被算成"HELLO"了,所以直接在常量池里命中。如果拼接里混入变量,比如"HE" + getSuffix(),编译期没法预判,运行时就会走 StringBuilder 或者其他方式生成新对象。
2. 源码级拆解:equals 的底层逻辑和重写套路
2.1 从 Object.equals 到 String.equals 的差异
==的规则非常死板,对所有引用类型一视同仁。而equals是方法,每个类都可以按自己的需求去重写,规则由具体类自己定义。
先看 Object 的默认实现:
public boolean equals(Object obj) { return (this == obj); }也就是说,如果子类不重写,equals和==没有区别,也是比较地址。真正让String变得特殊的是它重写了equals,把“地址相等”换成了“内容相等”。
查看 JDK 的 String.equals 源码(不同版本实现细节略有差异,思路一致):
public boolean equals(Object anObject) { if (this == anObject) { return true; } if (anObject instanceof String) { String anotherString = (String) anObject; int n = value.length; // value 是 String 内部的 char 数组(或 byte 数组) if (n == anotherString.value.length) { char v1[] = value; char v2[] = anotherString.value; int i = 0; while (n-- != 0) { if (v1[i] != v2[i]) { return false; } i++; } return true; } } return false; }这个源码透露了几个重要信息:
第一,if (this == anObject)是一个优化:如果两个引用本身就指向同一个对象,那内容和长度肯定一样,没必要再走后面的逐字符比较,直接返回 true。这正好解释了为什么equals对==为 true 的情况也全部返回 true——equals是“内容比较”的超集,只要地址相等,内容一定相等。
第二,先判断类型。如果不是 String 类型,直接返回 false。这意味着"abc".equals(someObject)不会抛异常,只是返回 false。所以很多老手习惯用常量调 equals,而不是someObject.equals("abc"),就是为了避免someObject为 null 时把 NullPointerException 打出来。
第三,逐字符比较。String 内部保存字符的数组,Java 9 之后还可能用 byte 数组配合 COMPACT_STRINGS 做压缩存储,但 equals 的逻辑本质是一样的:长度不相等就快速失败,长度相等再逐个字符比对。这是一个 O(n) 的操作,n 是字符串长度。长度差异大的字符串比较很快,因为长度检查先行;但两个等长的超长字符串比较,就要完整遍历一遍,性能上还是要注意的。
2.2 StringBuffer/StringBuilder 为什么没有把 equals 重写成内容比较
这也算是关联热词里“stringbuffer转换为string”比较常见的疑问。StringBuffer、StringBuilder 也是经常用来做字符串拼接的类,但它们继承的是 AbstractStringBuilder,并没有重写 equals。也就是说,你用两个内容都是"abc"的 StringBuilder 对象去 equals,结果是 false,因为底层用的还是 Object 的地址比较。
为什么 String 重写了而它们不重写?主要是因为 StringBuffer/StringBuilder 是可变对象。内容可变的对象如果重写 equals,会带来一个很麻烦的问题:把这个对象放进 HashSet 或 HashMap 的 key 之后,它的 hashCode 可能随时变化,从而导致在哈希表里“找不到原来的位置”。这也是官方不建议把可变对象作为 Map 的 key 的原因。String 不可变,所以重写 equals 和 hashCode 都很安全,这也是 String 适合当 key 的根本原因。
所以实际开发中,用 StringBuilder 做拼接,然后需要比较时,一定要先.toString()转回 String 再 equals。但注意,这里有个隐藏细节:StringBuilder.toString()每次都会 new 一个 String 对象,所以拿它和另一个字符串用==比较没意义,必须用 equals。
2.3 equals 重写的基本法:自反、对称、传递、一致
String.equals 做得好,还有一个原因是它严格遵循了 Java 规范中 equals 的约定。很多人面试被问“重写 equals 要注意什么”,其实就是在问这五条:
- 自反性:
x.equals(x)必须为 true - 对称性:
x.equals(y)为 true 时,y.equals(x)也必须为 true - 传递性:
x.equals(y)为 true,y.equals(z)为 true,则x.equals(z)为 true - 一致性:在对象内容不变的前提下,多次调用 equals 结果必须一致
- 非空性:
x.equals(null)必须返回 false
String 的实现完全满足这五条。但如果你自己写实体类,却不重写 hashCode,会让对象在 HashMap/HashSet 里出现“放进去容易、找不到值”的诡异问题。String 能作为完美的 key,恰恰因为它同时重写了 equals 和 hashCode,两者的计算都基于内部字符数组的内容,相同内容的两个 String 对象 hashCode 一定相同,这也为后面要讲的 HashMap 原理做了铺垫。
3. 项目里到底怎么选:哪些场景用 == 反而更合适
3.1 HashMap 的 get/put 为什么离不开 equals
热词里有“hashmap get和put原理 什么情况用equals比较”,这正是理解 equals 价值的绝佳入口。
HashMap 的 put 流程大致是:先根据 key 的 hashCode 定位到桶(数组槽位),如果桶里没有元素,直接放入;如果桶里有元素,也就是发生了哈希碰撞,就要拿现有 key 和新 key 做比较。这个比较拆成两步:先比 hashCode 是否相等,再用 equals 判断是否真的相等。hashCode 相等不代表 key 就一定相同,因为哈希算法可能让不同的内容算出同样的哈希值;equals 才是最终裁决者。
具体到 String,比如:
Map<String, Integer> map = new HashMap<>(); map.put(new String("s1"), 100); Integer v = map.get(new String("s1"));如果你只重写 hashCode 而没有让 String 自己保证等于关系,这里取出来的 value 很可能是 null。String 之所以能正确取到,是因为new String("s1")和另一个new String("s1")虽然地址不同,但是 hashCode 相同(基于内容计算),并且 equals 返回 true。HashMap 先在哈希值的引导下快速定位到同一个桶,再用 equals 确认是同一个 key,于是成功返回 100。
所以 equals 在哈希表里起到的作用是“二次确认”。如果两个 key 的 hashCode 不同,HashMap 连 equals 都不会调用,直接判定为不同 key。这也是为什么重写 equals 必须重写 hashCode:否则两个内容相同的对象,equals 为 true,但 hashCode 不同,HashMap 把它们放到不同桶里,从依赖方来看就破坏了“相等”的语义。
3.2 可以放心使用 == 的典型场景
虽然 equals 是字符串内容比较的正道,但==也不是一无是处,关键是分场景。
场景一:判断对象是否为 null。if (str == null)这种操作没人会用equals,因为null.equals直接崩溃。这是引用类型判断空值的标准写法。
场景二:比较同一个对象。如果两个引用指向的是同一个 String 对象,不管内容是什么,用==肯定为 true。但这类写法在业务代码里比较少见,更多出现在框架内部,比如判断“是不是当前线程持有的同一个对象”。
场景三:字面量或常量之间的比较。比如枚举开关、状态常量:
private static final String STATUS_PAID = "PAID"; if (status == STATUS_PAID) { ... }这里先用==在某些情况下确实能工作,当入参的 status 恰好来自常量池且和 STATUS_PAID 是同一个对象时结果为 true。但这依赖入参来源,如果 status 是反序列化出来的,就莫名其妙变成 false。因此我的建议是:即使两个都是编译期常量,也不要养成这种依赖内存共享的习惯。你省下的那点性能微乎其微,踩坑的心理成本远远高于收益。
3.3 intern():把堆对象拽回常量池的技巧
有时候业务里确实需要把内容相同但来源不同的字符串统一成同一个对象,可以用intern()。
String fromRemote = new String("remote-data"); String interned = fromRemote.intern(); String literal = "remote-data"; System.out.println(interned == literal); // trueintern()做的事是:如果常量池里已经有相同内容的字符串,直接返回常量池中的那个对象;如果没有,就把当前字符串内容注册进常量池并返回。听起来很方便,但要提醒几点:
第一,滥用 intern 可能造成内存压力。字符串常量池虽然也在堆里,但注册进去的字符串如果没有其他引用指向,会被回收;不过在大量动态内容都做 intern 的场景下,池子里的数据可能非常多,垃圾回收压力不小。很多老项目都有“intern 导致元空间/堆暴涨”的血泪史。JDK 6 及之前 intern 的字符串驻留在永久代,更是容易出现 OutOfMemoryError: PermGen space。
第二,intern 的真实收益在于“引用比较比内容比较快”。如果同一个字符串要被高频率反复比较,提前把所有候选值 intern,再用==比较,性能确实更好。但绝大多数业务场景字符串比较的频率远达不到需要这种优化的程度,所以不要盲炫 intern。
第三,从 Java 7 开始 intern 后的字符串也会参与 GC,所以 intern 并不是“永驻内存”,它只是进入了常量池这个共享区域。理解这一点之后,再去看诸如“字符串去重”之类的优化,思路会更清晰。
4. 从跨语言对比到转换链上的坑
4.1 C++、C#、Go、JavaScript 里的字符串比较
字符串相等性在不同语言里套路完全不同,这里列一个速览。关注这一点,是为了避免你从别的语言转过来时,把别的语言的直觉带到 Java 里。
| 语言 | 比较方式 | 本质说明 |
|---|---|---|
| Java | equals比内容,==比引用 | 引用类型,需要靠方法重写实现内容比较 |
| C++ | std::string直接用== | 操作符重载,内部按内容比较;但char*的==比的是指针地址 |
| C# | string直接用== | C# 对 String 重载了==运算符,底层调String.Equals |
| Go | string直接用== | string 是内置值类型,==直接比较字节内容 |
| JavaScript | ===或== | 原始字符串是值类型;但如果是 String 对象(new String()),==的行为会绕晕很多人 |
这里最坑的是 C++。同是 C++,std::string比较内容和char*比较地址是两个世界。如果两组代码混着用,偶尔能踩到完全一样的坑。C# 对用户更友好,把==重载成了内容比较,但代价是丢失了“引用相等”的语义,如果真要有意比较引用,还得用object.ReferenceEquals。Go 比较干脆,string 就是字节切片的值语义。JavaScript 的坑在于new String("a") == "a"看起来像 true,但new String("a") === new String("a")是 false,因为其中一个操作数是要拆箱的对象。
把这些语言放在一起看,并非为了比较优劣,而是为了提醒:语言的默认行为取决于底层类型体系,遇到字符串比较先查语言规范,不要想当然。
4.2 StringBuffer 转 String:拼接场景的隐藏陷阱
前文提过 StringBuilder/StringBuffer 不重写 equals,所以要先toString()。但实际项目里,问题往往出在“拼接出来的字符串到底是什么对象”。
比如:
String a = "hello"; String b = a + " world"; String c = "hello world"; System.out.println(b == c); // false这里b在运行时通过 StringBuilder 拼出来,是堆里的新对象;c是常量池里的字面量。哪怕内容一模一样,==也是 false。不少新手在循环里拼接字符串后又去和常量比较,半天想不通为什么。
JDK 编译器对字符串拼接的处理大致是:能静态确定的在编译期折叠,比如"hello" + " world";不能静态确定的,生成 StringBuilder 的 append 调用,然后把 toString() 结果返回。所以你实际拿到的是一串运行期 new 出来的对象。
还有一类常见问题是把 StringBuffer 内容清空后再对比:
StringBuffer sb = new StringBuffer("abc"); String s1 = sb.toString(); sb.setLength(0); String s2 = sb.toString(); System.out.println(s1.equals(s2)); // false,因为 s2 内容为空这种代码初看会觉得“啊,我对的是同一个缓冲区的两个快照吗?”实际上 toString 返回的是新对象,内容等于当前缓冲区的字符串。所以每一次 toString 都相当于拍一张快照,后面再改缓冲区不会影响之前的字符串。
4.3 List 里的 contains、Map 里的 key 与字典比较
热词里还有“编写程序练习 List<T> 的基本使用:创建一个只能容纳 string 对象的名为 names 的 List”,这其实也跟 equals 有关系。List 的contains判断用的是元素对象的 equals。如果你有一个List<String>,调用contains("张三"),它会遍历列表,逐个调用"张三".equals(元素)。因为 String 重写了 equals,所以只要内容相同就能命中,不管你当初放进列表的是 new 出来的 String 还是字面量。
但是换成List<StringBuilder>就完全不同了。contains依然用 equals,StringBuilder 没重写,所以它逐个比较的是地址。内容相同但对象不同的 StringBuilder,contains 返回 false。这里可见“字符串比较差异”绝不只存在于 String 本身,还波及所有集合类库的默认行为。
再延展一步到字典排序、词频统计这类场景。如果你自己实现一个文本分析工具,需要统计每个单词出现的次数,用HashMap<String, Integer>就很自然:相同内容的单词会被 equals 识别为同一个 key。但如果误把 key 设为 StringBuilder,你会发现相同文本被当成不同单词,统计结果完全乱掉。
还有一个很实用的细节:字符串比较不区分大小写时,用equalsIgnoreCase而不是先toLowerCase()再比。后者会额外创建两个字符串对象,在某些高频比较场景(比如实时过滤、敏感词扫描)会放大内存分配压力。equalsIgnoreCase内部是就地按字符比较,不需要生成新对象,性能和内存表现都好一些。
5. 高频误区和排查技巧实录
5.1 一张表看懂常见误区
很多问题脱胎于同一套逻辑,整理成速查表,方便直接对照:
| 场景 | 代码示意 | 结果 | 原因 |
|---|---|---|---|
| 字面量比较 | "a" == "a" | true | 常量池复用同一个对象 |
| 字面量与 new 对象比较 | "a" == new String("a") | false | 堆对象与常量池对象地址不同 |
| 两个 new 对象比较 | new String("a") == new String("a") | false | 两个独立的堆对象 |
| equals 比较 | new String("a").equals(new String("a")) | true | 重写为内容比较 |
| 常量拼接 | "a" + "b" == "ab" | true | 编译期折叠成字面量 |
| 变量参与拼接 | "ab" == "a" + getB() | false | 运行时生成新对象 |
| StringBuilder 比较 | new StringBuilder("a").equals(new StringBuilder("a")) | false | 未重写 equals |
| StringBuilder 转 String 后比较 | sb1.toString().equals(sb2.toString()) | true | 转换为 String 后走内容比较 |
这里特别强调一点:前两行虽然结果不同,但不是“规律变了”,而是“对象来源不同”。只要抓住==比的是对象地址,所有场景都能推出正确结果。
5.2 排查思路:遇到“字符串明明相等却进不去分支”怎么办
这种问题在线上出现时,最快的定位顺序如下:
第一步,确认是否用了==比较 String。肉眼扫代码一般就能发现。如果是,直接改成 equals 或 Objects.equals。
第二步,判断是不是编码问题。字符串在 IO、数据库读取、报文解析中经常伴随字符集问题,比如 UTF-8 和 GBK 混用导致内容看起来一样,字节流却不同。此时 equals 返回 false,但肉眼看起来两个字符串都一样。排查手段:先看打印出来是否真的完全一致,再用Arrays.toString(str.getBytes("UTF-8"))对比字节,往往能发现肉眼察觉不到的差异,比如不可见字符、零宽空格、全角/半角混用。
第三步,判断是不是来源问题。一个来自 JSON 字段,一个来自数据库字段,可能一个带前后空格,一个没有。建议先用strip()或trim()归一化再比较。热词里“attempt to perform string conversion on a secret string value”这种现象本质上也是在提醒:字符串并不是你想的那么纯粹,它可能包着安全包装或者其他类型转换链,比较前要先确认类型转换是否已经完成。
第四步,如果场景极其追求性能,确实需要常量池对象统一,再考虑 intern。但我再次强调,业务代码默认不要用 intern,它的收益在绝大多数场景里都体现不出来。
5.3 给新人和团队协作的三个实操建议
第一,字符串比较统一走Objects.equals(a, b)。这个方法在java.util包下,底层帮你处理了 null 情况:两个都为 null 返回 true,一个为 null 返回 false,两个非空则调用a.equals(b)。它最大的价值在于消除了 NullPointerException 隐患,也让代码意图更清晰。
// 不要写 if (a != null && a.equals(b)) { ... } // 可以写 if (Objects.equals(a, b)) { ... }注意别和Objects.equals的另一面搞混:Objects.equals内部并没有判断“引用相同”就短路返回,而是直接走到了 equals 方法,所以不存在“用 == 优化”的隐层逻辑。但值得一提的是,由于 String.equals 内部本身有this == anObject的短路判断,所以不会有额外性能损耗。
第二,实体类的 equals/hashCode 重写优先交给 IDE 或 Lombok 的@Data/@EqualsAndHashCode。手写 equals 容易忘掉 hashCode,或者把可变字段也纳入比较,造成后续集合操作异常。团队里可以约定:凡是被用作 Map key 的实体,必须重写 equals 和 hashCode;仅用作值对象的,可以不重写,但要在 code review 时明确说明原因。
第三,善用静态检查和单元测试。项目中接入静态代码扫描工具,自定义规则提示 String 不该使用==比较,能提前拦截大多数低级问题。同时给工具类、状态判断函数写单元测试,分别覆盖“字面量 vs 字面量”“堆对象 vs 字面量”“两个 null”这几类输入。
我个人在实际排查中还有一个小习惯:看到疑似字符串比较 bug 时,先把操作数打印出来,加上它们各自的对象地址特征,也就是System.identityHashCode(str)。两个值相等的字符串如果identityHashCode不同,说明它们确实不是同一个对象,此时==为 false 就完全符合预期的引用语义,问题出在用错比较符号,而不是 JVM 行为异常。这个方法比单纯看打印值更能帮助自己和同事建立直觉。
最后再分享一个我自己的操作心得:在写状态机、字典映射这类频繁使用字符串常量做判断的代码时,我习惯把所有状态定义成枚举而不是裸 String。枚举天然是单例,比较时甚至可以直接用==,因为 JVM 保证同一个枚举常量只有一个实例,status == OrderStatus.PAID是安全且高效的。这也算是绕开 String 比较坑的更上层设计手段:从源头上减少裸字符串在业务逻辑中的流转,而不是每次都在比较时提心吊胆。