第020篇 == 与 equals 的区别:从栈堆内存说起

简介: 面试官问“== 与 equals 区别”,真正考察的是完整心智模型:基本类型==比值,引用类型==比地址;equals默认等价==,重写后比逻辑内容,且**必须同步重写hashCode**。常见坑包括Integer缓存、字符串常量池、HashMap去重失效、枚举误用equals等。工程建议:值对象一律用Objects.equals,包装类/字符串禁用==,枚举用==更安全。

面试官问"== 和 equals 到底有什么不同",真正想听的不是一句"== 比地址、equals 比内容",而是一整套完整的心智模型:基本类型和引用类型在 == 下的行为为什么完全两样、equals 的默认实现是什么、为什么重写 equals 必须重写 hashCode。这套东西平时没用过、没踩过坑,现场是编不出来的。

先把结论放在前面:== 是运算符,比较的是"值是否相等"——对基本类型比的是数值,对引用类型比的是两个引用是否指向同一个对象(地址);equals 是 Object 的方法,默认实现就是 ==,只有被子类重写后才去比较"逻辑内容"。这题要答好,得先把"比较"拆成两层:比的是数值还是比的是身份,再逐层定位。

机制拆解

这篇的主角是== 与 equals 的区别:它是什么、底层怎么运转、真实项目里长什么样,层层往下讲。真正拉开差距的,不是记得住定义,而是能说清那些反直觉的现场表现。

这些坑的正确绕法

最常见的坑是用 == 比较包装类或字符串,测试环境通过、生产环境偶发失败。根因是 Integer 的缓存区间(-128~127)和字符串常量池复用:小数值或字面量恰好命中同一对象,== 为真;一旦数值超出缓存区间,或字符串走了 new 的路径,就变成两个独立的堆对象,== 立刻为假。这种 bug 在单测里复现不出来,因为测试数据往往集中在小整数或字面量上。

其次是只重写 equals 没重写 hashCode。放进 HashSet/HashMap 后,两个内容相等的对象被分到不同桶,"去重失效、查找不到"却毫无报错。很多人以为 equals 对了就万事大吉,忘了哈希集合是先按 hashCode 找桶、再按 equals 比内容的,桶都不在同一处,equals 根本不会被调用。

还有一个更隐蔽的坑:对枚举用 equals 而不是 ==。枚举在 JVM 里是单例,== 比较既安全(遇 null 返回 false,不会抛空指针)又直白;用 equals 虽然也能工作,却平白多了一层方法调用,且语义上弱了——既然枚举全局唯一,比身份才是最自然的选择。

代码里见真章

看一段能直接跑的代码,把上面的机制落到具体写法上:

// 1) 基本类型:== 比值
int x = 5, y = 5;
System.out.println(x == y);            // true:比的是值,与内存无关

// 2) 字符串:字面量进常量池,new 强制堆新对象
String a = "hi";
String b = new String("hi");
System.out.println(a == b);            // false:地址不同
System.out.println(a.equals(b));       // true:字符序列相同

// 3) Integer 自动装箱缓存:-128~127 复用,超出则新对象
Integer i1 = 100, i2 = 100;
System.out.println(i1 == i2);          // true:命中缓存,同一对象
Integer i3 = 128, i4 = 128;
System.out.println(i3 == i4);          // false:超出缓存,两个对象

// 4) 自定义类型:必须同时重写 equals 与 hashCode,否则进 HashMap 查不到
class User {
   
    final int id;
    User(int id) {
    this.id = id; }
    @Override public boolean equals(Object o) {
   
        return o instanceof User u && u.id == id;
    }
    @Override public int hashCode() {
    return Integer.hashCode(id); }
}

这段代码值得盯三处:第一处,基本类型 == 比的是值,跟对象地址完全无关;第二处,字符串字面量进常量池共享、new 强制新建,所以 a == b 为假而 a.equals(b) 为真;第三处,Integer 缓存区间的存在,让 i1 == i2 为真、i3 == i4 为假,这种"只差一个数值结果就反转"的现象,正是 == 在引用类型上最坑的地方。面试讲到这一层,基本就稳了。

这题在面试里怎么问、怎么答

"请简单介绍一下 == 与 equals 的区别,它在 Android 开发中起什么作用?"按"是什么、干什么用、项目里怎么用"递进讲,别超三分钟。对基本类型,== 比值;对引用类型,== 比地址(是否同一对象),equals 默认也比特地址,重写后才比逻辑内容。项目里凡是涉及缓存命中、对象去重、集合查找,都绕不开这两者分工,讲清这一点就够了。如果只能留一条团队约定,建议是"凡是值对象比较一律用 equals,且 equals 与 hashCode 配对重写"。

"== 与 equals 的底层原理是什么?能不能详细说一下?"原理题三步:动机、机制、开销与边界主动交代;机制别空讲,配核心片段最稳。要解释 == 的种种反直觉表现,必须回到内存布局:局部变量存在栈帧的局部变量表,对象实体在堆,引用类型变量里装的是堆地址;== 比较引用类型时比的就是这个地址。而 equals 是方法调用,默认实现 return (this == obj),所以不重写时它和 == 等价。String 的常量池、Integer 的缓存都是在"减少重复对象"这件事上做文章,理解了这个动机,所有反直觉表现都顺了。

"在使用 == 与 equals 时遇到过什么问题?"按"现象 → 定位 → 修复 → 验证"讲一个真实案例。比如在循环或数值计算里对包装类做 ==,自动装箱频繁发生,== 比较的是缓存对象而非数值,偶发返回 false 导致分支走错。定位靠把包装类型改成基本类型或用 equals 比对;修复后单测覆盖边界值(127/128、-128)验证。重点放在定位手段与修复后的量化效果,比空谈"要注意"有说服力得多。

"== 与 equals 和相关的替代方案相比,有什么优劣?"广度和选型一起考,别只答一个点。对比抓四个维度:性能(== 是运算符、零方法调用,equals 有虚调用开销)、易用性(equals 需正确重写)、生态(Objects.equals 提供空安全比较,首选)、成本(重写 equals 要同步 hashCode)。没有万能答案:能说清什么场景用什么,才叫真懂。最容易被忽略的一条铁律:覆写 equals 必须同时覆写 hashCode。选型时要把"只重写 equals 没重写 hashCode,导致 HashMap 去重失效"这类代价摆到台面上,再决定是否引入。

再补一个工程上高频的用法:Objects.equals(a, b) 是空安全的比较首选,它内部先判两个引用是否都为 null(都为 null 视为相等),再调用 a.equals(b),因此再也不用自己写 a == null ? b == null : a.equals(b) 这种样板。另一个容易混淆的点是 IdentityHashMap——它用 == 而不是 equals 来比较 key,适用于"对象身份即语义"的场景(比如给每个对象挂元数据、避免 equals 冲突)。理解 IdentityHashMap 能帮你解释"为什么有时需要绕过 equals",也反过来加深了对 == 与 equals 分工的理解。在集合查找、缓存键这类场景,提前想清楚"比的是地址还是内容",能省掉一大类隐性 bug。

在 Android 具体场景里,Parcelable 对象的相等比较尤其容易出错:两个从 Intent 里取出来的 Parcelable,即便字段完全相同,往往也是不同对象,== 一律为 false,必须依赖 equals 而非地址。又如两个 Rect 实例,用 == 比较比不出"区域是否重合",得用 equals 或 contains/intersect 这类语义方法。把"比地址还是比内容"这个判断固化成习惯,能在 UI 状态比对、列表 diff、缓存命中这些高频地方少写一堆隐性 bug。

给正在准备面试的你

\== 与 equals 的区别读完别停,真实编码加复盘各来一次,知识才留得住。第一,所有值对象比较一律用 equals,并用 Objects.equals(a, b) 获得空安全;第二,重写 equals 必重写 hashCode,且用相同字段集合;第三,包装类/字符串比较绝不用 ==,宁可多写一次 equals;第四,枚举比较直接用 ==,既快又语义正确。

再补工程案例与踩坑——应用落点是动手演示 new String 与字面量在 == 与 equals 下的四种组合,并逐一解释为什么。理解这四种组合,等于把"地址比较"和"内容比较"的边界彻底划清。

复习时别孤立刷题:ArrayList 源码与扩容机制——1.5 倍增长的细节相邻考点常被一起问,边界提前划清楚。

收个尾,两句最要紧的:基本类型 == 比值,引用类型 == 比地址;equals 不重写等于 ==,重写则比逻辑内容,且必须与 hashCode 配对。

下篇进入异常体系 Throwable:Checked 与 Unchecked 的边界,算是今天内容的下半场。


如果这篇文章对你有帮助,欢迎点赞、在看、转发三连。你的支持就是这个系列持续更新的动力。

「Android软件开发面试·从入门到精通」连载系列

上一篇:Object-通用方法:equals、hashCode-与-clone-契约

下一篇预告:异常体系-Throwable:Checked-与-Unchecked-的边界

有任何问题欢迎在评论区留言交流。

相关文章
|
1天前
|
缓存 编译器 API
第119篇 扩展属性与扩展伴生:工具方法的进阶形态
本节详解Kotlin扩展属性与伴生对象扩展:扩展属性无backing field,本质是静态getter/setter方法,每次访问均重计算;扩展伴生对象则为第三方类“添加静态方法”,语法简洁、语义清晰。二者均属编译期静态分派,不可覆盖,是检验Kotlin底层理解的关键点。
19 2
|
1天前
|
Java 编译器 测试技术
第114篇 函数式编程思维:纯函数与副作用管理
本文是Kotlin函数式编程思维的收官总结,揭示其核心:**用不可变数据 + 纯函数 + 显式副作用组织代码**。提炼三条可落地纪律——①默认`val`、状态变更即生成新快照;②副作用(IO/UI/日志)推至边界层(ViewModel/Composable);③用不可变数据流替代可变共享。强调FP本质是工程纪律,非教条,重在提升可测性、可推理性与协作清晰度。
16 2
|
1天前
|
Android开发
第128篇Service 生命周期:started 与 bound 两条线
Service本质无“运行中/已停止”状态,仅分启动模式(startService)与绑定模式(bindService)。前者靠onStartCommand响应,支持START_STICKY自动重启;后者依赖onBind/onUnbind,绑定全解即销毁。核心原则:不操作UI、不耗时、不持Activity引用,状态交由UI层订阅。
23 1
|
1天前
|
传感器 API Android开发
第121篇 Activity 生命周期全解:七个回调的成对关系
本文深入解析Activity生命周期的两大维度:视图生命周期(6个回调)与进程生命周期(状态保存/恢复),直击面试高频考点——“为何如此设计”及“进程被杀后如何恢复”。厘清`onSaveInstanceState`触发条件、Bundle大小限制、ViewModel与状态保存的边界,并给出工程最佳实践与避坑指南。
20 1
|
1天前
|
存储 缓存 安全
第099篇 属性委托实战:SharedPreferences 的现代写法
本文详解Kotlin属性委托的实战应用:何时自研委托(满足SP/DataStore存储、读写加工、多处复用三条件之一)、如何落地(内存缓存、加密、过期校验、key动态派生),并避坑SP全量加载、主线程commit、key硬编码等问题。强调委托核心价值——将状态读写策略从业务代码中解耦抽离。
15 0
|
1天前
|
编译器 测试技术 调度
第110篇 Flow 与 RxJava 对比:响应式迁移指南
本文深度对比 Flow 与 RxJava 的设计哲学、背压机制、Subject/SharedFlow 语义差异及迁移陷阱,直击面试高频考点。指出二者“问题重叠、取向相反”:Flow 借协程挂起实现天然背压,Rx 依赖显式策略;强调 `PublishSubject ≠ SharedFlow(replay=0)` 等关键误区,附对照表与实战代码。
18 0
|
1天前
|
缓存 Java API
第107篇 协程与线程性能对比:为什么更轻
本文深入剖析协程性能误区:协程“轻量”仅指创建/切换成本低(纳秒级、用户态),而非单任务执行更快;它优化的是高并发I/O场景的资源占用,而非CPU算力。关键区分协程(挂起单元)与线程(调度单位),并指出三大常见坑:误信协程能加速阻塞调用、盲目用于CPU密集型任务、过度细粒度切换。
18 0
|
1天前
|
安全 Java 编译器
第103篇 DSL 构建原理:type-safe builder 如何工作
Kotlin DSL 并非魔法,本质是三大特性协同:带接收者 Lambda(`T.() -> Unit`)提供隐式 `this`、扩展函数封装配置逻辑、尾随语法提升可读性。编译后即普通方法调用,零运行时开销;配合 `@DslMarker` 可实现严格作用域隔离,保障类型安全与工程健壮性。
15 0
|
1天前
|
Android开发
第124篇Fragment 生命周期:与 Activity 的联动陷阱
Fragment生命周期含三套独立流程:实例、视图、上下文。关键分界是`onCreateView`至`onDestroyView`为视图生命周期,此时Fragment实例仍存活;`onDestroy`后实例才真正销毁。常见坑如重复弹窗、内存泄漏,根源在于混淆三者时机。推荐实践:视图相关操作绑定`viewLifecycleOwner`,数据交由`ViewModel`管理。
17 0
|
1天前
|
缓存 Java 编译器
第085篇 类委托 by:装饰器模式的一行实现
Kotlin类委托`by`是编译期语法糖,自动生成接口抽象方法的转发实现(如`delegate.foo()`),零反射、零运行时开销。但仅支持接口/抽象方法,不委托`Any`三方法、具体类方法及Java默认方法;委托对象天然共享状态,需注意隔离与可变性边界。
14 0

热门文章

最新文章