JDK源码之Object:从对象头到锁协议的Java基石深度解析

发布时间:2026/10/8 3:30:31
JDK源码之Object:从对象头到锁协议的Java基石深度解析 上一次在团队做JDK源码分享我把排在第一位的主题给了Object类。有人嘀咕Object有什么好说的无非toString、equals、hashCode。我反问了一句如果让你从零设计Java你会把wait方法放到哪个类这个问题不难但很少有人认真想过——正因为每一个对象都可以被锁定、被等待、被唤醒这些能力必须放在所有类的基类上所以才有了我们看到的JDK源码之Object。Object.java方法不多放在IDE里一眼能扫完可它背后的对象头、哈希策略、锁模型、垃圾回收协作机制几乎关系着整个Java运行时的基石。这篇文章是我把JDK8中Object.java完整读了三遍再对照HotSpot实现和日常排障经验整理出来的适合刚开始读JDK源码的开发者也适合那些对Object停留在“背过”阶段、想真正“读懂”的老手。文中没有只讲API更多是讲每个方法为什么这样设计以及这些设计在真实项目里的回响。1. 为什么JDK源码之旅从Object开始而不是从ArrayList开始很多人开始读JDK源码时第一选择往往是HashMap、ArrayList这些“有东西可讲”的集合类。结果翻开没两行就陷入红黑树、扩容机制、迭代器快照这些细节里读得晕头转向。我的建议反而不是这样先读JDK源码之Object把根基打牢再去看集合和并发类不然很多问题你根本不知道要去哪里找答案。1.1 打开Object.java的第一印象方法很少契约很多JDK8里的Object.java算上静态代码块和wait的重载版本表面上一共也没几个方法。核心成员就是下面这张表方法修饰符职责getClassfinal native返回运行时类hashCodenative返回对象哈希值equalspublic判断逻辑相等cloneprotected native创建对象副本toStringpublic返回字符串表示notifyfinal native唤醒一个等待线程notifyAllfinal native唤醒全部等待线程waitfinal native释放锁并等待finalizeprotected垃圾回收前的回调看这张表你能直接感受到Object设计上的克制它不是工具类而是所有类的行为协议。Java没有多继承但又希望每个对象都拥有这些基本能力那就只能把它们全部放到公共基类。注意这些方法不是拿来直接用的有一部分是需要你重写的比如equals和toString有一部分是禁止你重写的比如wait、notify和getClass。如果一开始不理解为什么这样设计可以先记住结论后面一步步拆。1.2 一切要从对象头说起为什么这些能力能放在Object里因为每个Java对象在JVM里都带有一段公共的运行时结构。HotSpot中对象在堆内存里除了实例字段数据还包含一个mark word和一个类型指针。mark word里可能存放无锁状态下的identity hashCode、偏向锁的线程ID、轻量级锁的指针、重量级锁的monitor指针以及GC分代年龄。也就是说所有对象的“身份哈希”“锁状态”“GC状态”这些信息都在对象头里统一定义。所以Object.wait要释放锁Object.hashCode要取哈希都必须去操作这个头部结构自然更适合用native方法实现由JVM底层统一完成。这也解释了一个常见疑问为什么wait和notify不是定义在Thread类里而是定义在Object里因为锁的对象就是普通Java对象线程只是锁的持有者真正被等待、被唤醒、被锁定的是对象本身。放在Object里本质上是在说每一个对象天生就是一把可以用作线程协作的锁。1.3 从Object的“虚无”中读出继承设计的边界Object的源码第一行是public class Object没有extends关键字意味着它在继承树的最顶端。但别把这种“虚无”理解成什么都没有——它的静态代码块和native方法定义了类加载阶段的注册逻辑运行层面还有与JVM绑定的整套机制。祖先关系不一定体现在继承关键字里还有一层运行时层面的绑定。设计者的意图很明显Object是所有类的超类但它本身不承载业务只承载契约。我们写业务代码时常犯一个错误就是把公共基类越做越重什么通用方法都往BaseEntity里塞。Object给了个示范基类可以很小覆盖面可以很大关键是每个方法都有明确边界该锁死的锁死该开放的开放。这是读Object源码时第一个值得迁移到自身代码的设计思路。2. native方法的特权与边界hashCode、clone、getClass的真面目Object里标记为native的方法有五个getClass、hashCode、clone、notify、notifyAll和wait(long)。native意味着Java层只留一个方法签名真正的逻辑跑到JVM内部。很多人读到这里就放弃了觉得“反正实现不在Java层没法深究”。但恰恰是这几个native方法藏着Java对象模型最核心的秘密。2.1 hashCode的默认策略比想象中复杂在IDE里点进Object.hashCode看到的只有一行native。JVM为对象计算默认hashCode时会采用一组可配置的散列策略有的版本基于对象地址做运算有的版本用线程相关的随机数有的版本用全局自增序列。关键是两件事。第一默认hashCode不是必然等于对象地址。JDK官方注释只说“尽可能让不同对象产生不同值”没说一定是地址。网上很多资料把hashCode解释成“对象的内存地址”那是某个历史版本里的实现方式不能当公理背。如果你在日志里看到两个对象的hashCode一样也不要太吃惊哈希碰撞在32位int空间里是完全可能发生的。第二这个值一旦算出来就会被缓存到对象头的mark word里。之后哪怕对象发生GC移动、地址变了hashCode也保持不变。这正是hashCode契约要求的——同一个对象的hashCode在生命周期内不能变。如果每次GC移动后地址变、哈希就跟着变HashMap就会出现同一个key前后定位不一致的严重问题。从源码注释里推导出这个结论比背一句“hashCode要稳定”要扎实得多。2.2 clone()被设计成“你最好别直接用”Object.clone()是protected native。为什么是protected因为Object不能替子类决定clone的语义子类需要在覆盖时决定是否对外可见。再看注释里的描述创建并返回此对象的一个副本。“副本”到底是什么对普通Java对象来说就是逐字段复制也就是浅拷贝。如果你的类里有引用类型的成员浅拷贝后两个对象会共享同一个引用这时候修改子对象原对象也会跟着变。举例说明public class Order implements Cloneable { private ListItem items; Override public Order clone() { try { Order copy (Order) super.clone(); copy.items new ArrayList(items); // 手工深拷贝 return copy; } catch (CloneNotSupportedException e) { throw new AssertionError(); } } }这里真正跑的是两条线super.clone()由JVM按字段复制完成然后自己补一刀把集合深拷贝。如果忘了实现Cloneablesuper.clone()会抛CloneNotSupportedException这也是源码注释里明确写出的边界条件。工程上现在很多人直接用拷贝构造函数或copyOf替代clone因为可以显式控制字段深浅也不容易碰clone里的坑。这种替代不是否定clone而是理解了clone的浅拷贝边界后做出的更安全的选择。每当我看到有人直接调用super.clone()却不处理引用字段就知道他大概率踩过浅拷贝的坑。2.3 getClass()返回的不是“感觉上的类”而是运行时类getClass()返回的是类加载器实际加载的运行时Class对象。一个典型场景父类引用指向子类实例时getClass()得到的是子类Class。比如Object obj new ArrayList(); System.out.println(obj.getClass()); // class java.util.ArrayList System.out.println(obj instanceof ArrayList); // true在类加载器不同的情况下两个类的名字可以一样但Class对象不是同一个。这就是为什么涉及类判定时光看名字不可靠最好直接比较class对象或者用泛型约束必要时还要考虑类加载器维度。这层理解对排查NoSuchMethodError、NoClassDefFoundError一类问题很有帮助因为这类错误背后往往就是“同一个类在不同ClassLoader里各加载了一份”的典型纠纷。3. equals与hashCode契约从Object源码到HashMap、Spring的实战equals和hashCode是Object里被重写频率最高的两个方法也是面试必问的组合。但面试题往往停留在“重写equals为什么要重写hashCode”的标准答案上真正在项目里踩过坑的人才会明白这不是约定俗成的规矩而是集合类能正常工作的地基。3.1 默认equals为什么只做引用相等Object里的equals实现简单到一句话return (this obj);这个实现表达的是两个引用指向同一个实例才算相等。Java里每个对象天然有唯一身份默认equals比较的就是身份而不是内容。那为什么String、Integer这些类要重写因为它们在真实业务里代表的是“值”。比如两个字符串内容相同业务上就应该看成同一个。重写equals时常规套路是先比较引用如果this obj直接返回true 再判断类型用getClass或者instanceof挡住不同类型的对象 最后比较关键字段。这里有一个很多人在继承场景容易踩的点。如果父类用instanceof判断子类重写equals时很容易造成不对称。比如父类Person的equals判断obj instanceof Person子类Student重写equals后认为obj instanceof Student才算相等那就会出现person.equals(student)为true、student.equals(person)可能为false的情况直接违反对称性。一个比较严谨的做法是在equals里用getClass() ! obj.getClass()先挡住不同类实例保证对称性代价就是父类和子类之间永远不会相等这个取舍需要业务自己权衡。3.2 重写equals不重写hashCode等于给HashMap埋雷Object.hashCode与Object.equals在源码注释里立了一条硬契约如果两个对象equals返回true那么它们的hashCode必须相同如果两个对象hashCode不同那么equals结果一定是false。哈希相同并不代表一定equals但哈希不同可以直接判定不相等。为什么HashMap这么敏感因为HashMap插入和查找时先用hash定位到桶桶内再用equals逐个比较。假设重写了equals但没重写hashCode就会出现put时用对象Aget时用对象BA和B逻辑相等但hashCode不同于是get直接去了另一个桶永远也找不到。这个问题在缓存、去重、Set集合里同样存在。之前有同事在一个用户权益去重场景吃过这个亏他重写了User的equals只比较userId但没同步重写hashCode结果线上用多个渠道写入同一用户时HashSet里出现了两条重复权益记录。问题查到后来把两个对象的hashCode打印出来一对比才发现正是这个经典陷阱。事后我常常跟团队说一句话凡是重写equals的地方旁边必须紧接着重写hashCode这两者不是一个选择题而是一道捆在一起的必答题。3.3 从Object的注释里可以读到的完整等式规范你直接看Object.java的源码注释会发现JDK官方用大量篇幅定义了equals的五个性质自反性、对称性、传递性、一致性以及对非null对象比较null返回false。这些性质不是面试八股而是任何集合类都能稳定工作的前提。以传递性为例A.equals(B)为true、B.equals(C)为true那么A.equals(C)也必须为true。如果你在equals里比较的是可变字段对象中途被别的线程改了字段值equals结果就可能在一次请求内跳变这违反一致性。Spring的Bean比较、各种框架里的去重逻辑本质都在依赖这套契约。源码读到这里就不再是背方法而是在读一份“对象行为契约说明书”。这也是为什么我建议入门者第一份源码看Object——回望其他框架大量用到equals/hashCode的地方本质上都在遵守这套契约。4. wait/notify/notifyAllObject如何定义并发协作的基础协议如果说equals/hashCode是对象“身份与值”的协议那么wait/notify/notifyAll就是对象“线程协作”的协议。这三个方法算是JDK里最古老的并发原语日常写业务代码可能不常用但理解它们能帮你把synchronized、锁、Condition这些东西串成一条线。4.1 源码注释里的“前提条件”不满足会怎样Object.wait/notify/notifyAll在所有对象上都可用但有一个硬性前提当前线程必须持有该对象的监视器锁也就是处于synchronized同步块或同步方法内。如果直接调用wait会抛IllegalMonitorStateException。为什么会有这个限制因为wait的语义是“让出锁并进入等待集”。假如一开始就不持有锁JVM不知道该释放什么等待集也无从登记。要注意wait释放的是与这个监视器相关的全部锁所有权而不是让出一部分。如果在synchronized方法里调用了wait方法的锁会被完整释放等线程被唤醒重新获得锁后再继续执行余下代码。notify的语义是随机唤醒一个在同一监视器等待集里的线程但不保证公平性notifyAll唤醒全部等待线程让它们去竞争锁。从语义上看notifyAll在复杂场景下比notify更安全因为notify选错线程时被漏掉的线程可能永远等下去。4.2 用while循环而不是if判断条件来自wait源码注释的强烈暗示很多初学多线程的人写过这样的代码synchronized (queue) { if (queue.isEmpty()) { queue.wait(); } // 取元素... }这其实是有问题的。因为wait在返回时只代表线程被唤醒并重新获得了锁并不代表等待条件已经满足。可能同时唤醒了多个线程队列里的元素被第一个线程拿走了第二个线程醒来后queue依然是空的。更极端的情况还有虚假唤醒线程可能在没有被notify的情况下醒来。JVM规范并没有禁止虚假唤醒所以设计者必须防御它。所以标准的等待-通知模式必须长这样synchronized (queue) { while (queue.isEmpty()) { queue.wait(); } T item queue.poll(); }而另一侧的生产者要记得在队列发生变化后调用notifyAll。这个模式本身不是别人凭空想的Object源码注释的规范部分就明确提到线程应该在一个条件谓词上循环等待而不是假设醒来条件就成立。读源码时注意到这种注释往往比记住某个API更有价值。4.3 从Object.wait到Condition源码演进背后的选型逻辑Object.wait/notify有个短板就是所有线程共享一个等待集。比如一个容量为N的阻塞队列我们需要“队列未满”和“队列非空”两类条件但synchronizedwait只能把它们混在一起无法精准地把生产者唤醒到生产者等待集、消费者唤醒到消费者等待集。于是JUC里ReentrantLock的Condition就是为解决这个问题出现的一个锁上可以new出多个Condition每个条件对应各自的等待队列signal()可以选择性地通知某个条件上等待的线程。ArrayBlockingQueue内部就是用notEmpty、notFull两个Condition实现的。我的工程选型经验是简单的互斥加wait/notify就够用代码也直观一旦出现多类线程依赖不同条件通信就果断换ReentrantLock加Condition。这不是说Object的wait机制不行而是说源码的演进已经指出了每种工具的适用边界。5. toString、finalize、registerNatives逐渐被忽略但仍有味道的细节Object里除了那几组明星方法还藏着三个容易被忽略的细节toString的默认实现、finalize的兴衰史、registerNatives的静态初始化。它们不加注意时没什么存在感但读懂之后能避免很多误用。5.1 toString里的符号其实展示的是哈希的十六进制不是内存地址Object.toString()的源码是public String toString() { return getClass().getName() Integer.toHexString(hashCode()); }每次看到有人把日志里com.foo.User5bcab594解释成“对象内存地址”我都想纠正一下那是默认hashCode()的十六进制形式而hashCode由JVM的策略决定不一定代表地址。对象在GC后发生移动地址变了但hashCode只要计算过就被缓存下来toString打印的结果仍然稳定。工程上没人愿意靠默认toString排障它太没有辨识度。我更习惯在所有DTO和领域模型里重写toString至少包含id和核心业务字段。排查线上问题时一行日志里能不能直接看到userId、orderId效率差异非常大。网上很多“打印对象看到一串乱码”的求助本质上就是没有重写toString。5.2 finalize的覆灭以及我们该从中得到什么Object里最后一个方法finalize在JDK8的源码中是一个protected空方法注释明确说它“可能在垃圾回收时被调用”——注意是可能不是必然。它曾经被设计成在对象被GC回收前给一次清理机会但现实中问题很多JVM不保证finalize何时执行、不保证一定执行、不保证进程中全部finalizable对象都被处理。JDK9开始它被标记弃用JDK18已经标记为移除。如果你接手的老系统里还有finalize清理数据库连接或文件句柄我的建议是尽早替换成try-with-resources或Cleaner。就算为了兼容老JDK暂时不能删也要知道它只是兜底绝不能作为资源释放的主链路。读Object源码时看到finalize最有价值的其实是这个教训一个看似优雅的“回调钩子”如果执行时机不可控在工程里就是危险的。5.3 registerNativesObject里一段容易被忽略的静态初始化在Object.java开头还有一个很少被讲的细节private static native void registerNatives(); static { registerNatives(); }它做的就是把当前类里声明的native方法与JVM内部C/C函数做绑定注册。没有这一步native方法在首次使用时才去找绑定会有额外开销和不确定性。JDK的模块化基础类里很多类都有类似的静态初始化块。读源码时看到这种模式可以推演出一个思路凡是需要跟底层运行时打交道的类最好在类加载阶段就完成绑定和校验而不是等到调用时才处理。这对我们写一些涉及本地资源绑定的业务代码也有借鉴意义——连接、密钥、外部句柄这类东西能提前校验就提前校验不要拖到第一笔请求来了才暴雷。6. 读完Object源码之后我沉淀的阅读方法与可迁移经验最后这部分不聊Object本身了聊聊我把它读完之后沉淀下来的一些方法和习惯。这些东西是源码阅读的“副产品”但长期看比记住某个方法签名有用得多。6.1 先把“契约清单”列出来再去看实现这里给准备开始啃JDK源码的朋友一个方法读类之前先把官方文档里关于这个类的契约列成清单。以Object为例我一开始列的清单是equals满足五种性质hashCode两次调用不变clone返回独立浅拷贝wait必须持有锁toString包含类名和哈希finalize不保证执行。清单列完后再读源码你会发现每个方法的native实现只是回答“怎么做”而注释和规范回答的是“什么条件下能做”。这两者合起来才是一个完整的方法契约。后续读HashMap、ThreadLocal、ConcurrentHashMap我都沿用这个思路先契约后实现再联系实际场景。效果比直接一头扎进C代码好得多。6.2 final与protected的搭配是Object留给API设计者的示范Object的方法修饰符组合很有意思getClass、wait、notify、notifyAll全部是final不允许子类覆盖而clone和finalize是protected留给你按需扩展。这说明一个道理对“基础能力型方法”设计者要敢于锁死行为避免子类把它破坏掉对“可扩展型方法”则要明确定义扩展点而不是让所有人随意修改入口。我后来在业务系统里设计基础服务时也学着这么干核心状态流转方法一律final防止子类篡改流程模板方法里预留protected扩展点让业务方在受控位置上做定制。这是读Object源码顺手得到的一笔设计馈赠也是很多框架源码里可以反复看到的影子。6.3 把System.identityHashCode当调试点肉眼排查“同一个对象”问题最后分享一个实际排查技巧。很多并发或缓存问题时核心要判断的是“这个对象到底是不是同一个实例”。光看日志里的地址不可靠可以直接打印两个值System.identityHashCode(obj)和obj.getClass()。前一个无论equals/hashCode怎么被重写都代表默认身份哈希同一个实例一定相同后一个能快速确认运行时类型。之前排查一个连接池从回收队列取到过期连接的问题就是比对两个引用的identityHashCode不一致才发现原来是从不同缓存路径各拿了一个对象实例。JDK源码里Object.hashCode的native语义到了实战中就成了定位对象身份的一把钥匙。如果你也想开始读JDK源码我建议第一页翻的就是Object.java不用选太新的JDK版本JDK8的源码注释已经足够丰富。第一遍只看方法清单和注释第二遍对照HotSpot的实现理解native方法背后的对象头、监视器结构第三遍再去Class、String、HashMap这些类里验证契约怎么被具体实现。三遍下来你对“一个Java对象到底是什么”的理解会和只会背面试题的人完全在两个层次。对我个人来说Object这段源码最大的价值不是某个方法而是它让我明白真正的基础设施往往是那些看起来最简单、最容易被跳过的类。