☰
Java Object类全解析:equals、hashCode与toString的重写指南
2026/10/9 9:02:14 网站建设 项目流程

1. Object类到底是什么:Java继承体系的根

先抛一个问题:你写过的所有Java类,不管是User、Order还是StringUtil,有没有想过它们共同的“祖先”是谁?没错,就是java.lang.Object。这是Java语言里最特殊的一个类——除了它自己,其他所有类都直接或间接继承自Object。你要是用javap反编译任何一个类的字节码,都能在继承关系里看到它的身影。

这个设计不是随手定的。早在Java 1.0时代,设计者就打算用“单一根类”来统一整个对象模型。有了Object作为根基,所有对象都天然具备一组通用行为,比如比较、字符串表达、哈希计算、线程等待唤醒等等。这些能力被提炼成Object里的一组方法,任何类都能用。换句话说,Object定义了“一个对象最基本的体面”:你能知道自己是什么类、能比较是否相等、能把自己转成字符串、能计算哈希值。

初学者最容易忽略的一点是:Object不仅定义了一组方法,它还定义了这些方法的“默认实现”。这些默认实现往往是最朴素的版本——比如equals默认比较引用地址,hashCode默认基于内存地址生成,toString默认输出“类名@哈希十六进制”。很多线上bug的源头,就是开发者忘了重写这些方法,结果还在用默认行为。

我用一个生活类比来说明Object的地位:它就像是社会里的基本公民身份。不管你是程序员、医生还是外卖员,都得有姓名、身份证号,都能被描述和识别。Object就是Java对象世界的“基本公民条款”,而equals、hashCode、toString这些方法,就是我们每个人都要遵守的基本规则。你可以在此基础上发展个性,但底层规范不能丢。

这篇文章我会把Object里的核心方法全部过一遍:哪些应该重写、怎么重写、重写时有什么坑,再结合面试题和实际业务场景,把“为什么”讲透。适合正在学Java基础的同学,也适合准备面试、或者写代码时对equals和hashCode模棱两可的开发者。

2. 一图看懂Object的完整方法清单

在分析具体方法之前,先看清Object到底提供了哪些“家底”。我用实际代码带你过一遍,你自己也可以用反射打印出来:

import java.lang.reflect.Method; public class ObjectMethods { public static void main(String[] args) { Method[] methods = Object.class.getDeclaredMethods(); for (Method m : methods) { System.out.println(m.getName() + "() -> " + m.getReturnType().getSimpleName()); } } }

运行结果会输出以下方法(Java 8及之后的标准版本):

方法签名返回类型用途概括
getClass()Class<?>返回对象的运行时类
hashCode()int计算对象的哈希码
equals(Object)boolean比较两个对象是否相等
toString()String返回对象的字符串表示
clone()Object创建对象的一个副本(受保护方法)
finalize()void垃圾回收前的清理钩子(已标记废弃)
notify()void唤醒单个等待该监视器的线程
notifyAll()void唤醒所有等待该监视器的线程
wait(long)void让当前线程等待指定毫秒数
wait(long, int)void更精确的等待控制
wait()void让当前线程无限等待,直到被唤醒

除了这些,还有两个registerNatives()之类的内部方法,那是JVM底层做本地方法注册用的,业务代码基本接触不到,面试也一般不要求,这里就不展开了。

看到这个清单,你可能会问:wait、notify这些线程相关方法为什么会出现在Object里,而不是放在Thread类里?这个设计其实是“监视器锁(Monitor)”模型的核心。Java里每个对象都可以成为锁,而锁的等待与唤醒是对象级别的行为,所以放在Object上更合理。简单说:synchronized (obj)锁住的是obj这个对象,那对应的等待/唤醒自然也应该由obj来管理。这一点理解起来有点绕,但确实是Java并发设计的精髓之一。

有意思的是,equals、hashCode、toString这“三件套”在业务代码里出现频率最高,却恰恰是最容易被重写错误、或完全没被重写的。接下来的内容就把这三兄弟先啃透。

3. equals与hashCode:一对必须一起重写的孪生兄弟

3.1 equals的默认实现与重写规则

Object里的equals默认实现就一行逻辑:return this == obj。也就是说,只有两个引用指向同一个对象时,才返回true。这在绝大多数业务场景下显然不够用——两个User对象,id和name都一样,我们就应该认为它们是同一个用户,哪怕它们在内存里是两个不同的实例。

所以重写equals几乎是业务实体类的必修课。重写时要遵守的硬规则是Java语言规范里白纸黑字写死的五个约定:

  1. 自反性:x.equals(x)必须为true。这个不用多说,自己当然等于自己。
  2. 对称性:x.equals(y)为true,那么y.equals(x)也必须是true。这个最容易踩坑,比如子类和父类互相比较时,处理不好就违背对称性。
  3. 传递性:x.equals(y)为true,y.equals(z)为true,那么x.equals(z)也必须为true。
  4. 一致性:在对象没有被修改的前提下,多次调用equals返回的结果必须一致。
  5. 非空性:x.equals(null)必须返回false,不能抛NullPointerException。

我在实际代码评审中见过很多“看起来对、跑起来错”的equals写法。最典型的错误之一是不判断类型就直接强转,比如用instanceof判断时连子类实例也放行,结果子类对象和父类对象比较时出现“父类等于子类、子类不等于父类”这种不对称的荒唐结果。另一个常见错误是拿equals去比较不同类型的数据,比如用字符串"1"和整数1比较,这在Java里本来就是false,没必要在equals里做特殊处理。

这里给一个稳定的重写模板,照着写基本不会出错:

@Override public boolean equals(Object o) { // 1. 引用相同,直接返回true,节省开销 if (this == o) return true; // 2. 类型不符或者为null,返回false if (o == null || getClass() != o.getClass()) return false; // 3. 强转后逐字段比较 User user = (User) o; return Objects.equals(id, user.id) && Objects.equals(name, user.name); }

注意:这里判断类型用的是getClass() != o.getClass(),而不是instanceof。前者要求类型完全一致,能避免前面说的对称性问题;后者包含子类,在继承场景下容易出问题。如果你确定子类不需要参与比较,可以用getClass()精确判断。如果你希望所有子类都共用父类的equals逻辑,那instanceof会合适一点,但要小心对称性,一般不建议新手自行选这种方案。

Objects.equals这个工具方法是Java 7引入的,它会自动做null判断,避免你手写a != null && a.equals(b)这种啰嗦又容易漏判断的代码。强烈建议工具类都用它,而不是直接用a.equals(b)。

3.2 hashCode为什么必须同步重写

先说一句核心结论:重写equals的同时必须重写hashCode。这不是建议,是强制约束。这个约束的来源是Java官方的Object规范中的一条约定:“如果两个对象根据equals比较是相等的,那么调用两者的hashCode必须产生相同的整数结果。”

换句话说,a.equals(b) == true是a.hashCode() == b.hashCode()的充分不必要条件。反过来,hashCode相同不等于equals为true。这个不对称关系让很多新手犯迷糊,我来解释它是怎么影响HashMap的。

HashMap的put和get流程分成两步:先用hashCode定位桶(bucket),再在桶内用equals找精确匹配。如果你只重写了equals、没重写hashCode,就会出现一个灾难场景:两个完全相等的对象,算出来的hashCode不同,被分配到了不同的桶里。你往HashMap里放了一个key,然后用一个等值的对象去get,结果因为哈希值不同,查到了另一个桶,返回null。这是所有Java开发者早晚会踩的经典大坑。

我举个例子,假设你有下面这个类:

public class Order { private String orderNo; public Order(String orderNo) { this.orderNo = orderNo; } @Override public boolean equals(Object o) { if (this == o) return true; if (o == null || getClass() != o.getClass()) return false; Order order = (Order) o; return Objects.equals(orderNo, order.orderNo); } // 没有重写 hashCode }

然后执行这段逻辑:

Map<Order, String> map = new HashMap<>(); map.put(new Order("A1001"), "已支付"); String status = map.get(new Order("A1001")); // null!

明明orderNo一样,equals判断也相等,但get返回的就是null。原因就是两个new Order("A1001")哈希值不同。这个bug隐蔽性很强,因为单看equals逻辑是完全正常的,只有放到HashMap、HashSet这些依赖哈希的容器里才会出事。

正确的hashCode实现要给所有参与equals比较的字段都算上,以保证“相等对象哈希值一定相同”。常见写法:

@Override public int hashCode() { return Objects.hash(orderNo); }

Objects.hash内部会依次对参数调用hashCode,处理成散列码。如果你对性能有要求,可以手写:

@Override public int hashCode() { int result = orderNo != null ? orderNo.hashCode() : 0; result = 31 * result + (name != null ? name.hashCode() : 0); return result; }

这里用31是Java社区多年形成的习惯。31是奇素数,乘法可以用移位减法的位运算优化,而且奇素数能减少哈希碰撞的概率。你没必要深抠为什么一定是31,记住这个约定就好。

3.3 HashSet去重的底层机制

既然聊到哈希,顺便把HashSet的去重机制说透。HashSet内部就是一个HashMap的包装,元素存在key上,value统一放一个哑元对象。当你往HashSet里add一个元素时,流程是:先算hashCode定位桶,如果桶为空说明没重复,直接放入;如果桶不为空,说明可能有重复,再用equals逐个确认。只有hashCode和equals双重校验都通过,这个元素才算“重复”,新的元素会被丢弃。

这个机制也解释了另一个面试常问点:为什么HashSet的容量设置跟预期元素数量有关系,以及为什么重写equals而不重写hashCode会导致HashSet清除不了重复项。你把两个等值的对象放进去,它们哈希值不同,被HashSet当成两个完全不同的元素保留。恭喜你,重复数据就这样悄悄混进来了。

所以在设计实体类时,我的经验是要么三件套全不重写(比如纯POJO,只用默认引用比较),要么equals、hashCode、toString一起认真重写。只重写其中一个都是危险的。Java的record(Java 14+)和Lombok的@Data注解,本质上都是帮你自动生成这三件套,目的就是为了规避手动重写出错。

4. toString与getClass:调试神器与类型命门

4.1 学会用toString做“对象快照”

默认的toString()返回的是类名@哈希十六进制,比如com.example.User@1b6d3586。这种输出在实际排错中几乎没有任何信息量。想象一下线上日志里打出一行User@1b6d3586,你根本不知道这个用户是谁、状态是什么,排查问题全靠猜。

重写toString的价值,在于把对象的关键状态快速展示出来,让它变成一种“对象快照”。我见过不少团队在日志里输出实体类对象,靠的就是toString里塞满了核心字段。一个好的toString示例:

@Override public String toString() { return "User{id=" + id + ", name='" + name + '\'' + ", status=" + status + "}"; }

如果你用Lombok,直接在类上标注@ToString即可,和@Data一起用效果更好。不过要提醒一句:不要在toString里输出敏感字段,比如密码、手机号脱敏前的原文、支付密钥等。很多线上日志泄露事件就是这么来的——自以为方便的toString把敏感信息带进了日志系统。

另外一个经验之谈:在开发阶段,toString越详细越好,能直接看到对象全貌;在线上环境,toString最好做脱敏处理,或者只输出id、状态这类定位问题必须的字段。业务系统里这两个需求可以分开处理,比如加一个toStringBrief()方法,或者借助日志框架的脱敏能力。

4.2 用getClass识别真实类型,而不是用instanceof

getClass()返回的是对象在运行时的实际类型,它的用途发生在两种场景。

第一种场景是通过反射拿到类信息,比如动态创建实例、读取注解、获取方法列表。Spring框架的很多底层机制,就是靠getClass()来识别被代理对象的真实类型。

第二种场景就是我们前面提过的:在equals方法里用getClass()判断类型。为什么这里不用instanceof?回到那五个约定里的“对称性”。假设父类Animal和子类Dog各有自己的equals实现,你有一个Animal对象和一个Dog对象。用getClass()判断,类型不符直接返回false,两个方向的比较结果一致,都是false。用instanceof判断则可能出现:animal.equals(dog)为true(因为dog属于Animal的子类),而dog.equals(animal)为false(因为animal不是Dog类型)。这就不对称了。这个不对称会直接破坏Set集合的语义,引发各种诡异问题。

我在项目里见过一个真实案例:开发人员在一个订单实体里用instanceof判断类型,粗看没问题,直到后来引入了订单子类,equals的对称性被打破,导致两个相同订单号的对象在Set里能同时存在,数据重复插入数据库,排查了好久。所以我的建议是:如果你不确定继承关系,优先用getClass()做精确判断。

5. clone深入解析:浅拷贝与深拷贝的实战选择

5.1 为什么不能直接调用clone

clone()在Object里是一个受保护方法,且默认实现是native的。这意味着你在自己的类里如果不做任何处理,直接调super.clone()会抛出CloneNotSupportedException。因为Object的clone虽然能开辟新的内存空间、复制基本类型字段,但它要求这个类必须实现了Cloneable接口。Cloneable是一个标记接口,没有方法体,作用只是告诉JVM:“这个类的对象允许被克隆”。这里很多人会疑惑:为什么不能直接调Object.clone()?因为它把安全校验和权限控制放在了JVM层面,不能用Java语法强约束。

正确的自定义clone步骤是:

  1. 类实现Cloneable接口。
  2. 重写clone()方法,修饰符改为public(扩大访问权限)。
  3. 在方法里调用super.clone(),并捕获CloneNotSupportedException或直接向上抛。

一个最简单的例子:

public class User implements Cloneable { private String name; @Override public User clone() { try { return (User) super.clone(); } catch (CloneNotSupportedException e) { throw new AssertionError(); } } }

5.2 浅拷贝的陷阱与深拷贝的正确姿势

如果你以为clone就是“内存复制一把梭”,那很快就踩坑了。clone()的默认行为是浅拷贝:基本类型字段被完整复制,但引用类型字段只复制引用,不复制引用指向的对象。也就是说,克隆出来的对象和原对象共享同一个内部对象。你改了克隆对象的内部对象,原对象也跟着变。

举个例子,一个User对象里有一个List<Address>:

public class User implements Cloneable { private String name; private List<Address> addresses = new ArrayList<>(); @Override public User clone() { try { User u = (User) super.clone(); // 需要手动深拷贝 u.addresses = new ArrayList<>(addresses); return u; } catch (CloneNotSupportedException e) { throw new AssertionError(); } } }

new ArrayList<>(addresses)看起来像是深拷贝了,但注意:ArrayList的构造拷贝只是把元素引用复制了一遍,如果Address本身还有可变引用字段,这个拷贝仍然是浅的。真正彻底的深拷贝,要么让Address也实现Cloneable并递归克隆,要么用序列化方式实现。业界还有一种更现代的选择:Java 10+的List.copyOf返回不可变列表,配合不可变对象模型,可以从根上避开拷贝问题。

我的建议是:业务代码里少用clone,用构造器或者工厂方法手动创建新对象更直观可控。clone的语义容易迷惑人——浅拷贝深拷贝搞不清、Cloneable异常处理麻烦、还有重写权限的坑。我写代码时更倾向于提供一个copy()或者of()静态工厂方法,里面对每个字段逐个赋值,看起来啰嗦,但语义清晰,也不容易出错。

6. finalize的没落与清理资源的现代姿势

finalize()曾经被设计成在垃圾回收器回收对象前触发的清理方法,Java 9开始就被标记为废弃(deprecated),Java 18里直接移除了。它的核心问题在于:什么时候被调用、会不会被调用、调用几次,都是由垃圾回收器说了算,完全不可预测,甚至可能永远不被调用。用它来做资源清理,等于把确定性交付给了不确定性,迟早出事。

我见过有老项目在finalize()里关数据库连接,结果连接池连接泄漏、应用假死,最终排查是finalize执行时机滞后导致资源长时间不释放。所以如果你的代码还在用finalize,尽早迁移。

现代Java清理资源的正统姿势是AutoCloseable接口配合try-with-resources语法。任何实现了AutoCloseable的资源类——InputStream、Connection、Channel——都可以用下面的方式保证资源关闭:

try (FileInputStream fis = new FileInputStream("test.txt")) { // 使用流 } // 无论是否抛异常,fis都会被自动关闭

这个语法在Java 7引入,编译后会生成finally块来调用close,既保证了资源释放,又减少了代码噪音。如果还想更进一步,Java 9+的Cleaner机制可以注册清理动作,但它也是“尽力而为”的,不是万灵药。核心原则还是:凡是用了外部资源的对象,都要主动实现close(),不要依赖垃圾回收器帮你善后。

7. wait与notify:Object上的监视器方法

这部分面试考的概率不低,而且常常和synchronized一起问。先明确一点:wait()、notify()、notifyAll()都是只能在持有对象监视器锁的情况下调用,否则会抛IllegalMonitorStateException。这个“持有锁”指的是进入synchronized块或者synchronized方法。所以常规的配合方式是这样:

synchronized (lock) { while (condition) { lock.wait(); // 释放锁并等待 } // 条件满足,继续执行 }

另一个线程在某个时刻执行:

synchronized (lock) { lock.notifyAll(); // 唤醒等待者 }

理解wait的关键在于:它是释放锁的等待,而不是死等。当前线程调用wait()后,会释放掉当前持有的监视器锁,进入WAITING状态,直到其他线程调用notify或notifyAll唤醒它,或者等待超时。这个设计保证了一个线程等待时,其他线程有机会进入临界区。如果wait不释放锁,死锁几乎是必然的。

notify()和notifyAll()的区别是:前者只随机唤醒一个等待线程,后者唤醒所有等待线程。实践中我几乎只推荐notifyAll()。原因很简单:避免唤醒错误线程导致信号丢失。比如有两个线程在等不同的条件,你只调notify(),随机唤醒的那个可能条件还不满足,而满足条件的那个永远没被唤醒,程序就卡死了。notifyAll()会唤醒所有线程,让它们重新检查各自的条件,稳稳当当。

另外,官方推荐在循环里等待条件,而不是if判断条件。原因在于虚假唤醒(spurious wakeup)——即使没有其他线程调notify,线程也可能因为硬件或平台原因被唤醒。用while循环重新检查条件才安全。这也是很多并发bug的隐藏来源。

不过说句实在话,现在写并发代码,优先用java.util.concurrent包里的工具:CountDownLatch、Semaphore、Condition、BlockingQueue等等。这些高级原语是建立在wait/notify之上的封装,语义更清晰、更难出错。手写wait/notify的时候,大多是面试题或者在维护老代码。

8. 面试高频考点速查:Object类的“八股”也可以有逻辑

前面内容已经覆盖了大部分Object考点,这里我整理一个快速自查清单,方便面试前突击,也方便平时写代码时自查。

Q1:为什么Java要设计单根Object类?

因为有了统一的基类型,所有对象才能被当作Object来处理,泛型、反射、容器、日志框架才能在不知道具体类型的情况下操作对象。没有Object,List<Object>、toString()这类通用设计就无从谈起。

Q2:两个对象equals相等,hashCode一定相等吗?反过来呢?

equals相等,hashCode必须相等(约定);hashCode相等,equals不一定相等(哈希碰撞允许存在)。这也是HashMap先比hash再比equals的原因。

Q3:为什么重写equals一定要重写hashCode?

HashMap和HashSet依赖hashCode定位存储位置。只重写equals会导致相等对象哈希值不同,容器无法识别它们是同一个对象,进而在Map里get不到值、在Set里清除不了重复。

Q4:==和equals的区别?

==比较的是引用地址(基本类型比较值);equals默认就是==,但重写后可以比较内容。日常编码中,字符串比较必须用equals,这个坑几乎每个新手都踩过。

Q5:Object中有哪些方法在子类中经常被重写?

按频率排序:toString、equals、hashCode。clone用得少,finalize已经被移除,wait/notify属于并发场景的专家级内容。

Q6:getClass()和instanceof有什么区别?

getClass()是运行时精确类型判断,instanceof会考虑继承关系。equals重写时如果要求严格类型相等,用getClass()。

Q7:为什么wait要放在Object里而不是Thread里?

因为Java的锁本质是对象监视器,wait/notify是建立在“某个对象被锁定”的基础上的。锁属于对象,等待与唤醒操作自然也跟着对象走。

9. 实战复盘:实体类设计中最容易踩的2个坑

说了这么多理论,最后用两个真实案例收尾,你以后写实体类时直接照着避坑。

坑一:在Spring管理下误用equals判断“相等”,导致业务判断失误。

我们当时有个订单查询接口,开发人员在代码里用if (order.equals(cachedOrder))判断订单是否发生变化。结果发现缓存命中率极低,大量订单被误判为“变化了”。排查看代码,发现Order只重写了equals,里面比较了十几个字段,而订单每次从数据库查出来,一些时间戳字段总有细微差别(比如精确到毫秒)。equals永远返回false,等于判断永远成立。后来改成只比较几个核心业务字段——订单号、状态、金额——问题立刻解决。教训是:equals重写时要基于业务的“业务主键”和关键字段,而不是“全字段大而全”。全字段比较会让equals变成一个无意义的“深比较钳子”。

坑二:Lombok的@EqualsAndHashCode默认包含所有字段,导致缓存key错乱。

@Data自动生成的equals和hashCode会把所有非static、非transient字段纳入比较。有个同事用实体类作为Redis缓存key,实体类里加了一个remark备注字段,结果同一个订单加上不同备注后,Redis缓存key就变了,缓存命中率骤降,代码逻辑还看不出问题。这类问题的本质是:实体类参与了hash运算,它的字段变化直接影响了哈希值。解决方案是给缓存key单独设计一个值对象,比如OrderCacheKey,只包含id和状态,或者用@EqualsAndHashCode.Exclude把不参与业务key的字段排除掉。

最后再分享一个经验:如果你在写一个长时间维护的业务系统,实体类的equals、hashCode、toString这三件套,要么用Lombok统一生成,要么用record定义,尽量不要手写。手写最容易出现“改了一个字段,忘了同步另一个方法”的疏漏。Java 14后的record类型天然支持基于组件的equals、hashCode、toString,而且字段是final的,语义严谨,是不可变实体的首选。如果你还在Java 8,Lombok是最省心的方案。

我自己这些年写下来,最大的体会就是:Object类看起来简单,但它定义的每个方法都在不同的角落里影响着你的代码。equals和hashCode决定集合的正确性,toString决定日志的可读性,getClass决定类型判断的精确性,wait和notify决定并发程序的生死。把Object吃透,Java基础才算是真的站稳了。这篇文章可以作为你系统梳理Object类的起点,照着文中的建议重构一下你的实体类,很多潜在bug会提前暴露出来。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询