Java Object与String底层原理:equals、hashCode与不可变性全解析 不用急着翻到 Object 源码里一排方法看得头晕这一篇我们换个思路直接从“为什么需要这两个类”入手把 Java 世界里最底层的两个类型先彻底吃透。无论是刚入门的初学者还是写了两三年业务代码想回头看原理的老手Object 和 String 都是绕不开的两块基石。Object 是所有类的根String 是所有字符串字面量背后真正的实现。搞懂这两块后续理解集合、反射、还有各种框架的自动装配机制都会轻松很多。1. Object 类Java 世界里的“万物起源”1.1 为什么所有类都“天然”拥有 11 个方法Java 的设计哲学里有一个非常核心的原则万物皆对象而对象必须有最基本的通用行为。Object 类作为 java.lang 包下的根类是所有类的直接或间接父类。换句话说任何一个类不管你是写在包里的业务实体类、还是框架里的工具类往上追溯最终都会看到 Object。这个设计解决了什么问题最直接的是让整个类型系统有了一个统一的“锚点”。比如我们用ListObject就能存放任意类型的元素因为 Object 是所有类型的公共上界。再比如所有的类都能调用toString()打印自身、都能通过equals()来比较内容正是因为所有人继承了 Object 提供的能力。这一套遗产设计从 Java 1.0 就一直延续至今从来没有变过。Object 类自身的方法一共 11 个抛开getClass()、wait()、notify()这类有特定场景的方法真正在业务代码里高频被重写和使用的只有三个equals()、hashCode()、toString()。剩下两个我们会在后面几个章节里反复碰到的老朋友是clone()它涉及浅拷贝与深拷贝的经典讨论面试常客。还有一个finalize()这方法在 Java 9 之后被标记为废弃到 Java 18 已经被移除了说明 JVM 团队自己也承认依赖 finalize 做资源回收是个坏味道。1.2 equals 和 hashCode 到底在表达什么先说两者分别的职责。equals()方法用来判断两个对象在“逻辑上”是否相等。默认实现是引用比较this obj。也就是说如果你不去重写它那么两个对象即使内容一模一样只要不是同一个内存地址上的东西equals 结果就是 false。但业务上我们经常需要的却是内容比较比如判断两个 User 对象是不是同一个人通常看身份证号相等就认为相等而不是看它们在内存里是不是同一个实例。hashCode()返回的是一个 int 类型的散列值它最重要的用途出现在基于哈希的容器中比如 HashMap、HashSet。当你要把一个对象放进去哈希容器会先调用hashCode()定位到桶位然后再用equals()在桶内做精确比较。这里就存在一个铁律** equals 相等的两个对象必须具有相同的 hashCode。**为什么如果不遵守就会出现一个非常诡异的现象你在 HashMap 里 put 了一个对象再用另一个 equals 相等的对象去 get结果拿不到。因为它们在内存上完全是两份独立的实例而 hashCode 又不一致HashMap 在寻址阶段就分流到了不同的桶位根本轮不到 equals 出场。这个规则反过来并不成立hashCode 相同equals 不一定相等。因为哈希碰撞本身是数学上允许的不同对象算出来的散列值恰好相同完全合法。我只是在代码规范和逻辑一致性上要求 equals 相等时 hashCode 必同。实操中的推荐做法是只要业务上需要比较两个对象的内容就必须同时重写equals()和hashCode()绝不要只写其中一个。IDE 像 IDEA 直接 AltInsert 可以一键生成模板它会基于你选择的业务字段来生成两者的联动实现省心安全。1.3 toString 的默认输出为什么那么难看Object.toString()的默认实现是类全限定名 无符号十六进制哈希码。比如com.example.User1a2b3c4d。这种输出在日志定位对象是哪个类的实例时有用但想看对象内部字段的值就得靠 debug。平时打日志最烦的就是打印出来一串这样的东西既看不到业务数据也没法快速判断当前对象处于什么状态。重写 toString 是成本最低收益最高的操作。实现方式多种多样可以手写拼接也可以用 Lombok 的ToString注解自动生成或者使用StringBuilder组装。有一点经验是toString 里不要塞太深层的嵌套对象内容否则打日志时容易递归触发一系列懒加载、或者无意间打印出敏感字段。日志里带上 id、name、状态这些核心字段就够了。提示在日志中打印大对象前一定要确认里面没有密码、token 这类敏感信息。很多数据泄露事件其实就是在日志里偶然打出来的。2. String 的不可变性设计为什么字符串被做成“榨汁机”2.1 不可变到底是什么意思String 在 Java 中是不可变类。所谓不可变是指一个 String 对象一旦在堆内存中被创建它的字符序列就永远不能改变。所有看起来像“修改字符串”的操作比如concat、replace、substring底层都不是在原对象上做改动而是重新创建一个新的 String 对象并返回引用。原来的字符串对象纹丝不动地留在内存里等待被 GC 回收。这个设计听起来好像很浪费内存但却是 Java 语言安全性和稳定性的根基。为什么这么说举个简单的例子字符串经常被用作 HashMap 的 key如果 String 可变那么一个 key 存入 map 之后如果内容变了它的 hashCode 就变了那在 HashMap 散列时定位到的桶位就和当初 put 时完全不同这相当于直接摧毁了哈希表的数据结构一致性。而正因为 String 不可变它的 hashCode 才可以在第一次计算后直接缓存起来后续每次调用都返回同一个值提升了哈希容器的查询效率。再往安全层面看网络编程中大量用到字符串表示地址、类名、文件路径如果这些字符串能被人为改动攻击者就能轻易地操纵程序行为。不可变性保证了内容一经创建就无法被外部篡改等于一份锁死的合同谁也别想在上面做手脚。2.2 字符串常量池Java 对同一字符串的复用机制为了在不可变的基础上减少重复内存占用JVM 专门设计了字符串常量池String Constant Pool。当你写下String a hello这样一段代码时JVM 并不是直接在堆上创建对象而是先去常量池里查找是否已经存在字面量为“hello”的字符串对象。存在就直接返回引用不存在就在常量池中创建后再返回引用。这就是为什么String a hello; String b hello; a b的结果是 true。两者指向的是池中的同一个实例。但是用new String(hello)就完全是另一条路径它强制在堆上创建了一个新的、独立的字符串对象。所以new String(hello) hello是 false。这里面的门道在面试中已经被问烂了本质就是在考常量池与堆对象之间的区别。日常开发中我们一般直接使用字面量基本上没有任何理由主动写new String(...)除非是char[]转String这种场景。JDK 7 之前常量池放在永久代里有固定大小上限字符串多了容易 OOM。JDK 7 之后已经移到了堆中可以享受堆空间的动态扩容。JDK 9 开始 String 底层存储从char[]换成了byte[]同时引入了COMPACT_STRINGS的机制遇到纯 Latin-1 字符的字符串直接用单字节存储可以节省一半的内存。这是 Java 团队在内存优化上非常成功的一个案例。2.3 拼接、替换、截取的性能陷阱字符串因为不可变导致每次修改都会产生新对象。如果在一个循环里做大量拼接操作就会同时诞生大量无用的中间 String 对象给 GC 带来很大压力。老生常谈的问题在循环里用拼接性能有多差JDK 8 之后拼接在编译期实际上会被优化成StringBuilder.append()调用只做一次拼接时性能尚可。但如果是在一个循环体内每次循环相当于重新 new 一个 StringBuilder效率依然不理想。更稳的写法是直接在循环外定义好 StringBuilder 对象循环内只调用 append避免反复创建中间对象。替换和截取的操作也有讲究。String.substring()在 JDK 6 及之前有共享底层char[]的坑截取出来的子串仍持有原始大字符串的引用导致无法回收大对象内存。JDK 7 之后已经改为复制的方式虽然没有内存泄漏风险了但每次截取就是一次 O(n) 的拷贝。遇到不同版本的项目需要知道这个历史演变过程排查线上内存问题时才不会被旧知识误导。注意字符串拼接性能优化的核心只有一句话循环外建好 Buffers循环内只 append循环结束再 toString。3. 实操演示手写一个遵循规范的对象3.1 从零构建覆盖 equals、hashCode、toString 的实体类光说不练假把式我们直接写一个学生实体类模拟最常见的业务场景。假设它的核心字段是学号和姓名学号唯一标识一个学生。public class Student { private String studentId; private String name; private int age; public Student(String studentId, String name, int age) { this.studentId studentId; 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; Student student (Student) o; return Objects.equals(studentId, student.studentId); } Override public int hashCode() { return Objects.hash(studentId); } Override public String toString() { return Student{studentId studentId , name name , age age }; } // 省略 getter / setter }这里我们只把 studentId 作为业务主键参与equals和hashCode的计算。为什么不把 name 和 age 放进去因为业务上判定一个学生是不是同一个“人”只需要看学号即可。如果 name 也能标识唯一性那就可以加入 name但 age 几乎永远不会作为身份标识字段加入它还会导致某个对象的年龄一变化它在 HashSet 里的所在桶位就失效了这是要避免的。Objects.equals()和Objects.hash()是 Java 7 引入的工具方法内部妥善处理了 null 值判断能帮你省掉一堆判空代码。每个字段都用Objects.equals包装比手写字段的空指针安全性要高很多。注意equals方法里的getClass() ! o.getClass()判断这是防止子类对象混进来进行跨类比较的一种方式。或者你可以用instanceof判断不过那样会允许子类参与比较在很多需要严格类型约束的场景里不够严谨。3.2 验证 String 的不可变性与常量池机制我们跑一段代码来验证前面讲到的知识点帮助直觉上建立认知。String s1 java; String s2 java; String s3 new String(java); String s4 s1.concat(); System.out.println(s1 s2); // true 两个字面量引用指向常量池同一个对象 System.out.println(s1 s3); // false 堆上新对象和常量池对象不是同一个 System.out.println(s1.equals(s3)); // true 内容一致 System.out.println(s1 s4); // false concat 返回的是一个新对象s1.concat()看起来没有改变任何内容但 concat 的源码实现里最终会返回new String(...)。也就是说即使是去掉空字符串这样的无意义操作也会产生一个新对象。这就是不可变类的典型体现没有“修改”这个操作只有“生成新副本”这个操作。有人会问s1.intern()是不是可以把堆上的对象“变回”常量池对象确实如此。intern()方法会检查常量池是否已有相同内容的字符串有就返回池中的引用没有就把当前字符串内容加入常量池再返回引用。这个方法的真实价值在于当外部系统传入大量重复的字符串内容时可以通过 intern 让它们都复用同一份池内对象。但使用时要极为谨慎如果内容是动态拼接且基数极大常量池会膨胀到难以控制。我见过有人把用户输入的日志内容全部 intern结果直接撑爆了堆内存半天排查不出来。3.3 字符串内容不可变但引用可变很多人会把“String 不可变”理解成“String 类型的变量不能重新赋值”这是完全错误的。不可变针对的是对象内部状态不是引用本身。String s a; s b;这里 s 后来指向了另一个字符串对象原来的“a”对象在原地不动只是它的引用计数减少了。这个“引用”和“对象”之间的区别一定要拎清楚否则后面理解 StringBuffer、StringBuilder乃至 Java 传值和传引用问题都会糊涂。代码示例可以这样解释String message hello; String backup message; message world; System.out.println(message); // world System.out.println(backup); // hellobackup 仍然指向原来的“hello”对象说明 message 的重新赋值并没有修改原对象内容而是断开了对原对象的引用。这正是不可变类的典型特征任何看似改变内容的操作本质上都是引用重新指向了一个新的对象。理解了这一点再来理解为什么多线程环境下 String 天然是线程安全的就很容易了既然内容永远不会变那么多个线程同时读也不会有任何数据竞争。4. 面试高频焦点equals 与 、Object 与 String 的深层关系4.1 比较的是栈上的“线索”equals 比较的是堆里的“内容”Java 中的运算符用在引用类型上比较的是两个引用是否指向同一个地址也就是栈上保存的引用值是否相等。对于基本数据类型直接比较数值但在引用世界中它并不负责比较内容。equals()则是方法调用默认继承自上文提过的 Object 实现也是引用比较。要想让它变成内容比较就必须在目标类中显式重写。String 类在 JDK 源码中重写了equals()逻辑是先比较引用是否同一再判断对象类型然后逐个比较字符数组中的字符内容。这个差异在实际开发里最经典的翻车场景就是从数据库里查出来的字符串和前端传上来的字符串用去判断相等。你可能觉得内容明明一样结果却是 false就是因为二者不是同一个对象引用。正确做法永远是用equals()或者更现代一点的Objects.equals(a, b)这个方法会在 a 为 null 时不抛异常直接返回 false。还有一个小坑比较两个常量字符串时比如a a由于常量池复用结果是 true很多初学者看到这一点就会误以为能用来比较字符串。这种“碰巧为 true”的情况恰恰是最容易误导人的面试里也常拿来当陷阱。统一的判断准则是见引用必 equals除非你明确知道自己在做引用比较。4.2 Lambda 和 Stream 里如何安全地比较字符串现在很多人写代码都离不开 Stream一个常见的错误是在 filter 里直接用了。比如list.stream() .filter(item - item.getName() targetName) .collect(Collectors.toList());这段代码里只要 targetName 不是从常量池来的引用结果就完全不可控。正确的写法是filter(item - targetName.equals(item.getName()))但是这时候又要注意 null 陷阱如果 targetName 可能为 null最好调成Objects.equals(item.getName(), targetName)这一句就同时兼顾了双方为 null 的情况。在 Lambda 里写一行简单的比较背后涉及的规范意识其实就是之前讲的那些细节。把这些细节变成肌肉记忆写出来的代码会少掉很多隐雷。4.3 String、StringBuilder、StringBuffer 的选择题三者是面试里铁打的组合拳。String 不可变每次修改产生新对象适合少量、确定、无变化的字符串表示。StringBuilder 可变线程不安全底层是byte[]数组append 方法直接修改内部数组内容适合单线程下大量拼接的场景也是唯一推荐在业务中使用的可变字符串类。StringBuffer 在 StringBuilder 的基础上给所有公开方法加了 synchronized 关键字线程安全但锁竞争会带来额外开销日常业务代码中几乎不需要它。我个人对 StringBuffer 的定位是知道它的存在能解释清楚它在历史上的价值但实际项目里不要在单线程环境下用它。理由很简单加了锁的代价远大于它带来的安全感而且真的需要跨线程共享同一个可变字符串对象时更好的做法是使用不可变的 String 配合线程安全容器而不是把一个需要频繁写操作的对象抛给多个线程共享。忘掉 StringBuffer 是很多 Java 开发者做出高质量代码的第一步。拆解一下三者在等到大量拼接时的性能表现String 在循环里做 10 万次加号拼接即使有编译器优化仍然会产生大量 StringBuilder 临时对象耗时可能是 StringBuilder 直接复用同一个对象的几十倍。实测数据在 JDK 17 上差异非常明显这个结论从 JDK 5 至今一直没有改变。写底层库、网关、SQL 拼接、报文生成这些场景StringBuilder 是永远的首选。5. 高级场景当 Object 和 String 遇上 JSON 与反射5.1 toString 在日志中暴露敏感字段把对象直接传给日志框架打印已经是常规操作比如log.info(user: {}, user)。如果你依赖的是 Lombok 的Data或手写了包含所有字段的 toString那么密码、身份证、手机号这类字段都可能被直接打进日志文件。日志文件一旦保存时间过长或者被接入日志采集系统就成了一个巨大的数据泄露仓库。实践里的处理方式有两种第一种是我们项目中使用最多的给敏感字段加上ToString.Exclude注解让 Lombok 生成 toString 时跳过第二种是手写 toString只输出 id、业务状态等必要字段。记住一个原则能少打就少打不是信息越多越好。5.2 反射机制下 String 内容的“破解术”虽然 String 被设计为不可变但在反射面前这种不可变性可以被暴力绕过。在 JDK 8 之前通过反射获取 String 内部的char[]字段然后直接修改数组中的元素就能篡改字符串内容。这在安全渗透测试中是一个经典思路。JDK 9 之后底层换成了byte[]原理一样只是数组类型变了。不过这种操作在现代 JDK 上能否成功取决于你是否能绕过模块化系统对你的访问限制通常需要先对目标模块执行--add-opens。从工程角度这种行为属于典型的暴力破解不该出现在生产代码里。但了解一下这个思路能帮助你更深刻地理解不可变类的边界。不可变是设计层面的契约不是物理层面的牢笼。理解到这个程度才算是真正读懂了 Java 的安全模型。5.3 JSON 序列化时字符串的编码细节实体类转 JSON 时字符串字段的类型、特殊字符转义等问题会频繁暴露出来。最常见的坑是国际化字符乱码。HTTP 接口传输时如果服务端没有设置正确的字符集前端拿到 JSON 里出现???或乱码。根因通常是Content-Type头里没有带charsetUTF-8或者读请求体时用了平台默认字符集。写接口时显式指定字符编码这个不经意的习惯能帮你省掉很多联调时间。字符串在 JSON 里本身就是一个带引号的 UTF-8 序列序列化框架会处理内部的转义。如果某个字段内容是用户输入的原样文本要防止 JSON 注入问题需要在服务端正确转义。现在主流 JSON 库像 Jackson、Gson 默认设置下都会做转义处理但如果你自定义了序列化器或拼接 JSON 字符串就要特别小心。不要手动拼接 JSON能用对象模型就不要用字符串裸拼这是我在实际项目里踩过坑之后换来的经验。6. 常见问题排查速查表与实战建议我整理了几个真实开发中反复出现的和本篇主题直接相关的报错、异常和诡异现象直接以速查表的形式放在这里。问题现象可能原因排查方法解决方案HashMap 中能放入但 get 不到值键对象的 hashCode 在放入后发生了改变或者 equals 相等的对象 hashCode 不同检查键对象是否有可变字段参与 hashCodehashCode 使用不可变的业务唯一字段比如 idequals 返回 true但 HashSet 中仍出现重复元素只重写了 equals没重写 hashCode检查类中 hashCode 是否缺失同时重写两个方法日志里打印对象只输出类名哈希码没有重写 toString查看类定义是否有 toString重写或使用 Lombok字符串循环拼接导致 Full GC 频繁循环内制造大量隐式 StringBuilder 和中间 String 对象用 JVM 参数开启 GC 日志观察新生代波动循环外创建 StringBuilder字符串相等判断为 false用了 比较两个不同引用确认判断符号用 equals 或 Objects.equals大量 intern 之后内存暴涨常量池中加入了过多唯一字符串堆 dump 分析字符串占比避免 intern 动态增长的内容对比完这张表想提一个贯穿整个 Java 学习过程的分层认知Object 定义的是“对象如何存在”的基础协议String 展示的是“不可变对象如何优雅地应用”。这两个类看似简单背后却连接着内存模型、哈希算法、并发安全和性能优化这些最核心的系统性问题。日常开发中多花一点时间去看它们的源码实现比用十个工具框架更有收获。7. 进阶思考从 Object 和 String 延伸出去的 Java 世界观7.1 为什么说 String 是不可变类的最佳范本很多人在面试时说 String 不可变只会背一句“final 修饰”然后就结束了。这其实没有答到本质。String 的不可变性至少体现在四个层面第一类本身被final修饰防止被继承从而改变行为第二内部的存储字段被private final修饰引用一旦赋值不可再指向其他数组第三这个字段没有公开任何可以修改数组内容的 setter第四所有看似修改的方法都通过创建新对象完成。这四点合力才构成一个真正不可变的类。单单“final”两个字并不能概括全貌。如果要设计一个自己的不可变类可以参考 String 的模式。核心字段全部加 final不提供任何 setter构造时深度拷贝外部传入的可变对象getter 返回时也不直接暴露内部可变对象的引用。做到这几点你的类就是线程安全且不可变的。写不可变类在并发编程里是性价比极高的一种设计方式很多隐性的线程安全问题会直接从根源上消失。7.2 重写 equals 时的类型判断到底用 instanceof 还是 getClass这里存在两种流派取决于你想支持的继承行为。如果你想要父类.equals(子类) 的结果返回 true只要字段内容一致那就可以用instanceof做判断因为 instanceof 会包含子类对象。这种风格在 JPA 实体类的比较中比较常见。如果你希望严格限定同一个类的实例才能比较就用getClass() ! o.getClass()。两者没有绝对的对错只有适不适合业务场景。而如果子类打破了 equals 的对称性这又会让 equals 契约变得非常脆弱所以很多框架的设计要求业务实体类不要随意继承另一个业务实体类。Java 语言规范给 equals 定下了五条约定自反性、对称性、传递性、一致性、以及非空比较返回 false。这五条在面试时经常被拿出来问背下来不难但在写继承体系时逐条验证会非常容易翻车。我的建议是在业务代码里优先用组合而不是继承来构建实体从根源上减少 equals 的复杂情况。7.3 从常量池到现代 JVM 的优化足迹从 JDK 7 把字符串常量池从永久代挪到堆到 JDK 9 引入紧凑字符串再到 JDK 15 实验性支持-XX:CompactStrings默认开启字符串对象的存储和缓存策略一直在演进。未来还会有类似String Templates这样的新特性进入 Java 语言。了解这些演进的轨迹会让你在阅读 Java 版本发布说明时多一分敏锐知道哪些改动会影响字符串内存占用哪些会改变字符串比较行为读源码时就有方向感。我自己在做性能优化时经常会在应用启动后打一个堆 dump 看 string 数组的白白占用比例发现字符串对象时常能占到堆内存的一个大头。优化手段通常不外乎去掉重复内容、改用枚举或常量、以及调整业务逻辑避免中间字符串产生。这些手段本质上都是在利用 String 不可变和常量池这两大精神。理解了对象层面的原理再去看 JVM 调优会通透得多。