Java身份运算符详解:==与equals的内存机制与避坑指南

发布时间:2026/9/9 16:28:56
Java身份运算符详解:==与equals的内存机制与避坑指南 1. 身份运算符从一次“诡异”的 Bug 说起写 Java 这些年如果让我选一个“初学者最容易翻车、老手偶尔也会懵一下”的知识点身份运算符绝对排得进前三。你可能见过这种场面两个人对着屏幕吵得面红耳赤一个说“这俩明明相等为什么 if 不进去”另一个说“你等等这里用的是不是equals”——没错这就是身份运算符在搞事情。先给还不熟悉的朋友一句话解释身份运算符就是和!在 Java 里用来判断两个引用是否指向同一个对象。注意这里说的是“是不是同一个对象”而不是“两个对象的内容是否相同”。这个区别是所有混淆的根源。我见过太多人在项目里因为这个问题埋雷。有个真实案例团队里一个小伙子在缓存模块里用比较两个从不同地方取出来的用户对象本地测试怎么跑都对一上测试环境就偶发失败查了两天才发现是 Integer 缓存范围的问题。这类问题如果你没彻底理解身份运算符的机制排查起来会非常痛苦。所以这篇文章我就把和!这点事掰开揉碎讲清楚从内存层面到底层机制从经典陷阱到实战排查争取让你看完之后不光会背结论还能真正理解“为什么”。这个内容适合谁刚学完 Java 基础语法的初学者、准备面试的校招生、以及写了两三年代码但遇到和equals还会犹豫一下的开发者。哪怕你只是路过搞懂这一节你在代码评审里能少挨好几次骂。2. 完整的运算符体系里身份运算符站在哪个位置2.1 运算符家族的“族谱”梳理在正式聊身份运算符之前我习惯先带着学生把 Java 运算符的整体地图画一遍。因为很多人在学习的时候是“只见树木不见森林”今天学一个算术、明天学一个赋值学到后来全混在一起。实际上 Java 的运算符分这么几大类算术运算符 - * / %处理数值的基本运算注意%是取余不是取模负数场景下有区别赋值运算符 - * / %把右边的值算完再赋给左边复合赋值自带隐式强转关系运算符 比较大小只能用于基本类型身份运算符 !既能比较基本类型的值也能比较引用类型的地址逻辑运算符 || !处理布尔值注意短路特性位运算符 | ^ ~ 直接操作二进制位性能敏感场景有用三元运算符条件 ? 表达式1 : 表达式2简化 if-else 的语法糖类型转换运算符(类型)强制类型转换存在精度丢失和溢出风险。这里有个常见的误解很多教材把和!归到“关系运算符”里跟、放一起讲。我倒觉得这种分类对初学者不太友好因为和只能比较基本数值类型而和!能比较所有东西包括对象引用二者背后的机制完全不同。所以把这俩单独拎出来作为“身份运算符”讲是比较合理的划分方式也更容易帮你建立正确的认知模型。整体看下来你发现没有整个运算符体系里只有身份运算符这一个家伙“跨了两个世界”——既能比较基本类型的值又能比较引用类型的地址。这个“双重身份”就是所有混乱的源头。2.2 为什么叫“身份”运算符而不是“相等”运算符这是理解整个章节的关键问题。英文原文是 Identity Operator中文翻译成“身份运算符”是非常精准的。什么叫身份就是“你是你”每个对象在内存里都有一个独一无二的地址就像人的身份证号一样。两个变量如果指向同一个对象那它们的“身份”就是同一个如果指向两个内容相同的不同对象那它们的“身份”还是不一样的。我经常用一个类比来讲这个概念假设有两台一模一样的新车颜色、配置、里程全部相同。这时候你说“这两台车相等吗”——如果你说的是配置单上的参数那确实相等但如果你说的是“这是同一台车吗”那显然不是。身份运算符回答的是后者“这两个引用是不是指向同一个对象”。它回答不了“内容是否相同”这个问题。这个区分极其重要因为在实际开发里“内容相同但不是同一个对象”的情况太常见了。你从数据库查出来的两个用户对象字段一模一样但它们天然是两个独立的 Java 对象在内存里占两块不同的空间。这时候用比较结果必然是 false——哪怕它们的 name、age、email 全都相同。很多新人第一次遇到这种情况都会很崩溃“这俩数据都一样的你怎么能说它们不相等呢”所以请把这句话刻在脑子里比较的是“身份”不是“内容”。至于内容比较应该怎么做那是equals()方法的工作后面我会专门展开讲。3. 内存视角背后到底在比什么3.1 基本类型 vs 引用类型两条完全不同的路要真正理解身份运算符必须把 Java 的内存模型搞清楚。JVM 运行时会把内存分成几个区域对我们今天讨论的问题来说最核心的是栈Stack和堆Heap这两块。栈里存什么基本类型的变量值比如int a 10栈里就直接存 10 这个数值和引用类型的变量值存的是“指向堆里某个对象的地址”你可以理解成一个门牌号。堆里存什么真正new出来的对象本身比如new Student()创建的那个完整对象数据。比较的规则就分两种情况如果两边都是基本类型int、char、boolean、double 这种比较的是变量里存的数值是否相等。比如int a 10; int b 10; a b结果是 true因为 10 就等于 10没啥好说的。如果两边都是引用类型类、数组、接口这种变量里存的不是对象本身而是对象在堆里的地址所以比较的是这两个地址是否相同也就是“门牌号是不是同一个”。这里有一个很重要的点基本类型和引用类型用比较时比的东西完全不同一个是值一个是地址。所以你在写代码的时候一定要先问自己我当前比较的这两个变量是基本类型还是引用类型这个判决直接决定了结果。我见过一个特别典型的错误有人拿去比较两个字符串因为“我在别处看能比较基本类型值相等呀字符串不也是值吗”——这个认知错得很彻底。String 是引用类型不是基本类型。两个内容相同的字符串用比较绝大多数情况下结果是 false因为它们是两个不同的对象。只有一种特殊情况后面讲 String 常量池的时候会细说。3.2 图解两个变量指向同一个对象 vs 指向不同对象我用最朴素的方式画一下内存里的情况你感受一下“身份”这两个字的含义。第一种情况Person p1 new Person(); Person p2 p1;这时候 p1 和 p2 存的是同一个地址堆里只有一个 Person 对象。用p1 p2比较结果是 true因为这俩指向同一个家。第二种情况Person p1 new Person(); Person p2 new Person();哪怕这两个对象的 name、age 全一样堆里也是两个完全独立的 Person 对象地址不同。用p1 p2比较结果是 false因为这俩是“长得一样但不同的人”。这就是“身份”二字的真正含义。你会不会觉得这不公平我也觉得有点残忍但 JVM 就是这样设计的。Java 之所以保留这套机制是因为在很多底层场景里我们需要精确判断两个引用是否指向同一个对象。最典型的就是锁一个对象的 synchronized 锁只能被持有该对象引用的线程获取。如果两个引用指向不同对象那就是两把不同的锁完全互不相干。这时候你用来判断“这是不是同一把锁”逻辑就非常清晰。3.3 JVM 指令层面到底执行了什么操作说点稍微底层的内容虽然平时写代码用不到但理解了之后你会对整个机制产生“原来如此”的感觉。Java 源码编译成字节码之后比较在 JVM 指令层面分两种对于基本类型编译器会生成if_icmpeq、if_icmpne这类指令意思是“比较两个 int或其他整数类型是否相等”。对于引用类型编译器会生成if_acmpeq、if_acmpne指令这里的a就是 reference 的意思比较的是两个引用是否指向同一个对象。从指令名字就能看出来JVM 从底层就区分了这两类比较。有意思的是对于long和double这种 64 位类型由于 JVM 栈上一个 slot 只有 32 位需要两个 slot 来存储所以比较指令会稍微复杂一些但仍然遵循同样的语义。在 HotSpot 虚拟机里引用类型的比较实际上对比的是两个 oopordinary object pointer普通对象指针的值。在不开启压缩指针的情况下这就是一个 64 位的地址值。所以从最底层来看p1 p2本质就是在做一次指针地址的数值比较。这就是“身份”在机器层面的呈现方式——两个指针的数值是否相等。这个认知对我的帮助很大因为它让我明白身份运算符比较就是一个纯粹的地址比对过程不涉及任何“内容深度分析”。所以它的执行效率非常高比 equals 快得多因为 equals 往往要做类型判断、字段逐一比较等复杂操作。但快是有代价的它无法告诉你“内容是否相等”。4. 最容易踩的坑String 和 Integer 的“障眼法”4.1 String 常量池为什么两个内容相同的字符串用时而 true 时而 false现在我们来聊那个让无数人困惑的问题字符串比较。我敢打赌几乎所有学过 Java 的人都在这里栽过跟头。问题代码长这样String s1 hello; String s2 hello; System.out.println(s1 s2); // 输出 true String s3 new String(hello); String s4 new String(hello); System.out.println(s3 s4); // 输出 false为什么第一个是 true第二个是 false这就要引入 String 常量池的概念了。JVM 在方法区在 HotSpot 里是元空间里维护了一个字符串常量池专门用来存储字符串字面量。当你在代码里直接写String s1 hello的时候JVM 会先去常量池里找有没有内容为 hello 的字符串如果有直接把池里那个字符串对象的地址赋给 s1如果没有先在池里创建这个字符串对象再把地址赋给 s1。所以String s1 hello和String s2 hello这两行代码最终 s1 和 s2 拿到的是同一个池中对象的地址用比较自然是 true。但如果你用new String(hello)事情就不一样了。new关键字的意思很明确强制在堆里创建一个全新的 String 对象。哪怕内容同样是“hello”它在堆里的地址跟常量池里那个字符串对象的地址完全不同。两个new String(hello)创建出来的更是两个不同的对象用比较必然是 false。这个设计的初衷是性能优化字符串在业务代码里使用频率极高如果每次写一个字面量都创建一个新对象内存根本扛不住。但副作用就是字符串的相等判断绝对不能想当然地用。这里我建议你记住一条铁律在任何实际的业务代码里比较字符串内容一律用equals()方法不要用。虽然在某些场景下你会发现恰好返回了 true比如两个直接赋字面量的字符串但那只是常量池优化造成的巧合不是你可以依赖的保证。4.2 Integer 缓存127和128为什么命运不同讲完 String再说另一个高频陷阱Integer 的比较。这个问题在面试里出现频率极高好多人都被问过“Integer a 100; Integer b 100; a b结果是啥那Integer c 128; Integer d 128; c d结果又是啥”第一个是 true第二个是 false。答案说出来很简单但你有没有想过为什么是 127 和 128 这个分界线这就要谈到 Integer 缓存机制了。我在日常工作中总结下来的经验是不要跟 Integer 缓存机制较劲更不要试图“利用”这个机制来优化代码。直接无脑用equals()或者把 Integer 转成 int 再用省心一万倍。后面在实操部分我会给出一套完整的比较方案。4.3 其他包装类型的“缓存红线”一览不只是 Integer 有缓存机制Java 对多个包装类型都设计了类似的优化这就是著名的“包装类型缓存池”。我用一个表格把常用类型的缓存范围整理出来方便你随时查阅包装类型缓存范围说明Booleantrue / false只有两个值全部缓存Byte-128 ~ 127全部缓存因为 byte 总共就 256 个值Short-128 ~ 127缓存部分Integer-128 ~ 127上限可通过 JVM 参数调整Long-128 ~ 127缓存部分Character\u0000 ~ \u007F0 到 127Float无浮点数不缓存Double无浮点数不缓存注意Float 和 Double 是没有缓存的因为它俩的取值空间太大缓存没有意义。这个表格建议你存一下面试和工作里都经常会用到。但我的核心建议依然是这些缓存机制都是 JVM 的优化手段不是语言规范强制要求的Integer 的 -128 到 127 是规范明确要求的但上限可以扩展你不能把自己的代码逻辑建立在某个分界值上。一旦你在代码里写了“我相信 127 以内是同一个对象”之类的判断逻辑你就把自己置于了一个随时可能踩雷的位置将来换 JDK 版本、调整 JVM 参数都可能让行为变得完全不同。5.vsequals()什么时候用哪个一次说清楚5.1Object.equals的默认行为其实也是身份比较很多人以为equals()天生就是做“内容比较”的这个理解对了一半。严谨地说equals()方法的设计初衷是让你重写Override来定义“内容相等”的规则但如果一个类没有重写equals()那它继承的是Object类的默认实现。Object.equals()的源码大概是这样的public boolean equals(Object obj) { return (this obj); }看到了吗Object 默认的equals()方法内部就是在调用做身份比较。也就是说如果一个类没有重写equals()那a.equals(b)和a b的结果完全一样都是在比较地址。所以“equals比较内容、比较地址”这句话准确的说法应该是“重写过的equals比较内容永远比较地址针对引用类型”。如果你用的某个类没有重写equals()那你调用它的equals其实还是在比地址这时候你跟的结果没区别。哪些类重写了equals()呢String、Integer、Long 等所有包装类型、BigDecimal、File、Date 等等JDK 里大部分常用类都重写了。但你自己定义的业务类默认是不重写的——所以如果你需要按照业务规则判断两个对象“内容相等”记住一定要自己重写equals()和对应的hashCode()。5.2 String 的equals()为什么是“内容比较”的标杆String 重写后的equals()是理解“内容比较”的最好范本。它做了一系列操作大致流程是先判断是不是同一个对象引用如果是直接返回 true省去后续比较的开销判断传入对象是否为 String 类型不是就直接返回 false比较两个字符串的长度长度不同直接 false逐个字符比较只要有一个字符不同就返回 false全部相同才返回 true。这个流程设计得非常经典先用做一次快速判断如果本来就是一个对象没必要再慢慢比了再做类型检查再比长度最后才逐一比内容。这种“先快后慢、层层过滤”的思路在你自己重写equals()的时候也值得借鉴。我在代码评审的时候经常看到有些人重写equals()特别粗糙一上来就挨个字段比连类型检查都不做。明明可以先判断if (this obj) return true;省掉大量无谓的比较也懒得加。这种代码在对象数量一大、比较频繁的场景下性能差距还是挺明显的。5.3 一套务实的比较策略按场景选方案看了前面这么多分析你可能会觉得“这么多规矩那我到底该怎么比较”别慌我根据自己的开发经验给你总结了一套比较策略场景一比较两个基本类型变量的值或者确认引用类型是否为 null。用和!。if (count 0) { ... } if (user null) { ... } if (user ! null) { ... }场景二比较两个引用类型的“内容”是否相等。用equals()而且建议用常量或已知非空对象来调用避免空指针// 推荐常量在前永远不会空指针 if (SUCCESS.equals(status)) { ... } // 不推荐status 可能为 null跑起来容易掉坑 if (status.equals(SUCCESS)) { ... }场景三比较包装类型比如 Integer、Long的数值是否相等。两个选择要么用equals()要么先转成基本类型再用。Integer a getValue(); Integer b getMaxValue(); // 方案一equals安全 if (a.equals(b)) { ... } // 方案二转 int 后用 性能更好 if (a.intValue() b.intValue()) { ... }场景四判断两个引用是否是同一个对象比如判断缓存命中。用但要清楚自己在做什么不要误用。if (cache.get(key) targetObj) { ... }你发现没有绝大多数业务代码里比较对象内容的场景占绝大多数所以equals()的使用频率远高于引用比较。的真正用武之地在判断 null、判断基本类型、以及极少数需要确认对象身份的底层场景。6. 实操演练一组经典代码把这些问题一次跑明白6.1 搭建一个最小的验证环境纸上谈兵没意思我带你实际跑一组代码把这些结论验证一遍。环境很简单不需要什么花哨的工具就一个 JDK8 以上就行和一个文本编辑器。我这边的开发环境是 JDK 17但下面这些代码在 Java 8 到 21 上跑出来的结果都一样。先建一个最简单的 Java 文件起名叫IdentityOperatorDemo.java。我习惯自测代码的时候都用这种方式不用 IDE 也能快速验证结果。文件内容先写成这样public class IdentityOperatorDemo { public static void main(String[] args) { // 基本类型比较 int a 10; int b 10; System.out.println(基本类型 a b (a b)); // 引用类型比较 String s1 new String(hello); String s2 new String(hello); System.out.println(不同对象 s1 s2 (s1 s2)); System.out.println(不同对象 s1.equals(s2) (s1.equals(s2))); // 字符串字面量 String s3 hello; String s4 hello; System.out.println(字面量 s3 s4 (s3 s4)); } }保存后在命令行执行javac IdentityOperatorDemo.java java IdentityOperatorDemo我运行的结果是基本类型 a btrue 不同对象 s1 s2false 不同对象 s1.equals(s2)true 字面量 s3 s4true是不是完全符合前面讲的理论如果你跑出来的结果跟我说的不一致那一定是环境或者代码跟我的有出入。特别是最后一行有的同学如果之前用new创建过 s3 和 s4结果就会是 false这恰恰说明了一个问题常量池和堆里的两个“hello”共享机制不是绝对的。6.2 完整测试Integer、null 判断、对象引用再上一组更有意思的把 Integer 缓存、null 判断和对象引用都测一遍public class IdentityOperatorDemo { public static void main(String[] args) { // Integer 缓存边界测试 Integer i1 127; Integer i2 127; Integer i3 128; Integer i4 128; System.out.println(Integer 127 127 (i1 i2)); // true System.out.println(Integer 128 128 (i3 i4)); // false // 手动 new 的 Integer 不受缓存影响 Integer i5 new Integer(127); System.out.println(new Integer(127) 127 (i5 i2)); // false // 转成 int 比较 System.out.println(intValue 比较 (i3.intValue() i4.intValue())); // true // 对象引用同一性 Person p1 new Person(张三); Person p2 new Person(张三); Person p3 p1; System.out.println(p1 p2 (p1 p2)); // false System.out.println(p1 p3 (p1 p3)); // true System.out.println(p1.equals(p2) (p1.equals(p2))); // false因为 Person 没重写 equals // null 判断 String str null; System.out.println(str null (str null)); // true System.out.println(str ! null (str ! null)); // false } } class Person { String name; public Person(String name) { this.name name; } }这里再强调一下p1.equals(p2)输出的是 false不是 true。因为 Person 类没有重写equals()它继承的是 Object 的默认实现默认实现就是。很多人以为 “equals 就是比较内容”结果在自己定义的类上翻车。如果你希望两个名字相同的 Person 被认为是“相等”的就必须自己重写equals()和hashCode()。编译运行后我自己机器上的输出如下Integer 127 127true Integer 128 128false new Integer(127) 127false intValue 比较true p1 p2false p1 p3true p1.equals(p2)false str nulltrue str ! nullfalse这些结果你最好自己跑一遍看输出然后对着前面讲的内存模型思考一下每个结果背后的原因。我始终觉得学编程最有效的方式就是亲手验证、亲手推翻自己之前的错误认知。6.3 一个意外的重头戏重写 equals 的正确姿势既然上面提到了“重写 equals”这里我多给你展开讲一下因为很多人在实际项目里写出来的equals()是有问题的。我知道有些人会想“反正这个方法 IDE 能自动生成我还学它干嘛”——但如果你不理解背后的规则遇到复杂场景比如继承、集合去重还是会懵。一个标准的equals()重写需要遵循这些约定自反性x.equals(x)必须返回 true对称性x.equals(y)和y.equals(x)结果必须一致传递性x.equals(y)为 true 且y.equals(z)为 true则x.equals(z)必须为 true一致性对象没被修改的前提下多次调用equals()结果必须稳定非空性null.equals(x)不行但x.equals(null)必须返回 false。以 Person 类为例一个比较标准的写法是这样class Person { private String name; private int age; public Person(String name, int age) { this.name name; this.age age; } Override public boolean equals(Object o) { // 同一个引用直接返回 true if (this o) return true; // null 或者类型不匹配返回 false if (o null || getClass() ! o.getClass()) return false; // 类型匹配后强转 Person person (Person) o; return age person.age Objects.equals(name, person.name); } Override public int hashCode() { return Objects.hash(name, age); } }注意几点第一先做this o快速判断这是利用了身份运算符的特性做短路优化第二用getClass() ! o.getClass()而不是instanceof可以避免继承场景下的对称性被破坏第三如果你重写了equals()务必同时重写hashCode()否则这对象放进 HashSet、HashMap 里会神秘“找不到”这个坑我在实战里排过太多次了。7. 身份运算符之外那些“不算身份”却容易混淆的比较7.1 与关系运算符的边界和的本质区别现在我们要厘清一个概念边界、、、这些关系运算符跟是什么关系从功能上说它们都做“比较”但机制完全不同。只能用于基本数值类型char 也能比比的是 Unicode 编码它比较的是大小关系结果天然是布尔值。而既能比较基本类型大小相等又能比较引用地址身份相同适用范围更广。从运算符优先级来说这俩也有区别。的优先级低于、这些关系运算符但高于赋值运算符。所以在写boolean result a b c;这种代码时会先算b c再把结果跟a比较。虽然这种写法不推荐但你得知道解释器是怎么执行的否则排错的时候会一脸蒙。一个常见的坑是有些变量是 Boolean 包装类型但你会下意识拿去跟true比较。比如Boolean flag getFlag(); if (flag true) { ... }这种写法。编译其实能过但如果flag是 null这里就会抛 NullPointerException——因为自动拆箱发生在比较之前。更稳妥的写法是if (Boolean.TRUE.equals(flag))这样即使 flag 为 null 也不会崩。7.2instanceof判断“身份类别”而不是“身份同一”instanceof运算符是另一个容易跟身份运算符混在一起的概念。它用来判断一个对象是不是某个类的实例比如obj instanceof String返回 true 表示 obj 是 String 类型或其子类型。区别在哪里问的是“你是不是某个具体的对象”instanceof问的是“你是不是属于某个类型家族”。一个是“个体身份”一个是“类别归属”。Netty 源码里有一段代码先用instanceof判断消息类型是否匹配匹配了再强转之后处理至于这些消息对象之间动不动用判断是不是同一个引用那是在做缓存或去重的时候才需要。这两个操作组合起来就是“先看是什么类别再看是不是同一个个体”的典型用法。我建议你在写代码的时候把这两件事分开思考不要混在一起。看到instanceof的时候脑子里想的是“类型层级”看到的时候脑子里想的是“地址相等”。7.3 注意点在浮点数比较里的特殊之处最后聊一个很多人甚至不知道的坑浮点数的相等比较。float和double的比较在大多数情况下是“不靠谱”的。原因很简单浮点数在二进制里无法精确表示很多十进制小数比如 0.1 在二进制里是一个无限循环小数存储的时候会截断所以0.1 0.2 0.3的结果是 false。这跟身份运算符没有直接关系但它会干扰你“比较值相等”的直觉。处理浮点数相等比较的正确做法是设定一个精度范围比如double a 0.1 0.2; double b 0.3; double epsilon 1e-9; System.out.println(Math.abs(a - b) epsilon); // true或者用 BigDecimal 来做精确计算。我见过有人因为用比较浮点数导致价格判断出错的线上事故这种问题一旦出现非常难排查因为你很难一开始就怀疑到“0.1 0.2 不等于 0.3”这个事实。所以这里提一嘴算是提前帮你排雷。8. 实战排查我遇到过的几个身份运算符经典问题8.1 问题一数据库查出来的对象为啥“不相等”之前帮一个朋友排查过一个真实问题。他写了一段代码从缓存和数据库各取了一个用户对象然后判断“如果这两个对象相同就不更新缓存”。代码长这样User cacheUser cache.get(userId); // 从 Redis 反序列化出来的 User dbUser userMapper.selectById(userId); // 从 MySQL 查出来的 if (cacheUser dbUser) { // 认为相同跳过更新 return; } // 更新缓存 cache.set(userId, dbUser);这段代码的问题我相信你现在一眼就能看出来了cacheUser和dbUser是两个独立创建的对象分别经过 Redis 反序列化和 MyBatis 映射它们在内存中必然是两块不同的内存比较永远为 false。所以这段代码的“跳过更新”分支永远走不到每次请求都会刷新一次缓存。怎么改这就取决于业务语义了。如果目标是判断两个对象的内容是否相同那就得重写 User 类的equals()或者逐个字段比较或者比较某个唯一标识比如 userId。如果目标是判断“是不是同一个对象引用”那反而是正确的。但在这个场景下业务上是想判断内容是否相同所以应该用值比较。这个案例告诉我们遇到返回了不符合预期的结果先问自己我到底是想要“内容相等”还是“同一对象”90% 的情况下业务上想要的是前者。8.2 问题二字符串比较写反了空指针还有一个更经典的场景。很多新手喜欢的写法是String status getStatus(); // 可能返回 null if (status.equals(SUCCESS)) { // 处理成功逻辑 }这段代码的问题在于printStackTrace定位到这一行发现是 NullPointerException。原因很好理解status为 null 时调它身上的equals()方法相当于“在 null 上调用方法”必然空指针。正确且稳妥的写法是if (SUCCESS.equals(status)) { // 处理成功逻辑 }字符串字面量是固定的常量永远不为 null所以调用它的equals()不会空指针。这其实也是equals()方法设计得好的地方它的参数允许为 null而且规范要求必须返回 false——“SUCCESS”.equals(null) 会返回 false不会抛异常。我在给团队做 Code Review 时只要看到变量.equals(常量)这种写法就会提醒他改成常量.equals(变量)。这不是吹毛求疵而是这种错误实在太容易触发了。8.3 问题三集合去重失效罪魁祸首居然是没重写 equals最后一个场景直接跟我前面讲的equals()重写有关。假设有一个订单列表你想去重于是用了ArrayList.contains()ListOrder orders getOrders(); Order target new Order(20240816-001); boolean exists orders.contains(target);如果 Order 类没有重写equals()那contains()内部调用indexOf()后者是用equals()逐个比较的。由于 Order 没重写 equals实际用的是 Object 默认的身份比较——只要 orders 里的元素不是 target 这个对象本身exists永远是 false。结果就是去重逻辑完全失效。这时候有人可能会问那我用去重可以不可以但前提是你能保证两个“相同”的订单确实是同一个对象引用。现实中从数据库查出来的数据每次都是新对象根本不可能保证引用同一。所以正确做法依然是重写equals()和hashCode()让“内容相同”的订单被视为同一个。这个经历让我养成了一个习惯自己定义的业务类只要有可能被放进集合、做去重、做比较就第一时间把equals()和hashCode()一起写好。IDE 一键生成就好成本极低却能避免未来无数个隐秘 bug。9. 一张速查表 面试中身份运算符的常见问法9.1 身份运算符核心结论速查为了方便你以后翻看我把这篇文章讲的精髓整理成了一张速查表建议你收藏起来场景推荐做法不推荐做法基本类型比数值int a int b无判断引用是否为 nullobj null或obj ! nullnull.equals(obj)会空指针比较 String 内容常量.equals(变量)变量 常量比较包装类型数值Integer.equals()或intValue()后再直接依赖缓存机制判断两个引用是否同一对象a ba.equals(b)如果没重写结果一样重写了就变成内容比较了浮点数相等比较用精度范围或 BigDecimal直接自定义对象内容比较重写equals()和hashCode()后用 equals直接用判断对象类型obj instanceof Classobj Class语法都过不了这张表基本上是实战中最高频的场景了。如果你能把这些场景下的选择都搞清楚日常开发里身份运算符相关的坑你基本都能避开。9.2 高频面试题与破题思路既然搜“身份运算符”的可能有不少是为了准备面试我再顺手整理几个高频面试题和破题思路。当然面试官问的不是会不会背而是你能不能把背后的原理讲清楚题目一和equals()的区别回答框架对于基本类型比较的是值对于引用类型比较的是地址equals()是 Object 的方法默认实现是但可以被重写为内容比较。然后举一个 String 的例子说明。题目二为什么两个内容相同的字符串用比较有时是 true回答框架讲清楚字符串常量池字面量赋值会复用池中对象new String()会强制创建新对象所以结果取决于是否引用了同一个池中对象。题目三Integer a 127; Integer b 127; a b为 truea 128时为 false为什么回答框架讲 Integer 缓存机制-128 到 127 之间的值会从缓存中取超过范围会 new 新对象。题目四equals()和hashCode()为什么要一起重写回答框架HashMap、HashSet 等集合先根据 hashCode 定位桶再用 equals 比较桶内元素。如果只重写 equals 不重写 hashCode相同的对象可能被分配到不同的桶里导致集合里有“重复”元素。题目五Objects.equals(a, b)和a.equals(b)的区别回答框架Objects.equals内部先判断a b然后判断a ! null a.equals(b)所以它对 null 值安全不会空指针。而a.equals(b)在 a 为 null 时直接空指针。这些题目如果你都能用自己的话解释清楚甚至能补充一两个实际踩坑案例面试官对你的评价会完全不一个。9.3 再看一眼全文身份运算符的“道”与“术”写到这里这个知识点该讲的、不该讲的我都快讲完了。最后我想跳出来用稍微大一点的视角再说几句。身份的“术”是那些具体的比较规则、缓存范围、方法重写约定这些是可以在度娘和面试宝典上查到的。而身份的“道”是**“我是谁不等于我看起来像谁”**——在 Java 的内存世界里两个对象就算长得一模一样只要块头不同它们就是两个截然不同的个体。这个观念一旦建立起来很多语法层面的困惑都会迎刃而解。我后来带新人的时候看到他在纠结和 equals我不直接告诉他答案而是反问他“你在这行代码里是想问‘这是不是同一个东西’还是想问‘这两个东西一不一样’”这个问题想明白了代码怎么写基本上就不容易出错了。如果你把这篇文章读到这里并且亲手动笔把示例代码跑了一遍那我可以负责任地说身份运算符这块你已经比很多工作两三年的开发都扎实了。剩下的就是到实战里去验证、去犯错、去总结自己的经验了。