面试复盘
1. 为什么项目中用 MVVM 架构?(对比 MVC/MVP)
MVVM 核心是 “数据驱动 + 解耦 View 与业务逻辑”,选择它是为了解决 MVC、MVP 的痛点,适配安卓开发场景:
对比 MVC:MVC 中 Controller(如 Activity)既管用户交互又管业务逻辑,容易臃肿;且 View 和 Model 有间接耦合(如 Controller 传 Model 给 View)。MVVM 中 ViewModel 完全隔离 View 和 Model,View 只负责渲染,不写任何业务代码。
对比 MVP:MVP 需定义大量接口(如 IView 的 showLoading()/updateList()),代码冗余;且 Presenter 持有 View 引用,页面销毁时易内存泄露。MVVM 中 ViewModel 不持有 View,通过 LiveData/Flow 暴露可观察数据,View 仅观察数据变化,自动更新 UI,无泄露风险。
核心优势:ViewModel 生命周期与 Activity/Fragment 解耦(屏幕旋转不重建),数据绑定(如 DataBinding/Compose)简化 UI 更新,适合复杂 UI 或频繁数据刷新的场景(如列表页、表单页)
2. 了解 MVI 架构吗?(你面试时说 “不了解”,补充核心逻辑)
MVI(Model-View-Intent)是 “单向数据流 + 状态不可变” 的架构,比 MVVM 更强调 “状态统一管理”:
核心组件:
Model:不是数据层,而是 “UI 状态模型”(如 UiState),包含 View 所有状态(加载中 / 成功 / 失败、列表数据、弹窗文案等),且状态 不可变(更新时必须生成新对象);
View:接收 UiState 渲染 UI,将用户操作(如点击按钮、输入文本)包装成 Intent(如 LoadDataIntent/SubmitIntent)发给业务层;
Intent:用户操作的 “统一载体”,业务层只处理 Intent,不直接依赖 View。
数据流向(严格单向):View(用户操作→Intent)→ 业务层(处理 Intent→生成新 UiState)→ View(观察 UiState→更新 UI)。
适用场景:状态复杂、多组件共享数据的项目(如电商购物车、跨页面用户状态),调试时可回溯所有状态变化。
3. Handler 如何使用?原理是什么?
(1)Handler 基本使用(核心是 “发送消息 / 任务到指定线程”)
发送延迟消息:handler.sendEmptyMessageDelayed(1, 1000);
发送 Runnable(切换到主线程):
java
运行
new Handler(Looper.getMainLooper()).post(() -> {
// 主线程更新 UI(如 textView.setText())
});
(2)底层原理(4 个核心角色:Handler + Message + MessageQueue + Looper)
Message:消息载体(存数据、what 标识);
MessageQueue:消息队列(链表结构),负责存储 Handler 发送的消息;
Looper:“消息循环器”,调用 loop() 方法循环从 MessageQueue 取消息,交给 Handler 处理;
主线程(ActivityThread)在 main() 方法中初始化 Looper:Looper.prepareMainLooper()(全局唯一,不可退出);
子线程需手动调用 Looper.prepare() 创建 Looper,否则无法使用 Handler。
Handler:负责 “发送消息”(sendMessage())和 “处理消息”(handleMessage()),发送时会绑定当前线程的 Looper 和 MessageQueue。
流程总结:Handler 发消息→MessageQueue 存消息→Looper 循环取消息→Handler 处理消息。
4. 能用 Kotlin 协程和 ViewModel 实现网络请求吗?
完全可以,这是安卓官方推荐的 “无内存泄露” 方案,核心是 ViewModelScope 生命周期感知 + 协程自动线程切换:
(1)实现步骤
ViewModel 中定义协程作用域:viewModelScope 是 ViewModel 的扩展属性,ViewModel 销毁时会自动取消所有协程,避免内存泄露;
指定 IO 线程发请求:用 Dispatchers.IO 切换到子线程执行网络请求(如 Retrofit 接口);
暴露可观察数据:用 LiveData/StateFlow 将请求结果暴露给 View,View 观察数据更新 UI。
(2)优势
无需手动管理线程(Dispatchers.IO/Main 自动切换);
无内存泄露(viewModelScope 自动取消协程);
代码简洁(同步风格写异步逻辑,无回调地狱)。
5. 安卓应用从点击图标到运行,中间发生了什么?(冷启动流程)
核心是 “系统孵化进程→初始化主线程→创建组件→绘制 UI”,分 6 步:
触发启动:点击图标,Launcher 应用通过 AMS(ActivityManagerService) 向系统发起 “启动应用” 请求;
孵化进程:AMS 通知 Zygote 进程(安卓所有应用的 “父进程”),Zygote 孵化出该应用的独立进程(分配 PID);
初始化主线程:进程创建后,启动 ActivityThread(应用主线程),执行 main() 方法:
初始化 Looper(Looper.prepareMainLooper())和 Handler(主线程消息循环核心);
调用 ActivityThread.attach() 绑定 AMS。
创建 Application:AMS 通知主线程创建 Application,执行 Application.onCreate()(整个应用只执行一次);
创建目标 Activity:按启动参数创建目标 Activity,执行生命周期:onCreate()(加载布局)→ onStart() → onResume();
onCreate() 中 setContentView() 会解析 XML 生成 View 树,最终通过 ViewRootImpl 发起 “绘制请求”;
UI 绘制:ViewRootImpl 调用 performTraversals(),执行 “测量(measure)→ 布局(layout)→ 绘制(draw)”,将 View 渲染到屏幕,应用启动完成。
热启动区别:若应用进程已存在(如按返回键退到后台),则跳过 “孵化进程” 和 “Application 创建”,直接创建 Activity 并绘制 UI,速度更快。
6. 浏览器输入网址后,一系列流程是怎样的?
核心是 “DNS 解析→建立连接→请求响应→页面渲染”,分 7 步:
DNS 解析:将域名(如 www.xiaohongshu.com)转换为服务器 IP 地址(先查本地缓存→路由器缓存→DNS 服务器层级查询);
建立 TCP 连接:通过 IP 与服务器建立 TCP 连接(三次握手:客户端发 SYN→服务器回 SYN+ACK→客户端发 ACK);
HTTPS 额外:TLS 握手:若协议是 HTTPS,需建立加密通道:
客户端请求服务器证书→验证证书合法性→生成对称密钥→用服务器公钥加密密钥发给服务器→双方用对称密钥通信;
发送 HTTP 请求:浏览器向服务器发送请求(含请求行:GET /index.html HTTP/1.1、请求头:Host/User-Agent、请求体);
服务器处理请求:服务器接收请求,解析参数,调用业务逻辑(如查数据库),生成响应数据;
返回 HTTP 响应:服务器返回响应(含状态码:200 成功 / 404 未找到、响应头:Content-Type/Cache-Control、响应体:HTML 内容);
浏览器渲染页面:
解析 HTML 生成 DOM 树,解析 CSS 生成 CSSOM 树;
结合 DOM 树和 CSSOM 树生成 渲染树(只包含可见元素);
执行 “布局(计算元素位置大小)→ 绘制(填充像素)→ 合成(合并图层)”,最终显示页面。
7. Kotlin 和 Java 的类加载机制有什么区别?
核心:Kotlin 编译成 Java 字节码后,类加载仍遵循 Java 的 “双亲委派模型”,差异仅来自编译产物的特殊处理:
(1)相同点:核心机制一致
两者都运行在 JVM(或安卓 ART 虚拟机),类加载都经过 5 步:加载→验证→准备→解析→初始化,且都遵循 “双亲委派模型”(先让父类加载器加载,避免类重复加载)。
(2)不同点:编译产物的特殊处理
Kotlin 特殊语法的类加载:
「对象声明」(object Singleton):编译成单例类,类初始化时通过静态代码块创建实例(static { INSTANCE = new Singleton(); });
「扩展函数」:编译成静态类(如 StringKt),扩展函数是静态方法,加载时按静态类规则加载;
「数据类(data class)」:编译成含 equals()/hashCode()/toString() 的普通类,加载逻辑无差异。
协程相关类加载:Kotlin 协程依赖 kotlinx-coroutines-core 库,协程相关类(如 CoroutineScope、Dispatchers)加载时需依赖库的类加载器,与 Java 第三方库加载逻辑一致。
初始化顺序:Kotlin 的 init 代码块编译成构造函数内的代码,初始化顺序与 Java 构造函数一致,但 Kotlin 支持 “属性初始化器”(如 val a: Int = 1),编译后会在构造函数中执行。
总结:无本质区别,Kotlin 是 Java 的 “语法糖”,类加载最终依赖 JVM 机制,差异仅来自 Kotlin 语法编译后的字节码特殊处理。
8. 垃圾回收(GC)相关的知识了解吗?
分 3 部分:垃圾判定→回收算法→垃圾回收器,核心是 “识别无用对象,高效回收内存”。
(1)如何判定 “垃圾”(无用对象)?
主流用 可达性分析算法:
以 “GC Roots” 为起点(如虚拟机栈引用的对象、静态变量引用的对象、本地方法栈引用的对象),遍历对象引用链;
若对象到 GC Roots 无任何引用链(不可达),则判定为垃圾,可回收。
(2)常用垃圾回收算法
算法 核心逻辑 优点 缺点 适用场景
标记 - 清除 先标记垃圾,再直接清除垃圾 简单,不移动对象 产生内存碎片 老年代(CMS 用)
标记 - 整理 标记垃圾后,将存活对象向一端移动,再清除垃圾 无内存碎片 移动对象,开销大 老年代(G1 用)
复制算法 将内存分为 2 块,存活对象复制到另一块,清除原块 无碎片,效率高 内存利用率低(仅 50%) 年轻代(Eden/S0/S1)
分代收集 按对象生命周期分代(年轻代 / 老年代),用不同算法 结合各算法优点,高效 实现复杂 主流 JVM(HotSpot)
(3)常见垃圾回收器(HotSpot 虚拟机)
Serial:单线程回收,简单高效,适合客户端(如 Windows 桌面应用);
Parallel Scavenge:多线程回收,追求吞吐量(运行用户代码时间 / 总时间),适合服务端;
CMS(Concurrent Mark Sweep):并发回收(与用户代码同时执行),追求低延迟,适合对响应时间敏感的场景(如 Web 服务);
G1(Garbage-First):分区回收,兼顾吞吐量和低延迟,适合大内存(如 8G+)应用,是 JDK 9+ 默认回收器;
ZGC:低延迟回收(停顿时间 < 10ms),适合超大内存(如 16G+),JDK 11+ 引入。
9. 讲讲 Kotlin/Java 中的引用类型?(4 种,按回收优先级排序)
核心是 “引用强度决定对象被回收的时机”,从强到弱:
强引用(Strong Reference):
默认引用(如 Object obj = new Object()),GC 不会回收强引用指向的对象,即使内存不足也会抛出 OOM;
应用:普通对象引用(如 Activity 中的 View 引用)。
软引用(Soft Reference):
用 SoftReference 包装(如 SoftReference<Object> softRef = new SoftReference<>(obj));
内存充足时不回收,内存不足时(即将 OOM)才回收;
应用:内存敏感的缓存(如图片缓存,避免 OOM)。
弱引用(Weak Reference):
用 WeakReference 包装(如 WeakReference<Object> weakRef = new WeakReference<>(obj));
只要发生 GC,无论内存是否充足,都会回收弱引用指向的对象;
应用:WeakHashMap(键是弱引用,键无其他引用时自动移除键值对,避免内存泄露)、生命周期短的临时对象。
虚引用(Phantom Reference):
用 PhantomReference 包装,必须结合 ReferenceQueue 使用;
虚引用不影响对象回收,仅用于 “跟踪对象被 GC 回收的时机”(如回收时触发资源释放);
应用:JDK NIO 的 DirectBuffer(跟踪直接内存回收,避免内存泄露)。
10. 讲讲堆(Heap)内存?(JVM / 安卓 ART 内存结构)
堆是 JVM 中最大的内存区域,用于存储对象实例和数组,是 GC 主要回收区域,结构上按 “对象生命周期” 分代(以 HotSpot 为例):
(1)堆的分区(分代模型)
分区 存储内容 大小特点 回收算法
年轻代(Young Generation) 新创建的对象(如 new Object()) 占堆内存 1/3 左右 复制算法
- Eden 区 绝大多数新对象首先分配到 Eden 区 年轻代中最大 -
- S0(From)区 存活对象从 Eden 区复制过来的临时区域 与 S1 大小相等 -
- S1(To)区 存活对象从 Eden+S0 复制过来的临时区域 与 S0 大小相等 -
老年代(Old Generation) 存活时间长的对象(如单例对象、长期缓存) 占堆内存 2/3 左右 标记 - 清除 / 标记 - 整理
元空间(Metaspace) 存储类元信息(类名、方法、字段)、常量池 不占用堆内存,用本地内存 不参与 GC(JDK 8+)
(2)核心特点
线程共享:所有线程都可访问堆中的对象(需同步锁保证线程安全);
动态分配:对象内存按需分配,大小不固定(如 new int[10] 和 new int[100] 分配不同大小);
GC 重点区域:年轻代 GC(Minor GC)频繁(新对象存活时间短),老年代 GC(Major GC/Full GC)频率低(对象存活时间长),Full GC 会同时回收年轻代和老年代,开销大。
(3)安卓中的堆差异
安卓 ART 虚拟机堆结构与 JVM 类似,但有优化:
支持 “堆压缩”(避免内存碎片);
引入 “大对象区”(存储超大对象,如大数组,避免占用年轻代空间);
堆大小受设备内存限制(可通过 ActivityManager.getMemoryClass() 获取应用最大堆内存)。
11. 普通方法加 synchronized 与静态方法加 synchronized 的区别?(你面试时可能理解错,重点梳理)
核心区别是 “锁的对象不同”,直接决定同步范围:
对比维度 普通方法 + synchronized 静态方法 + synchronized
锁对象 当前实例对象(this) 当前类的 Class 对象(类名.class)
同步范围 仅对 “同一个实例” 的线程竞争有效 对 “整个类的所有实例” 的线程竞争有效
保护的变量 实例变量(非 static 变量,如 private int a) 静态变量(static 变量,如 private static int b)
示例(是否阻塞) 线程 A 调用 d1.method(),线程 B 调用 d2.method():不阻塞(锁是 d1 和 d2,不同对象) 线程 A 调用 d1.staticMethod(),线程 B 调用 d2.staticMethod():阻塞(锁是 Demo.class,同一对象)
总结:普通同步方法锁 “实例”,各实例互不干扰;静态同步方法锁 “类”,所有实例共享一把锁,适合保护类级别的资源(如静态变量)。
12. 讲下 synchronized?(重点讲锁升级)
synchronized 是 Java/Kotlin 中的 “内置锁”,保证线程安全(原子性、可见性、有序性),JDK 1.6 后引入 “锁升级” 优化,避免直接使用重量级锁(性能差)。
(1)锁的 4 种状态(从低到高升级)
无锁状态:对象刚创建,无线程竞争,不占用锁资源;
偏向锁:单线程反复获取锁,锁会 “偏向” 该线程(在对象头中记录线程 ID),避免 CAS 操作,提升性能;
触发条件:只有一个线程获取锁,无竞争;
升级时机:有其他线程尝试获取锁时,偏向锁撤销,升级为轻量级锁。
轻量级锁:多线程交替获取锁(无同时竞争),通过 CAS 操作(比较并交换)获取锁,不阻塞线程;
原理:线程将对象头的 “锁记录” 复制到栈帧,用 CAS 修改对象头的锁指针指向自己;
升级时机:多线程同时竞争锁,CAS 失败次数达到阈值,升级为重量级锁。
重量级锁:多线程同时竞争锁,依赖操作系统的 “互斥量(Mutex)” 实现,未获取锁的线程会阻塞(进入内核态),性能最低;
适用场景:多线程高并发竞争锁的场景。
(2)核心优势
无需手动释放锁(同步块执行完或抛异常时自动释放);
JDK 1.6 后优化(锁升级),兼顾单线程和多线程场景的性能;
保证可见性(释放锁时将变量刷新到主内存,获取锁时从主内存读取变量)。
13. Java 中的反射了解吗?(原理 + 应用 + 优缺点)
反射是 “通过 Class 对象动态获取类信息、调用类成员(方法 / 字段)” 的机制,打破了类的封装性。
(1)核心原理
获取 Class 对象:3 种方式(Class.forName("全类名")、obj.getClass()、类名.class);
操作类成员:
获取构造函数:getConstructor(Class<?>... parameterTypes) → 创建实例;
获取方法:getMethod(String name, Class<?>... parameterTypes) → 调用 invoke(obj, args);
获取字段:getField(String name) → 调用 set(obj, value)/get(obj);
突破封装:调用 setAccessible(true) 可访问私有成员(如私有方法、私有字段)。
(2)应用场景
框架开发:如 Retrofit(动态生成接口实现类)、ButterKnife(绑定控件)、Spring(依赖注入);
序列化 / 反序列化:如 Gson(反射解析 JSON 生成对象);
动态调用:如插件化开发(动态加载插件中的类并调用方法)。
(3)优缺点
优点:灵活,可动态操作类成员,适配复杂场景(如框架);
缺点:① 性能低(反射操作需解析字节码,比直接调用慢 10~100 倍);② 破坏封装性(可访问私有成员,增加代码风险);③ 编译期无法检查错误(如方法名写错,运行时才抛异常)。
14. HashMap 的底层实现?(Java 8 版本,你面试时一开始没反应过来,重点梳理)
Java 8 中 HashMap 是 “数组(桶)+ 链表 / 红黑树” 的混合结构,核心是 “用哈希表快速查找”,解决哈希冲突。
(1)核心结构
数组(桶,Bucket):默认初始容量 16(2 的幂,方便计算桶位),每个元素是链表或红黑树的头节点;
链表:用于解决哈希冲突(同一桶位的元素用链表连接),Java 8 中当桶内元素≥8 且数组长度≥64 时,链表转为红黑树(提升查询效率,从 O (n) 到 O (logn));
红黑树:当桶内元素≤6 时,红黑树转回链表(避免红黑树维护开销)。
(2)核心方法:put 流程(存数据)
计算哈希值:hash(key) = (key == null) ? 0 : (h = key.hashCode()) ^ (h >>> 16)(将 hashCode 的高 16 位与低 16 位异或,减少哈希冲突);
计算桶位:index = (n - 1) & hash(n 是数组长度,2 的幂,等价于 hash % n,但效率更高);
插入元素:
若桶位为空,直接创建节点插入;
若桶位是链表,遍历链表:key 相同则覆盖值,否则尾插法插入;
若桶位是红黑树,按红黑树规则插入;
扩容判断:插入后若 size > 容量×负载因子(默认 16×0.75=12),触发扩容。
15. 如何解决哈希冲突?(你面试时说 “偏移量”,补充完整方案)
哈希冲突是 “不同 key 计算出相同桶位”,4 种常见解决方法:
开放定址法(你说的 “偏移量” 属于这种):
核心:冲突后,按一定规则找下一个空桶位(如线性探测:index = (hash + i) % n,i=0,1,2...);
优点:无链表,内存连续;缺点:容易产生 “聚集”(多个冲突元素集中在某区域),查询效率低。
链地址法(HashMap 用的方案):
核心:每个桶位存储一个链表 / 红黑树,冲突元素直接挂在链表 / 树上;
优点:无聚集,处理冲突效率高;缺点:链表长时查询慢(Java 8 用红黑树优化)。
再哈希法:
核心:冲突后,用第二个哈希函数重新计算哈希值,直到找到空桶位;
优点:无聚集;缺点:多次哈希计算,性能低,可能产生死循环(无空桶位)。
建立公共溢出区:
核心:分 “基本表” 和 “溢出表”,冲突元素全部放入溢出表,查询时先查基本表,再查溢出表;
优点:基本表查询快;缺点:溢出表大时查询慢,适合冲突少的场景。
主流方案:链地址法(如 HashMap),平衡了效率和实现复杂度。
16. 10 个数据,用 ArrayList 存还是 HashMap 存?(你面试时回答后补充完整逻辑)
关键看 “数据的结构和使用场景”,核心是 “是否需要键值映射”:
选择 适用场景 核心原因 示例
ArrayList 1. 数据是 “有序列表”(如 [1,2,3]);
2. 频繁按索引查询(如 list.get(0));
3. 无键值关联,仅需 “存一组数据” 1. 动态数组结构,索引访问 O (1) 效率;
2. 内存开销小(仅存数据,无键);
3. 支持排序、遍历等列表操作 存 10 个用户的昵称(["张三","李四"...])
HashMap 1. 数据是 “键值对”(如 {1: 张三,2: 李四});
2. 频繁按 key 查询 / 修改(如 map.get(1));
3. 需要 “通过唯一标识找数据” 1. 哈希表结构,key 查询 O (1) 效率;
2. 支持快速增删(按 key 操作);
3. 键唯一,避免重复 存 10 个用户的 ID - 昵称映射(1→张三,2→李四...)
总结:无键值关联用 ArrayList,有键值映射用 HashMap;10 个数据量小,性能差异可忽略,重点看 “后续如何使用数据”。
17. HashMap 的扩容机制?(你面试时类比分布式 ID,补充准确流程)
HashMap 扩容是 “解决数组容量不足,减少哈希冲突” 的核心机制,触发条件和流程如下:
(1)触发扩容的条件
当 size > 容量 × 负载因子 时触发扩容(size 是实际存储的键值对数量):
默认初始容量:16;
默认负载因子:0.75(平衡空间和性能:负载因子太高易冲突,太低浪费内存);
示例:默认情况下,size>12(16×0.75)时扩容。
(2)扩容流程(Java 8)
计算新容量:新容量 = 原容量 × 2(始终是 2 的幂,方便计算桶位);
创建新数组:按新容量创建新的桶数组;
迁移元素:将原数组中的元素(链表 / 红黑树)迁移到新数组:
遍历原数组的每个桶位;
若桶位是链表:计算每个元素在新数组的桶位(newIndex = (newCap - 1) & hash),按链表插入;
若桶位是红黑树:先判断是否需要转回链表(元素≤6),再迁移到新数组的对应桶位;
Java 8 优化:迁移时无需重新计算 hash,只需判断 hash 的 “新增位”(原容量的位),若为 0 则留在原桶位,若为 1 则迁移到 原桶位+原容量 的新桶位(减少计算开销)。
更新参数:将 HashMap 的容量更新为新容量,size 不变。
(3)核心注意点
扩容是 “耗时操作”(需迁移所有元素),尽量初始化时指定合适容量(如 new HashMap<>(100)),避免频繁扩容;
扩容后数组容量是 2 的幂,保证 (newCap - 1) & hash 等价于 hash % newCap,且计算效率更高。
算法部分:你提到 “算法老是记不住”,建议针对高频算法(二叉树、链表、动态规划)做 “模板总结”(如对称二叉树的递归 / 迭代模板),每天刷 1~2 题保持手感;
八股部分:你对 HashMap、类加载、安卓启动流程的细节掌握不够,建议用 “对比法” 记忆(如 HashMap 与 ConcurrentHashMap 对比、MVC/MVP/MVVM 对比);
项目部分:MVVM 架构的回答可以更结合项目场景(如 “项目中用 MVVM 解决了列表页频繁刷新的问题,ViewModel 缓存数据,旋转屏幕不重新请求”),让回答更具体。