1. 先搞清楚Object类在Java里到底是什么角色
很多人在初学Java时,第一次接触Object类往往是从toString()开始的。为什么呢?因为当你打印一个对象时,控制台会输出一串看不懂的地址,比如com.example.User@1b6d3586,然后老师告诉你:“这是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); } }现在执行这样的代码:
Map<Student, 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)返回false,a.hashCode()和b.hashCode()是否相等是可选的。如果相等,就叫“哈希冲突”,它们会落到同一个桶里,只要桶内用equals()能区分就没问题,只是性能会下降。如果频繁冲突,哈希表的查找就从O(1)退化成O(n)。
规则三:equals使用的字段,hashCode计算时必须使用相同的字段
如果你用name来判断相等,但hashCode()只算id,那就会出现两个对象equals()为true,hashCode()却不同的情况,直接违反规则一。
2.4 如何用IDE和Objects工具类正确重写
大多数情况下我不建议手写这两个方法,现在的IDEA直接Command + N(Windows是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(),结果用Set<User>去重时,重复数据全都没被过滤掉。排查了很久才发现,HashSet底层就是HashMap,每个元素都是key,它先计算元素的hashCode()定位桶,重复元素的哈希值不同,直接被放进了不同的桶里,“去重”自然失效。
这类问题不会让程序报错,只会让结果悄悄出错,属于最难排查的那类Bug。所以你以后写实体类,要么一个都别重写,要重写就两个一起重写,这个习惯比背任何面试题都管用。
3. toString与getClass:日常高频方法里的学问
说完了最“重”的equals和hashCode,接下来看两个轻量级方法,它们平时不起眼,但在日志排查、类型判断中起着非常关键的作用。
3.1 默认toString输出的是什么,为什么要重写
每个Java开发者都很熟悉这个流程:代码里打了一个System.out.println(object),控制台输出了一长串看起来像乱码的内容。
这串内容的格式是:
类全限定名@十六进制的无符号哈希码比如:
com.example.entity.User@6d03e736这里的6d03e736其实是identityHashCode()的十六进制表示,也就是对象默认哈希码(与内存地址相关,但并不是实际内存地址)。这个输出对调试几乎没有帮助,因为你根本看不出对象内部是什么状态。
所以实践中的规范很明确:所有用于业务传输、持久化的实体类,都应该重写toString()。
一个好的toString应该包含类名和关键字段,比如:
@Override public String toString() { return "User{" + "userId=" + userId + ", userName='" + userName + '\'' + ", status=" + status + '}'; }重写之后,打日志时会输出:
User{userId=1001, userName='张三', status=1}一眼就能看出对象内容,排查问题效率翻倍。
3.2 从字符串拼接看toString被隐式调用的情况
还有一个细节很多人没意识到:当对象参与字符串拼接时,toString会被隐式调用。
String message = "当前登录用户是:" + user;这行代码中,编译器会在拼接时调用user.toString()。如果User类没重写toString,你得到的日志就是:
当前登录用户是:com.example.entity.User@6d03e736这有什么用?完全没用。
所以我还是建议:实体类、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 List<String> itemNames; // 引用类型字段 @Override protected Object clone() throws CloneNotSupportedException { return super.clone(); } }执行克隆后,两个Order对象的itemNames指向的是同一个List。你往克隆对象的itemNames里加一个元素,原对象的itemNames也会多一个元素。
这一下就会引出两类问题:
第一类是数据被意外篡改。比如你把一个Order克隆一份用来做订单修改操作,修改关联列表时,原订单同步被改了,等发现问题时数据已经错了。
第二类是多线程并发下的数据共享。如果多个线程各自持有一个“看起来独立”的克隆对象,实际上它们的引用字段共享同一个底层对象,会出现并发修改冲突,而且排查时非常隐蔽,因为从代码逻辑上看每个线程操作的都是自己的对象。
4.3 深拷贝的三种替代方案
那如果你确实需要深拷贝该怎么办?我的建议是按优先级选:
方案一:不使用clone(),自己实现“拷贝构造方法”或“静态工厂方法”。
public class Order { private List<String> 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-resources(AutoCloseable)是唯一正确的方式。
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有什么区别?”
这道题不仅考概念,还考细节:
| 区别维度 | wait | sleep |
|---|---|---|
| 所属类 | Object | Thread |
| 是否释放锁 | 释放对象锁 | 不释放锁 |
| 是否需要持锁 | 必须在synchronized块中 | 无需持有锁 |
| 唤醒方式 | 依赖notify/notifyAll | 时间到自动唤醒 |
| 是否抛异常 | InterruptedException | InterruptedException |
注意,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生态中的位置”——这个转变,才是真正基础扎实的标志。