ARTICLE DETAIL

资讯详情

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

Object类深度解析:equals/hashCode、clone与wait/notify的核心机制与实战

Object类深度解析:equals/hashCode、clone与wait/notify的核心机制与实战 做了这么多年Java开发面试过别人也被别人面试过几乎每次聊到基础Object类都是绕不开的一个点。可能有人觉得这不就是所有类的父类嘛有什么好讲的但真正把equals和hashCode的契约关系说清楚、把clone的深浅拷贝讲明白、把wait与notify的底层机制解释利索的人还真不多。这篇文章不搞流水账直接把Object类最核心的方法挨个拆开揉碎从契约规范到实战写法再到高频面试题和排查技巧一次讲透。适合正在学Java基础的同学夯实概念也适合工作了两三年的开发查漏补缺。你会发现很多线上诡异的bug根源就是Object类某两个方法没写对。1. 先说清楚Object类到底是什么1.1 从继承树的顶端说起Java是单继承语言除了Object类自己任何一个类都直接或间接继承自Object。这句话你可能听过但真正意识到它分量的人不多。这意味着你自己写的User类、Order类天然就拥有了Object定义的那一批方法。如果不重写代码里调用的就是Object的默认实现。比如最常见的User user new User(张三, 28); System.out.println(user);输出的是com.example.User1a2b3c4d这种格式。这就是Object.toString()的默认实现。如果你觉得这串东西在日志里毫无辨识度那就应该重写toString。在排障和生产日志定位时一条清晰的toString信息能帮你省下大量时间。Object类作为整个Java对象体系的基石它的每一个方法都不是随便设计的。每一套规则背后都是大量工程实践总结出来的行为约定。理解Object类实际上是在理解Java对对象这件事的底层认知——对象怎么比较、怎么描述自己、怎么复制、怎么协作、怎么销毁。1.2 方法全景与分组Object类在不同JDK版本里方法略有差异但以JDK 8为界核心方法大概可以分成下面几组分组方法作用对象比较equals(Object obj)判断两个对象的逻辑相等哈希计算hashCode()返回对象的哈希值字符串表示toString()返回对象的字符串描述类信息getClass()返回运行时Class对象对象克隆clone()创建对象的副本线程协作wait() / wait(long) / wait(long, int)当前线程进入等待线程协作notify() / notifyAll()唤醒等待线程资源回收finalize()对象销毁前的回调已废弃JVM内部registerNatives()注册本地方法还有一个细节Object类里有一个registerNatives()方法它是在类加载时由JVM调用的用来注册native方法平时开发不会直接碰它。但了解它存在能帮你理解为什么Object类里有些方法标了native却还能被正常调用。把这些方法分组之后后续分析就有脉络了equals和hashCode是面试重灾区也是集合框架正确运行的前提wait和notify是并发编程的底层基础clone是深浅拷贝的经典考题finalize代表了一个已经被证明走不通的旧设计。逐个拆开才能看到全貌。2. equals和hashCode最常被问倒的两个方法2.1 equals的契约到底怎么理解先看默认实现。Object.equals的内部逻辑就是一次引用比较public boolean equals(Object obj) { return (this obj); }也就是说不重写的话相等等于同一个对象。Java面试第一关最爱问的就是这个equals和有什么区别。回答完区别接着就会问那你要判断两个User对象的内容是否相等怎么办答重写equals。重写不是随意比几个字段就完事了官方文档定义了五条规则。我用大白话翻译一遍自反性x.equals(x)必须返回true。这个一般不会违反但如果equals里写了奇怪的逻辑就可能犯错。对称性x.equals(y)为true那么y.equals(x)也必须为true。这句听着简单实际最容易踩坑让子类字段参与比较时经常破坏对称性。传递性x.equals(y)为truey.equals(z)为true那么x.equals(z)也必须为true。这个比对称性更隐蔽。一致性在对象没有改变的前提下多次调用equals结果必须一致。非空判断x.equals(null)必须为false不能抛NullPointerException。以对称性被破坏的经典场景为例子类继承父类并且在equals里加入了子类特有字段的比较而父类的equals方法又没有处理子类的情况。父类对象和子类对象互相调用equals结果可能一个是true一个是false。这种bug在集合里表现极其诡异Set里明明看着相等的对象却去不了重。2.2 hashCode与equals的绑定关系hashCode的契约核心就一句话如果equals判断两个对象相等那么它们的hashCode必须相等。反过来不成立——hashCode相等不代表equals成立因为哈希冲突是允许的。为什么要有这条强制规则因为基于哈希的集合HashMap、HashSet、Hashtable用hashCode定位存储桶再用equals在桶内比较。如果两个equals相等的对象hashCode不同HashMap里就会存两份Set去重也会失效。举例说明public class User { private String name; private int age; public User(String name, int age) { this.name name; this.age age; } 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); } // 注意这里故意不重写hashCode演示错误 }这时候执行SetUser userSet new HashSet(); userSet.add(new User(张三, 28)); System.out.println(userSet.contains(new User(张三, 28))); // false两个对象用equals比较的结果是true但contains依然返回false。为什么因为第二次new出来的对象hashCode不同压根没被放到同一个桶里equals根本没有机会被调用。这就是只重写equals不重写hashCode的经典翻车现场。我在实际项目里见过不少这种代码排查了一个下午才发现是hashCode没重写。遇到这类异常时不要怀疑框架先看实体类的这两个方法是不是都写全了。2.3 重写时最容易踩的三个坑第一个坑就是上面说的只重写equals不重写hashCode。第二个坑是equals里使用了错误类型判断方式。getClass()和instanceof是两种不同的语义getClass()判断的是运行时精确类型子类对象和父类对象用getClass()比较不相等instanceof则考虑继承关系子类对象instanceof父类类型返回true。在equals中使用instanceof子类对象和父类对象就有机会互相相等可能破坏对称性使用getClass()则比较严格子类对象永远不会和父类对象相等。两种策略没有绝对的对错取决于业务需要但在实现自定义业务实体时我更倾向于getClass()严格判断理由很简单避免子类继承引发的意外相等。第三个坑是参与equals比较的字段用了可变对象比如List、Map甚至数组。等对象放进HashSet之后又被外部代码修改了这些字段hashCode就变了。此时再调用contains、按key查找结果都是不可预期的。HashMap的key尤其要注意最佳实践是使用不可变对象作为key。String和Integer为什么最适合当key核心原因就是它们不可变、哈希值稳定。有一个方法论值得记住重写equals时尽量选择业务上稳定的字段参与比较主键、唯一编码优先可变字段可以只做展示用途不参与判断。2.4 高效且规范的实现方式手工写equals和hashCode容易漏字段所以Java 7开始提供了Objects工具类配合IDE自动生成是目前最可靠的路径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); }Objects.hash底层用的是Arrays.hashCode把参与比较的字段组合成数组再计算哈希。这样写的好处是字段和equals保持一致不容易漏。还有一个常用方案是Lombok的EqualsAndHashCode注解它能自动生成方法还支持排除指定字段。但要注意用了Lombok就得理解它生成了什么逻辑否则排查问题时容易一头雾水。我个人的建议是团队里Lombok已经很普及直接用注解省心如果是需要长期维护的老项目IDE生成反而更直观至少看到代码就能明白它在比较什么。3. toString与getClass从调试到反射3.1 默认toString为什么不够用Object.toString()的默认实现是public String toString() { return getClass().getName() Integer.toHexString(hashCode()); }输出类似com.example.User1b6d3586。对开发者来说这个表示只能看到类名和一个无意义的哈希值。在生产日志排查问题的时候打印对象如果只能看到这一串基本等于没打印。所以我的习惯是给所有业务实体重写toString把核心字段拼接出来格式类似User{name张三, age28}。这里有一个需要特别注意的点toString里千万不要把敏感信息打进去比如密码、身份证号、银行卡号。日志系统会长期保留这些内容一旦日志被采集或泄露后果非常严重。用IDE生成的toString一般够用。如果追求字段自动拼接可以使用Apache Commons Lang3的ToStringBuilder它支持反射自动拼接字段还能控制style。但要注意反射有性能损耗日志量大的场景不建议频繁调用。3.2 getClass与instanceof的边界感getClass()返回的是对象在运行时的真实类型它的常见用途有三个类型判断、获取类的元信息包名、方法、字段、注解、配合反射做通用处理。类型判断这里有个经典对比——obj.getClass() Target.class和obj instanceof Target的区别getClass()比较的是运行时精确类型不考虑继承关系。子类对象和父类对象用getClass()比较就是不相等的。instanceof则考虑继承子类对象instanceof父类类型返回true。这个区别在equals里直接决定了对称性能否保持。前面提到过如果用instanceof判断类型子类和父类的equals对称性就可能出问题用getClass()判断子类永远不会和父类对象相等。在实现业务实体时我更推荐getClass()让类型关系保持严格。Java标准库里的做法其实两种都有AbstractList的equals用了getClass()AbstractMap的equals则用了instanceof。这跟它们要支持的具体语义有关。理解区别以后看源码就不会再犯迷糊。3.3 反射场景下的getClass实战getClass()拿到了Class对象之后后续一切反射操作就都展开了。比如写一个通用的对象转Map工具public static MapString, Object objectToMap(Object obj) throws IllegalAccessException { MapString, Object map new HashMap(); Class? clazz obj.getClass(); for (Field field : clazz.getDeclaredFields()) { field.setAccessible(true); map.put(field.getName(), field.get(obj)); } return map; }这里需要注意setAccessible(true)会绕过Java访问控制在安全敏感场景下要谨慎使用但在普通业务系统的通用工具里非常常见。还有一个知识点getClass()返回的Class对象在同一个类加载器下是唯一的每个类只有一份Class实例所以可以用直接比较。面试里如果问两个Class对象能不能用equals答案不是不能用而是用更精确、更快因为Class对象实例是唯一且单例的。4. clone深浅拷贝的经典实战4.1 protected权限与Cloneable标记接口clone()是Object中唯一一个protected的方法它不是public。为什么这么设计因为Java默认不允许外部任意克隆对象只允许子类在自己范围内调用。同时如果一个类想要支持克隆还必须实现Cloneable接口否则调用clone()会抛出CloneNotSupportedException。这里有个很特别的细节Cloneable接口里没有任何方法它是纯粹的标记接口。Java标准库里很多接口都有实际方法但Cloneable存在的唯一意义就是通知JVM这个类的对象可以被克隆。这是一种从C语言时代的传统延续下来的特殊设计。正确的使用方法一般是这样public class User implements Cloneable { private String name; private Address address; Override protected Object clone() throws CloneNotSupportedException { return super.clone(); } }然后在业务代码里调用User copy (User) user.clone();注意返回类型是Object需要强制转换。同时clone()声明抛出CloneNotSupportedException调用方也得处理异常。4.2 浅拷贝和深拷贝的本质区别super.clone()默认执行的是浅拷贝。用一个生活化的类比浅拷贝相当于把租房合同复制了一份复制出来的人还住在同一个房子里深拷贝相当于连房子也重新租了一套两个住户互不干扰。对应到代码上浅拷贝只复制了基本类型字段的值和引用类型字段的引用。copy.address和user.address指向的是同一个Address对象修改copy的address属性user的address也会跟着变。什么时候会踩坑用户订单实体里往往嵌套了List、自定义对象、数组。浅拷贝之后修改副本中的List原对象也跟着变。这种bug特别隐蔽因为基本类型字段都是对的只有嵌套结构出问题。测试用例如果不专门针对嵌套修改做断言根本发现不了。4.3 手写一个安全的深拷贝方法实现深拷贝常见有几种方案重写clone方法逐层克隆、手动new对象并复制字段、使用序列化克隆、使用第三方库。逐层克隆的代码示例Override public Object clone() { User copy; try { copy (User) super.clone(); copy.address (Address) this.address.clone(); copy.orders new ArrayList(); for (Order order : this.orders) { copy.orders.add((Order) order.clone()); } return copy; } catch (CloneNotSupportedException e) { throw new RuntimeException(克隆失败, e); } }每一层对象都实现了Cloneable和clone()才能保证整条链路是深拷贝。这种方案的缺点是样板代码多层级深了很容易漏掉某一个引用字段漏掉一处就回归浅拷贝。序列化克隆是另一种思路public static T T deepCopy(T obj) throws IOException, ClassNotFoundException { ByteArrayOutputStream bos new ByteArrayOutputStream(); ObjectOutputStream oos new ObjectOutputStream(bos); oos.writeObject(obj); ObjectInputStream ois new ObjectInputStream(new ByteArrayInputStream(bos.toByteArray())); return (T) ois.readObject(); }前提是对象及所有嵌套对象都实现Serializable接口。这个方案的优点是代码统一不需要为每个类单独写clone逻辑缺点是性能差要求类可序列化且序列化机制会忽略static字段和transient字段。我在实际项目中的建议是对象结构简单就用逐层克隆结构复杂且要求强一致优先考虑序列化方案或者用Gson、Jackson的序列化反序列化方式能不用clone就不用clone更好的是用构建器模式或工厂方法显式创建新对象语义最清晰也最容易审查。5. wait/notify与finalize线程与生命周期的遗珠5.1 管程模型下的wait和notifywait、notify、notifyAll这三个方法很容易被忽略因为它们和线程、锁绑定在一起平时写业务代码用得不多但面试问并发基础时经常出现。先明确三个方法的使用前提必须持有当前对象的监视器锁synchronized否则抛出IllegalMonitorStateException。也就是说只能在synchronized块或synchronized方法里面调用。wait()的作用是让当前线程进入该对象的等待集wait set并释放锁。这句话有两个关键点释放锁、进入等待。与Thread.sleep()相比sleep不会释放任何锁wait会放弃监视器锁把执行机会让给其他线程。这两个方法经常被混为一谈实际上完全是两码事。notify()是随机唤醒等待集中的一个线程notifyAll()是唤醒所有等待线程。用notify有个风险如果被唤醒的那个线程因为条件不满足又继续等待而其他真正满足条件的线程没有被唤醒就可能造成死等。所以实践上多数建议使用notifyAll让所有线程醒来后自己去检查条件。wait(long timeout)是带超时等待超时后线程会自动醒来避免因为通知丢失导致永久挂起。这是一个很实用的防呆设计。5.2 生产消费场景的代码演示一个经典的基于Object类线程方法的简易阻塞队列public class SimpleQueue { private final LinkedListString list new LinkedList(); private final int capacity; public SimpleQueue(int capacity) { this.capacity capacity; } public synchronized void put(String item) throws InterruptedException { while (list.size() capacity) { wait(); } list.addLast(item); notifyAll(); } public synchronized String take() throws InterruptedException { while (list.isEmpty()) { wait(); } String item list.removeFirst(); notifyAll(); return item; } }这里用while而不是if来判断条件这是wait标准用法里的关键。如果用if判断线程被唤醒之后发现条件已经不满足就会直接往下执行结果不可预期。用while的好处是线程醒来后重新检查条件这也是官方文档推荐的写法。实际场景中如果你用的是JDK 5以上的版本优先使用java.util.concurrent包下的BlockingQueue实现比如ArrayBlockingQueue、LinkedBlockingQueue这些类已经把wait/notify封装得很完善。Object类的wait/notify更多是理解并发原理的基石面试时用它手写队列能证明你对管程模型有真正的理解。5.3 finalize的教训不要依赖它finalize()在JDK 9开始被标记为废弃。原因是它的执行时机不确定、性能有严重问题而且根本无法保证资源被可靠释放。曾经的代码习惯是在finalize里关闭文件流或数据库连接这是很危险的做法GC不一定会调用finalize就算调用也可能很晚系统可能已经把文件句柄或数据库连接耗尽。正确的资源释放姿势是使用try-with-resources和AutoCloseable接口。比如try (FileInputStream fis new FileInputStream(a.txt)) { // 业务逻辑 }这个写法能保证无论是否抛出异常fis都会被关闭。Java 7之后这个语法已经非常成熟完全不依赖finalize。如果你是做代码评审的人看到新业务代码里出现finalize重写应当直接打回要求改用AutoCloseable。6. 面试高频问答与开发避坑清单6.1 面试官连问五连场景面试里关于Object类的题目几乎可以串成一条连环提问线。最典型的是从说一下Object类有哪些方法开始接着追问equals和的区别再延伸到为什么重写equals必须重写hashCode然后过渡到HashMap的put过程最后落到深拷贝浅拷贝怎么实现。我总结的答题思路是先把Object类的核心方法名称列出来然后重点讲equals和hashCode强调契约关系。答题时不用背八股文关键是讲出连贯性让面试官觉得你是真的理解而不是死记硬背。有几个容易混淆的点值得专门拎出来equals比较的是内容比较的是引用地址。基本类型用比较的是值。字符串比较优先用equals不要用。new String(a) a的结果是false很多初学者在这里翻车。常量池里的字符串a a结果又是true因为两个引用指向同一份常量。这也是Java面试的经典陷阱。6.2 HashMap/HashSet中的Object方法实践HashMap的key查找流程先计算key.hashCode()定位到具体桶再在桶内通过equals比较查找。所以key对象的hashCode和equals必须同时正确。这也是前面反复强调只重写equals不重写hashCode会出问题的原因。HashSet内部其实就是HashMap把元素放在key的位置value是一个固定的Object占位。所以HashSet去重依赖的同样是hashCode和equals。实际调试的时候如果发现HashSet明明没去重先打印元素的hashCode看看值是否一致。如果一致再看equals。一般两步就能定位。我在排查线上问题时这条流程用过太多次了。另外如果实体类用了LombokData注解默认会生成equals和hashCode但它是基于所有非static、非transient字段。如果两个对象的业务主键相同但某个备注字段不同Data仍然会判为不相等。这种情况就要用EqualsAndHashCode的exclude属性排除不该参与比较的字段。6.3 我踩过的那些坑与建议按个人经验Object类的坑集中在四个场景一是Set去重失败。排查结果多半是hashCode没有重写或重写时漏了字段。二是HashMap按对象查不到值。原因往往是对象被放进Map后又修改了参与hashCode计算的字段。三是clone只做了浅拷贝导致改副本数据影响原对象。这个在导出报表、批量处理场景里特别容易出问题。四是toString没重写日志里全是类名哈希值排查问题只能靠人肉比对内存地址。这个虽然不影响功能但严重拖慢排障效率。我的建议是实体类统一使用IDE生成或Lombok生成equals/hashCode/toString涉及嵌套结构时显式写好深拷贝或者干脆不克隆改用新对象构造日志里输出对象前确认toString是重写过的涉及线程等待时不管用什么方式写条件判断用while不用if。个人做Java这么多年每次回头补基础的时候都能发现新的盲点。Object类看着就十几个方法但它们串联起的知识面覆盖了对象模型、集合框架、并发编程和JVM运行时行为。这篇文章里写的每一条都是我在实际写代码、改bug、面别人和被面试时反复验证过的。如果你正处在学Java的阶段建议把equals和hashCode的契约关系背熟如果你已经工作几年回去翻翻自己项目里的实体类看看有没有只重写equals不重写hashCode的隐患。基础这东西永远值得多花时间。
返回列表