资讯动态

Java判等10大陷阱:从==到equals的避坑全指南

发布时间:2026/10/3 10:30:24 来源:尧图企业网站定制
Java后端开发里判等是我见过最容易被低估的Bug来源。一个和一个equals用错地方轻则登录失败重则数据错乱而且定位起来特别费劲——代码看上去完全正常日志打出来值也一样但结果就是不对。这篇文章就把我这些年踩过的10个判等相关的坑串起来讲透从的引用语义讲到equals和hashCode的契约再讲到业务对象设计里的对称性大坑。刚入行的同学可以借此建立完整认知有经验的开发也能拿来当代码评审的对照清单。1. 线上翻车回忆录从一次登录态判断失败说起1.1 那个深夜的登录失败工单有一次线上收到工单说是某个内部系统的管理员账号偶发登不进去。排查到最后问题出在一行看起来人畜无害的判断上String dbName userMapper.getNickname(userId); // 从数据库查到 超管 if (dbName 超管) { // 管理员放行逻辑 }这个判断有时true有时false。数据库返回的字符串来自JDBC底层是运行期在堆上构造出来的新对象而字面量超管在字符串常量池里。两个引用指向不同的内存地址比较的是引用而不是内容结果就是大部分时候返回false管理员永远进不去。很多人会觉得谁会写出这种代码但实际情况是字符串判等用的代码在存量项目里真的不少见尤其是从文件、请求、数据库、外部接口拿到的字符串相互比较时最容易踩。这个案例成了我后面做代码评审时必讲的一个反例。1.2 字符串常量池与 intern 机制的底层逻辑要真正理解上面那个翻车案例得先搞清楚字符串在JVM里是怎么存的。我尽量用大白话讲。Java里字符串字面量比如超管在类加载阶段会进入字符串常量池。字符串常量池在JDK 7以后位于堆中之前是在永久代里这也是老项目偶尔会碰到的元空间/永久代问题之一。关键点在于只要内容相同字面量字符串在常量池里只有一份所有引用都指向同一个对象。用一段代码来看String a admin; String b admin; System.out.println(a b); // true两个变量指向常量池同一个对象 String c new String(admin); System.out.println(a c); // falsec 指向堆上新对象 System.out.println(a c.intern()); // trueintern() 返回常量池中的引用new String(admin)这个写法如果常量池里之前没有admin会创建两个对象一个在常量池里一个在堆上。如果池里已经有admin了那就只创建堆上那一个。所以a c永远是false因为c引用的是堆对象。还有一个容易忽略的细节——字符串拼接。看这个String d ad min; // 编译期常量折叠等价于 admin System.out.println(d a); // true String suffix min; String e ad suffix; // 运行期拼接生成新对象 System.out.println(e a); // falsead min两个都是编译期常量编译器直接折叠成admin所以d也指向常量池。而ad suffix涉及变量只能在运行期通过StringBuilder或JVM的字符串拼接指令生成新对象结果就落到堆上了。理解了这套机制再看intern()就清楚了。intern()做的事是如果常量池里已有相同内容的字符串直接返回池中引用没有则把当前字符串加入常量池并返回引用。JDK 7以后常量池在堆里所以intern()不再有JDK 6时代那种永久代OOM的致命风险但也不建议滥用——大字符串池本身也是内存压力。1.3 在字符串上唯一的安全用法聊完原理给你一个直接可用的结论业务代码里字符串内容判等永远不要用。有两个例外第一你明确知道两个变量引用的是同一个对象比如同一个方法内部分配的同一个变量这种情况用只是比较是不是它本身但既然内容都确定一样了用equals也不会有副作用没必要赌。第二你想判断的是引用是否同一个而不是内容是否相等。这种场景在intern()去重、常量缓存、单例管理等底层代码里才用得到。普通业务代码碰不到。字符串内容判等的正确姿势是这样// 推荐JDK 7 引入的 Objects.equals天然判空不会NPE if (Objects.equals(dbName, 超管)) { ... } // 或者确定两边都不会为 null 时 if (超管.equals(dbName)) { ... } // 需要忽略大小写 if (admin.equalsIgnoreCase(input)) { ... }超管.equals(dbName)这种写法有个额外好处字面量不为null永远不会触发空指针。而Objects.equals则是两边都为null时返回true更符合业务直觉。我在团队里定的规范是所有字符串判等一律走Objects.equals不接受任何这里我确定不为null的理由——你确定的时候越自信将来接手的人越容易在不确定的场景里复制你的写法。提示字符串判等是后端最基础的八股但也是线上问题最多发的点之一。只要涉及外部输入和内部存储的字符串比较一律用equals系方法。2. 数字世界的四个判等暗礁2.1 Integer 缓存为什么100等于100、128不等于128第二个让我印象深刻的案例来自一次线上订单号判断。代码大概是这样的Integer status1 getOrderStatus(); // 返回 128 Integer status2 128; if (status1 status2) { // 某个状态下的特殊处理 }结果有时候生效有时候不生效后来发现是Integer缓存范围的问题。看这段代码Integer a1 100; Integer a2 100; System.out.println(a1 a2); // true Integer b1 128; Integer b2 128; System.out.println(b1 b2); // false原因在于自动装箱。Integer a1 100等价于Integer.valueOf(100)而valueOf内部有一个IntegerCache缓存了-128到127之间的所有Integer对象。在这个范围内valueOf直接返回缓存中的同一个对象所以a1 a2是true。一旦超出这个范围valueOf就会new Integer(...)每次都创建新对象b1 b2比较两个不同对象的引用自然是false。更隐蔽的是new Integer(100)这种写法即使值在缓存范围内它也强制新建对象Integer c1 new Integer(100); Integer c2 new Integer(100); System.out.println(c1 c2); // falsenew 出来的对象永远不相等如果一边是int基本类型一边是Integer包装类型比较时会发生自动拆箱此时就是纯数值比较不受缓存影响int i 128; Integer wi 128; System.out.println(i wi); // trueInteger 拆箱成 int 后比较这个案例给我的教训是包装类型和基本类型混用时的语义会变极其容易出问题。统一用equals或Objects.equals收益远大于那一点点性能开销。2.2 隐藏在 if (count 0) 里的空指针这个坑我当年自己踩过印象特别深。统计接口返回的count是个Integer为了判断没有记录写了Integer count queryCount(); // 某条路径下返回 null if (count 0) { // 处理无记录分支 }代码跑起来直接NPE。原因是count 0中右边是int左边是Integer比较时Integer要先自动拆箱成int拆箱就是调用intValue()count为null时直接抛空指针。还有更隐蔽的版本Integer a null; Integer b null; System.out.println(a b); // true两边都是 Integer走的是引用比较null null 不触发拆箱 Integer x null; int y 0; // System.out.println(x y); // NPE右边是 int强制拆箱null null在引用比较的语境下是合法的返回true所以不会报错。但如果其中一边是基本类型就会触发拆箱NPE就来了。这种有时报错有时不报的随机性比一直报错更让人头疼。处理方案很简单Integer count queryCount(); if (Objects.equals(count, 0)) { // count 为 null 时返回 false不 NPE // 处理无记录分支 }或者明确判空再处理。我在代码评审里看到Integer和int用比较的一律标红要求改掉。2.3 BigDecimal 纠纷1.0 怎么就不等于 1.00 了金额相关场景是BigDecimal的主场但它的判等坑比想象中多。我整理过一个线上资损相关的案例两个接口返回的金额被判为不相等导致对账失败。先说第一个坑——构造方式BigDecimal a new BigDecimal(0.1); BigDecimal b new BigDecimal(0.1); System.out.println(a.equals(b)); // falsenew BigDecimal(0.1)传入的是double而0.1在二进制里是无限循环小数实际存的是0.1000000000000000055511151231257827021181583404541015625。new BigDecimal(0.1)传入的是字符串能精确表示字面意义上的0.1。两者当然不相等。正确做法是BigDecimal c new BigDecimal(0.1); // 推荐字符串构造 BigDecimal d BigDecimal.valueOf(0.1); // 也可以内部先转字符串第二个坑就是标题里说的1.0不等于1.00BigDecimal m new BigDecimal(1.0); BigDecimal n new BigDecimal(1.00); System.out.println(m.equals(n)); // false System.out.println(m.compareTo(n) 0); // trueequals不仅比较数值还比较scale小数位数。1.0的scale是11.00的scale是2数值相等但精度不同equals返回false。这在数据库字段精度不一致、不同服务返回的金额精度不统一时特别容易触发。业务判等应该用compareToif (amount1.compareTo(amount2) 0) { // 金额相等 }但注意compareTo判断相等时两个BigDecimal即使scale不同也被视为相等。如果需要把结果存入TreeMap或TreeSet这种依赖compareTo决定唯一性的集合可能出现看起来相等但实际精度不同的元素互相覆盖的情况。这通常没问题但如果业务上确实需要区分1.0和1.00就得先setScale规范后再比较。提示金额判等的标准姿势是compareTo 0不是equals。涉及精度存储时数据库字段统一用DECIMAL并固定精度从源头杜绝 scale 不一致。2.4 浮点数的二进制幻觉浮点判等是我面试时几乎必问的题因为绝大多数人都知道不能直接比较浮点数但具体为什么能讲清楚的不多。double d1 1.0 - 0.9; double d2 0.1; System.out.println(d1 d2); // false System.out.println(d1); // 0.09999999999999998原因一句话计算机用二进制表示小数但0.1在二进制里无法精确表示。就像十进制无法精确表示1/3一样0.1在二进制里是个无限循环小数存储时会舍入到一个近似值。1.0 - 0.9这个运算的中间结果和直接写0.1的近似值不一样所以比较失败。实际开发里浮点数直接判等的场景并不多——金额已经用BigDecimal了。但会有一些性能指标、坐标、比例之类的场景比如判断某个比率是否达到0.85这时候用阈值比较double rate (double) completed / total; double target 0.85; if (Math.abs(rate - target) 1e-6) { // 近似相等 }或者干脆也用BigDecimal处理。另外提一个冷门点Double.NaN ! Double.NaN是true而Double.NaN Double.NaN引用比较取决于对象如果都用基本类型且都是NaN常量则为false。这就是IEEE 754规范里NaN不等于任何数包括自身的体现。虽然这个场景少但在做数据校验时碰到过知道总比不知道好。3. equals、hashCode 与集合的三角关系3.1 一个 HashSet 里出现两个相同元素的诡异现场有一次排查线上数据重复问题发现错了很久。原因是同事在一个实体类里只重写了equals没重写hashCode。业务上按用户ID相同就算同一个人定义了equals但是SetUser userSet new HashSet(); User u1 new User(1001, 张三); User u2 new User(1001, 张三); // 业务上认为是同一个用户 userSet.add(u1); userSet.add(u2); System.out.println(userSet.size()); // 2而不是1两个对象equals返回true但Set里出现了两个。问题的根源在HashSet的底层机制HashSet内部其实是个HashMap向Set添加元素时先根据hashCode()计算哈希值定位到某个桶如果桶为空直接放入根本不调用equals只有哈希值相同哈希冲突时才会用equals判断桶里是否已有相同元素。u1和u2没有重写hashCode()所以用的是Object.hashCode()默认实现基于对象内存地址生成两个对象的哈希值自然不同被分到不同的桶里。于是即便equals认为它们相同集合也看不到。这个问题的严重性在于它让人以为Set去重是可靠的结果却在业务上埋了雷。重复用户数据被反复写入等到报表统计、对账的时候才爆发。所以equals和hashCode必须成对重写这不是规范问题是正确性问题。3.2 HashMap get 不到值的完整排查链路另一个高频问题出在HashMap的Key上。假设有个订单Key类class OrderKey { String orderNo; OrderKey(String orderNo) { this.orderNo orderNo; } Override public boolean equals(Object obj) { if (this obj) return true; if (obj null || getClass() ! obj.getClass()) return false; OrderKey other (OrderKey) obj; return Objects.equals(orderNo, other.orderNo); } // 注意没重写 hashCode }然后MapOrderKey, String orderMap new HashMap(); OrderKey key1 new OrderKey(A001); orderMap.put(key1, 订单A); OrderKey key2 new OrderKey(A001); String value orderMap.get(key2); // null怎么放入的订单查不到了排查链路是这样的第一步确认key2.equals(key1)是true。没问题两个对象orderNo相同。第二步检查key1.hashCode()和key2.hashCode()。因为没重写一个对象一个哈希值根本不相等。第三步看HashMap.get的逻辑。get先计算key2.hashCode()定位桶再去桶里找。桶和key1所在的桶完全不同equals连被调用的机会都没有直接返回null。修复方案就是重写hashCodeOverride public int hashCode() { return Objects.hash(orderNo); }还有更隐蔽的一层对象放入HashMap后才修改了hashCode依赖的字段。比如OrderKey key new OrderKey(A001); orderMap.put(key, 订单A); key.orderNo A002; // 哈希值变了但它在 map 里还在原来的桶 String v orderMap.get(new OrderKey(A001)); // null这就是可变对象HashMap键的经典问题。hashCode依赖的字段一旦变化对象在Map里的位置就错位了。后续用任何相同内容的key都查不到而且这个key永远留在Map里成为内存泄漏点。经验法则是放入HashMap/HashSet的Key类字段应该是不可变的或者在放入后绝对不要修改参与hashCode计算的字段。3.3 为什么 List.contains 也背叛了你说完HashMap和HashSet再来说说看起来更简单的ArrayList。class User { String name; // 没有重写 equals 和 hashCode } ListUser userList new ArrayList(); userList.add(new User(张三)); System.out.println(userList.contains(new User(张三))); // falseArrayList.contains的源码走的是indexOf逻辑就是遍历数组逐个调用o.equals(elementData[i])。如果User没有重写equals就用Object.equals比较引用两个new出来的对象引用不同contains当然是false。到现在为止你可能已经总结出规律了只要是值比较的需求所有依赖equals的集合方法contains、remove、indexOf都要求对象正确重写equals。List不依赖hashCode所以只重写equals就能让contains正常工作但对象如果进入HashSet/HashMaphashCode又是硬性要求。还有一个会被忽略的点对象放进List后如果修改了equals依赖的字段contains的判断也会变得不可预测。比如userList里有个User的name张三后来有人把这对象的name改成了李四此时再contains一个name张三的User会返回false。这类问题在缓存、Session存储、批量任务里经常出现值可变导致判等结果前后不一致定位起来极其折磨人。4. 实体设计中的相等性悖论继承、Lombok 与代理对象4.1 父类与子类互判的对称性灾难如果说前三章的坑是用错了方法这一章的坑就是设计本身就埋了雷。想象一个很常见的模型Person是父类Student继承它并增加了学号字段。class Person { String name; Override public boolean equals(Object obj) { if (!(obj instanceof Person)) return false; return Objects.equals(name, ((Person) obj).name); } } class Student extends Person { int studentId; Override public boolean equals(Object obj) { if (!(obj instanceof Student)) return false; return super.equals(obj) studentId ((Student) obj).studentId; } }然后执行Person p new Person(张三); Student s new Student(张三, 1001); System.out.println(p.equals(s)); // trues instanceof Person 成立 System.out.println(s.equals(p)); // falsep instanceof Student 不成立p.equals(s)返回true反过来S.equals(p)返回false。这在Java的equals规范里叫违反对称性——如果x.equals(y)是true那么y.equals(x)也必须是true。一旦违反集合里就会出现人类无法理解的灵异现象一个对象在Set里但当你想取出它、判断它是否包含它时结果完全取决于从哪个方向调用。实际业务里这种坑常见于ORM实体继承。比如基类BaseEntity定义了id字段的equals子类扩展后没有正确处理导致关系判断、去重、缓存Key全部错乱。Effective Java对这个问题的建议很直接要么子类不要重写equals要么父类的equals用getClass() obj.getClass()做严格类型判断。用getClass会牺牲一定的父类视角相等性但保证了对称性。如果你的业务模型确实需要对不同子类型做统一判定更推荐的做法是组合而不是继承——把公共字段抽到一个组件类里而不是放在父类里让equals互相打架。4.2 Date 与 TimestampJDK 自己都没做到对称继承体系下equals对称性被破坏最著名的官方翻车案例就是java.util.Date和它的子类java.sql.Timestamp。这俩在JDK里自己都不满足对称性Date date new Date(); Timestamp ts new Timestamp(date.getTime()); System.out.println(date.equals(ts)); // trueDate.equals 允许子类实例 System.out.println(ts.equals(date)); // falseTimestamp.equals 要求对方也是 Timestamp源码层面Date.equals的判断是obj instanceof Date getTime() ...Timestamp作为子类自然满足instanceof Date。而Timestamp.equals的逻辑是如果对方不是Timestamp直接返回false。所以同一个毫秒值一个true一个false。这个坑在真实业务里不难遇到。用JDBC查数据库的datetime字段早期驱动返回的类型就是java.sql.Timestamp而代码里往往拿它和java.util.Date比较。如果你习惯了date.equals(timestamp)的写法可能一直没事但某天有人反着写或者集合里的contains从另一个方向调用问题就蹦出来了。解决方案也比较简单新项目用java.time包下的LocalDateTime、Instant。LocalDateTime.equals按字段精确比较没有继承体系行为一致干净利落。在老项目里如果必须比较Date和Timestamp统一转成long的getTime()再比或者统一先转成LocalDateTime。4.3 Data 的隐式重写循环引用与比较字段失控Lombok的Data极大简化了实体类开发但也悄悄引入了一类你看不见的equals。Data默认会生成equals和hashCode规则是包含所有非static、非transient字段。问题就在所有字段这仨字上。最典型的翻车场景之一是循环引用。假设有A和B两个实体Data class User { Long id; String name; ListOrder orders; } Data class Order { Long id; String orderNo; User user; // 反向引用 }User.equals会遍历ordersOrder.equals会遍历user如果两个对象互相引用equals递归调用直接栈溢出StackOverflowError。这在ORM双向关系、树形结构、图状结构里非常常见而且是运行时崩溃不是静默错误。另一个场景是不该参与判等的字段参与了。比如Data class User { Long id; String name; LocalDateTime createTime; // 创建时间不应该影响业务相等性 String salt; // 随机盐每个对象都不一样 }两个业务上代表同一个用户的实体只要createTime或salt不同equals就返回false。这在从数据库不同表、不同服务组装同一个业务实体时特别容易触发导致去重失败、相等判断失败。解决方案是显式控制Lombok的判等字段Data EqualsAndHashCode(onlyExplicitlyIncluded true) class User { EqualsAndHashCode.Include Long id; String name; LocalDateTime createTime; String salt; }这样equals和hashCode只关心id其他字段一概不参与。判等的语义清晰而且以后加字段不会悄悄改变相等性规则。手动写equals时也建议只挑选业务主键或真正的唯一标识不要把附加信息拖进来。另外还要注意一个ORM特有的坑JPA/Hibernate的懒加载代理。实体类的关联对象如果是代理对象代理对象和真实对象的getClass()可能不同getClass() ! obj.getClass()这种写法会在equals里直接返回false。所以很多ORM团队在实体里用instanceof或id比较但前面说了instanceof又可能引发对称性问题。这就是为什么现代ORM开发里实体类不要重写equals/hashCode业务判等交给DTO/VO是一条主流设计建议——把判等的复杂度从领域模型里剥离出去。5. 经验沉淀一个后端老兵常用的判等决策表5.1 十次踩坑浓缩成的判等速查表把前面10个案例揉在一起我会在代码评审和写代码时反复对照这张表场景推荐判等方式为什么不推荐其他方式字符串内容相等Objects.equals(a, b)或a.equals(b)比较引用常量池/堆对象分布不确定基本类型数值相等基本类型没有equals方法包装类数值相等Objects.equals或.equals受缓存影响-128~127以外会false包装类和基本类型混比拆箱后比较或统一转包装类混合比较会触发自动拆箱可能NPEBigDecimal金额compareTo(...) 0equals会比较scale1.0和1.00不相等浮点数近似比较阈值Math.abs(a-b) 1e-6二进制表示不精确直接有精度误差数组内容相等Arrays.equals(a, b)/Arrays.deepEquals数组没有重写equals默认是引用比较普通对象业务相等重写equalshashCode只重写equals会导致HashSet/HashMap失效枚举判等枚举是单例引用比较天然相等且安全日期/时间相等LocalDateTime.equals或统一转时间戳Date/Timestamp混用违反对称性List/Set/Map包含判断依赖元素正确实现equals/hashCode元素判等规则错误集合全是假结果ORM实体判等尽量用id/业务主键判等DTO用字段判等懒加载代理、循环引用、对称性都会出问题这张表不是理论是踩坑清单。每一条背后都对应至少一个线上问题或一次代码评审的激烈讨论。5.2 手写判等代码的推荐模板如果你需要手写equals我推荐你直接抄这个模板Effective Java风格的现代写法Override public boolean equals(Object o) { // 1. 引用相同直接true性能优化 if (this o) return true; // 2. 类型不符或为null直接false if (o null || getClass() ! o.getClass()) return false; // 3. 类型转换后逐一比较核心字段 User user (User) o; return Objects.equals(id, user.id) Objects.equals(name, user.name); } Override public int hashCode() { // 4. 和equals用到的字段必须保持完全一致 return Objects.hash(id, name); }几个容易忽略的点Objects.equals和自己依赖的字段必须一致多一个少一个都会导致equals和hashCode契约被破坏。hashCode可以累加多个字段但在业务上只需要用id判等时只放id一个字段就足够。提示修改实体字段时一定检查equals和hashCode是否受到影响。举个例子原来只拿id判等后来加了status字段并放进了equals旧的HashMap里所有已存在的对象就全失效了——这不是改了个字段是改了整个判等语义。总结一个代码评审的检查清单我每次review都会过一遍所有字符串、包装类、BigDecimal判等是否用了正确方法所有重写equals的类是否同时重写了hashCode所有进入HashMap/HashSet的自定义Key是否同时满足不可变字段的要求继承体系中父类和子类的equals是否满足对称性与传递性使用Lombok的Data时是否显式设定了只比较业务主键涉及金额、时间这类特殊类型是否统一了精度和类型10个案例写到这里我其实把项目里的实体类又翻了一遍还是发现了两处只重写equals没重写hashCode的代码。判等这个问题看起来是Java基础里的入门知识点实际是生产环境Bug的高发区而且越是在大型系统里越会因为集合、缓存、ORM这些基础设施的叠加而变得隐蔽。把这张表和清单存下来下次写代码或review之前先过一眼应该能帮你少加几个深夜排查的班。

读完文章,也想定制专属网站?

尧图设计师 24 小时内与您沟通定制方案

免费获取报价 →
↑