拓冰建站拓冰建站
首页 / 资讯中心 / 正文

Java对象比较:从多米诺记忆游戏看==与equals陷阱

1. 先给一个可复现的多米诺记忆游戏原型1.1 一张牌的两个数字是所有隐藏Bug的起点多米诺记忆游戏的玩法很简单桌面上扣着N张牌每张牌印着一组多米诺点阵左区间数字 右区间数字比如[2|3]。玩家每次翻开两张如果两张牌的点数组合完全一致就消除这对牌如果点数对不上就把它们翻回去。全程累计翻错次数超过上限就判负。这个游戏用来练手Java再合适不过——有对象建模、有集合操作、有状态机、有UI事件甚至还能捎带讲一波算法和内存问题。多数人写它的时候都会觉得逻辑这么简单能出什么错但真实项目里往往就是这种看起来人畜无害的小东西能把Java对象比较的底裤扒得干干净净。常见的实现方式是先创建一副牌组比如24对共48张把DominoCard对象放进一个List然后Collections.shuffle()洗牌再按4行12列摆到面板上。每张DominoCard有leftValue、rightValue两个数字字段外加faceUp和matched两个布尔状态。玩家点击桌面上一张未翻开的牌时把它翻开再点另一张时拿这两张牌做匹配判断。命中的话两张都变成matchedtrue一定时间后继续没命中的话等展示时间结束再翻回去同时失败次数mistakeCount加1。到这里为止一切听起来都很顺畅但真正上线跑起来问题就开始冒头了。最典型的就是两张明明应该配对的牌永远显示不匹配。我就见过好几个朋友的初版代码卡在这里而且更迷惑的是他们核对数值时发现数字明明一样为什么程序就是说不相等这就要从最基础的比较逻辑开始查起。1.2 核心类结构与匹配流程的简化版为了把问题说清楚我先给出一个简化但完整的核心模型。DominoCard的典型写法长下面这样public class DominoCard { private final int leftValue; private final int rightValue; private boolean faceUp; private boolean matched; public DominoCard(int leftValue, int rightValue) { this.leftValue leftValue; this.rightValue rightValue; } public boolean isFaceUp() { return faceUp; } public void setFaceUp(boolean faceUp) { this.faceUp faceUp; } public boolean isMatched() { return matched; } public void setMatched(boolean matched) { this.matched matched; } // getter... }游戏主控部分的核心匹配逻辑早期版本通常长这样public boolean isMatch(DominoCard a, DominoCard b) { return a b; }这就是第一颗雷。如果a和b是两个不同的实例哪怕它们的leftValue和rightValue完全一样也只会返回false。你可能会说谁会这么写——现实中真的有人这么写。原因往往是最初误以为可以比较内容或者从某些简易教程里抄来了这个写法自己也没跑过完整的成对测试。更隐蔽的情况是有些老代码在某个阶段曾经用枚举或池化对象管理牌面那时候可能碰巧成立后来改了数据模型比较逻辑却没跟着改Bug就一直潜伏着。如果这关过了紧接着的另一颗雷是字段类型。假设把leftValue和rightValue定义成了Integer很多初学者或从某些代码规范里继承下来的人会这样写然后在匹配时用a.getLeftValue() b.getLeftValue()来比较那就会触发Integer的缓存机制-128~127范围内会返回true超过这个范围就返回false。而多米诺骨牌上的点数通常刚好在1~6之间于是所有同点数的牌在下都相等看起来游戏跑得好好的。但这是一种错觉它掩盖了真正的对象比较问题。2. 复现相同牌面却永远配对不成功的Bug2.1 真实症状同一对牌时好时坏先把 Bug 现场还原一下。一个刚写出来的多米诺记忆游戏点击两张[3|4]的牌正常情况下应该配对成功、两张牌从桌面消失。但实际表现是大部分情况下点击完直接弹回仿佛你点错了偶尔几次又能成功。为什么偶尔因为如果匹配代码存在字段是Integer但值恰好落在缓存区间和字段是int但两个对象恰好来自同一个内部池比如某些单例工厂或常量复用两种情况时的表现就会变得非常跳跃时而相等、时而不相等。我排查时最喜欢用的切入点是先把表象拆开判断到底是数据不同还是比较方式不同。所以第一步不是去看比较代码而是先打日志把两张被点击的牌的leftValue、rightValue、对象内存地址也就是System.identityHashCode()全部打出来。日志一出来真相往往就藏不住了牌面数值一样但两个对象的身份哈希完全不一样说明它们就是两个独立的实例。这时候再去读比较代码基本一眼就能锁定。2.2 排查链路从行为异常到最终根因这里我把排查过程完整写出来方便你以后遇到类似问题时照着这个思路走。第一层确认是否真的进入过匹配分支。有些时候卡片配对不成功不是比较逻辑的问题而是点击事件压根没触发第二次。所以先在isMatch入口打一行日志确认两个参数都非空、都来自被点击的牌。这个检查建议永远放在最前面避免在错误方向上浪费几个小时。第二层用equals和分头验证数据。在日志里分别输出a b、a.equals(b)、a.getLeftValue() b.getLeftValue()和两个对象的完整字段。早期版本写的是a b所以a.equals(b)大概率也是false——因为Object.equals默认就是。而字段比较的结果会暴露另一个线索如果值是Integer且在-128~127区间字段用比较会返回true这就解释了为什么偶尔成功——因为Integer缓存救了它救得了一时救不了一世。第三层排除并发或时序干扰。有些项目里点击事件和动画回调是异步的第一次翻开还没完成时第二次点击已经进来了导致比较时拿到的是同一个对象或半初始化对象。这一层要检查faceUp标志位是否正确设置、是否在比较前就做了状态翻转。我见过不止一次游戏配对失败的根因不是比较而是因为第一张牌在翻开动画结束前就被标记成了已翻转第二张牌点击后状态判断直接跳过导致永远走不到比较分支。第四层追到根因把比较逻辑替换成基于字段或基于equals的实现。这层完成后Bug 表面终结。但我要提醒你替换成equals之后如果只重写equals不重写hashCode那只是把噩梦从匹配失败换成了放进集合时行为异常问题并没有真正结束。2.3 第一轮修复改用值比较后为什么只是暂时看起来正常第一轮修复的常见做法是把a b改成public boolean isMatch(DominoCard a, DominoCard b) { return a.getLeftValue() b.getLeftValue() a.getRightValue() b.getRightValue(); }如果getLeftValue()返回的是int这段代码在点数1~6的牌面上是完全行得通的。但假如leftValue是Integer这个写法在-128~127区间内依旧能通过而一旦游戏需求扩展成点数范围更广的多米诺牌比如支持 0~9、甚至支持超过 127 的编号Integer缓存不够用的时候同样的代码就会突然大面积失效。第一轮修复之所以是看起来正常是因为数据范围恰好落进了缓存区把问题埋得更深了。真正可靠的修复方式是为DominoCard重写equals和hashCode然后用equals去比较。下面这篇博文的核心部分就是围绕到底该怎么重写才算合格展开的。3. 从一行比较代码展开的Java对象比较体系3.1 到底在比什么引用还是内容在Java里的语义非常明确比较两个引用变量是否指向堆内存中的同一个对象。它不是比较内容而是比较地址。哪怕两个对象的字段完全一致只要它们是通过两次new创建出来的就是false。用一个生活化的类比比较的是是不是同一个人而equals比较的是是不是同一张身份证对应的合法身份或者两个人的姓名、出生日期等关键信息是否一致。记忆游戏里的两张[3|4]牌相当于两个外表完全相同的人但它们是两个独立的个体。你用是不是同一个人来判断它们是否配对自然永远失败。面试里最经典的问法就是两个对象内容一样返回false为什么 答案就在引用语义上。但面试官第二个问题通常会接上那equals有没有可能返回true 这就需要看这个类有没有重写过equals方法。如果没重写Object.equals默认实现就是结果只能是false。3.2 equals与hashCode一对必须同生共死的契约equals方法在Java对象体系中承担的职责是定义逻辑相等。默认情况下如果没有重写它的行为与完全一致。但很多业务场景需要我们自定义什么才算相等——比如两张多米诺牌leftValue和rightValue一致就算同一张。重写equals时必须遵循几条硬性约定自反性x.equals(x)必须为true对称性x.equals(y)与y.equals(x)结果一致传递性如果x.equals(y)且y.equals(z)那么x.equals(z)也必须为true一致性只要对象内容不变多次调用equals结果不变非空性x.equals(null)必须为false与equals绑定的另一条铁律是重写equals必须重写hashCode。原因是Java 中所有基于哈希的集合HashMap、HashSet、Hashtable都依赖先算hashCode定位桶再通过equals精确比较。如果两个对象equals相等但hashCode不同它们在哈希表中会被分到不同的桶HashMap.get()永远找不到目标HashSet也会出现内容相同却能重复放入的诡异现象。hashCode的约定是如果两个对象按equals比较相等那么它们的hashCode必须相等如果两个对象的hashCode相等它们不一定equals相等这是允许的哈希冲突只要对象内容不变多次调用hashCode返回值应一致3.3 String与Integer的缓存陷阱为什么感觉相等却没有真正相等Java里有两个极其经典的缓存机制经常让人在比较时栽跟头。第一个是字符串常量池。如下代码String s1 java; String s2 java; System.out.println(s1 s2); // true因为两个字面量都指向常量池中的同一个对象但这并不代表String的可以安全使用。改成这样String s3 new String(java); String s4 new String(java); System.out.println(s3 s4); // false因为创建了两个不同实例如果s3.intern()之后再与s1比较结果又变成true了。可见String的结果完全取决于对象是否从常量池来很容易被误导。第二个是Integer缓存。Integer默认缓存了-128~127之间的对象所以Integer a 100; Integer b 100; System.out.println(a b); // true走缓存 Integer c 200; Integer d 200; System.out.println(c d); // false超过缓存范围创建了新对象如果项目里用Integer存多米诺点数恰好点数都在1~6那么card1.getLeftValue() card2.getLeftValue()会表现出假的相等。这个假象一旦遇到超过127的点数就会瞬间击穿。这类问题的通用结论是基本类型用对象类型一律用equals或Objects.equals。对于Integer、String这类值对象永远不要依赖。这是面试高频考点也是实际项目里最容易引发线上偶发Bug的根因之一。3.4 自定义对象的equals要怎么写才算合格结合多米诺记忆游戏的场景DominoCard的equals应该判断牌面点数组合是否一致。不过这里还有一个隐藏需求[1|2]和[2|1]算不算同一张牌从纯逻辑上讲很多游戏规则会认为算因为牌面由两个数字组成左右顺序只是显示布局。当然也可以指定必须完全一致这取决于需求。但作为一个通用设计equals应该体现业务语义所以我实现时会同时兼容左右互换Override public boolean equals(Object o) { if (this o) return true; if (o null || getClass() ! o.getClass()) return false; DominoCard that (DominoCard) o; boolean direct this.leftValue that.leftValue this.rightValue that.rightValue; boolean reversed this.leftValue that.rightValue this.rightValue that.leftValue; return direct || reversed; } Override public int hashCode() { // 组合时考虑顺序无关性 int sum leftValue rightValue; int diff Math.abs(leftValue - rightValue); return Objects.hash(sum, diff); }用sum和diff的组合作为哈希值可以保证左右顺序互换的牌得到相同的hashCode。这个技巧很适合用来回答如果equals里做了顺序无关判断hashCode该怎么写这类面试题。这里有几个细节值得展开讲讲。第一getClass() ! o.getClass()的写法在处理继承时会变得很严格。如果DominoCard有子类父类和子类实例永远不会相等。另一种常见写法是用instanceofif (!(o instanceof DominoCard)) return false;instanceof允许子类与父类之间比较但对hashCode的一致性要求更高因为子类可能扩展了新字段破坏了对称性。在实际项目中如果实体类没有继承层级getClass()更安全如果有多态需求instanceof更灵活但需要保证equals仍然遵守对称性。第二equals里不要做太多额外计算。像上面的写法先比较字段再Objects.hash也行但直接字段比较性能更好。对记忆游戏这种高频点击场景字段比较足够了。第三hashCode不要用死板的Objects.hash(leftValue, rightValue)直接怼上去因为那会让顺序无关的[1|2]和[2|1]产生不同哈希。虽然哈希值不同不至于让equals失效比如放在ArrayList里就无所谓但放进HashMap、HashSet时就会出问题所以必须严格保证equals 相等则 hashCode 相等。4. 修复后的完整代码与连带Bug清理4.1 修复后的匹配逻辑对比把equals和hashCode修好之后匹配判断就变得极简public boolean isMatch(DominoCard a, DominoCard b) { return a.equals(b); }与初版对比差别一目了然比较方式初版修复版比较目标引用是否相同业务内容是否相同同值不同实例falsetrue左右互换判定不支持支持按需求放入HashSet/HashMap可能重复正常去重隐患隐藏的Integer缓存依赖无这个表也方便你写复盘文档时直接引用。4.2 顺藤摸瓜状态机与步数边界修完匹配逻辑只是第一步。很多项目里的Bug是连环的你把第一个Bug按下去第二个Bug才浮出水面。这个记忆游戏也不例外。我在实际测试中就遇到过连续快速点击三张牌游戏状态直接错乱。因为翻牌匹配是一个有状态的过程理想状态机应该是enum GameState { IDLE, // 等待第一张牌翻开 ONE_FLIPPED, // 已翻开第一张等待第二张 RESOLVING, // 两张已翻开正在做匹配或动画 }当玩家点击时需要根据当前状态决定是否允许这次点击。初版代码往往只判断了牌是否已经翻开没判断当前是否在动画处理中于是第三张牌就能在RESOLVING状态下被翻开导致三张牌同时展示状态错乱。修复方式是在点击入口加状态判断public void onCardClicked(DominoCard card) { if (gameState GameState.RESOLVING) return; // 动画期间忽略点击 if (card.isFaceUp() || card.isMatched()) return; // 正常处理... }另一个高频Bug是失败次数边界。假设游戏规则是最多允许10次翻错初版可能写成if (mistakeCount 10) { gameOver(); }这会导致第11次失败才触发游戏结束而用户已经多玩了1次。正确写法是if (mistakeCount 10) { gameOver(); }这类边界问题虽然和对象比较无关但和逻辑修复的主题高度契合。排查时可统一使用边界值验证法把次数分别设为9、10、11观察程序是否按预期在第10次结束。4.3 回归测试清单修完所有Bug后一定要做一轮完整的回归测试。我给自己列过一份清单每次改完都能直接复用两张相同点数的牌能否配对成功两张不同点数的牌是否配对失败且计数加1[1|2]与[2|1]是否按需求视为同一对连续快速点击三张牌状态是否稳定第10次失败是否触发游戏结束第9次失败后游戏是否还能继续点击已matched的牌是否被忽略洗牌后List中是否还有重复对象引用比如同一张牌被放入了两次使用HashSet存放已配对牌时能否正确去重这份清单不仅能用于这个游戏凡是涉及对象比较、状态机、边界条件的项目基本都能套用。5. 面试官视角这些坑会以什么方式考你5.1 必问的 /equals/hashCode 连环题搜索热词里频繁出现 java面试题、java基础、java八股文说明这类问题确实是面试重灾区。根据我踩过的坑和面试官朋友反馈最常见的连环题是这样的第一问和equals的区别是什么 答出比较引用equals比较内容前提是重写算基础分。第二问不重写equals会怎样 这时要能答出默认Object.equals等同于。第三问重写equals时为什么必须重写hashCode 这里要展开HashMap底层的先哈希后比较机制。如果你能把HashSet去重的底层流程讲清楚基本就能让面试官点头。第四问进阶如果某个类的hashCode每次都返回同一个固定值有什么后果 答案是所有元素都会落在同一个哈希桶里导致查询性能退化成链表顺序查找严重时接近O(n)。这题考的是对哈希冲突的理解。第五问高频Integer的什么时候返回true 能答出-128~127缓存区间并且指出Integer.valueOf会走缓存但new Integer不会就是满分答案。第六问几乎是必考题HashMap的查找流程是什么 标准回答是先计算key.hashCode()定位到具体桶若桶内是链表或红黑树结构再用equals逐个比较若命中则返回对应value。如果有多个对象的hashCode相同桶内会形成链表JDK 8 之后链表长度超过阈值会转成红黑树。这个回答能把哈希、equals、性能三个点串起来。5.2 更深一层的坑String池、Integer缓存与HashMap面试中把基础题答完之后能不能从基础题延伸到更深一层的坑往往是区分会背八股和真懂Java的分水岭。以String为例如果你说String重写了equals所以可以用equals比较这当然没问题。但如果你能补充字符串常量池对字面量做了复用导致在某些情况下返回true但这种行为不可依赖因为new String会创建新对象面试官会认为你真的踩过坑。以Integer缓存为例很多面试者知道缓存区间是-128~127但不知道的是这个上限可以通过 JVM 参数-XX:AutoBoxCacheMax调整JDK 9 之后对某些版本有效缓存机制又是通过Integer.valueOf实现的而new Integer(100)则完全不走缓存。如果你主动把这些细节讲出来会显得平时确实读过源码。还有一个经常被忽略的点自定义对象放进HashMap的key时如果这个对象是可变的修改字段后哈希值会改变导致HashMap里再也查不到原来的键。比如DominoCard的leftValue是可变的放进HashMap后改了点数hashCode跟着变了但它在桶里的位置还是基于旧哈希值计算的于是get找不到。这个坑一旦踩到比和equals更隐蔽但也是面试官很爱聊的话题——用可变对象当HashMap的 key 有什么风险。5.3 用这个项目当项目经历时怎么讲出亮点如果你准备把Java多米诺记忆游戏写进简历或者面试时讲项目经历我不建议只讲我做了一个游戏。更好的讲法是我在开发过程中遇到一个匹配逻辑Bug表现是相同牌面偶尔配对失败排查后发现是对象比较方式选错并由此深入理解了Java对象比较体系顺带修复了状态机和边界条件问题。这种讲法的好处是有具体场景、有Bug表象、有排查链路、有底层原理、有修复方案、有回归验证正好构成一个完整的项目复盘。面试官顺着往下问基本都会落在/equals/hashCode上而这片你已经滚瓜烂熟了。如果要再往上拔高可以加一句设计层面的考虑在重写equals时我特意考虑了多米诺牌左右点数的顺序无关性并设计了对顺序无关的hashCode生成策略。 这句话能体现你对业务语义的理解而不是机械地套模板。另外一个值得补充的细节是equals中不要调用可以被子类重写的方法否则在继承体系下可能出现不可预期的行为。这也是为什么很多类库推荐用final修饰实体类或至少把equals设计成对称的。最后说一个我自己的习惯每次写完equals我都会配一组单元测试把null、自反、对称、传递、哈希一致性全部覆盖到。这不只是应付面试而是这类代码太容易被后续维护者改出问题。手动测试只能验证当前路径单测才能锁死契约。这个项目让我最深的体会是一个看似简单的记忆游戏只要认真抠一次匹配逻辑就能把、equals、hashCode、HashMap、Integer缓存、状态机、边界条件整整一串Java基础全部串起来。做项目最值钱的部分往往不是把功能跑通而是把一个Bug查到底、把一类知识点吃透。后续再做类似游戏时我建议你在写完第一版后故意把所有equals替换成跑一遍看看日志里的表现再亲手修回来——这个过程比看十篇八股文都管用。
分享:

看完干货,该让你的企业上线了

免费需求沟通 · 48 小时内出具建站方案 · 河南本地可上门