Kotlin 与协程 — 面试题篇
更新: 5/15/2026 字数: 0 字 时长: 0 分钟
Q1: Kotlin 协程的挂起原理是什么?
考察点:协程底层实现
完整回答:
Kotlin 协程的挂起本质是 CPS(Continuation Passing Style)变换 + 状态机。
编译器将 suspend 函数转换为带 Continuation 参数的普通函数,函数体被改写为一个 when(label) 状态机。每个挂起点对应一个 label 状态。
执行到挂起点时:
- 保存当前局部变量到 Continuation 对象
- 设置下一个 label
- 调用挂起函数,如果返回
COROUTINE_SUSPENDED则函数返回(挂起) - 异步操作完成后,调用
continuation.resumeWith(result)恢复执行 - 恢复时从上次的 label 继续执行,从 Continuation 中恢复局部变量
关键点:协程挂起不会阻塞线程,线程可以去执行其他协程。恢复时由调度器决定在哪个线程继续执行。
追问:suspend 关键字的作用?
suspend 只是一个标记,告诉编译器这个函数可能挂起。编译器会为它添加 Continuation 参数并生成状态机代码。suspend 函数只能在协程或其他 suspend 函数中调用。
追问:协程比线程轻量在哪?
- 协程是用户态调度,不需要内核态切换(线程切换需要系统调用,开销约 1-10μs)
- 协程挂起时只保存少量状态(Continuation 对象),线程需要保存完整的栈帧(默认 1MB 栈空间)
- 一个线程上可以运行成千上万个协程
Q2: launch 和 async 的区别?异常处理有什么不同?
考察点:协程构建器与异常传播
完整回答:
launch 返回 Job,用于不需要返回值的场景。async 返回 Deferred(继承 Job),可以通过 await() 获取返回值。
异常处理的关键区别:
launch:异常立即向上传播到父协程,如果没有 CoroutineExceptionHandler,会导致整个协程作用域取消async:异常被封装在 Deferred 中,调用await()时才抛出
// launch 异常立即传播
val scope = CoroutineScope(Job())
scope.launch {
throw RuntimeException("boom") // 立即传播,scope 被取消
}
// async 异常延迟到 await
val deferred = scope.async {
throw RuntimeException("boom") // 暂时不传播
}
deferred.await() // 这里才抛出异常追问:CoroutineExceptionHandler 在哪里生效?
只在根协程(顶层 launch)中生效,子协程的异常会向上传播到父协程,不会被子协程的 handler 捕获。
val handler = CoroutineExceptionHandler { _, e -> println("Caught: $e") }
// ✅ 生效:handler 在根协程
CoroutineScope(Job() + handler).launch {
throw RuntimeException()
}
// ❌ 不生效:handler 在子协程
CoroutineScope(Job()).launch {
launch(handler) { // handler 在子协程,不生效
throw RuntimeException()
}
}追问:supervisorScope 和 coroutineScope 的区别?
coroutineScope:任何子协程失败,所有子协程都被取消supervisorScope:子协程失败不影响兄弟协程
SupervisorJob 的原理是重写了 childCancelled() 返回 false,不将子协程的失败传播给父协程。
Q3: Flow 和 LiveData 的区别?什么时候用哪个?
考察点:响应式数据流
完整回答:
| 维度 | Flow | LiveData |
|---|---|---|
| 所属 | Kotlin 协程库 | Android Jetpack |
| 生命周期感知 | 需要配合 lifecycleScope.collect | 自动感知,只在 STARTED 以上分发 |
| 操作符 | 丰富(map/filter/flatMap/combine/zip 等) | 只有 map/switchMap |
| 背压 | 支持(buffer/conflate/collectLatest) | 不支持 |
| 线程切换 | flowOn 指定上游线程 | 只在主线程观察 |
| 冷/热 | Flow 冷流,SharedFlow/StateFlow 热流 | 类似热流 |
选择建议:
- 纯 UI 层观察简单状态:StateFlow 或 LiveData 都可以
- 需要复杂数据变换、组合多个数据源:Flow
- Repository/数据层:Flow(不依赖 Android 框架)
- 新项目推荐全面使用 Flow + StateFlow 替代 LiveData
追问:StateFlow 和 SharedFlow 的区别?
- StateFlow:必须有初始值,replay = 1,自动 distinctUntilChanged(相同值不重复发射)
- SharedFlow:无需初始值,可配置 replay 和 buffer,不去重
StateFlow 适合表示状态(UI State),SharedFlow 适合表示事件(一次性事件如 Toast、导航)。
追问:LiveData 的粘性事件问题?
LiveData 内部有版本号机制,新观察者注册时如果版本号落后于最新值,会立即收到最后一次数据。这在事件场景下会导致重复消费(如 Toast 重复弹出)。解决方案:用 SharedFlow(replay=0) 替代,或用 Event 包装类。
Q4: Kotlin 的内联函数原理?什么时候用?
考察点:inline 机制
完整回答:
inline 函数在编译时将函数体和 lambda 参数直接内联到调用处,不会创建 Function 对象和额外的方法调用。
主要用途:
- 消除 lambda 开销:普通高阶函数每次调用会创建一个 Function 对象(匿名内部类),inline 消除这个开销
- reified 泛型:只有 inline 函数才能用 reified,在运行时获取泛型类型
- 非局部返回:inline lambda 中可以 return 直接从外层函数返回
inline fun <reified T> isType(value: Any): Boolean = value is T
// 调用处编译后直接展开为:value is String
isType<String>("hello")noinline:标记不需要内联的 lambda(需要将 lambda 存储或传递给非内联函数时)。 crossinline:禁止 lambda 中的非局部返回(lambda 会在其他执行上下文中调用时)。
追问:什么时候不该用 inline?
- 函数体很大时:内联会导致调用处代码膨胀
- 没有 lambda 参数时:没有 Function 对象开销需要消除,inline 没有意义
- 递归函数:无法内联
Q5: Kotlin data class 需要注意什么?
考察点:Kotlin 语法细节
完整回答:
data class 编译器自动生成 equals/hashCode/toString/copy/componentN。
注意事项:
- 只基于主构造函数参数:body 中声明的属性不参与 equals/hashCode
data class User(val name: String) {
var age: Int = 0 // 不参与 equals/hashCode!
}
User("张三").apply { age = 20 } == User("张三").apply { age = 30 } // true- copy 是浅拷贝:引用类型属性共享同一对象
- 必须有至少一个主构造函数参数
- 不能是 abstract/open/sealed/inner
- 解构声明按顺序:
val (name, age) = user依赖 component1/component2 的顺序,如果属性顺序变了会出 bug
追问:data class 和普通 class 在 HashMap 中的表现?
data class 自动生成基于属性值的 equals/hashCode,所以两个属性相同的 data class 实例在 HashMap 中被视为同一个 key。普通 class 默认用 Object 的 equals(引用比较),两个属性相同但不同实例的对象是不同的 key。
Q6: Kotlin 的扩展函数是怎么实现的?有什么限制?
考察点:扩展函数原理
完整回答:
扩展函数编译后是一个静态方法,接收者对象作为第一个参数:
fun String.addStar() = "*$this*"
// 编译为:
public static String addStar(String $this) { return "*" + $this + "*"; }限制:
- 静态分发:根据声明类型而非运行时类型调用,不支持多态
- 成员函数优先:如果类有同名同参数的成员函数,成员函数优先
- 不能访问 private/protected 成员:扩展函数本质是外部静态方法
- 可以被遮蔽:子类和父类定义同名扩展函数时,调用哪个取决于变量的声明类型
加分点:扩展函数非常适合给第三方库的类添加工具方法,比如给 Context 添加 toast 扩展、给 View 添加 visible/gone 扩展,代码更简洁且不需要继承。
Q7: 协程的结构化并发是什么?为什么重要?
考察点:协程生命周期管理
完整回答:
结构化并发是指协程的生命周期被限定在一个作用域(CoroutineScope)内,形成父子层级关系:
- 父协程取消时,所有子协程自动取消
- 父协程会等待所有子协程完成后才完成
- 子协程的异常会传播到父协程
// viewModelScope 绑定 ViewModel 生命周期
// ViewModel 销毁时,所有协程自动取消,不会泄漏
class MyViewModel : ViewModel() {
fun loadData() {
viewModelScope.launch {
val user = fetchUser() // ViewModel 销毁时自动取消
val posts = fetchPosts() // 不会泄漏
}
}
}为什么重要:
- 防止协程泄漏(类似内存泄漏)
- 不需要手动管理每个协程的取消
- 异常传播有明确的层级关系
追问:GlobalScope 为什么不推荐?
GlobalScope 的生命周期是整个应用进程,启动的协程不会自动取消,容易泄漏。而且 GlobalScope 没有父 Job,异常不会传播,难以管理。应该使用与组件生命周期绑定的 scope(viewModelScope、lifecycleScope)。
加分点:提到 coroutineScope {} 和 withContext() 都是结构化并发的体现——它们创建子作用域,等待内部所有协程完成后才返回。
Q8: Kotlin 的 object 关键字有哪些用法?
考察点:Kotlin 语法
完整回答:
三种用法:
- 对象声明(单例):
object AppConfig {
val baseUrl = "https://api.example.com"
}
// 编译为:static final INSTANCE + static 初始化块(线程安全的饿汉式单例)- 伴生对象(companion object):
class User {
companion object {
fun create(): User = User() // 类似 Java 的静态方法
}
}
User.create()- 匿名对象(对象表达式):
val listener = object : View.OnClickListener {
override fun onClick(v: View) { /* ... */ }
}
// 类似 Java 的匿名内部类,但可以实现多个接口追问:companion object 和 Java static 的区别?
companion object 本质是一个单例对象,不是真正的 static。Java 调用时需要 User.Companion.create()。加 @JvmStatic 注解才会生成真正的静态方法。
Q9: Kotlin 的协程取消是怎么工作的?
考察点:协程取消机制
完整回答:
协程取消是协作式的,不是强制中断。调用 job.cancel() 后:
- Job 的状态变为 Cancelling
- 在下一个挂起点(suspend 函数调用处)检查取消状态
- 如果已取消,抛出 CancellationException
- CancellationException 不会被当作异常传播(正常取消流程)
关键点:如果协程中没有挂起点(纯 CPU 计算),取消不会生效:
val job = launch {
var i = 0
while (i < 1000000) { // 没有挂起点,cancel 无效!
i++
}
}
job.cancel() // 不会立即取消
// ✅ 正确:检查 isActive
val job = launch {
var i = 0
while (isActive && i < 1000000) { // 检查取消状态
i++
}
}
// ✅ 或使用 ensureActive()
while (i < 1000000) {
ensureActive() // 已取消则抛 CancellationException
i++
}
// ✅ 或使用 yield()
while (i < 1000000) {
yield() // 让出执行权,同时检查取消
i++
}追问:取消后怎么做清理工作?
val job = launch {
try {
doWork()
} finally {
// 取消后执行清理
withContext(NonCancellable) {
// NonCancellable 确保即使已取消也能执行挂起函数
closeResource()
}
}
}Q10: Kotlin 的 val 和 var 的区别?val 是不是线程安全的?
考察点:Kotlin 基础
完整回答:
val:只读引用(类似 Java final),赋值后不能重新赋值var:可变引用,可以重新赋值
val 不等于不可变:
val list = mutableListOf(1, 2, 3)
list.add(4) // ✅ 可以修改内容!val 只是引用不可变
// list = mutableListOf() // ❌ 不能重新赋值val 不是线程安全的:
- 自定义 getter 每次可能返回不同值
- 引用的对象内部状态可能被其他线程修改
- 只有
val+ 不可变对象(如 String、data class)才是线程安全的
Q11: repeatOnLifecycle 和 flowWithLifecycle 是什么?为什么需要它们?
考察点:Flow 与生命周期
完整回答:
直接在 lifecycleScope.launch 中 collect Flow,即使 Activity 进入后台(onStop),collect 仍在继续,浪费资源甚至导致崩溃(如更新已销毁的 View)。
// ❌ 不安全:后台仍在收集
lifecycleScope.launch {
viewModel.uiState.collect { state -> updateUI(state) }
}
// ✅ 安全:只在 STARTED 以上状态收集
lifecycleScope.launch {
repeatOnLifecycle(Lifecycle.State.STARTED) {
viewModel.uiState.collect { state -> updateUI(state) }
}
}
// ✅ 简写(单个 Flow)
viewModel.uiState
.flowWithLifecycle(lifecycle, Lifecycle.State.STARTED)
.onEach { state -> updateUI(state) }
.launchIn(lifecycleScope)repeatOnLifecycle 在生命周期进入目标状态时启动协程,离开时取消,再次进入时重新启动。
实习面试补充:Kotlin 入门高频题
实习 Android 岗常把 Kotlin 当作基础能力考察,重点是空安全、常用语法和协程的正确使用方式。
Q12: Kotlin 的 ?、?.、?:、!! 分别是什么意思?
考察点:空安全
完整回答:
String?:表示变量可以为 null。?.:安全调用,左边为 null 时整体返回 null,不继续调用。?::Elvis 操作符,左边为 null 时返回右边的默认值。!!:非空断言,强制认为不为 null;如果实际为 null,会抛NullPointerException。
val name: String? = null
val length = name?.length ?: 0追问:开发中为什么要少用 !!?
!! 会绕开 Kotlin 的空安全检查,一旦数据为空就会崩溃。更推荐使用安全调用、默认值、提前 return 或明确的异常处理。
Q13: val 和 var 的区别?
考察点:变量声明、不可变思想
完整回答:
val声明只读引用,初始化后不能重新赋值。var声明可变变量,可以重新赋值。
val list = mutableListOf(1, 2)
list.add(3) // 可以,list 指向的对象内容可变
// list = mutableListOf(4) // 不可以,val 不能重新赋值加分点:优先使用 val 可以减少状态变化,降低代码理解和并发问题的风险。
Q14: data class 有什么作用?
考察点:Kotlin 常用类
完整回答:
data class 适合表示数据模型,编译器会自动生成:
equals()/hashCode()toString()copy()componentN()解构方法
data class User(val id: Long, val name: String)
val user = User(1, "Tom")
val newUser = user.copy(name = "Jerry")追问:data class 适合做什么?
适合接口响应、列表 item、页面 UI 状态等数据承载对象。不适合承载复杂业务行为或需要继承体系的对象。
