ARTICLE DETAIL

资讯详情

深耕编程入门与网站建设的一线实战洞察。

深入理解Java中==与equals的区别及底层原理

深入理解Java中==与equals的区别及底层原理 记得我第一次面试Java岗位面试官问出这道题时我内心是窃喜的这题太熟了张口就能背。可当他把代码题写到白板上让我判断每一行输出的时候我发现自己只背会了一半。再后来我参与过一些技术面试见过各种各样的回答才真正搞清楚这道看似基础的题目背后面试官到底想验证什么。这篇不打算把答案从其他资料里抄一遍而是把这个问题连带的底层知识一起拆开让你既能应付面试也能在写代码时真的用得上。先给个结论式的提醒 在比较引用类型时比较的是两个引用是否指向同一个对象equals 没被重写时行为跟 完全一致一旦某个类重写了 equals它比较的才是业务意义上的相等。真正把大多数面试者筛掉的不是这两句结论而是为什么。1. 这道题背后的考点地图面试官真正想确认什么1.1 一个让我印象深刻的面试回答有次面试候选人我让他解释 和 equals 的区别他非常流利地说 是地址比较equals 是内容比较所以字符串要用 equals 判断相等。 我接着问了一句那 Integer a 128; Integer b 128; a b 输出什么 他愣了几秒小声说应该是 false 吧…… 再问为什么 127 是 true 而 128 是 false 他就只能摇头了。这个场景我见过太多次。不是候选人没背题而是他背的答案只覆盖了 String 这一个例子没有形成知识体系。一旦问题从 String 换到 Integer、从常量赋值换成 new 对象、从 equals 换到 hashCode原来的知识框架就塌了。1.2 三个层次决定回答的上限如果给这道题的回答分层次大概是这样的层次典型表现面试评价背结论只记得比地址equals比内容及格线以下懂原理能结合 JVM 内存模型解释 能讲清 Object.equals 默认实现中等偏上能应用能解释 Integer 缓存、String 常量池、hashCode 契约并能答出项目里的实际比较策略稳过面试问基础题从来不是考察你是否背过课本原文而是考察你能不能用一个基础问题引出整条知识链。所以下面的内容顺序也是按这条知识链设计的先搞懂 在 JVM 层面的真实语义再搞清楚 equals 被谁重写、为什么重写最后把面试最常追问的 String、Integer、hashCode、集合比较统一串起来。2. 先用 JVM 内存模型看透 它比的从来都是栈上的值2.1 引用是什么用储物柜和仓库来理解Java 的内存模型对新手不算友好但 这个问题必须从这里切入。简单说我们创建一个对象时对象本体在堆内存中而方法里的局部变量存储在栈上栈上存的不是对象内容而是指向堆中对象的一个引用你可以把栈想象成一排储物柜柜子里放着一张便利贴便利贴上写着仓库里的货架编号仓库是堆内存货架编号就是引用值。执行User user new User()时虚拟机先在堆里分配一块内存放 User 对象然后把这块内存的地址作为引用值存到栈上的变量user里。所以对引用类型执行本质上是比较两个储物柜里的便利贴是不是写着同一个货架编号也就是堆内存地址是否相同。还有一个很重要的点是基本类型不走这套流程。int a 10; int b 10;中栈上直接保存数值本身不涉及堆对象a b比较的就是两个数值是否相等。这一点和 equals 毫无关系因为基本类型没有方法不能用点运算符调用 equals。2.2 一张表读懂基本类型和引用类型的 差异看下面这段代码int x 100; int y 100; System.out.println(x y); // true数值比较 String s1 new String(abc); String s2 new String(abc); System.out.println(s1 s2); // false两个堆对象引用地址不同x 和 y 在栈上都是数值 100所以相等s1 和 s2 分别 new 了两个对象即使内容完全一样它们在堆里是两块不同的内存栈上的引用地址自然不同返回 false。这是第一步也是整个问题的地基永远比较栈上的值。对基本类型来说这个值就是数据本身对引用类型来说这个值是堆内存地址。记住这句后面所有场景都能推导出来。3. equals 的本来面目Object 默认实现就是 重写它的人改了什么3.1 Object.equals 的源码真相很多人以为 equals 天然就是比较内容这是个危险的误解。看一下 Object 类的源码public boolean equals(Object obj) { return (this obj); }你没有看错默认的 equals 内部就是用判断的。Object 是所有类的父类它根本不知道子类有哪些字段所以只能力所能及地比较引用地址。只有当某个类重写了 equals我们才能获得业务上的相等语义。换句话说面试时如果有人问equals 是不是就是比较内容标准答案是默认 Object.equals 比较的是引用只有重写后的 equals 才有可能是内容比较。这也是为什么题目问的是 和 equals 有什么区别而不是 比较地址equals 比较内容这么简单的原因。3.2 哪些类重写了 equals为什么在标准类库里以下常见类都没有继承默认的 Object.equals而是根据自己的业务语义重写了类equals 的语义典型用途String逐字符比较字符串内容判断两个字符串是否长得一样Integer 等包装类比较包装的数值数值比较Long比较数值但注意派生自 Number 时略有差异数值比较BigDecimal比较数值与精度 scale金额计算Date比较时间戳时间判断集合类List/Map/Set比较元素是否完全相同集合相等判断自定义实体类由开发者自己决定哪些字段参与比较业务主键判断这些类的共同点是它们都有值语义。String 的值是字符串内容Integer 的值是int 数值一个 ArrayList 的值是内部元素列表。既然它们代表的是值就得按值来判断相等而不是按内存地址。这个设计动机如果你记住了面试官问为什么 Integer 要重写 equals你就不会被问倒。3.3 Java 规范对 equals 重写的五条硬性契约任何类重写 equals都要遵守 Java 官方规定的五条规则违反任何一条都会导致集合类、依赖 equals 的框架出现诡异问题自反性x.equals(x)必须返回 true集合里不会出现自己不等于自己的怪事对称性x.equals(y)为 true 时y.equals(x)也必须为 true传递性x.equals(y)和y.equals(z)都为 true 时x.equals(z)必须为 true一致性在对象没有被修改的前提下多次调用 equals 结果必须一致不能今天 true 明天 false非空性x.equals(null)必须返回 false不能抛空指针。对称性这条尤其容易在继承场景踩雷父类用instanceof判断类型子类也同样的写法就很可能出现parent.equals(child)为 true、child.equals(parent)也为 true但一旦两边 hashCode 不一致HashMap 直接翻车。我见过有同事在 equals 里偷懒只比较 id但 id 用比较 Long导致两个 id 都是 100L 的对象一个 true 一个 false就属于违反一致性。这些坑后面会展开。4. 把 String 单独拎出来讲字符串池、new String 与 intern 的连环追问4.1 几乎必考的字符串判断输出到底怎么排这道代码题我面试时经常现场写String s1 abc; String s2 abc; String s3 new String(abc); String s4 s3.intern(); System.out.println(s1 s2); // 输出 true System.out.println(s1 s3); // 输出 false System.out.println(s1.equals(s3)); // 输出 true System.out.println(s1 s4); // 输出 true逐行看原因。第一行s1和s2都是字面量赋值编译期就能确定内容虚拟机会把它们放进字符串常量池两个变量最终指向常量池里的同一个对象所以为 true。字符串常量池的本质就是复用避免相同内容的字符串重复占用内存这是 JVM 做出的优化设计。第二行关键在new String(abc)。abc 这个字面量先进了常量池然后 new 又在堆里新造了一个 String 对象。s3指向的是堆里那个新对象而s1指向的是常量池里的老对象二者地址显然不同所以为 false。注意一个细节即使堆里已经有一个内容为 abc 的对象JVM 也不会因此取消 new 的执行new 的语义就是创建一个新对象。第三行就是 String 重写 equals 的价值虽然内存地址不同但逐字符比较下来内容一致所以返回 true。这也是实际开发中判断字符串是否相等、永远不要用的原因。第四行涉及intern()方法它把字符串内容放到常量池中如果常量池已存在相同内容就直接返回那个引用。所以s4拿到的正是s1指向的那个对象为 true。JDK7 以后常量池就移到了堆内存中这里面的细节很多但面试能答到intern 会把字符串引用转存到常量池基本就足够了。4.2 字符串拼接的另一种坑还有一个高频变形题String a hello; String b world; String c hello world; // 编译期常量折叠 String d a b; // 运行期拼接 System.out.println(c helloworld); // truec 在编译期已经确定 System.out.println(d helloworld); // falsed 在运行期通过 StringBuilder 拼接hello world两边的都是常量编译期 javac 会直接计算出结果 helloworld所以 c 指向常量池里的对象。而a b两个变量在编译期无法确定内容运行时会通过StringBuilder.append拼出新的 String 对象d 指向堆内存新对象自然不等于常量池里的字面量。面试进行到这里基本上已经把 String、常量池、引用比较全部覆盖了。如果候选人能顺着这个思路自己推出所以字符串相等判断应该用 equals面试官对基础能力的信任度会高很多。5. 重写 equals 的正确姿势以及 hashCode 为何必须一起重写5.1 一个规范的业务实体 equals 模板重写 equals 不是随便把字段拿来一遍尤其是含有 Long、String 这种引用类型字段时直接用比较字段值等于没比较。下面是一个比较标准的写法public class User { private Long id; private String name; private int age; Override public boolean equals(Object o) { if (this o) return true; // 同一对象直接 true if (o null || getClass() ! o.getClass()) return false; // 类型严格相同 User user (User) o; return age user.age // 基本类型用 Objects.equals(id, user.id) // Long 用 Objects.equals空安全 Objects.equals(name, user.name); // String 用 Objects.equals } Override public int hashCode() { return Objects.hash(id, name, age); } }这里说明几个关键点。先判断this o是同一个对象直接返回 true省去后面的字段比较性能最好。再判断o null满足非空性契约。类型判断这里用getClass() ! o.getClass()严格要求必须是同一个类避免父类子类混比。比较字段时基本类型直接用引用类型调用Objects.equals这个静态工具类内部做了空值判断两个都为 null 时返回 true避免手写空指针判断。Objects.equals的源码逻辑是这样的return (a b) || (a ! null a.equals(b));入参两个都 null 时第一个条件已经成立一个为 null 时第二个条件里的a ! null直接屏蔽掉 equals 调用所以永远不会空指针。写业务 equals 时直接用它就够了。5.2 如果不重写 hashCodeHashMap 现场翻车重写 equals 必须同时重写 hashCode这条契约入职第一天就该刻在脑子里。原因要用 HashMap 的存和取来说明。假设我定义了一个PhoneNumber类只有 areaCode 和 number 两个字段只重写了 equals 没重写 hashCodeMapPhoneNumber, String map new HashMap(); map.put(new PhoneNumber(020, 12345678), 张三); String name map.get(new PhoneNumber(020, 12345678)); System.out.println(name); // 输出 null找不到put 的时候HashMap 先用 hash 算法算出 key 的 hashCode把 entry 放进某个桶里get 的时候它先根据传入 key 的 hashCode 定位到同一个桶然后在桶里用 equals 逐个比对。两个对象虽然 equals 相等但 hashCode 不同put 和 get 分别定位到了不同的桶equals 根本没机会被调到。这个案例能直观说明 hashCode 契约的意义equals 相等的两个对象hashCode 必须相等否则基于 hash 的集合统统失效。反过来 hashCode 相同的两个对象equals 不一定要相等因为 hash 碰撞本身允许发生碰撞时再用 equals 区辨。这也是为什么 HashMap 允许不同 key 落在同一桶里。5.3 类型判断instanceof 和 getClass 该怎么选写 equals 时一个容易被追问的细节是类型判断用instanceof还是getClass()。getClass()严格要求两个对象运行时类型完全一致比如 User.class 只能和 User 比较子类对象不会被认为是相等的。这种方案安全但偏保守一旦父类有子类父类的 equals 永远无法把子类视为相等业务上想比较父子类型同 id 也算相等就很难做到。instanceof允许子类对象在父类的 equals 里通过类型检查配合向下转型比较字段能实现更灵活的比较。但它有个著名副作用破坏对称性。例如父类用instanceof允许比较子类子类也重写了 equals 的话可能会出现parent.equals(child)为 true 而child.equals(parent)为 false 的不对称情况。Effective Java 处理这个问题建议配合 canEqual 模式很多框架的内部类就这么做。我个人的实践倾向是业务实体类通常不会主动设计继承体系直接用getClass() ! o.getClass()最省事也不会出幺蛾子如果确实要支持子类扩展再考虑用instanceof配合子类重设 canEqual。面试时能说出这两种方案的取舍是加分项。6. 真实业务里那些 造成的踩坑实录6.1 Integer 缓存127 和 128 的惊魂一刻这是我见过新人最容易摔跤的地方Integer a 127; Integer b 127; System.out.println(a b); // true Integer c 128; Integer d 128; System.out.println(c d); // false同样是自动装箱为什么结果不一样因为 jdk 在 Integer 类里自带一个缓存池IntegerCache默认缓存了 -128 到 127 之间的对象。当代码里出现Integer a 127时自动装箱会调用Integer.valueOf(127)这个方法在缓存范围内直接返回缓存池里的同一个对象所以a b命中同一个引用。到了 128超出缓存范围valueOf 就会 new 一个新对象返回c 和 d 各 new 各的自然为 false。这个缓存的边界可以通过-XX:AutoBoxCacheMax参数调整但实际开发中我不会依赖它做判断。唯一的正确姿势是包装类之间比较Integer、Long、Short、Byte、Character、Boolean一律用equals或Objects.equals不要赌缓存范围。Long 也有同样的缓存机制范围同样是 -128 到 127。6.2 ArrayList.contains 和 HashSet 去重其实是 equals 在替你干活真实业务里经常要判断列表是否包含某个元素ListString list Arrays.asList(a, b); System.out.println(list.contains(a)); // trueString 重写了 equals ListUser users new ArrayList(); users.add(new User(1L, 张三)); System.out.println(users.contains(new User(1L, 张三))); // 如果 User 没有重写 equals输出 false重写了就输出 trueArrayList 的 contains 逻辑很直接遍历内部数组对每个元素调用元素.equals(入参)逐个比对找到就返回 true。如果 User 不重写 equals默认走 Object.equals比较的是引用地址新 new 出来的 User 和已放进去的 User 不是同一个对象所以 contains 永远返回 false。很多人开发时遇到明明数据看起来一样contains 却返回 false多半就是这个原因。HashSet 去重也是同理。HashSet 底层是 HashMap往里面 add 元素等于把元素作为 key 插入 map去重依赖的就是 hashCode 和 equals。两个内容相同的对象如果 hashCode 不一样HashSet 会把它们当成两个 key去重形同虚设。这也是为什么写了 equals 就必须看一眼 hashCode 的典型场景。6.3 BigDecimal 金额比较equals 比的是数值还是精度金额计算里 BigDecimal 是主角但它的 equals 有个大坑BigDecimal m1 new BigDecimal(0.0); BigDecimal m2 new BigDecimal(0); System.out.println(m1.equals(m2)); // false数值都是 0但 scale 不同 System.out.println(m1.compareTo(m2)); // 0compareTo 才真正相等BigDecimal 重写 equals 时不仅比较数值还比较精度 scale。0.0的 scale 是 10的 scale 是 0所以 equals 返回 false。但是业务上判断金额是否相等通常只关心数值是否一样用 equals 就会出问题。金额比较的正确姿势是用compareTo它忽略尾部多余的零只看数值。类似的还有 Long 与 Integer 的 mix 比较、Double 与 Float 的比较都是看起来一样equals 却 false的家族成员。业务里判断数据库主键或状态值时我的习惯是统一走Objects.equals(attr1, attr2)而不是attr1 attr2。因为 attr 一旦从方法返回很可能是包装类型在超范围场景下会给出错误结果用 Objects.equals 既空安全又避免掉进缓存区间的陷阱。7. 面试官的高频追问链与一套能压住场的回答话术7.1 常见追问链从这道题可以延伸到哪里面试官通常不会只满足于标准答案他会顺着你的回答不断深挖问equals 和 hashCode 有什么关系问为什么重写 equals 必须重写 hashCode不重写会怎样问String 的 equals 是怎么实现的 在字符串上什么时候相等问Integer 的 什么时候返回 true为什么 127 和 128 结果不同问两个对象 equals 相等hashCode 一定相等吗反过来呢问HashMap 的 get 过程中hashCode 和 equals 分别起了什么作用问对象放入 HashSet什么时候能去重什么时候不能问你的实体类重写 equals 了吗用什么比较主键这条追问链非常经典本质上是想看你能否把一个知识点串成体系同时观察你平时写代码时有没有思考过集合类的工作原理。所以准备这道题时不建议只背标准答案建议把 HashMap 的存取流程、Integer 缓存机制、String 常量池这几块都过一遍。7.2 一套参考回答话术如果面试官让你用 1 到 2 分钟回答 和 equals 的区别可以这样说先分对象类型。对于基本类型 比较的是数值对于引用类型 比较的是两个引用是否指向堆里的同一个对象也就是比较内存地址。equals 是 Object 类的方法默认实现其实就是return this obj所以没有重写时equals 和 效果完全一样。String、Integer、BigDecimal 这些类为了值语义都重写了 equals改成了根据内容判断相等。在业务代码里判断字符串相等用 equals判断包装类数值相等用 equals 或 Objects.equals判断两个对象是否相等则依赖实体类是否重写了 equals 和 hashCode。并且重写 equals 必须同时重写 hashCode否则 HashMap、HashSet 这类散列集合在存和取时可能定位到不同桶导致 equals 相等却取不到数据。这段话的好处是先给分类结论再落到 Object 默认实现接着举标准库例子最后引出 hashCode 契约和集合类的关系。面试官顺着任意一条往下问你都有内容接得上。最后分享一个我自己的实践习惯给实体类写 equals 之前先问自己几个问题——这个类会不会放进 Set 或作为 Map 的 key会不会用来做 List.contains 判断如果会就老老实实地把所有业务字段都纳入比较并配上 hashCode如果只在单一方法内做局部比较用 Objects.equals 判断几个字段就够了不必为整个类承担重写 equals 的负担。很多线上问题并不复杂翻来覆去就是这几个比较语义没想清楚。把这套东西弄明白这道面试题和它背后的知识链基本就稳了。
返回列表