ARTICLE DETAIL

资讯详情

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

Java Object类深度解析:equals、hashCode与并发机制一次讲透

Java Object类深度解析:equals、hashCode与并发机制一次讲透 1. 先搞清楚Object类在Java里到底是什么角色很多人在初学Java时第一次接触Object类往往是从toString()开始的。为什么呢因为当你打印一个对象时控制台会输出一串看不懂的地址比如com.example.User1b6d3586然后老师告诉你“这是Object类的默认toString方法你可以重写它。”但如果你只在面试前背一背equals和hashCode的关系会很容易漏掉一个关键认知Object类是Java所有类的祖先这个“祖先”的身份决定了它在语言设计层面的特殊地位。你想要真正理解Object类的核心方法第一步不是去背11个方法的名字而是先想清楚一件事为什么Java要设计这样一个“万物之父”1.1 从继承体系最顶端的设计意图说起Java是单继承的语言一个类只能有一个父类。但如果我们不让所有类都默认继承一个公共基类那么List和String之间就没有任何类型上的联系泛型、集合框架、反射、垃圾回收这些基础设施全都玩不转。比如在没有泛型的旧版本Java里ArrayList可以往里面扔任何对象原因是它内部维护的是Object[]。这个数组能装下所有类型正是因为所有类都直接或间接继承了Object类。换一种说法Object类就是整个Java类型系统里最大的公共接口它定义了一组“所有对象都应该具备的基本能力”。这种设计不是Java首创但它对开发者的影响是深远的。每当你写一个普通的POJO类你实际上已经免费继承了11个方法方法名作用分类使用频率toString()对象字符串表示极高equals(Object obj)对象相等性比较极高hashCode()哈希值计算极高getClass()获取运行时类信息高clone()对象拷贝中但坑很多wait()/wait(long)/wait(long,int)线程等待中notify()/notifyAll()线程唤醒中finalize()垃圾回收前清理已废弃几乎没人用你注意观察这些方法大致可以分成三类对象基本行为类toString、equals、hashCode、getClass、clone并发协作类wait、notify、notifyAll生命周期类finalize。有意思的是把线程通信的方法放在Object类里这个设计决定一直被很多人忽略也是面试官喜欢追问的点。我们后面会用专门的章节展开讨论为什么是Object类而不是Thread类来管wait和notify。1.2 先看一幅完整的Object方法地图在深入每个方法之前我建议你先在脑海里存一张地图知道每个方法解决什么问题。我一直觉得Object类的方法不需要死记硬背你可以从“一个Java对象在运行时会被谁使用”这个角度来推演对象要被打印、拼接字符串、打日志就会调用toString()。对象要被放进HashMap、HashSet就会调用hashCode()和equals()。对象要被比较内容是否相等就会调用equals()。对象要被拷贝复制就会调用clone()。对象要被序列化、反射、类型判断就会调用getClass()。对象背后关联了线程锁多线程协作时就会调用wait()和notify()。对象即将被垃圾回收器回收多年前JVM会调用finalize()。你看这张地图本质上就是“对象的一生”创建、使用、比较、拷贝、并发协作、销毁。这篇文章我会沿着这个顺序把每个核心方法背后的原理、使用场景、面试考察点和实际开发中的坑一次讲清楚。有些方法你每天都在用但未必知道它们为什么这样设计有些方法你可能一辈子都用不到但面试官就爱拿它来试探你对JVM和对象生命周期的理解深度。2. equals与hashCode一对捆绑出现的方法拆开就出事先说一个我每次带新人都会问的问题你重写过equals()吗大概三分之一的人会说“写过”当问到“那你同时重写hashCode()了吗”基本都沉默了。这是Java面试最高频的题目之一也是实际开发中引发线上Bug最常见的原因之一。原因很简单equals()和hashCode()在Java集合框架里是一对“契约绑定”的关系只实现其中一个程序就会在某些场景下产生难以发现的逻辑错误。2.1 为什么要重写equals默认的equals不够用吗默认的equals()实现是Object类提供的它的逻辑非常简单粗暴public boolean equals(Object obj) { return (this obj); }在比较引用类型时比较的是内存地址也就是说默认的equals()等价于“两个引用是否指向同一个对象”。但问题来了。业务上我们经常需要基于“内容”来判断两个对象是否相等。举个最典型的例子用户登录功能User user1 new User(admin, 123456); User user2 new User(admin, 123456); // 业务上我们希望user1和user2是同一个用户 // 但默认equals比较的是内存地址结果必然是false这时候如果你不重写equals()那后面的一切逻辑都会跑偏用户在A接口拿到了User对象在B接口又查了一次数据库得到另一个User对象你用user1.equals(user2)去判断是否同一个用户结果返回false权限校验直接失败。所以什么时候应该重写equals当你需要“逻辑相等”而不是“引用相等”的时候。这句话收录在《Effective Java》里也被无数面试官反复引用。2.2 hashCode重写的必要性从HashMap的一次查找说起理解了equals()的重要你也只会说“把这两个方法一起重写是规范”但说不清楚为什么会这样。没问题我们用一个具体的HashMap例子来演示。假设我只重写了equals()没有重写hashCode()public class Student { private String studentNo; private String name; public Student(String studentNo, String name) { this.studentNo studentNo; this.name name; } Override public boolean equals(Object o) { if (this o) return true; if (o null || getClass() ! o.getClass()) return false; Student student (Student) o; return Objects.equals(studentNo, student.studentNo); } }现在执行这样的代码MapStudent, String map new HashMap(); map.put(new Student(1001, 张三), 三年级二班); String className map.get(new Student(1001, 张三)); System.out.println(className); // 输出什么你可能会想两个Student的studentNo都是“1001”通过equals()比较确实相等了那map.get()应该能查到“三年级二班”吧答案是输出null查不到。原因出在hashCode()没重写上。HashMap的工作流程是先用key的hashCode()定位到数组的某个桶bucket如果桶里有多个元素再通过equals()在链表/红黑树里逐个比较。这一步调用的是Object类默认的hashCode()它跟对象的内存地址有关所以两个new Student(...)虽然业务上相等但hashCode()的返回值完全不同直接被分到了不同的桶里equals()方法根本没有机会被调用。这个过程可以用一句话总结HashMap先找“桶”hashCode再在桶里“找对象”equals。桶都找错了equals再正确也没有用。2.3 重写这两个方法时必须遵守的“契约”现在你应该理解了为什么equals()和hashCode()必须成对重写。接下来是重写时绝对不能违反的三个硬性规则它们来自Java官方文档面试时能背出这几条会显得你基础非常扎实规则一equals相等hashCode必须相等如果a.equals(b)返回true那么a.hashCode()必须等于b.hashCode()。这是最核心的一条不满足它HashMap、HashSet全都会乱套。规则二equals不相等hashCode可以相等但应尽量不同如果a.equals(b)返回falsea.hashCode()和b.hashCode()是否相等是可选的。如果相等就叫“哈希冲突”它们会落到同一个桶里只要桶内用equals()能区分就没问题只是性能会下降。如果频繁冲突哈希表的查找就从O(1)退化成O(n)。规则三equals使用的字段hashCode计算时必须使用相同的字段如果你用name来判断相等但hashCode()只算id那就会出现两个对象equals()为truehashCode()却不同的情况直接违反规则一。2.4 如何用IDE和Objects工具类正确重写大多数情况下我不建议手写这两个方法现在的IDEA直接Command NWindows是Alt Insert选择equals() and hashCode()勾选关键字段就能生成。Java 7以后官方工具类Objects也简化了写法Override public boolean equals(Object o) { if (this o) return true; if (o null || getClass() ! o.getClass()) return false; Student student (Student) o; return Objects.equals(studentNo, student.studentNo) Objects.equals(name, student.name); } Override public int hashCode() { return Objects.hash(studentNo, name); }顺便说一个容易忽略的细节getClass() ! o.getClass()和o instanceof Student其实是两种不同的判等策略。getClass() ! o.getClass()严格校验类型子类对象和父类对象直接判为不相等。o instanceof Student允许子类实例参与比较只要类型兼容就继续比较字段。两者没有绝对的对错取决于你的继承设计。但如果类会被继承且子类可能影响相等性绝大多数框架和规范推荐使用getClass()严格判断避免子类对象和父类对象“相等”引发隐蔽问题。2.5 实际开发中的一个高频踩坑场景最后再补充一个实战中非常常见的坑用HashSet去重时你必须同时正确重写equals和hashCode否则“去重”完全失效。我印象最深的一次是处理一批Excel导入的用户数据需求是“按身份证号去重”。同事在User类里只重写了equals()比较身份证号没有重写hashCode()结果用SetUser去重时重复数据全都没被过滤掉。排查了很久才发现HashSet底层就是HashMap每个元素都是key它先计算元素的hashCode()定位桶重复元素的哈希值不同直接被放进了不同的桶里“去重”自然失效。这类问题不会让程序报错只会让结果悄悄出错属于最难排查的那类Bug。所以你以后写实体类要么一个都别重写要重写就两个一起重写这个习惯比背任何面试题都管用。3. toString与getClass日常高频方法里的学问说完了最“重”的equals和hashCode接下来看两个轻量级方法它们平时不起眼但在日志排查、类型判断中起着非常关键的作用。3.1 默认toString输出的是什么为什么要重写每个Java开发者都很熟悉这个流程代码里打了一个System.out.println(object)控制台输出了一长串看起来像乱码的内容。这串内容的格式是类全限定名十六进制的无符号哈希码比如com.example.entity.User6d03e736这里的6d03e736其实是identityHashCode()的十六进制表示也就是对象默认哈希码与内存地址相关但并不是实际内存地址。这个输出对调试几乎没有帮助因为你根本看不出对象内部是什么状态。所以实践中的规范很明确所有用于业务传输、持久化的实体类都应该重写toString()。一个好的toString应该包含类名和关键字段比如Override public String toString() { return User{ userId userId , userName userName \ , status status }; }重写之后打日志时会输出User{userId1001, userName张三, status1}一眼就能看出对象内容排查问题效率翻倍。3.2 从字符串拼接看toString被隐式调用的情况还有一个细节很多人没意识到当对象参与字符串拼接时toString会被隐式调用。String message 当前登录用户是 user;这行代码中编译器会在拼接时调用user.toString()。如果User类没重写toString你得到的日志就是当前登录用户是com.example.entity.User6d03e736这有什么用完全没用。所以我还是建议实体类、DTO、VO这些承载数据的类务必重写toString()。不仅是为了规范更是在关键时候帮你省去大量排查日志的心力。顺带提一个Lombok用户的常见误区。很多人会直接加Data注解它确实会生成toString但默认会输出所有字段。如果对象里有byte[]内容比如文件流日志输出会非常长如果有循环引用toString还可能引发栈溢出。碰到这种情况应该用ToString.Exclude排除无关字段或者直接手写toString。3.3 getClass()的返回值是什么为什么说“动态”才是关键getClass()方法返回的是Class类型的对象它代表当前对象运行时的实际类。这里需要注意“运行时”三个字。看这段代码class Animal {} class Dog extends Animal {} Animal animal new Dog(); Class? clazz animal.getClass(); System.out.println(clazz.getName()); // 输出 com.example.Dog虽然变量的静态类型是Animal但getClass()返回的是Dog因为JVM在运行时知道这个对象实际上是Dog的实例。这正是反射机制的基础你可以在程序运行时动态获取对象的类信息、字段、方法、注解。3.4 getClass()和instanceof一比较就分高下这是一道高频面试题判断对象类型时应该用getClass()还是instanceof它们的核心区别在于判断方式判断依据典型使用场景obj instanceof Dog判断对象是否是Dog类或其子类的实例多态场景、方法入参校验obj.getClass() Dog.class精准判断对象运行时类是否就是Dog本身equals方法、严格类型过滤看一个直观示例class Animal {} class Dog extends Animal {} class Puppy extends Dog {} Animal a new Puppy(); System.out.println(a instanceof Dog); // true因为Puppy是Dog的子类 System.out.println(a.getClass() Dog.class); // false因为运行时类是Puppy实战中的选择规则很简单你需要支持多态比如一个方法要接收Animal及其所有子类用instanceof。你需要精确匹配类型不允许子类“冒充”用getClass()。回到我们前面说的equals()实现重写equals方法时getClass() ! o.getClass()就属于第二种情况目的是保证只有类型完全相同的对象才能进行比较避免子类对象和父类对象相互比较时字段不完整导致误判。4. clone方法与深浅拷贝我能不用就不用clone()可能是Object类里最坑的方法没有之一。它声明为protected不重写你根本没法在类外部调用。就算你重写了还有浅拷贝的坑藏在后面。我在工作中经常跟团队说一句话除非你真的知道自己在做什么否则尽量别用clone()更稳妥的替代方案有很多。4.1 protected修饰符带来的第一个“坑”打开JDK源码看clone()的声明protected native Object clone() throws CloneNotSupportedException;它有两个特征对这一节很重要首先它是protected的意味着外部类无法直接调用别人的clone()其次它是native的底层由JVM实现不是普通的Java代码。所以你要在一个普通类里使用clone()必须重写它并把访问修饰符改为public。重写时还要注意类必须实现Cloneable接口否则调用clone()会抛出CloneNotSupportedException。这就是Cloneable接口最大的特点它是一个标记接口里面没有任何抽象方法纯粹是告诉JVM“这个类允许被克隆”。这种设计在Java中不算常见但也不算罕见后来JDK还推出了Serializable等类似接口。4.2 浅拷贝导致的两个经典问题就算你按规范实现了Cloneable重写了clone()深水区才刚刚开始。默认情况下Object.clone()执行的是浅拷贝shallow copy。什么意思对于基本类型字段它会直接复制一份值但对于引用类型字段它只是把引用复制了一份两个对象仍然指向同一个底层对象。看这个例子public class Order implements Cloneable { private Long id; private ListString itemNames; // 引用类型字段 Override protected Object clone() throws CloneNotSupportedException { return super.clone(); } }执行克隆后两个Order对象的itemNames指向的是同一个List。你往克隆对象的itemNames里加一个元素原对象的itemNames也会多一个元素。这一下就会引出两类问题第一类是数据被意外篡改。比如你把一个Order克隆一份用来做订单修改操作修改关联列表时原订单同步被改了等发现问题时数据已经错了。第二类是多线程并发下的数据共享。如果多个线程各自持有一个“看起来独立”的克隆对象实际上它们的引用字段共享同一个底层对象会出现并发修改冲突而且排查时非常隐蔽因为从代码逻辑上看每个线程操作的都是自己的对象。4.3 深拷贝的三种替代方案那如果你确实需要深拷贝该怎么办我的建议是按优先级选方案一不使用clone()自己实现“拷贝构造方法”或“静态工厂方法”。public class Order { private ListString itemNames; // 拷贝构造方法 public Order(Order source) { this.id source.id; this.itemNames new ArrayList(source.itemNames); } }这种做法最直观代码的可读性最好任何看过代码的人都能立刻明白新对象和原对象的引用字段是独立的。方案二使用序列化实现深拷贝。对象实现Serializable后可以通过字节流序列化再反序列化得到一个深拷贝副本。但注意序列化有性能开销对象里的static和transient字段不会被拷贝使用场景有限。方案三使用JSON序列化。这个在互联网项目里非常常见。用Jackson或Gson把原对象转成JSON字符串再转回目标类也能实现深拷贝。注意被转换的类需要有默认构造方法和对应的getter/setter否则会报错或丢失字段。我的日常工作里80%的场景用拷贝构造方法就足够了10%用JSON序列化不到万不得已不用原生clone()。记住拷贝这件事要的是明确和可控而不是花哨的API。5. finalize与wait/notify从对象生命周期到并发协作还剩下一批方法与JVM和并发紧密相关。这批方法在Java面试中出镜率也很高尤其是wait/notify的设计原理属于“答得上方法论、答不上设计论”的差异化考点。5.1 从被官方标记废弃的finalize谈起finalize()在JDK 9就被标记为废弃deprecated了到JDK 18里已经不推荐使用甚至不能主动调用了。为什么会走到这一步先看它的设计初衷。开发者可以在finalize()中编写资源释放、清理逻辑JVM垃圾回收器在回收对象之前会调用这个方法。听起来很合理对不对但在实际运行中finalize有三个严重问题第一个问题调用时机不确定。垃圾回收的发生时机本身就是不确定的一个对象可能在JVM即将耗尽内存时才被回收也可能长时间存活你永远不知道finalize什么时候会被执行。这对“必须及时释放”的资源来说是不可接受的。第二个问题性能开销巨大。包含finalize方法的对象在回收时会被JVM单独处理需要放入一个队列再由专门的线程逐一执行finalize逻辑这会让垃圾回收的吞吐量大幅下降。第三个问题finalize方法可能“复活”对象。在finalize里把这个对象的引用重新赋值给一个静态变量这个对象就“复活”了不会被回收。这种写法会严重干扰内存管理。所以我的观点很明确别用finalize永远别用。释放文件流、数据库连接、网络连接等资源使用try-with-resourcesAutoCloseable是唯一正确的方式。5.2 wait/notify为什么放在Object而不是Thread中这可能是Object类里最值得深挖的设计问题了。但先说一个很多初学者容易混淆的点Object类里的wait和notify不是用来控制线程状态的而是用于线程之间的协作通信。为了理解这个问题我们需要先理解Java对象头背后的锁概念。每个Java对象都可以成为一把锁——synchronized关键字可以实现这一点。当线程进入一个synchronized代码块时它就是获取了“这个对象的锁”。wait和notify的调用是有前提的必须在持有该对象锁的前提下才能调用否则会抛出IllegalMonitorStateException。synchronized (lockObject) { // 释放lockObject锁并进入等待状态 lockObject.wait(); } synchronized (lockObject) { // 唤醒一个正在等待lockObject锁的线程 lockObject.notify(); }现在回到那个面试题为什么wait/notify要放在Object类里而不是Thread类里我的理解是这样的wait和notify本质上是在“对象的monitor监视器锁”上等待或唤醒而不是在“线程”上操作。谁拥有锁谁就是通信的媒介。Java设计者希望线程之间通过共享的对象来进行通信而不是让某个线程直接控制另一个线程。举个例子。在生产者消费者模型中多个生产者线程和消费者线程共享同一个队列对象生产者发现队列满了就调用queue.wait()让自己停住等待消费者消费。消费者消费完调用queue.notifyAll()唤醒所有等待这个队列的生产者。这里的关键是线程们是通过“共享的queue对象”达成协作的。如果把wait/notify放在Thread类里变成thread.wait()和thread.notify()那语义就成了“线程之间互相控制”这不但会引入复杂的线程间依赖也不符合Java设计中“尽量避免直接操作线程”的理念。不过我这里想多说一句务实的建议Java并发工具类的出现已经让wait/notify近乎“过时”了。现在的实际开发中我强烈建议使用java.util.concurrent包下的高级工具比如BlockingQueue阻塞队列、Semaphore信号量、CountDownLatch计数器这些工具把wait/notify的复杂性封装了起来能够避开大量手动控制锁导致的bug。但是理解wait/notify的原理依然是值得的。因为并发包的底层实现正是基于这些机制而且这依然是面试官判断你对Java并发理解深度的试金石。6. 从一道面试题的变体看Object类知识的考察逻辑聊完了核心方法我们最后从面试角度来收个尾。文章开头列出的热词里有大量关于“Java面试题”“Java八股文”的搜索而Object类几乎是Java面试中第一轮必考题。所以这一章我整理了一套高频题和考察逻辑帮读者检验所学。6.1 一个考题的四种问法背后的考察点完全不同第1个变体“说说Object类中有哪些方法分别有什么作用”这是最入门的一道考察的是知识广度。你至少需要说出11个方法并对每个方法进行分类。背出方法名不稀奇能按“对象基本行为、并发协作、生命周期”三个维度讲出来的候选人说明是真的有结构感。第2个变体“重写equals为什么一定要重写hashCode”这道题考察的是知其所以然。如果你能画出HashMap的查找逻辑先通过hashCode定位桶再通过equals比较桶内元素面试官会立刻觉得你拥有实际经验而不只是背过八股文。第3个变体“有两个对象它们的equals返回true但hashCode不同把这两个对象放入HashMap会发生什么”这算进阶变体了。答案是可以放入但HashMap会认为它们是两个不同的key导致数据冗余和get时的逻辑错误。这道题考的是对HashSet/HashMap底层机制的理解深度而不是简单记住“要同时重写”。第4个变体“wait方法到底能不能被中断wait和sleep有什么区别”这道题不仅考概念还考细节区别维度waitsleep所属类ObjectThread是否释放锁释放对象锁不释放锁是否需要持锁必须在synchronized块中无需持有锁唤醒方式依赖notify/notifyAll时间到自动唤醒是否抛异常InterruptedExceptionInterruptedException注意wait会释放锁sleep不会释放锁。这一点是面试官最爱延伸追问的地方也是理解“wait为什么能让出CPU空等资源给其他线程”的核心。6.2 关于Object类学习的个人建议写了这么多最后给出几点实际建议。如果你是在准备Java面试不要把Object类当成一个孤立的知识点去背。更好的学习姿势是以Object类为圆心向四周辐射——从hashCode辐射到HashMap底层原理从wait/notify辐射到线程生命周期从finalize辐射到垃圾回收算法从getClass辐射到反射机制从clone辐射到深浅拷贝。这样你的知识才是成体系的而不是零散的八股。如果你是在实际开发中想把这些方法用好我核心总结三条经验实体类合理重写equals/hashCode/toString判断类型时根据场景选instanceof或getClass;保护性拷贝优先用构造方法或序列化不要依赖clone方法。最后再分享一个小技巧阅读JDK源码时可以优先关注java.util.Objects这个工具类它里面封装了很多对Object方法的增强实现equals/hashCode/requireNonNull/isNull。这个类能让你的代码更现代、更安全并且可以真正替代日常开发中大量手写的判空和比较逻辑。Object类是Java的基础但把基础讲透彻并不容易。希望在读完这篇文章后你能从“背了11个方法名”升级到“理解了这11个方法在Java生态中的位置”——这个转变才是真正基础扎实的标志。
返回列表