1. 先给一个可复现的多米诺记忆游戏原型
1.1 一张牌的两个数字,是所有隐藏Bug的起点
多米诺记忆游戏的玩法很简单:桌面上扣着N张牌,每张牌印着一组多米诺点阵(左区间数字 + 右区间数字),比如[2|3]。玩家每次翻开两张,如果两张牌的点数组合完全一致,就消除这对牌;如果点数对不上,就把它们翻回去。全程累计翻错次数,超过上限就判负。这个游戏用来练手Java再合适不过——有对象建模、有集合操作、有状态机、有UI事件,甚至还能捎带讲一波算法和内存问题。多数人写它的时候都会觉得"逻辑这么简单能出什么错",但真实项目里往往就是这种"看起来人畜无害"的小东西,能把Java对象比较的底裤扒得干干净净。
常见的实现方式是:先创建一副牌组,比如24对共48张,把DominoCard对象放进一个List,然后Collections.shuffle()洗牌,再按4行12列摆到面板上。每张DominoCard有leftValue、rightValue两个数字字段,外加faceUp和matched两个布尔状态。玩家点击桌面上一张未翻开的牌时,把它翻开;再点另一张时,拿这两张牌做匹配判断。命中的话两张都变成matched=true,一定时间后继续;没命中的话等展示时间结束,再翻回去,同时失败次数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. 复现"相同牌面却永远配对不成功"的Bug
2.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有子类,父类和子类实例永远不会相等。另一种常见写法是用instanceof:
if (!(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); }与初版对比,差别一目了然:
| 比较方式 | 初版 | 修复版 |
|---|---|---|
| 比较目标 | 引用是否相同 | 业务内容是否相同 |
| 同值不同实例 | false | true |
| 左右互换判定 | 不支持 | 支持(按需求) |
| 放入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替换成==跑一遍,看看日志里的表现,再亲手修回来——这个过程比看十篇八股文都管用。