ARTICLE DETAIL

资讯详情

深耕郑州网站建设与运营推广的一线实战洞察。

==与equals()的区别:Java对象比较的底层原理与面试陷阱

==与equals()的区别:Java对象比较的底层原理与面试陷阱 1. 面试现场为什么“ 和 equals() 有什么区别”能刷掉一半人1.1 一个看似简单的问题暴露的是内存模型的底子前段时间帮公司做技术面Java基础环节我几乎必问和equals()的区别。问之前我心里很清楚这题难度不大但筛人效果极好。很多候选人简历上写着“熟练掌握Java”可真到了这个问题张口就来的往往只有一句“ 比较的是地址equals 比较的是内容”。这话不算错但太粗糙。再深问一句“那equals()如果不重写呢”立刻卡壳的人不在少数。面试官问这道题绝对不是想听你背一个结论。他想确认三件事第一你知不知道 Java 里的数据在内存里是怎么放的第二你理不理解Object是所有类的根第三你有没有踩过集合框架里因为equals/hashCode不一致而引发的诡异 bug。这三个点全藏在“ 和 equals() 的区别”这个朴素问题背后。先说结论后面再一点点拆在基本类型上比较的是数值在引用类型上比较的是引用地址equals()是Object里的方法默认实现就是但很多类比如String、包装类重写了它让它去比较对象内部的字段值。一句话概括不了全部因为真正值钱的是这些语义背后的边界和坑。1.2 面试官想从答案里听到的三个层次同样回答这个问题候选人的水平高低一眼就能看出来。第一层只会背“ 比地址equals 比内容”。这是最浅的也是大多数培训出来的新人能给出的答案。第二层能说出equals()的默认实现来自Object而且默认就是只有重写之后才比较内容。第三层能主动提到hashCode()的约束关系、String.intern()、Integer缓存、以及自己在项目里踩过的BigDecimal比较的坑。能答到第三层的人说明他真的写过代码、调过 bug而不是只刷了八股。后面我会按从浅到深的顺序把这题彻底拆开。这不仅仅是为了应付面试更是在帮你把 Java 内存模型和对象相等的判断逻辑理顺。很多线上事故最后排查下来就是一句if (a b)写错了。2. 在基本类型和引用类型里的两套逻辑2.1 基本类型比的是栈上的数值没有商量余地Java 里的基本类型有 8 种byte、short、int、long、float、double、char、boolean。这些类型的变量存的是实实在在的值。当你写int a 100; int b 100; if (a b)时比较的就是这两个变量里存的数值结果当然为true。这里有个很多人容易忽略的点基本类型变量是直接存在栈帧里的局部变量或者作为对象字段存在堆里但比较时语义始终是“值相等”。比如int x 100; long y 100L; System.out.println(x y); // trueint 会先转成 long 再比较基本类型的不会引发任何歧义这是 Java 语言从 C 语言那里继承下来的最直观的相等判断方式。所以在业务代码里判断int、boolean这类值直接用是完全正确的不需要、也不应该用equals()——基本类型根本没有equals()方法你写intValue.equals()编译器直接报错。2.2 引用类型比的是指向同一个对象而不是内容一旦变量类型是类、数组、接口这种引用类型的语义就变了它比较的是引用本身也就是两个变量在栈里存的“对象地址值”。换句话说a b等价于问“这两个引用是否指向堆里同一个对象”而不是问“这两个对象的内容是否一样”。这个机制理解起来非常简单但你有没有想过为什么 Java 要这么设计因为对象是放在堆里的变量名只是一个“遥控器”。用比较遥控器当然只能比较两台遥控器是不是同一个批次出厂也就是指向同一个电视机。如果要比对两台电视机画面里的内容就得靠equals()或者自己手动逐字段比对。看一段代码String s1 new String(hello); String s2 new String(hello); System.out.println(s1 s2); // false两个不同对象地址不同 System.out.println(s1.equals(s2)); // trueString 重写了 equals比较字符序列new了两次就在堆里创建了两个独立对象s1和s2各指向一块内存。所以是false而equals()因为被String重写成了逐字符比较所以是true。2.3 缓存池带来的“看起来像按值比较”的错觉这也是面试官最爱挖坑的地方。下面这段代码你猜结果是什么Integer a 127; Integer b 127; System.out.println(a b); // truefalse Integer c 128; Integer d 128; System.out.println(c d); // truefalse答案是第一组true第二组false。很多人第一次遇到都会懵为什么 127 相等128 就不相等了原因在于Integer的内部类IntegerCache默认缓存了[-128, 127]范围内的整数对象。当你用Integer a 127这种自动装箱写法时编译器会把它转成Integer.valueOf(127)而valueOf()方法会先查缓存命中的话直接返回缓存里的同一个对象。所以a和b指向的是同一个对象为true。但 128 超出了缓存范围valueOf()会new一个新的Integer对象c和d指向不同对象就是false。这个现象完美地印证了在引用类型上比较的是地址而不是数值。而它之所以成为面试高频题是因为它在实际代码里真的会引发 bug。比如你从数据库查出的 ID 是Integer然后用去比较万一 ID 超过 127结果就全错了。3. equals() 的默认面目以及 String 为什么非要重写它3.1 Object.equals() 没重写时就是 equals()是Object类的实例方法而 Java 里所有类都默认继承Object所以任何对象都能调用equals()。问题是Object里的equals()到底做了什么看 JDK 源码public boolean equals(Object obj) { return (this obj); }没错Object.equals()的实现就是简单粗暴的。也就是说如果一个类没有重写equals()那么调用它的equals()和没有任何区别都是在比较引用地址。这一点至关重要。很多初学者会把“equals 比较内容”当成公理但其实那只是因为他们在最早接触 Java 时遇到的都是String、Integer这种重写过equals()的类。一旦你自己定义了一个类比如class User { String name; }然后写new User().equals(new User())结果一定是false因为User没有重写equals()用的还是Object里的地址比较。3.2 String 重写 equals按字符序列逐个比String应该是重写equals()最典型的类。它的实现逻辑是先比较引用是否相同快速判断再判断对方是否为String然后比较字符数组的长度最后逐个字符比对。核心思想就是“只要字符序列一样就算相等的字符串”。这就是为什么字符串是 Java 里最特殊、也最容易踩坑的类型。正因为String.equals()比较的是内容所以业务里判断用户输入、状态值、枚举名全都得用equals()而不能用。但也因为String有字面量池和intern()机制在某些场景下又会恰巧返回true比如String s1 hello; String s2 hello; System.out.println(s1 s2); // true两个字面量指向常量池同一个对象 String s3 new String(hello); String s4 s3.intern(); System.out.println(s1 s4); // trueintern() 返回常量池里的对象这种“恰巧相等”最害人因为它会让你误以为在字符串上也能用直到某天代码里出现了一个动态拼出来的字符串变成false线上问题立刻爆发。3.3 其他包装类的 equals各自都有不同实现除了StringJava 的包装类也都重写了equals()。比如Integer.equals()public boolean equals(Object obj) { if (obj instanceof Integer) { return value ((Integer) obj).intValue(); } return false; }它内部比较的是intValue()拆箱后的数值。Long、Short、Byte、Character、Boolean也都是类似的思路类型一致且值相等才返回true。所以用new Integer(200).equals(new Integer(200))结果是true不会受缓存范围影响。有意思的是Float和Double有点例外它们还额外处理了NaN和正负零的情况Float.NaN不等于任何值包括它自己0.0和-0.0不相等。这些细节一般面试不会深挖但如果你做数值计算心里要有个数。4. 自己写实体类时equals/hashCode 的重写规范和事故现场4.1 五条约束与一段合格的示例代码面试时经常有手写代码环节让你写一个类的equals()和hashCode()。这个考点考察的是你对 Java 语言规范的理解。JDK 文档里给equals()定了五条约束虽然不是让你背但你要知道为什么它们重要自反性x.equals(x)必须为true。对称性x.equals(y)为true则y.equals(x)也必须是true。传递性x.equals(y)且y.equals(z)则x.equals(z)必须为true。一致性如果两个对象内部字段没变多次调用equals()结果必须一致。非空性x.equals(null)必须为false。注意不是null.equals(x)那是空指针异常。很多人写的equals()就是从网上一顿抄从不检查对称性。比如用getClass()做类型判断和用instanceof做类型判断看起来差不多实际上在父子类继承场景下行为完全不同。A instanceof B在子类是B的实例时返回true而getClass() ! obj.getClass()则要求精确类型一致这样能避免不对称问题。你的类如果是 final 的两种写法都可以如果可能会被继承推荐用getClass()判断。一段比较规范的示例public class User { private String id; private String name; 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 Objects.equals(id, user.id) Objects.equals(name, user.name); } Override public int hashCode() { return Objects.hash(id, name); } }这里的Objects.equals()是 JDK 7 引入的工具方法它会先判断a b再调用a.equals(b)如果a为null就返回false完美避免了你最头疼的“name是 null 导致空指针”的问题。Objects.hash()则会根据传入字段生成一个复合哈希值省得你自己写乘法和加法。4.2 重写 equals 不重写 hashCode在 HashMap 里有多糟面试连环问的高潮来了为什么重写equals()必须同时重写hashCode()答案藏在哈希表的原理里。HashMap、HashSet、HashTable这些容器判断元素是否重复是先看hashCode()再看equals()。哈希值不同直接判定不相等根本不会调用equals()哈希值相同才需要用equals()进一步确认。如果你只重写了equals()而不重写hashCode()那么两个内容完全一样的对象可能得到不同的哈希值。它们放进HashMap的同一个逻辑集合时会被放到不同的桶里导致你map.get(key)的时候永远查不到之前放进去的值。最经典的场景就是用实体对象做Map的 key或者往HashSet里存对象去重。不重写hashCode()去重就成了玄学。反过来只重写hashCode()而不重写equals()就会命中哈希值相同但对象真的不同的情况判断为“是同一个”完全是灾难。所以规范就是两个方法必须成对出现且满足“equals()相等的两个对象hashCode()必须相等”。这条规则不是建议是硬性要求。4.3 用 Objects 工具方法避免空指针和重复代码手写equals()时很多人会陷入一种繁琐的写法if (this o) return true; if (o null || getClass() ! o.getClass()) return false; User user (User) o; if (id ! null ? !id.equals(user.id) : user.id ! null) return false;这种三元嵌套不但丑而且容易出现漏判。现在主流的 Java 8 项目直接用Objects.equals()搞定所有空值判断代码又短又不容易错。Objects.hash()同理如果你有大量字段一行就能构造出哈希值。不过要注意一点Objects.hash()的实现其实是用数组接收变长参数每次调用都会创建一个Object[]如果你的对象是高频创建和比较的比如百万级流水对象这个开销可能值得优化。这时候可以手动用31 * hash field.hashCode()的方式写一个更高效但稍微啰嗦的hashCode()。面试时如果提到这个细节加分非常明显——它证明你不只是会照抄模板还真的考虑过性能和实现原理。5. 面试官最爱追问的连环坑Integer 缓存、String.intern 与集合去重5.1 Integer 比较的经典三连问答案就在缓存范围 [-128, 127]面试官不会满足于你背出“128 比较为 false”他一定会继续问new Integer(127) new Integer(127)结果是答案false。因为用了new强制创建新对象不走缓存。Integer i 127; int j 127; i j结果是答案true。因为i会自动拆箱成int这时变成了基本类型的数值比较。Integer i 200; int j 200; i j结果是答案true。同样是拆箱后再比与缓存无关。认真看这三个问题你会发现关键在于“到底是引用比较还是值比较”。只要有一方是基本类型且发生了拆箱就会退化成值比较只有当两边都是包装类型且没有拆箱时才会走引用比较这时候缓存池才能起作用。理解了这一点你就能举一反三Long也有缓存范围同样是 [-128, 127]Character缓存范围是 0 到 127Boolean只有两个实例TRUE和FALSE。凡是包装类型的都应该警惕。业务代码里最稳妥的写法是包装类型比较一律用equals()或者直接拆箱成基本类型再比但拆箱前记得判空。5.2 equals 相等但 hashCode 不等HashSet 为什么会“失灵”面试官还喜欢让你写一段代码看看HashSet去重能不能正常工作。比如你有一个User类只重写了equals()没重写hashCode()然后把两个字段完全相同的User对象先后 add 进HashSet请问集合里最终有几个元素答案是两个。很多人不理解我明明把equals()重写了为什么HashSet没去重原因前面提过HashSet内部就是HashMap它先算hashCode()定位到桶再用equals()在桶内寻找。两个对象的hashCode()不同被分配到两个桶里equals()根本没机会被调用。所以“equals 相同 hashCode 不同”最直接的后果就是哈希容器全部失效。更麻烦的是如果同一个对象被放进集合后你又修改了它参与hashCode()计算的字段那它的哈希值就变了但它在HashSet里的桶位置不会跟着变于是造成“对象明明在集合里但contains()查不到”的灵异现象。这也是为什么我们建议放进HashSet或作为HashMapkey 的对象最好是不可变的或者至少别在存进去之后修改关键字段。5.3 String 的 比较直接字面量、new String 和 intern 三者的纠缠字符串面试题同样绕不开和equals()。先看一段代码String a abc; String b abc; String c new String(abc); String d c.intern(); System.out.println(a b); // true System.out.println(a c); // false System.out.println(a d); // true背后的逻辑是字符串字面量在类加载时会被放入常量池a和b引用了常量池里的同一个对象所以为true。new String(abc)会在堆里创建一个新对象c指向堆里的对象和常量池里的对象不是同一个所以a c为false。intern()会去常量池查找如果已存在相同内容的字符串就直接返回常量池的引用所以a d又是true。这个知识点在面试里出现频率很高但实际业务代码里基本不会用intern()因为String.intern()的底层数据结构在 JDK 6 里还是存在永久代遇到大量动态字符串很容易性能问题JDK 7 之后挪到了堆里但仍然有开销。真正需要记住的是字符串相等判断永远、永远用equals()不要用。省掉的不是性能是事故。6. 我写业务代码时真的会注意的几条经验6.1 判断字符串相等永远用 equals 或常量前置在真实项目里字符串比较是出现频率最高的操作。我的团队规范里有一条写得很死任何String之间的比较禁止使用调用equals()时把常量放前面变量放后面。为什么要把常量放前面看这段代码String status getStatusFromApi(); // 可能为 null if (SUCCESS.equals(status)) { // ... }常量放在前面即使status是null也只会返回false不会报错。反过来写status.equals(SUCCESS)status为null时直接空指针。这个习惯养成了能帮你省去大量判空代码也让团队 Code Review 更顺畅。当然如果你是 Java 8 以上也可以用Objects.equals(status, SUCCESS)效果一样但可读性上我还是更喜欢常量前置。另外要特别提醒的是switch语句匹配字符串底层调用的是equals()所以不用担心。但如果你拿字符串做Map的 key一定确认 key 的字符串是常量还是一致构造的如果使用new String当 key第一次放进去和后面取出来只有equals()能让你拿到值HashMap内部也是用equals()去查的不会因为失败而取不到这点可以放心。6.2 金额比较别用 equalsBigDecimal 的 equals 和 compareTo 不一样这是比String更隐蔽的一个坑。你可能会想BigDecimal是引用类型当然要用equals()比较。但BigDecimal.equals()有个特殊规则它既要比较数值又要比较精度scale。所以new BigDecimal(1.0).equals(new BigDecimal(1.00))结果是false因为精度不同一个 scale 是 1另一个是 2。但按金额的语义1.0 和 1.00 显然是相等的。这时候必须用compareTo()BigDecimal a new BigDecimal(1.0); BigDecimal b new BigDecimal(1.00); System.out.println(a.equals(b)); // false精度不同 System.out.println(a.compareTo(b) 0); // true按数值比较所以业务里涉及金额、价格、折扣率这类精确小数判断相等要用compareTo() 0千万别用equals()。否则同样一笔钱数据库里存的是1.0另一个系统传过来的是1.00你一比对就判不相等账都对不上。顺带一提BigDecimal的构造函数也有讲究。new BigDecimal(0.1)会得到一个看似“0.1000000000000000055511151231257827021181583404541015625”的值因为二进制浮点数的精度问题而new BigDecimal(0.1)才是精确的 0.1。所以只在字符串、long或int的基础上构造BigDecimal这一点项目中应该用规范强制。6.3 团队规范里怎么约束这两个操作符很多新人问那到底什么时候用什么时候用equals()我总结成一条简单粗暴的规则比较基本类型int、long、boolean、char等的值用。比较两个对象的“内容是否相等”用equals()。比较两个引用是否真的是同一个对象单例、枚举、缓存对象等罕见场景才用。这个规则基本覆盖了日常代码的 99%。还有几个细节值得写进团队规范第一枚举比较用是完全安全的。枚举类型在 JVM 里是单例同一个枚举值就是同一个对象和equals()效果相同且天然免空指针所以行业里普遍推荐status StatusEnum.SUCCESS而不是status.equals(StatusEnum.SUCCESS)。第二所有包装类型的比较除非你能确认自己写的是基本类型拆箱后的比较否则一律用equals()。特别是在从Map取Integer、从数据库框架取Long的场景全是风险。第三静态代码扫描工具里把“包装类型使用 ”的规则开成 error 级别。这个规则不是靠自觉而是靠机器拦住。我见过太多线上事故最后定位到就是一行Integer的。工具拦住了比人能记住可靠得多。我自己写代码的习惯是实体类的equals()和hashCode()能不用就不用 Lombok 的默认生成而是手动看看字段再决定。Data生成的equals()会包含所有非静态字段如果你的实体类里有List、Map这种集合字段或者有日志、代理字段equals()的语义可能完全不符合预期。所以关键类手写并且配上单元测试把对称性和哈希一致性验掉。这套知识点看着基础但基础从来不是用来背的是用来解决问题的。你能把和equals()讲清楚、写对、踩过坑面试官心里对你的评价一定比那些背了十篇八股的人高得多。
返回列表