做Java开发这些年,我面试过不少候选人,也被人问过很多次“Object类有哪些方法”。这个问题看似基础,但它就像一面镜子,能照出一个人对Java语言底层设计到底理解到什么程度。毕竟Object是所有类的父类,Java里一切对象行为的基础都从这里来。它的11个核心方法——equals、hashCode、toString、getClass、clone、finalize、wait、notify、notifyAll,以及wait(long)、wait(long, int)这两个重载——几乎决定了对象的比较、哈希、字符串表示、拷贝、生命周期终结和线程协作方式。这篇文章我会把这11个方法逐个讲透,从源码原理到实战用法,再结合我实际踩过的坑和面试中反复出现的考点,一次性给你说明白。不管你是刚学Java的新手,还是准备跳槽的老兵,这份梳理都值得收藏。
1. Object 类到底是什么:Java 世界的根基
1.1 为什么说“万物皆对象”
先聊聊基础。Java是单根继承的语言,所有类,不管是JDK自带的String、ArrayList,还是你自己写的业务类,最终都直接或间接继承自java.lang.Object。这个设计不是拍脑袋定的,它带来一个巨大的好处:你可以用Object类型引用任何实例,把所有对象当成一个统一的类型来处理。比如写一个通用的日志打印方法,接收Object参数,就能打印任何对象的信息,这就是多态的底层支撑。
也正因如此,Object里定义的方法就成了所有Java对象天生具备的行为。你可以对任何对象调用equals比较是否相等,调用hashCode拿哈希值,调用toString转成字符串。理解Object类,其实就是在理解Java对象模型的基础规则。这些方法虽然看着简单,但每一条背后都有JVM层面的设计和约定,不是随便重写一下就完事的。
1.2 11个方法按照职责来分类
Object类的方法数量,不同资料说法略有差异,常见说法是11个公开的实例方法。如果算上私有静态的registerNatives(),那就是12个,但那个方法只是用来注册本地方法实现的,平时开发根本碰不到。真正需要我们掌握的,是下面这11个:
| 方法签名 | 类型 | 主要用途 |
|---|---|---|
| boolean equals(Object obj) | 实例方法 | 比较两个对象是否相等 |
| int hashCode() | 实例方法 | 返回对象的哈希码 |
| String toString() | 实例方法 | 返回对象的字符串表示 |
| Class<?> getClass() | 实例方法 | 获取对象运行时的Class对象 |
| Object clone() | 受保护实例方法 | 创建对象的拷贝 |
| void finalize() | 受保护实例方法 | 垃圾回收前的清理(已废弃) |
| void wait() | 实例方法 | 让当前线程进入等待状态 |
| void wait(long timeout) | 实例方法 | 带超时时间的等待 |
| void wait(long timeout, int nanos) | 实例方法 | 带毫秒和纳秒精度的等待 |
| void notify() | 实例方法 | 唤醒一个等待线程 |
| void notifyAll() | 实例方法 | 唤醒所有等待线程 |
你会发现,这些方法天然分成两拨:一拨是通用对象方法,负责对象自身的比较、描述、拷贝和生命周期;另一拨是线程协作方法,负责让线程之间配合干活。后一拨是Object类最特别的地方——为什么线程同步相关的方法不放Thread里,而是放在Object里?这个问题我后面会专门讲,先卖个关子。
2. equals 与 hashCode:面试高频区,也是bug重灾区
2.1 equals 默认实现的原理
不重写equals时,Object.equals使用的是引用比较,通俗说就是“看是不是同一个对象”。源码就这么简单:
public boolean equals(Object obj) { return (this == obj); }这行代码的意思是:只有两个引用指向同一块堆内存时,才返回true。对于String、Integer这些类为什么能用equals比较内容?因为它们重写了equals。所以记住一个关键点:默认的equals是引用比较,而绝大多数业务场景下你需要的是“逻辑相等”——两个对象内容一样就算相等,这时候就必须自己重写。
2.2 重写 equals 必须遵守的5条铁律
Java官方文档里给equals定了几条约定,违反了会出现非常诡异的bug,尤其是往HashSet、HashMap里放对象的时候。我用大白话给你翻译一遍:
- 自反性:对象无论如何都要等于自己。a.equals(a)必须为true。
- 对称性:a.equals(b)为true,那么b.equals(a)也必须为true。很多人重写equals时忘了这点,结果前向判断和反向判断结果不一致。
- 传递性:a等于b,b等于c,那么a必须等于c。这条在继承场景下最容易踩坑。
- 一致性:对象没被修改过,多次调用equals结果必须相同。千万别在equals里放一个随机数或者可变状态。
- 非空性:任何对象调用equals(null)必须返回false,而不是抛NullPointerException。
日常开发里最常见的坑是:判断类型用instanceof还是getClass()。如果你用instanceof,那么子类对象和父类对象之间也可能equals成功,这符合对称性吗?不一定。比如父类Person和子类Student,student.equals(person)在instanceof判断下可能返回true,但person.equals(student)返回false,这就破坏了对称性。所以如果你要求两个对象必须类型完全一致才相等,用getClass()更安全;如果允许子类参与比较,则需要小心设计字段比较规则。我习惯是:严格模式下用getClass(),继承设计比较深时干脆不重写equals,或者用组合代替继承。
2.3 hashCode 与 equals 的爱恨纠葛
hashCode的约定最核心的就一条:两个对象equals相等,那么它们的hashCode必须相等。反过来不成立:hashCode相等,equals不一定相等,因为哈希碰撞是允许的。
这个约定的本质原因在于HashMap、HashSet这些哈希容器的查找逻辑:先通过hashCode定位到桶,再在桶里用equals精确匹配。如果你重写了equals却没重写hashCode,会出现一个非常经典的bug:两个内容完全一样的对象,因为hashCode不同被放进HashMap的不同桶里,导致get方法取不到值,contains判断失效。我排查过的最诡异的一个线上问题就是这个:业务方重写了equals但没重写hashCode,导致Set去重完全失效,同一个用户被插入了好几条记录。
实战代码示范一个标准写法:
public class User { private final String name; private final 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); } @Override public int hashCode() { return Objects.hash(name, age); } }这里用了Objects.hash,它内部会调用Arrays.hashCode,把多个字段合成一个整数。合成时用31作为乘法因子是有讲究的,31是奇素数,乘法可以用移位和减法优化,而且能有效降低碰撞概率。很多老手写成31 * name.hashCode() + age,就是这个道理。
3. toString 与 getClass:调试时最不能忽略的两个方法
3.1 toString 默认输出到底是什么
默认的toString长这样:
public String toString() { return getClass().getName() + "@" + Integer.toHexString(hashCode()); }输出大概就是com.example.User@3f0ee7cb这种。这串东西对调试的用处其实不大,你看到的是一堆类名加地址,根本不知道对象内部字段是什么值。所以强烈建议在实体类、DTO、配置类上都重写toString,方便打日志和排查问题。
我自己写toString的经验是:
- 不要用字符串拼接,用String.format或者更现代的写法,可读性更好。
- 不要把敏感信息放进去,比如密码、token,日志会打印出来。
- 字段比较多的时候,可以只打关键业务字段,避免把整个对象打到日志里刷屏。
@Override public String toString() { return String.format("User{name='%s', age=%d, createdTime=%s}", name, age, createdTime); }如果你在用IDE,大部分IDE都有“Generate toString()”的快捷键,生成完按自己习惯微调就行。生产环境排查问题时,一行清晰可读的toString能帮你省下大量猜时间。
3.2 getClass 与反射入口
getClass返回的是这个对象运行时的Class对象,它和User.class这种编译期常量不完全是一回事。虽然大多数情况下obj.getClass() == User.class都是true,但遇到继承时就有区别了:
Object obj = new User("张三", 18); System.out.println(obj.getClass()); // class com.example.User哪怕引用类型是Object,getClass拿到的依然是实际创建的User类。这个特性在写框架、做JSON序列化、ORM映射时是核心入口,因为拿到Class对象你就能通过反射拿到它的字段、方法、注解、父类和接口,几乎什么事都能干。
getClass和instanceof的区别也是面试常客。instanceof判断的是“是不是这个类或它的子类实例”,getClass判断的是“运行时类型是不是严格等于这个类”。举个例子,Dog继承Animal,dog instanceof Animal是true,但dog.getClass() == Animal.class是false。什么时候用哪个?如果你只关心对象能不能当某种类型用,用instanceof;如果你要求精确匹配类型,用getClass。
4. clone 与 finalize:拷贝和清理的冷知识
4.1 clone 的浅拷贝原理与坑
clone方法的本意是创建一个对象的副本,它是一个protected native方法。为什么是protected而不是public?这是JDK的刻意设计:Object并不知道你的对象里有哪些字段,它只能做“逐位复制”,也就是浅拷贝——基本类型字段直接拷贝值,引用类型字段拷贝的是引用地址。这意味着复制出来的对象和原对象会共享同一个引用类型的子对象。
要正确使用clone,必须做两件事:
- 实现Cloneable接口。这个接口是个空标记,它不定义任何方法,只是告诉JVM“这个类允许被clone”。不实现的话调用super.clone()会抛CloneNotSupportedException。
- 重写clone方法,并把返回类型收窄为自己的类型,同时把protected改成public,方便外部调用。
一个典型的浅拷贝写法:
public class User implements Cloneable { private String name; private int age; private Address address; @Override public User clone() throws CloneNotSupportedException { return (User) super.clone(); } }这里address是引用类型,浅拷贝后两个User的address指向同一个Address对象。如果你改了其中一个的address属性,另一个也会变。这在很多业务场景下是致命的。所以需要深拷贝时,我的做法是:
- 手动new一个Address,把字段逐个复制进去;
- 或者用序列化反序列化的方式,实现Serializable接口后,通过对象流复制;
- 更现代的做法是用Jackson/Gson做JSON序列化再反序列化,简单粗暴但性能稍差;
- 如果对象结构很复杂,可以考虑第三方库,比如Apache Commons Lang的SerializationUtils。
实战中我其实很少用clone。它的设计被不少人诟病,接口是空的、方法自带checked exception、浅拷贝误导性强,很多现代Java代码规范里甚至明确禁止在业务代码里使用clone。我更推荐直接用构造器、Builder模式或者工厂方法做拷贝,语义清晰得多。但如果是面试,clone的原理还是得答得上来。
4.2 finalize 为什么被官方标记废弃
finalize是JVM垃圾回收在回收对象之前会调用的一个钩子方法,设计意图是让你在对象被回收前释放一些非Java资源,比如关闭文件句柄、释放本地内存。听起来很贴心,但实际用起来坑非常多:
- 调用时机不确定。GC什么时候执行、对象会不会被回收,都不由你说了算,所以不能依赖它做关键清理。
- 可能造成对象“复活”。如果在finalize里给对象重新赋了一个强引用,GC就会把它标记为不可回收,这等于把垃圾回收流程搅乱了。
- 性能开销大。一个重写了finalize的对象,进入回收队列时需要额外的处理流程,会比普通对象慢很多。
- 它根本不能保证执行。JVM退出时没被回收的对象,finalize可能根本不会被调用。
正因为这些原因,finalize从Java 9开始被标记为废弃(deprecated),Java 18里虽然没有移除,但官方明确建议不要使用。现在释放资源的正确姿势是:实现AutoCloseable接口,配合try-with-resources语句使用;或者用Java 9引入的Cleaner机制做更底层的资源清理。一句话总结:看到finalize,直接绕道走就对了。
5. wait、notify、notifyAll:线程协作的三剑客
5.1 为什么线程等待方法要放在 Object 里
这可能是Object类里最反常识的地方了。线程等待和唤醒,为什么不放到Thread类里?答案是:wait/notify操作的并不是线程本身,而是某一个对象的监视器锁(monitor)。Java里每个对象都内置了一把锁,线程要调用wait,必须先持有该对象的锁,调用之后会释放这把锁并进入等待状态;其他线程拿到同一把锁后调用notify,才能唤醒等待在这个对象上的线程。
如果把这些方法放在Thread类里,就没有办法把“线程和对象锁”绑定在一起了。所以JDK把wait/notify/notifyAll设计成Object的方法,让它们天然和对象锁关联。理解了这个设计,你就能明白为什么这些方法必须在synchronized块里调用——不持有监视器锁就没有资格操作等待队列,直接调用会抛IllegalMonitorStateException。
5.2 wait 和 sleep 到底差在哪
面试常考的一道题:wait和sleep有什么区别?我从四个维度给你拆开:
- 锁的释放:wait会释放持有的对象锁,让其他线程有机会进入同步块;sleep不会释放任何锁,一直占着锁睡。
- 使用位置:wait必须在synchronized块或方法里调用;sleep可以用在任何地方。
- 唤醒方式:wait需要其他线程调用notify/notifyAll才能唤醒,也可以设置超时时间自动醒;sleep到时间自然醒。
- 异常处理:两者都会抛InterruptedException,但语义不同,wait被中断通常意味着条件变了或者外部要求停止等待,sleep被中断意味着你不想让它睡了。
5.3 生产者和消费者实战:一个完整的例子
用一个最简单的阻塞队列把wait/notifyAll串起来:
class SimpleQueue { private final LinkedList<String> items = new LinkedList<>(); private static final int MAX_SIZE = 10; public synchronized void put(String item) throws InterruptedException { while (items.size() >= MAX_SIZE) { wait(); } items.addLast(item); notifyAll(); } public synchronized String take() throws InterruptedException { while (items.isEmpty()) { wait(); } String item = items.removeFirst(); notifyAll(); return item; } }这里有几个细节必须注意:
- 条件判断用while不用if。这是wait最常见的坑。如果用if,线程被唤醒后不会再重新检查条件,如果多个线程同时被唤醒,就可能出现“虚假唤醒”或者条件已经被其他线程改变的情况。官方文档明确推荐在循环里调用wait。
- 唤醒用notifyAll而不是notify。notify只会随机唤醒一个线程,如果唤醒的是同类型的生产者或消费者,可能造成死等。我只在明确知道只有两个线程且角色互斥的场景下才用notify,否则一律notifyAll。
- put和take方法都加了synchronized,确保操作的是同一个监视器锁。wait/notifyAll必须和synchronized配合使用,这一点我在代码里用注释强调了很多次。
5.4 现代实践:能用 Condition 就别手动 wait
Java 5之后有了java.util.concurrent包,里面的Lock和Condition是更高级的替代方案。Condition提供了await、signal、signalAll方法,语义和wait/notify对应,但功能更强:你可以为同一个锁创建多个Condition对象,分别管理不同的等待队列。比如在生产者消费者里,一个Condition管理队列满的等待,另一个管理队列空的等待,这样生产者只需要唤醒消费者,消费者只需要唤醒生产者,效率更高,代码也更清晰。
我的建议是:老代码里看到wait/notify不急着改,能看懂就行;但新写的代码优先用BlockingQueue、Condition这些并发工具。它们把底层的等待唤醒逻辑封装好了,能让你的bug少很多。不过面试题考察wait/notify原理依然很常见,原理必须懂。
6. 高频问题排查与面试真题速查
6.1 我遇到的 Object 相关线上事故复盘
第一个事故是equals和hashCode没一起重写导致的。当时有个业务在做用户信息去重,往HashSet里塞用户对象,结果同一个用户ID的对象因为hashCode不同,被当成了两个不同的人,数据库里插入了重复数据。排查时我先打印了对象的hashCode,发现相同的userId但hashCode不一样,再一看代码,只重写了equals,hashCode还是默认的。修复方式很简单:用userId字段重写hashCode,问题立刻消失。
第二个事故是toString里打印了敏感信息。有一次排查日志,发现用户手机号和身份证号被日志完整打印了出来,就是因为某个DTO重写了toString把所有字段都打了出来。后来我加了脱敏工具类,凡是跟安全相关的字段在toString里一律打星号。
第三件事跟clone有关,不是事故,但很典型。当时有个对象要被用于多线程处理,为了不互相影响,直接调用了clone想复制一份。结果两个线程改同一个List,数据全乱了。原因就是浅拷贝共享了List引用。后来改成构造函数里new一个新的ArrayList,问题解决。这种问题非常隐蔽,遇到对象共享数据异常时,可以先检查是不是浅拷贝导致的。
6.2 面试中常见的 10 个问题
| 面试题 | 关键回答方向 |
|---|---|
| Object类有哪些方法? | 列出11个方法,说明用途分类 |
| equals和==有什么区别? | ==比引用,equals默认比引用,重写后比内容 |
| 为什么重写equals必须重写hashCode? | 哈希容器的存储和查找依赖hashCode定位,相等就必须同哈希 |
| hashCode相同但equals不同可以吗? | 可以,这是哈希碰撞 |
| HashMap为什么用hashCode? | 先用hashCode定位桶,再用equals处理碰撞 |
| clone为什么是protected? | 防止任意对象被外部克隆,需主动实现Cloneable并重写 |
| 浅拷贝和深拷贝的区别? | 浅拷贝共享引用,深拷贝复制引用指向的对象 |
| wait和sleep区别? | 锁、位置、唤醒方式、异常四方面展开 |
| 为什么wait/notify必须在synchronized里? | 等待和唤醒基于对象监视器锁,不持锁操作会抛异常 |
| 为什么用while而不是if判断wait条件? | 防止虚假唤醒和条件变化后继续执行 |
6.3 日常写代码的三个实用习惯
最后分享三个我个人的习惯,都是踩过坑之后总结出来的:
第一,实体对象一律重写toString。哪怕你觉得日志用不上,等出了问题你就知道有一行完整可读的对象信息是多么幸运。给自己约法三章:不打敏感字段,不打超长字段,用格式化的方式输出。
第二,类中如果出现了“判断两个对象字段是否相等”的逻辑,直接使用IDE生成的equals和hashCode,然后检查是否需要调整类型判断方式。手写这两个方法又慢又容易漏字段,IDE生成至少保证约定齐全。
第三,做对象拷贝时先问一句:这个对象被拷贝之后会和其他线程共享可变状态吗?如果有,浅拷贝直接出局。宁可多写几行构造函数、Builder或者工厂方法,也别为了省事用clone。在很多团队的代码规范里,clone已经被列进禁用清单了。
Object类这11个方法看起来零零碎碎,其实是把Java对象模型、哈希容器、线程协作、垃圾回收这四大块知识串成了一条线。我准备了这张对照表给你随时查阅,熟悉到能默写出来,应付面试和日常开发都会从容得多。