ARTICLE DETAIL

资讯详情

深耕网站视觉设计与运营推广的一线实战洞察。

Java对象比较全解析:==、equals、hashCode与排序实战

Java对象比较全解析:==、equals、hashCode与排序实战 说起Java里的对象比较我总是想起当年面试时被连环追问的场景和equals到底什么区别重写equals为什么必须重写hashCode为什么Integer1000和1000用比较是false而100和100却是true后来做了这么多年开发和代码评审发现这些并不是考试刁难而是实打实的生产事故来源。List.contains()判断不准、HashMap查不到数据、Collections.sort()抛ClassCastException追根溯源几乎都是对象比较的基础没吃透。这篇文章我打算把Java对象比较这件事彻底讲透。从底层的引用比较到equals/hashCode契约再到Comparable和Comparator的排序实战每一层都会结合我实际开发里遇到的翻车现场和排查经验来讲。不管你是刚学Java的初学者还是写了好几年代码想查漏补缺的老手只要还在和对象打交道这篇文章就值得你读完。1. 为什么对象比较不能只用1.1 引用比较与值比较的本质区别很多新手在写业务代码时最容易犯的错误就是用去判断两个对象内容是否相等。比如判断两个String或者两个User对象一旦从数据库查两次拿到的对象明明数据一样的结果却是false整个人就懵了。原因在于在Java里比较的是操作数栈上的引用地址也就是“这两个变量是否指向同一个对象”。对于基本数据类型比如int、double比较的是字面值这是没问题但对象类型的变量存的是引用不是对象本身自然比较的是引用地址。这个叫引用比较或者叫身份比较。举个例子User user1 new User(张三, 25); User user2 new User(张三, 25); System.out.println(user1 user2); // false因为两个对象在堆上各自独立user1和user2的内容完全一样但它们的内存地址不同。如果你希望“内容相同就算相等”就需要用equals。什么时候结果为true呢只有两种情况要么两个变量引用同一个对象比如User user3 user1;要么是比较同一个小池子里的对象比如String字面量。这就引出一个关键认知内容比较和引用比较本质是不同维度的“相等”。业务上绝大多数“相等”判断指的是内容相等所以要学会主动选择equals或Objects.equals而不是只凭本能写。1.2 包装类型与String的比较陷阱包装类型的比较是个热门面试题也是新手容易踩进去的深坑。看这段代码Integer a 127; Integer b 127; System.out.println(a b); // true Integer c 128; Integer d 128; System.out.println(c d); // false第一次见到这个输出的人都会觉得“见鬼了”。其实原理并不神秘Integer在类加载时维护了一个[-128, 127]的缓存池当你用字面量赋值时如果值在这个范围内直接返回缓存池里的同一个对象。所以a和b指向同一个缓存对象为true而128超出缓存范围c和d各自new了一个新对象就返回false了。顺着这个坑再来看String。字符串有字符串常量池String s1 hello;和String s2 hello;指向常量池同一块区域所以s1 s2为true。但你只要用new String(hello)就是强制创建新对象必然为false。如果业务代码里比较字符串全用数据一从接口或数据库那边传过来结果可能全乱套。结论很简单也很重要对象类型之间做相等比较一律走equals别用。只有需要判断“是不是同一个对象实例”这种极少数场景比如判断缓存对象是否复用才用。1.3 从一次线上Bug看比较语义选择之前我遇到过一起线上问题用户提交订单时系统要判断提交的商品是否与购物车选中的一致开发写了类似这样的代码if (selectedProduct currentProduct) { // 执行下单 }两个Product对象是从不同服务接口查出来的虽然id一样但它们是堆上两个独立的实例永远为false导致用户永远无法下单。修复方案很简单重写Product的equals方法或者直接比较业务主键id。这个教训让我意识到写代码前先把“比较的语义”想清楚远比牢记API更重要。比较语义大致分三种对象身份比较用对象内容相等用equals对象排序大小用Comparable或Comparator。很多框架源码、类库设计也遵循这个语义划分。Java的核心类比如String、Integer、BigDecimal都实现了equals和Comparable目的就是为了让“相等”和“大小”都能被统一处理。下面对equals的深入分析正是围绕这种语义展开的。2. 深入equals与hashCode的契约2.1 equals方法的重写规范不只是“比字段”我见过不少人重写equals时只是把所有字段都拿来比一遍然后就不再管了。这样写出来的代码十有八九会违反equals的基本规范造成逻辑错误。equals方法并没有被Java强制标注成“你必须要怎么做”但它有一套约定俗成的规则违反之后后患无穷。equals必须满足以下特性特性要求反例自反性x.equals(x)必须为true用时间戳比较自己导致偶尔false对称性x.equals(y)和y.equals(x)结果必须一致继承体系下使用instanceof误判子类传递性x.equals(y)且y.equals(z)则x.equals(z)必须为true子类增加字段后重写equals导致传递链断裂一致性对象未修改时多次调用结果必须稳定依赖可变字段或随机数比较非空性任何对象equals(null)必须为false没有判空导致NPE我重点想说对称性和非空性这俩是日常最容易翻车的点。比如你写了User类重写equals时用instanceof判断于是能和一个User子类的实例返回true反过来该子类重写的equals里如果用getClass()判断就认为对方不是自己这个类重新调用时结果不一致对称性就破了。另一个例子没有判空导致someone.equals(null)抛NullPointerException这已经是我在代码评审中反复提醒过的高频问题。2.2 hashCode与equals的约定散列集合的基石为什么重写equals一定要重写hashCode原因在于Java的散列集合如HashSet、HashMap、HashTable先用hashCode定位桶再用equals精确比较。如果两个对象equals为true但hashCode不同它们会被放进不同桶集合中就会出现“重复”元素导致HashMap查不到值。看这个经典反例public class User { private String name; private int age; // 只重写equals不重写hashCode Override public boolean equals(Object o) { if (this o) return true; if (o null || getClass() ! o.getClass()) return false; User user (User) o; return age user.age Objects.equals(name, user.name); } } // main里执行 User u1 new User(张三, 25); User u2 new User(张三, 25); HashSetUser set new HashSet(); set.add(u1); System.out.println(set.contains(u2)); // falseu1和u2的equals明明返回truecontains(u2)却是false因为u2的hashCode跟u1不一样。这个Bug排查起来非常隐蔽因为逻辑上没有报错只是数据“神秘地消失”了。hashCode契约有三条最少要记住两条第一equals返回true的两个对象hashCode必须相等第二hashCode相等时equals不一定为true这是允许的只是碰撞会造成性能下降。所以hashCode的计算要稳定不能依赖会变化的状态字段否则对象放到HashMap里后字段一改动hashCode变了原来存的位置就找不到了。2.3 高效实现equals与hashCode的实践方案手写equals和hashCode容易出错我在实际项目里更推荐用工具生成或使用Java 7引入的Objects工具类。比如这样Override public boolean equals(Object o) { if (this o) return true; if (o null || getClass() ! o.getClass()) return false; User user (User) o; return age user.age Objects.equals(name, user.name); } Override public int hashCode() { return Objects.hash(name, age); }很多IDEIDEA、Eclipse都有自动生成功能生成的代码通常靠谱。但要注意不要盲目把参与equals的字段和参与hashCode的字段设成完全一样这会提高碰撞概率。合理的做法是equals参与业务主键或全部字段hashCode只对核心字段生成或者对全部字段生成减少碰撞但牺牲一点性能。我一般做法是hashCode里用业务主键字段这样既稳定也不会因为可变字段改变而让对象在集合里“流浪”。还有一个细节equals中用getClass() ! o.getClass()和用instanceof有本质区别。前者要求类型完全一致子类对象和父类对象永远不相等后者允许子类对象参与比较但容易破坏对称性。如果你的类被设计为可继承需要非常小心这个选择。通用建议是写业务实体类时直接用getClass()判断保持类型严格一致省去继承场景下的麻烦。3. Comparable与ComparatorJava对象的大小排序实战3.1 Comparable接口自然排序的实现方式当业务上要求对象可以比较大小比如按年龄排序、按价格排序你可以让类实现Comparable接口重写compareTo方法。这个方法是“自然排序”的核心this小于传入对象时返回负数相等返回0大于返回正数。一个规范的用户类示例public class User implements ComparableUser { private String name; private int age; // 构造方法、getter/setter省略 Override public int compareTo(User other) { if (other null) { return 1; // 自己不为null所以大于null } return Integer.compare(this.age, other.age); } }这里有个值得一提的细节compareTo判断相等和equals判断相等在Java库的语境下是两回事。TreeSet和TreeMap用compareTo判断元素是否重复不调用equals。如果你想让它们行为一致就要保证compareTo返回0时equals也为true否则集合里可能出现“逻辑上相等但拿来比较却不同”的奇怪状态。另一个坑是不要用return this.age - other.age这种减法写法。年龄是int当this.age远大于other.age时减法结果可能溢出变成负数比较逻辑就反了。推荐用Integer.compare(this.age, other.age)安全、可读性也高。3.2 Comparator接口定制排序的灵活方案现实需求往往不是“一种自然排序就能覆盖”的。用户列表有时按年龄排有时按姓名排还有时候要按年龄倒序。如果把这些逻辑全揉进User类的compareTo里类会变得臃肿也违反单一职责。此时就该用Comparator。Comparator是一个函数式接口核心方法是compare(T o1, T o2)返回值的语义和compareTo一样。最简单的使用方式是匿名内部类Collections.sort(userList, new ComparatorUser() { Override public int compare(User u1, User u2) { return u1.getName().compareTo(u2.getName()); } });Java 8之后可以用Lambda代码更简洁userList.sort(Comparator.comparing(User::getName));Comparator.comparing接受一个提取排序键的函数返回一个比较器。倒序只要再加.reversed()userList.sort(Comparator.comparing(User::getAge).reversed());多条件排序也能用链式调用比如先按姓名姓名相同再按年龄userList.sort(Comparator.comparing(User::getName) .thenComparing(User::getAge));这种写法的可读性和维护性比在compareTo里堆条件判断强太多。我在实际项目里实体类通常坚持“自然排序只搞最常用的一种”其他需求全部交给Comparator动态组合。3.3 Java 8函数式写法与null处理的进阶技巧Comparator还有一个很容易被忽略的能力处理null值。比如排序时某个用户的姓名为null直接u1.getName().compareTo(...)就会抛NullPointerException。Comparator.nullsFirst和nullsLast就是为这种情况准备的userList.sort(Comparator.nullsLast(Comparator.comparing(User::getName)));nullsLast表示null排在最尾部nullsFirst则相反。这个方法内部用了一个NullComparator逻辑严谨比自己写if判断省事也不容易写错。再看一个业务场景按价格排序但价格可能null且价格是BigDecimal。BigDecimal自己实现了Comparable但直接比较时还要关注compareTo和equals的差异——BigDecimal的equals会比对精度而compareTo不会。比如new BigDecimal(1.0).equals(new BigDecimal(1.00))是false但compareTo返回0。如果拿BigDecimal做HashMap的键就要特别小心这个精度问题否则可能“内容相等却取不到”。到这一步比较的三大支柱/equals/ComparableComparator已经在代码层面讲完了。真正的高手会在具体需求面前做合理取舍。接下来我结合一个完整的实战案例把这三种方式放到一起演一遍你就能对方案选型有更直观的认识。4. 实战案例用户对象比较方案的完整设计4.1 需求场景设计假设你在做一个电商后台的用户管理系统实体类叫User字段有id用户唯一标识、name用户名、age年龄、score积分可能为null。现在的需求有四个判断两个用户是否为同一个用户规则是id相同即相同用户列表要支持按年龄自然排序页面展示时要先按积分倒序积分相同再按姓名正序查询结果放到HashSet中要能正确去重。这个场景几乎涵盖了Java对象比较的所有核心用法。下面一步步实现。实体类定义完整代码import java.util.Objects; public class User implements ComparableUser { private Long id; private String name; private Integer age; private Integer score; public User(Long id, String name, Integer age, Integer score) { this.id id; this.name name; this.age age; this.score score; } // getter、setter、toString省略 Override public boolean equals(Object o) { if (this o) return true; if (o null || getClass() ! o.getClass()) return false; User user (User) o; return Objects.equals(id, user.id); } Override public int hashCode() { return Objects.hashCode(id); } Override public int compareTo(User other) { if (other null) { return 1; } return Integer.compare(this.age, other.age); } }这里equals判断id是否相同正好符合“同一个用户”的业务规则。hashCode也只基于id生成保证两个equals相等的用户hashCode一定相等。这里我在类的头部实现了ComparableUser让User具备自然排序能力排序键是年龄。4.2 测试核心场景与代码运行结果我把几个场景放到main方法里测试方便你直观对照输出。import java.util.*; public class UserCompareDemo { public static void main(String[] args) { User u1 new User(1L, 张三, 25, 90); User u2 new User(1L, 李四, 25, 80); // id相同视为同一用户 User u3 new User(2L, 王五, 22, 95); User u4 new User(3L, 赵六, 30, 85); // 场景1判断用户是否相同 System.out.println(u1.equals(u2) u1.equals(u2)); // true因为id相同 System.out.println(u1 u2 (u1 u2)); // false因为是不同对象 // 场景2HashSet去重 SetUser userSet new HashSet(); userSet.add(u1); userSet.add(u2); userSet.add(u3); System.out.println(userSet.size() userSet.size()); // 2u2被去重 // 场景3按年龄自然排序 ListUser userList new ArrayList( Arrays.asList(u1, u3, u4) ); Collections.sort(userList); System.out.println(按年龄正序 userList); // 场景4按积分倒序、姓名正序 userList.add(u2); userList.sort(Comparator.comparing(User::getScore, Comparator.nullsLast(Integer::compareTo)) .reversed() .thenComparing(User::getName)); System.out.println(按积分倒序 userList); } }运行结果可以看出u1和u2虽然name不同但根据业务规则“id相同即同一个用户”equals返回true因此HashSet里只有一条记录。场景3里Collections.sort会调用compareTo按年龄排不会有问题。场景4先按积分降序score为null时排到最后积分相同再用name升序这是用Comparator.comparing和.reversed()组合实现的。这段代码还体现了一个重要原则equals可能和compareTo不一致。这里equals看idcompareTo看age那么TreeSet、TreeMap这类基于比较接口的集合判重时会认为“年龄相同即相同”这与HashSet基于equals的判重结果可能不一致。如果你的业务同时用了HashSet和TreeSet态度必须明确这两种集合用了两种比较语义结果本身就可能不同。这也是很多开发者容易忽略的细节。4.3 方案选型总结什么时候用哪种比较我把实战中用到的选择整理成一张表格方便你日后对照需求使用方式理由判断对象是否“内容相等”equals/Objects.equals符合业务语义不受引用地址影响判断是否同一个对象只有身份比较不涉及内容对象需要内置一种排序规则实现Comparable让类具备自然顺序Collections.sort可直接用多种排序规则或动态排序Comparator匿名类/Lambda排序规则独立灵活组合不改动实体类使用HashMap/HashSet必须重写equalshashCode保证契约一致避免查不到或去重失效使用TreeSet/TreeMap同时合理实现compareTo/Comparator排序与判重基于比较器与equals可能不同存在null值的排序Comparator.nullsFirst/nullLast避免NPE且可控制null位置这张表其实也暴露了一个常见的认知误区不少面试者把“比较”等价于“实现Comparable”或者等价于“重写equals”其实它们服务不同需求也存在相互影响。建立“比较语义”这个全局视角后遇到排序、去重、查找、相等判断等问题时就不会再写出一刀切的代码了。5. 常见坑点与排查技巧实录5.1 高频坑点速查表在代码评审中我几乎每次都能说出一两条踩坑经验列成速查表坑点后果正确姿势用比较字符串/包装类型结果随机取决于常量池或缓存出现诡异Bug用equals包装类型避免直接用equals不判空用other.getName().equals(...)参数为null时抛NPE用Objects.equals(a, b)重写equals但不重写hashCodeHashMap/HashSet表现异常同时重写并保证契约一致用getClass()判断类型导致子类不能比较继承场景下对称性被破坏明确类层级必要时用instanceof并同步调整hashCode使用可变字段对象在集合中后字段变化变成“幽灵数据”只用不可变字段或业务主键compareTo用减法返回溢出导致比较逻辑反转用Integer.compare/BigDecimal.compareToTreeSet/TreeMap中compareTo与equals不一致去重逻辑偏离业务预期理解两种集合的判重机制谨慎设计比较器排序时忽略null字段运行期NPEComparator.nullsFirst/nullLast这张表是从真实项目里沉淀出来的不是理论推演。每一行背后都可能对应一次线上告警或者debug到凌晨的经历。5.2 排查定位“诡异Bug”的调试思路当代码出现“明明逻辑没问题就是结果不对”的现象先不要急着怀疑框架或者数据库按这个顺序排查第一步确认比较代码跑的是哪个方法。很多同学打印日志只打印对象本身没打印比较结果导致问题定位困难。建议在怀疑点前后分别输出equals、hashCode、compareTo这些方法的返回值用日志把实际路径亮出来。第二步测试对象的equals和hashCode是否一致。可以写一个小的单测创建两个字段相同的对象A和B断言A.equals(B)为true再断言A.hashCode() B.hashCode()为true。如果第二个断言失败说明hashCode实现不对。这种单测建议直接加进单元测试套件防止后续改动破坏了契约。第三步关注equals中是否出现了集合或数组字段。数组的equals是引用比较不会比较元素内容。如果你在equals里直接比对两个int[]永远返回false。正确做法是使用Arrays.equals。这个点很冷门但一旦碰上比hashCode的Bug更难发现。我印象里有个项目出现过一个问题HashMap里已经存了key按理说containsKey应该返回true实际却是false。最后定位到equals里比较的字段里有一个是数组当时开发图省事直接Arrays.toString之后用字符串比较但不同调用顺序下toString的内容有细微差异导致偶发不相等。后来改成Arrays.equals就稳定了。这个案例充分说明equals里字段比较方式的选择也是严谨的技术活。5.3 三个值得留下的自定义小技巧最后分享几个我在实际项目中经常用的自定义处理方法第一个是统一基础父类的技巧。在基础框架模块里定义一个抽象BaseEntity统一实现equals、hashCode、toString以id作为比较键。所有业务实体继承它既省重复代码又保证比较规则一致public abstract class BaseEntity { protected Long id; Override public boolean equals(Object o) { if (this o) return true; if (o null || getClass() ! o.getClass()) return false; BaseEntity that (BaseEntity) o; return Objects.equals(id, that.id); } Override public int hashCode() { return Objects.hashCode(id); } }第二个是高阶技巧equals和compareTo可以互相“借用”。当你实现了完善的equals后如果有“equals为true时compareTo必须返回0”的约束可以在compareTo里先调用equals判断再比较其他字段。这样能避免排序结果和相等判断撕裂但要注意性能。第三个是Comparator的无副作用边界。Comparator接口不强制要求实现为纯函数但设计上应该保证不修改集合内容不在里面做耗时IO操作。有人图省事在Comparator里访问数据库补充字段结果排序时每比较一次就查一次库性能直接爆炸。正确的做法是先在外部批量补充字段再传入比较器排序。我个人在实际开发里的体会是理解对象比较与其说是一堆语法规则不如说是一套“语义取舍”的思维训练。每次写比较逻辑前我都会先问自己三个问题这次比较是为了什么相等和排序要不要保持一致是否可以安全处理null问完之后代码写起来基本就不会再玄学bug了。希望这篇文章不只是帮你应付面试更能让你在以后写每一行比较代码时心里都有一本明白账。
返回列表