Kotlin中的自动拆装箱

在 Kotlin 中,对于基本数据类型的包装类,比如 IntegerBoolean 等,Kotlin 设计了一套特殊的类,被称为原生类型的包装类或者叫做原生类型的对象,例如 IntBoolean 等。这些类的行为表现得如同 Java 的基本类型,同时它们具备了对象的一些特性。在编译阶段,Kotlin 会尽量使用 JVM 的原生类型来提高性能,但在需要时(例如作为泛型参数时),这些原生类型会自动装箱。

自动装箱与拆箱

Kotlin 处理原生类型和装箱类型的自动转换,以保证性能同时提供丰富的类库支持。这个过程包括两个部分:自动装箱(boxing)和自动拆箱(unboxing)。

  • 装箱(Boxing):当一个原生类型的值需要作为对象处理时,它会自动被装入对应的包装类。例如,当你将一个 int 值放入一个泛型集合如 List<Int> 时,这个值会自动被装箱成 Integer
  • 拆箱(Unboxing):当从对象中需要一个原生类型的值时,这个包装对象会自动被拆箱。例如,从 List<Int> 中取出一个元素时,它会自动从 Integer 转换为 int

示例

1
2
val list: List<Int> = listOf(1, 2, 3)  // 装箱
val x: Int = list[0] // 拆箱

在上面的例子中,整数列表中的数字自动被装箱成 Integer 类型的对象以存入 List<Int>。当从列表中检索一个整数时,它自动拆箱回 Int 类型。

注意事项

虽然 Kotlin 试图隐藏装箱和拆箱的复杂性,但在某些情况下,装箱对象的身份不会保留。例如,两个独立装箱的整数可能不会在内存中具有相同的引用:

1
2
3
4
val a: Int = 1000
val boxedA: Int? = a
val anotherBoxedA: Int? = a
println(boxedA === anotherBoxedA) // 可能输出 false

在上面的代码中,boxedAanotherBoxedA 是相同原始值的两个独立的装箱实例。使用 === 比较它们的引用时可能得到 false,因为它们可能指向不同的对象。

总结

Kotlin 在编写代码时提供了类似于基本数据类型的简洁性和效率,同时也保留了对象的灵活性。通过自动装箱和拆箱,Kotlin 旨在提供无缝的集合操作和泛型支持,同时减少需要程序员关注的底层细节。

协程中的取消与异常:取消操作详解

在 Android 开发中,及时取消不再需要的协程对于节约设备电量和内存至关重要。协程的取消并非“强制杀死”,而是一种协作式(Cooperative)的过程。


1. 取消的层级特性

取消作用域会取消其所有子协程

当你取消一个 CoroutineScope 时,它会传播并取消该作用域下启动的所有子协程。这在 Activity 销毁时批量清理任务非常有用。

1
2
3
4
5
6
// 假设 scope 是一个已经定义好的作用域
val job1 = scope.launch { /* ... */ }
val job2 = scope.launch { /* ... */ }

// 取消作用域,job1 和 job2 都会被取消
scope.cancel()

单独取消某个协程不影响兄弟协程

如果你只想停止某个特定的任务,可以单独调用该 Job 的 cancel() 方法,这不会影响同一个作用域下的其他协程。

1
2
3
4
5
val job1 = scope.launch { /* ... */ }
val job2 = scope.launch { /* ... */ }

// 只取消第一个协程,第二个不受影响
job1.cancel()

2. 取消的底层机制:CancellationException

协程通过抛出一个特殊的异常 CancellationException 来处理取消。

  • 调用 cancel() 时可以传入原因。
  • 这是一个静默异常,不会导致应用崩溃,但它会沿着协程树向上通知父协程,向下取消子协程。
1
2
3
4
// 源码逻辑片段
public override fun cancel(cause: CancellationException?) {
cancelInternal(cause ?: defaultCancellationException())
}

3. 为什么我的协程调用了 cancel 却没有停止?

这是初学者最常遇到的坑。协程的取消是协作式的。如果你的代码正在执行密集的计算任务且没有检查取消状态,它会一直运行直到任务结束。

错误示例:不可取消的协程

1
2
3
4
5
6
7
8
9
10
11
12
val job = launch(Dispatchers.Default) {
var i = 0
while (i < 5) { // 没有任何地方检查取消状态
if (System.currentTimeMillis() >= nextTime) {
println("这是第${i}次")
i++
nextTime += 1000
}
}
}
delay(1000)
job.cancel() // 调用了取消,但上面的 while 循环依然会跑完

4. 如何让协程变得“可取消”?

要让协程支持取消,你需要定期检查其活跃状态。

方法 A:检查 isActiveensureActive()

在循环体中检查 isActive 状态。ensureActive() 的好处是它会在已取消时直接抛出异常。

1
2
3
4
5
val job = launch(Dispatchers.Default) {
while (i < 5 && isActive) { // 检查 isActive
// 执行任务...
}
}

方法 B:使用 yield()

yield() 会检查当前协程是否已取消。如果是,则抛出 CancellationException。此外,它还会让出 CPU 资源,让其他协程有机会运行。

方法 C:调用挂起函数

绝大多数官方挂起函数(如 delay(), withContext(), await())都是可取消的。它们在执行前都会自动检查取消状态。


5. 处理取消后的清理工作

当协程被取消时,你可能需要释放资源或打印日志。

使用 try-finally

由于取消会抛出异常,finally 块是执行清理逻辑的最佳场所。

1
2
3
4
5
6
7
8
val job = launch {
try {
doWork()
} finally {
// 无论是否被取消,这里都会执行
println("清理资源...")
}
}

特殊场景:在取消后仍需执行挂起函数

默认情况下,已经取消的协程无法再次挂起。如果你必须在 finally 中调用挂起函数(例如关闭数据库连接),需要将其包装在 NonCancellable 上下文中:

1
2
3
4
5
6
finally {
withContext(NonCancellable) {
delay(1000L) // 在已取消的协程中仍能正常挂起
println("清理完成")
}
}

6. 核心面试考点总结

  1. 取消是协作的:协程不会被强行停止,必须在代码中检查 isActive 或调用挂起函数。
  2. join vs cancel:调用 cancel() 只是发出取消指令,协程可能还在运行;调用 cancelAndJoin() 则会挂起当前协程,直到目标协程彻底结束。
  3. NonCancellable 的用途:专门用于清理逻辑中需要调用挂起函数的场景。

Kotlin中有哪些类

在 Kotlin 中,类的概念是非常广泛的,包括各种类型的类设计用于不同的目的和场景。Kotlin 提供了丰富的类类型以支持现代软件开发的需要。下面是一些在 Kotlin 中常见的类类型:

1. 数据类(Data Class)

数据类是专门用于存储数据的类。Kotlin 的数据类通过 data 关键字定义,它自动从所声明的属性中派生出 equals()hashCode()toString() 等方法,以及 copy() 函数和 componentN() 函数(按声明顺序对应于所有属性)。

1
data class User(val name: String, val age: Int)

2. 枚举类(Enum Class)

枚举类用于定义一组命名常量。Kotlin 中的枚举不仅可以有属性,还可以有自己的方法。

1
2
3
enum class Direction {
NORTH, SOUTH, EAST, WEST;
}

3. 密封类(Sealed Class)

密封类用于表示受限的类层次结构,即一个值只能是有限集合中的某个类型,而不能是任何其他类型。这对于当你在使用 when 表达式时,想要确保覆盖所有可能的类型非常有用。

1
2
3
4
sealed class Expr
data class Const(val number: Double) : Expr()
data class Sum(val e1: Expr, val e2: Expr) : Expr()
object NotANumber : Expr()

4. 抽象类(Abstract Class)

抽象类是不能被实例化的类,通常用作其他类的基类。抽象类可以包含抽象方法(没有实现的方法)和非抽象方法。

1
2
3
4
abstract class Vehicle {
abstract fun drive()
fun park() { println("Parked") }
}

5. 内部类(Inner Class)

内部类是定义在另一个类内部的类。内部类持有其外部类的一个引用,因此可以访问其成员。

1
2
3
4
5
6
class Outer {
private val bar: Int = 1
inner class Inner {
fun foo() = bar
}
}

6. 嵌套类(Nested Class)

与内部类相比,嵌套类没有对外部类的隐式引用。

1
2
3
4
5
6
class Outer {
private val bar: Int = 1
class Nested {
fun foo() = 2
}
}

7. 对象声明(Object Declaration)

Kotlin 支持对象声明,这是实现单例模式的一种方式。对象声明的实例自动成为一个单例。

1
2
3
4
5
object DataProviderManager {
fun registerDataProvider(provider: String) {
println("Provider registered: $provider")
}
}

8. 伴生对象(Companion Object)

在 Kotlin 中,没有静态方法,但可以用伴生对象来模拟静态方法的效果。伴生对象的成员可以通过类名直接访问。

1
2
3
4
5
class MyClass {
companion object Factory {
fun create(): MyClass = MyClass()
}
}

9. 接口(Interface)

虽然不是类,但接口在 Kotlin 中用于定义可以由类实现或继承的协定。

1
2
3
4
5
interface Drivable {
fun drive() {
println("Driving")
}
}

这些类类型展示了 Kotlin 语言的灵活性和现代特性,旨在提供简洁而强大的语法来支持各种编程范式和设计模式。

密封类

在 Kotlin 中,密封类(sealed class)是一种特殊的类,它用于表示严格的类层次结构。使用密封类,你可以定义一个类的可能的子类集合,而且这些子类只能在与密封类相同的文件中定义。这种限制确保了除文件内定义的子类之外,无法有其他子类存在,从而使得使用时更加安全和维护更加方便。

密封类的主要特点和优势:

  1. 受限的继承

    • 密封类本身是抽象的,不能直接实例化,只能通过其子类进行实例化。
    • 所有的子类必须与密封类在同一个文件中声明,这提高了可维护性,因为所有扩展都在一个集中的位置。
  2. 类型安全

    • 密封类非常适合用在 when 表达式中,因为它们可以确保覆盖所有可能的情况,不需要再添加一个 else 子句。这是因为编译器能够检测到所有定义的子类。
  3. 更精确的控制

    • 使用密封类可以精确控制类的继承结构,这对于构建不可变数据类型和状态管理非常有用。

密封类的用法示例:

首先,定义一个密封类,然后在同一个文件中定义其所有子类:

1
2
3
4
5
6
7
8
9
10
11
sealed class Expr {
data class Const(val number: Double) : Expr()
data class Sum(val e1: Expr, val e2: Expr) : Expr()
object NotANumber : Expr()
}

fun eval(expr: Expr): Double = when (expr) {
is Expr.Const -> expr.number
is Expr.Sum -> eval(expr.e1) + eval(expr.e2)
Expr.NotANumber -> Double.NaN
}

在这个例子中,Expr 是一个密封类,有三个子类:ConstSumNotANumber。这使得 eval 函数可以安全地使用 when 表达式来处理所有可能的 Expr 类型,而不需要 else 分支,因为编译器知道所有可能的子类。

使用密封类的场景:

  • 状态管理:在应用程序状态管理或者在处理有限状态机(FSM)时,密封类提供了一种清晰的方式来表示所有可能的状态。
  • 返回类型的多样性:在函数需要返回多种类型的结果时,可以使用密封类来封装这些不同类型的返回值。
  • 在模式匹配中增强类型安全:如上面示例中的 eval 函数,使用密封类可以确保 when 表达式已经处理了所有可能的情况,这在处理复杂的逻辑分支时非常有帮助。

通过这种方式,Kotlin 的密封类增加了代码的安全性和清晰度,特别是在需要表达一个有限的类层次结构时。

内联类

Kotlin 1.3 引入了内联类,主要目的是提供一种无开销的抽象方式。内联类允许你创建一个包含单个属性的类,当这个类被使用时,它会在编译时被内联,即直接替换为它包含的那个值,从而避免了额外的内存分配和间接访问。

内联类的定义和使用

内联类定义时需要使用 inline 关键字,且必须有一个主构造函数,该构造函数恰好接收一个参数:

1
inline class Password(val value: String)

这里的 Password 类包裹了一个字符串,但在编译后,Kotlin 编译器会尽可能将 Password 类的实例替换为简单的 String 类型,从而减少对象创建的开销。当你在代码中使用 Password 类型时,例如将它作为函数参数或从函数中返回时,实际上传递的将是一个 String 类型。

内联类的特点和优势

  1. 性能优化:内联类主要用于性能优化,可以避免对象分配,并减少方法调用的层次。
  2. 类型安全:虽然内联类在运行时表现为它们包装的类型(例如 StringInt),但在编译时,它们是不同的类型。这意味着你可以用它们来实现类型安全的操作,例如防止将普通字符串与经过验证的密码字符串混淆。
  3. 限制:内联类不能有初始化块 (init 块),它们也不能包含其他属性或构造函数。此外,内联类可以实现接口,但不能从其他类继承。

示例代码

1
2
3
4
5
6
7
8
9
10
inline class Password(val value: String)

fun takePassword(password: Password) {
println("Password is ${password.value}")
}

fun main() {
val password = Password("my_secret_password")
takePassword(password) // 在这里,password 被内联,实际传递的是一个 String 对象
}

在这个例子中,尽管我们定义了一个名为 Password 的内联类,并在函数 takePassword 中使用它,实际上,在编译后,这些函数调用会直接使用 String 类型,而不会有任何包装和解包的性能开销。

结论

内联类是 Kotlin 提供的一种非常有用的特性,特别适合那些需要通过类型来提供更丰富语义但又不想引入运行时开销的场景。通过内联类,Kotlin 开发者可以在享受类型安全的同时,保持代码的高性能。

Kotlin 协程异常处理与上报机制

kotlinx.coroutines 项目中,异常的处理和上报遵循结构化并发(Structured Concurrency)的原则。异常并非简单的 try-catch 就能捕获,而是有一套从子协程到父协程层层上报的机制。

1. 异常传播的核心规则

协程构建器分为两类,它们处理异常的方式不同:

  • 自动传播(launch / produce):这些构建器将异常视为未捕获异常。它们会立即将异常向上传播给父协程,最终由 CoroutineExceptionHandler 或线程的未捕获异常处理器处理。
  • 暴露给用户(async / broadcast):这些构建器依赖用户最终消费异常。例如,通过 await()receive() 来捕获。如果未被消费,异常会被封装在 Deferred 对象中。

2. 异常上报的层层传递过程

当一个子协程抛出非 CancellationException 的异常时,会发生以下过程:

  1. 子协程失败:子协程捕获到异常。
  2. 上报父协程:子协程将异常传递给它的父 Job。
  3. 父协程取消:父协程接收到异常后,会首先取消自身,并同时取消其它的子协程。
  4. 继续向上传递:父协程继续将异常上报给它的父协程,直到到达根协程(Root Coroutine)。
  5. 最终处理:一旦到达根协程,异常将通过上下文中的 CoroutineExceptionHandler 进行处理。如果没有安装处理器,则由 platform 相关的默认机制处理(如 Android 的应用崩溃或 JVM 的标准错误输出)。

3. 监督机制(Supervision):阻断上报

有时我们不希望一个子协程的失败导致整个协程树崩溃,这时可以使用监督机制:

  • SupervisorJob:父协程使用 SupervisorJob 时,子协程的失败不会导致父协程取消,也不会影响兄弟协程。异常会直接由子协程尝试在自己的上下文中使用 CoroutineExceptionHandler 处理(如果存在)。
  • supervisorScope:创建一个监督作用域,其内部发生的异常不会向上传播给作用域外的父协程。

4. 异常聚合(Exception Aggregation)

如果多个子协程同时抛出异常:

  • 第一个异常胜出:第一个抛出的异常会被上报并处理。
  • 后续异常被抑制:在第一个异常之后发生的其它异常会被附加到第一个异常的 suppressed 列表中,不会丢失,但也不会触发多次处理逻辑。

5. 总结

在本项目中,try-catch 的有效性取决于你在哪里使用它:

  • launch 内部:可以捕获并内部消化。
  • asyncawait() 调用处:可以捕获该异步任务的异常。
  • 在协程外部或父协程级别:通常无法直接通过普通的 try-catch 拦截子协程的异常,必须依赖 CoroutineExceptionHandlersupervisorScope

Kotlin 协程异常处理源码解析:从抛出到拦截的全链路追踪

本文将深入 kotlinx.coroutines 源码,解析一个异常是如何从子协程产生,并一步步通过 Job 层次结构向上汇报,最终被拦截或导致程序崩溃的。

1. 异常的源头:协程体的执行

launchasync 中,协程体被包装在 AbstractCoroutine 的实现类中。当协程体执行抛出异常时,由于它运行在 Continuation 的环境下,异常会被 resumeWith 捕获。

什么是 Continuation

在 Kotlin 协程中,Continuation 是实现挂起和恢复的核心接口。它本质上是一个通用的回调接口,代表了程序运行到某个挂起点之后“接下来的逻辑”。

1
2
3
4
public interface Continuation<in T> {
public val context: CoroutineContext
public fun resumeWith(result: Result<T>)
}
  • **resumeWith(result: Result)**:这是核心方法。当异步操作完成或协程体执行结束时,会调用此方法。如果操作成功,result 携带结果值;如果发生了异常,result 则携带异常信息(Result.failure)。

简单来说,Continuation 就像是一个信使,它不仅知道该去哪(Context),还负责把信(Result)送到目的地。

Continuation 是如何实现挂起的

挂起(Suspend)并不是阻塞线程,而是函数在执行过程中,保存当前的执行状态(Continuation),然后退出当前的栈帧,将线程控制权交还给调度器。

其实现机制的核心在于 CPS(Continuation-Passing-Style)变换

  1. 状态机拆分:Kotlin 编译器会将带有 suspend 关键字的函数转换成一个内部状态机。每一个挂起点(如 delay 或其他挂起函数)都会将函数体拆分成不同的状态。
  2. 传递 Continuation:每一个挂起函数在编译后,都会在参数列表的最后多出一个 Continuation 参数。
  3. COROUTINE_SUSPENDED 标记:当挂起函数被调用时,它会检查是否需要真正挂起。如果需要,它会返回一个特殊值 COROUTINE_SUSPENDED。此时,调用方的状态机就会感知到挂起,并立即返回,从而释放线程。
  4. 恢复执行:当异步操作完成时,会调用之前保存的 Continuation.resumeWith。这会触发状态机进入下一个状态,协程从而在之前挂起的地方“复活”。

补充理解:保存状态与“复活”的本质

很多开发者对“保存状态” and “复活”感到抽象,我们可以从 JVM 内存模型的角度来拆解这个过程:

  • 保存状态(堆内存化):在普通的 JVM 函数中,局部变量和执行进度(程序计数器)都保存在栈帧(Stack Frame)中。一旦函数返回,栈帧就被销毁。
    • 在协程中,当遇到挂起点时,编译器生成的代码会将栈帧里的局部变量、参数以及当前的“执行进度标记”(label)全部拷贝到 Continuation 对象的成员变量中。
    • 因为 Continuation 实例存储在堆(Heap)上,所以即使函数返回、栈帧销毁,这些数据依然存在。
  • 退出栈帧:函数通过 return COROUTINE_SUSPENDED 结束执行。此时,当前线程被释放,可以去处理其他的任务,实现了非阻塞。
  • “复活”(重入状态机)
    • resumeWith 被调用时,协程的代码块(状态机函数)会被再次调用
    • 函数重新进入后,第一步就是从堆上的 Continuation 对象中读取之前保存的成员变量,重新赋值给局部变量。
    • 随后,根据保存的 label 进行跳转(类似于 switch-case),直接跳到上次挂起的位置继续执行。

通过这种“数据从栈到堆的迁移与回填”,协程模拟出了函数暂停和恢复的神奇效果。

伪代码模拟:编译器对挂起函数的改造

为了让“保存状态”和“复活”更好理解,我们可以通过伪代码看看编译器是如何将一个普通的挂起函数转换成状态机的:

原始代码:

1
2
3
4
5
suspend fun showInfo() {
val user = "Jason" // 局部变量
delay(1000) // 挂起点
println(user) // 恢复后需要用到 user
}

编译器改造后的伪代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
// 1. 自动生成一个包装类(存档文件),存储在堆上
class ShowInfoContinuation(val completion: Continuation<Any?>) : ContinuationImpl(...) {
var user: String? = null // 用于保存局部变量
var label: Int = 0 // 用于保存执行进度(状态)

override fun resumeWith(result: Result<Any?>) {
showInfo(this) // 恢复时再次调用原函数,并传入自己
}
}

// 2. 原函数被改造,增加了一个 Continuation 参数
fun showInfo(completion: Continuation<Any?>) {
// 检查是否是第一次运行,还是从存档恢复
val cont = completion as? ShowInfoContinuation ?: ShowInfoContinuation(completion)

// 核心状态机切换
when (cont.label) {
0 -> {
// --- 第一阶段:初次运行 ---
cont.user = "Jason" // 【保存状态】将局部变量存入堆对象
cont.label = 1 // 【记录进度】标记下一步该从哪开始

// 尝试执行 delay
if (delay(1000, cont) == COROUTINE_SUSPENDED) {
return // 【退出栈帧】直接返回,不阻塞当前线程
}
}
1 -> {
// --- 第二阶段:复活 ---
// 函数重新进入后,根据 label 直接跳到这里执行
val user = cont.user // 【恢复数据】从堆中读回局部变量
println(user)
}
}
}

总结“复活”的逻辑

  1. 挂起时:函数执行到一半,发现需要等待(如 delay)。它把当前的局部变量 user 和进度 label = 1 存进堆里的 cont 对象,然后直接 return
  2. 等待中:当前线程空闲了,去干别的事了(比如处理其他的协程)。
  3. 时间到delay 定时器触发,它会调用 cont.resumeWith()
  4. 复活时resumeWith 内部再次触发 showInfo(cont)。因为 cont.label 已经是 1 了,函数通过 when 分支直接跳过了第一阶段,从第二阶段开始执行,并通过 cont.user 拿回了之前的数据。

这就是协程能够“复活”的奥秘:它不是在同一个函数调用里死等,而是通过多次“重新进入”函数,并配合堆内存中的数据备份,模拟出了暂停的效果。

深入理解:AbstractCoroutine 也是 Continuation

在 Kotlin 协程底层,每一个协程块最终都被编译为一个 Continuation。在调用 launchasync 时,创建的 AbstractCoroutine 实例本身就实现了 Continuation<T> 接口。

源码解析:
当协程体(block)执行结束(无论是正常返回还是抛出异常)时,Kotlin 编译器生成的代码会调用该协程所属的 completion.resumeWith(result)。对于协程库来说,这个 completion 就是 AbstractCoroutine

1
2
3
4
5
6
7
8
9
10
11
// AbstractCoroutine.kt
public abstract class AbstractCoroutine<in T>(...) : JobSupport(active), Job, Continuation<T>, CoroutineScope {

// 当协程体抛出异常,编译器会自动调用此方法,result 此时为 Result.failure(exception)
public final override fun resumeWith(result: Result<T>) {
// result.toState() 会将异常包装成 CompletedExceptionally 对象
val state = makeCompletingOnce(result.toState())
if (state === COMPLETING_WAITING_CHILDREN) return
afterResume(state)
}
}

这意味着,协程体外部并没有一个传统意义上的 JVM try-catch 块来捕获异常。相反,它是通过 Continuation 的回调机制,将异常作为“结果”传递给了 AbstractCoroutineresumeWith 方法,并由此开启了后续的异常上报流程。

2. 状态机流转:JobSupport.makeCompletingOnce

resumeWith 调用 makeCompletingOnce 进入 JobSupport 的状态机处理逻辑。如果 result 是失败的,它会携带一个 CompletedExceptionally 状态。

核心代码位置: JobSupport.kt

tryMakeCompletingSlowPath 中,如果发现有异常,会进入异常处理流程:

1
2
3
4
5
6
7
8
9
10
11
// JobSupport.kt
val notifyRootCause: Throwable?
synchronized(finishing) {
// ...
// 添加异常到 Finishing 状态中
(proposedUpdate as? CompletedExceptionally)?.let { finishing.addExceptionLocked(it.cause) }
// 如果是第一次变更为取消状态,则记录 rootCause
notifyRootCause = finishing.rootCause.takeIf { !wasCancelling }
}
// 触发取消通知
notifyRootCause?.let { notifyCancelling(list, it) }

3. 向上汇报:childCancelledparentHandle

核心枢纽:parentHandle 的建立

在协程的层级结构中,每一个子 Job 都持有一个指向父 Job 的引用,这个引用被封装在 parentHandle 成员变量中。

源码解析: JobSupport.kt

当一个子协程被创建时(例如通过 launch),它会调用 initParentJob 方法:

1
2
3
4
5
6
7
8
9
10
// JobSupport.kt
protected fun initParentJob(parent: Job?) {
if (parent == null) {
parentHandle = NonDisposableHandle
return
}
// 关键:将当前子 Job 挂载到父 Job 上
// attachChild 会返回一个 ChildHandle 对象,子 Job 将其保存为 parentHandle
parentHandle = parent.attachChild(this)
}

这个 parentHandle 类型为 ChildHandle,它是双向通信的桥梁:

  1. 父到子:父协程取消时,通过这个 Handle 通知子协程取消(调用 parentCancelled)。
  2. 子到父:子协程发生异常时,通过调用 parentHandle.childCancelled(cause) 向上汇报。

异常的上报过程

一旦子协程通过 resumeWith 捕获到异常并进入取消状态,它会立即通过 parentHandle 寻找它的“家长”。

JobSupport 中,子 Job 通过 ChildHandleNodeChildHandle 的具体实现)与父 Job 关联。

核心代码位置: JobSupport.kt 中的 ChildHandleNode

1
2
3
4
5
6
7
private class ChildHandleNode(
@JvmField val childJob: ChildJob
) : JobNode(), ChildHandle {
// ...
// 当子 Job 发生异常调用时,实质上是调用了父 Job 的 childCancelled
override fun childCancelled(cause: Throwable): Boolean = job.childCancelled(cause)
}

父 Job 的 childCancelled 方法会被触发:

1
2
3
4
5
public open fun childCancelled(cause: Throwable): Boolean {
if (cause is CancellationException) return true // 正常取消不向上汇报
// 关键:尝试取消自身并看是否能处理
return cancelImpl(cause) && handlesException
}
  • 如果父 Job 是普通的 JobcancelImpl 会返回 true 并在内部继续调用自己的父 Job 的 childCancelled,实现层层上报
  • 如果父 Job 是 SupervisorJob,它会重写此逻辑或返回 false,从而切断异常向上传播

什么是 SupervisorJob?

从源码上看,SupervisorJob 的实现极其简单且暴力。

源码解析: Supervisor.kt

1
2
3
4
private class SupervisorJobImpl(parent: Job?) : JobImpl(parent) {
// 直接重写 childCancelled,永远返回 false
override fun childCancelled(cause: Throwable): Boolean = false
}

当子协程报错并通过 parentHandle.childCancelled(cause) 通知 SupervisorJob 时:

  1. 它执行重写后的方法,直接返回 false
  2. 它不会调用 cancelImpl 导致自己取消。
  3. 因为它自己没有被取消,所以也就不会去取消它的其他子协程。

这就是为什么 SupervisorJob 能够实现“子协程故障互不干扰”的根本原因。

4. 根协程的终点:handleJobException

异常上报到最顶层的根协程(没有父 Job 的协程)后,cancelImpl 流程结束。此时,协程会进入完成阶段,并调用 handleJobException

对于 launch 启动的协程,其实际实现类是 StandaloneCoroutine

核心代码位置: Builders.common.kt

1
2
3
4
5
6
7
private open class StandaloneCoroutine(...) : AbstractCoroutine<Unit>(...) {
override fun handleJobException(exception: Throwable): Boolean {
// 最终调用全局的异常处理器
handleCoroutineException(context, exception)
return true
}
}

5. 最后的拦截:handleCoroutineException

这是异常处理的最后一站。它会检查协程上下文中是否有用户定义的 CoroutineExceptionHandler

核心代码位置: CoroutineExceptionHandler.kt

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
public fun handleCoroutineException(context: CoroutineContext, exception: Throwable) {
// 1. 尝试从上下文中获取拦截器
try {
context[CoroutineExceptionHandler]?.let {
it.handleException(context, exception)
return
}
} catch (t: Throwable) {
// 如果拦截器本身抛出异常,则交给全局兜底
handleUncaughtCoroutineException(context, handlerException(exception, t))
return
}
// 2. 如果没找到,交给平台相关的兜底处理器 (如 JVM 的 ServiceLoader 查找或 Thread.uncaughtExceptionHandler)
handleUncaughtCoroutineException(context, exception)
}

6. 源码解析总结:调用链图示

当子协程发生 Exception 时:

  1. AbstractCoroutine.resumeWith(Result.failure)
  2. JobSupport.makeCompletingOnce -> 状态变为 Cancelling/Finishing
  3. JobSupport.notifyCancelling -> 取消所有子协程
  4. parentHandle.childCancelled(exception) -> 向父 Job 汇报
  5. (循环上报直至 Root)
  6. Root Coroutine 调用 handleJobException
  7. handleCoroutineException(context, exception)
  8. CoroutineExceptionHandler.handleException(...) -> 用户代码拦截点

7. SupervisorJob 的使用场景与示例

在理解了 SupervisorJob 的源码原理后,我们来看它在实际开发中的典型应用。

场景一:UI 组件的生命周期作用域

在 Android 开发中,一个 ViewModel 可能同时发起多个不相关的请求(如用户信息、配置信息、广告位信息)。如果其中一个接口报错,我们通常不希望导致整个页面崩溃或者其它正在进行的请求被取消。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
// 使用 SupervisorJob 确保子协程互不影响
val viewModelScope = CoroutineScope(SupervisorJob() + Dispatchers.Main)

fun fetchData() {
viewModelScope.launch {
// 请求 A 失败,由于父 Job 是 SupervisorJob,不会影响请求 B
throw IOException("Failed to load ads")
}
viewModelScope.launch {
// 请求 B 继续运行
val data = api.getUserInfo()
println("User info loaded: $data")
}
}

场景二:使用 supervisorScope 处理局部故障

如果你只想在某个特定的业务逻辑块中实现故障隔离,可以使用 supervisorScope。它会创建一个临时的监督作用域。

1
2
3
4
5
6
7
8
9
10
11
12
13
suspend fun processTasks() = supervisorScope {
val job1 = launch {
println("Task 1 started")
throw RuntimeException("Task 1 crashed")
}

val job2 = launch {
println("Task 2 started")
delay(100)
println("Task 2 completed successfully")
}
// supervisorScope 会等待 job1 和 job2 全部完成
}

关键区别:SupervisorJob vs supervisorScope

  • SupervisorJob:通常用于定义一个长生命周期的作用域(Scope),作为整个作用域的根 Job(如全局 Scope 或 ViewModel Scope)。
  • supervisorScope:是一个挂起函数。它会创建一个子 Job 并将其设为 Supervisor,然后等待内部所有协程完成。它适合在现有的协程流中临时开启一段并行的、容错的任务。

注意事项:异常依然需要处理

虽然 SupervisorJob 阻止了异常向上传播,但异常本身并没有消失

  1. 如果子协程使用 launch 启动,且没有内部 try-catch,异常最终会触发 handleCoroutineException
  2. 如果此时没有配置 CoroutineExceptionHandler,在 Android 等平台上仍然可能导致程序崩溃。
  3. 结论SupervisorJob 只是保护了“邻居”不被牵连,你仍然需要为每个子任务提供异常处理逻辑。

Kotlin 协程上下文与 CombinedContext 源码解析

在 Kotlin 协程中,CoroutineContext 是一组元素的集合,如 JobDispatcherCoroutineName 等。虽然从逻辑上看它像是一个 Map,但为了性能和协程的特殊需求,它的底层并没有使用 java.util.HashMap,而是使用了一种特殊的递归链表结构:**CombinedContext**。

1. 逻辑上的 Map,物理上的链表

CoroutineContext 定义了类似 Map 的 API:

  • get(key):获取元素。
  • plus(context):合并上下文(使用 + 运算符)。
  • minusKey(key):移除元素。

然而,它的实现类只有三种:

  1. **EmptyCoroutineContext**:空集合。
  2. **CoroutineContext.Element**:单个元素(如 Dispatchers.Main 本身就是一个 Element)。
  3. **CombinedContext**:将两个上下文连接在一起的容器。

2. CombinedContext 的结构

CombinedContext 定义在 Kotlin 标准库中。它通过 leftelement 两个字段构成一个二叉树状的链表(通常向左倾斜)。

1
2
3
4
5
6
7
// 逻辑示意(标准库实现)
internal class CombinedContext(
private val left: CoroutineContext,
private val element: Element
) : CoroutineContext {
// ...
}
  • **左侧 (left)**:可以是另一个 CombinedContext 或一个 Element
  • **右侧 (element)**:始终是一个单体 Element

这种设计使得上下文的合并(+ 运算)非常轻量,不需要像 HashMap 那样计算哈希值或调整数组大小,只需创建一个新的 CombinedContext 节点即可。

举例说明:

当你写下 val context = Job() + Dispatchers.Main 时:

  1. Job() 是一个 Element
  2. Dispatchers.Main 也是一个 Element
  3. + 操作符会触发 plus 函数,最终创建一个 CombinedContext 实例:
    CombinedContext(left = Job, element = Dispatchers.Main)

如果继续合并:context + CoroutineName("MyContext"),则会再包裹一层:
CombinedContext(left = CombinedContext(Job, Main), element = CoroutineName)

这种结构类似于剥洋葱,最新的元素总是被包裹在最外层的 element 字段中,而旧的集合则被推入 left 指针。


3. 为什么不使用 HashMap?

1. 元素数量极少

大多数协程上下文只包含 2-4 个元素(例如:Job + Dispatcher + Name)。在元素数量如此之少的情况下,遍历微型链表的开销远小于 HashMap 的维护开销。

2. 不可变性与持久化

CoroutineContext 是不可变的。每次 + 运算都会产生新的上下文,而旧的上下文保持不变。CombinedContext 的设计天然支持这种持久化数据结构(Persistent Data Structure),允许新旧上下文共享节点。

详细示例:节点共享机制

假设我们有以下代码:

1
2
val contextA = Job() + Dispatchers.IO
val contextB = contextA + CoroutineName("MyCoroutine")

在底层的物理存储中,它们的关系如下:

  1. contextA 的结构:

    • CombinedContext(left = Job, element = Dispatchers.IO)
  2. contextB 的结构:

    • CombinedContext(left = contextA, element = CoroutineName("MyCoroutine"))

关键点在于:

  • contextB 并没有拷贝 JobDispatchers.IO
  • 它的 left 指针直接指向了现有的 contextA 实例。
  • 如果你启动了 1000 个协程,它们都基于同一个 ParentContext 增加自己的 CoroutineId,那么这 1000 个子上下文的 left 字段都指向内存中同一个 ParentContext 对象。

对比 HashMap:
如果使用 HashMap,每次添加元素(由于不可变性)都需要创建一个新的 Map 实例,并把旧 Map 中的所有 Entry 重新 Put 进去(或者进行复杂的写时复制)。对于频繁创建协程的场景,这会产生大量的垃圾回收压力。而 CombinedContext 只需要创建一个包含两个引用的新对象。

3. 内存效率

对于只有 1-2 个元素的上下文,使用 HashMap 会产生显著的内存浪费(Entry 对象、内部数组等),而 CombinedContext 仅需一个包含两个引用的对象。

4. 特殊优化:ThreadState 与数组缓存

虽然上下文本身是链表结构,但在某些性能敏感的场景下,协程库会将其转换为临时数组以加速访问。

例如在 ThreadContext.kt 中,当需要跨线程恢复多个 ThreadContextElement 时,系统会使用 ThreadState

1
2
3
4
5
private class ThreadState(val context: CoroutineContext, n: Int) {
private val values = arrayOfNulls<Any>(n) // 快速索引数组
private val elements = arrayOfNulls<ThreadContextElement<Any?>>(n)
// ...
}

这是一种典型的“空间换时间”策略:在执行频率极高的切换逻辑中,通过一次性 fold 遍历将链表转为数组缓存,从而避免后续多次递归查找。

5. 总结

Kotlin 协程上下文巧妙地避开了通用的 HashMap

  1. 基础存储:使用递归链表 CombinedContext,通过“引用共享”实现高效的持久化操作。
  2. 查找逻辑:由于元素极少,线性遍历优于哈希查找。
  3. 极端优化:在线程切换等关键路径,通过临时数组(ThreadState)进行加速。

这种“因地制宜”的设计是协程能够胜任数百万级并发的重要基石。

Kotlin Flow 常用用法指南

Kotlin Flow 常用用法指南

Kotlin Flow 是基于协程的异步流,用于按顺序发出多个值。以下是项目中常用的 Flow 操作与模式总结。

1. 创建 Flow

常用的创建方式包括 flow 构建器、flowOf 和扩展函数 asFlow

1
2
3
4
5
6
7
8
9
10
11
12
13
// 1. 使用 flow {} 构建器
val simpleFlow = flow {
for (i in 1..3) {
delay(100)
emit(i) // 发送值
}
}

// 2. 使用 flowOf() 发送固定值
val fixedFlow = flowOf("A", "B", "C")

// 3. 将集合或序列转换为 Flow
val collectionFlow = listOf(1, 2, 3).asFlow()

2. 末端操作符 (Terminal Operators)

末端操作符会触发 Flow 的收集。

  • collect: 最基础的收集操作。
  • toList / toSet: 将 Flow 转换为集合。
  • first: 获取第一个值(并取消后续发送)。
  • reduce / fold: 对流中的值进行累加计算。
1
2
val sum = (1..5).asFlow()
.reduce { a, b -> a + b } // 结果为 15

3. 中间操作符 (Intermediate Operators)

用于对流进行变换,它们是冷操作符,不会立即触发执行。

  • map: 转换每个值。
  • filter: 过滤值。
  • transform: 最通用的变换操作符,可以多次调用 emit
  • take: 限制发送数量。
1
2
3
4
5
(1..10).asFlow()
.filter { it % 2 == 0 }
.map { "Number $it" }
.take(2)
.collect { println(it) } // 输出 Number 2, Number 4

4. 线程切换 (flowOn)

Flow 遵循上下文保存原则。使用 flowOn 改变上游操作符执行的协程上下文。

1
2
3
4
5
6
7
8
flow {
// 这部分将运行在 Dispatchers.Default
emit(computeResult())
}.flowOn(Dispatchers.Default)
.collect { value ->
// 这部分运行在收集器的上下文中(如 Dispatchers.Main)
updateUI(value)
}

5. 背压处理 (Buffering & Conflation)

当生产者速度快于消费者时,可以使用以下方式:

  • buffer(): 缓冲发送,让生产者和消费者并发运行。
  • conflate(): 合并发送,消费者只处理最新的值,跳过中间值。
  • collectLatest: 当有新值发出时,取消当前的收集处理并开始处理新值。
1
2
3
4
5
6
simpleFlow()
.buffer() // 并发处理,提高效率
.collect { value ->
delay(300)
println(value)
}

6. 组合多个 Flow

  • zip: 一对一合并两个流的值。
  • combine: 只要任何一个流发出新值,就使用两个流最新的值进行合并计算。
1
2
3
val nums = (1..3).asFlow()
val strs = flowOf("One", "Two", "Three")
nums.zip(strs) { a, b -> "$a -> $b" }.collect { println(it) }

7. 展平流 (Flattening)

当流的值本身也是流时(Flow<Flow<T>>):

  • flatMapConcat: 按顺序连接(等待前一个内部流完成)。
  • flatMapMerge: 并发合并内部流。
  • flatMapLatest: 只要外部流发出新值,就取消前一个内部流的收集。

8. 异常处理与完成

  • catch: 捕获上游抛出的异常。
  • onCompletion: 在流完成(无论正常还是异常)时执行。
1
2
3
4
simpleFlow()
.onCompletion { cause -> println("Done with $cause") }
.catch { e -> emit(-1) } // 捕获异常并发送默认值
.collect { println(it) }

深度解析:Java 锁的膨胀机制(偏向锁、轻量级锁、重量级锁)

在 Android 的 ART(Android Runtime)和 HotSpot JVM 中,synchronized 关键字的性能优化主要依赖于锁的膨胀(Inflation)机制。系统会根据竞争情况,自动在四种状态间转换:无锁 -> 偏向锁 -> 轻量级锁 -> 重量级锁


一、 锁状态的核心存储:Mark Word

锁的信息存储在 Java 对象的对象头(Object Header)中的 Mark Word 字段里。

  • Mark Word 会根据锁状态的变化,复用自己的空间来存储:偏向线程 ID、指向栈中锁记录的指针、或指向互斥量(Monitor)的指针。

二、 四种锁状态详解

1. 偏向锁 (Biased Locking)

  • 核心思想:假设锁总是由同一个线程多次获得,那么就没有必要进行同步操作(如 CAS)。
  • 实现:在 Mark Word 中记录当前线程的 ID。下次该线程进入时,只需检查 ID 是否一致,完全不需要 CPU 原子指令。
  • 优缺点
    • 优点:单线程执行临界区代码时,性能接近无锁。
    • 缺点:一旦出现另一个线程竞争,撤销偏向锁会产生较大的性能开销(需要等待全局安全点 Safe Point)。
  • Android 现状:在 Android ART 中,偏向锁的实现逻辑与 HotSpot 略有不同,但在高版本中出于简化系统和减少 Safe Point 停顿的考虑,偏向锁的使用逐渐被弱化甚至在某些场景下默认关闭。

2. 轻量级锁 (Lightweight Locking)

  • 触发时机:当偏向锁被撤销,或者有第二个线程尝试获取锁(但竞争不激烈)时。
  • 实现:线程在自己的虚拟机栈中创建一个名为“锁记录(Lock Record)”的空间,尝试用 CAS 操作将对象的 Mark Word 指向这个记录。
  • 自旋优化:如果 CAS 失败,线程不会立即阻塞,而是执行一段自旋(Spinning)循环,期望持锁线程能迅速释放。
  • 优点:避免了操作系统内核态与用户态切换的昂贵开销。

3. 重量级锁 (Heavyweight Locking)

  • 触发时机:竞争激烈。如果自旋超过一定次数,或者在轻量级锁状态下又有第三个线程来抢锁。
  • 实现:Mark Word 指向堆中的 Monitor(监视器) 对象。Monitor 依赖于操作系统的 mutex 指令。
  • 行为:未能抢到锁的线程会被挂起(Park),进入阻塞状态,等待操作系统唤醒。
  • 代价:涉及内核态切换,上下文切换开销巨大。

三、 锁的升级(膨胀)流程图

  1. 初始状态:对象处于“无锁”或“可偏向”状态。
  2. 偏向开启:线程 A 访问,Mark Word 记录 A 的 ID。
  3. 轻度竞争:线程 B 访问,发现 ID 不匹配。撤销偏向锁,升级为轻量级锁。
  4. CAS 竞争:线程 A 和 B 通过 CAS 争夺锁记录指针。
  5. 激烈竞争:CAS 失败次数过多,轻量级锁膨胀为重量级锁,线程 B 进入阻塞队列。

注意:锁的升级通常是不可逆的(但在某些 JVM 实现中,在全局垃圾回收 GC 时可能会发生锁降级)。


四、 补充优化:锁消除与锁粗化

除了状态膨胀,编译器(JIT)还做了以下两项神级优化:

1. 锁消除 (Lock Elimination)

  • 原理:通过逃逸分析,如果发现一个对象只会在当前线程内部使用(不会被其他线程看到),JIT 编译器会直接把 synchronized 去掉。
  • 例子:在方法内部定义的 StringBuffer.append()

2. 锁粗化 (Lock Coarsening)

  • 原理:如果一系列连续的操作都对同一个对象加锁(如循环内加锁),编译器会把加锁的范围扩大到整个操作序列外部,避免频繁开关锁。

🎙 面试考察点:资深级别回答

Q:既然重量级锁慢,为什么不一直用轻量级锁自旋?

  • :自旋是需要消耗 CPU 算力的(虽然没挂起,但 CPU 在跑空转指令)。如果持锁线程执行时间很长,让其他线程一直自旋会白白浪费大量 CPU 资源。因此,当自旋达到一定阈值(适应性自旋),必须膨胀为重量级锁,让竞争线程去“睡觉”(挂起),把 CPU 让给真正干活的线程。

Q:在 Android 开发中,这套机制给我们的启示是什么?

  • :1. 尽量减少锁的竞争范围(减小临界区)。2. 对于高频调用的代码,优先使用无锁结构(CAS/Atomic),因为一旦膨胀到重量级锁,在移动端有限的算力下,性能损耗非常直观。

ArrayDeque 的接口

正文

ArrayDeque 实现了 Deque 接口,该接口继承自 Queue 接口。下面是 Deque 接口中定义的一些主要方法:

  1. 添加元素操作:

    • addFirst(element: E):将元素添加到双端队列的开头。
    • addLast(element: E):将元素添加到双端队列的末尾。
    • offerFirst(element: E):将元素添加到双端队列的开头,并返回是否成功。
    • offerLast(element: E):将元素添加到双端队列的末尾,并返回是否成功。
  2. 获取元素操作:

    • getFirst(): E:获取双端队列的第一个元素,但不删除它。
    • getLast(): E:获取双端队列的最后一个元素,但不删除它。
    • peekFirst(): E:获取双端队列的第一个元素,如果队列为空则返回 null。
    • peekLast(): E:获取双端队列的最后一个元素,如果队列为空则返回 null。
  3. 移除元素操作:

    • removeFirst(): E:移除并返回双端队列的第一个元素。
    • removeLast(): E:移除并返回双端队列的最后一个元素。
    • pollFirst(): E:移除并返回双端队列的第一个元素,如果队列为空则返回 null。
    • pollLast(): E:移除并返回双端队列的最后一个元素,如果队列为空则返回 null。

此外,ArrayDeque 还实现了 Queue 接口中定义的方法,如 offer(element: E)remove(): Epoll(): E 等。

需要注意的是,ArrayDeque 是一个可变大小的数组双端队列,可以在队列的两端进行高效的插入和删除操作,同时也支持随机访问。

Android 面试题库:深度解析并发锁机制与应用实践

在 Android 高级开发面试中,并发锁是考察候选人底层功底、内存模型理解以及系统架构能力的核心考点。本文针对 Android 实际开发场景,系统性梳理了各种锁的原理与选型。


一、 Java 基础锁原语

1. synchronized (内置锁/监视器锁)

  • 特性:自动获取/释放、可重入、非公平。在现代 ART 虚拟机中经过了偏向锁、轻量级锁、重量级锁的膨胀优化。
  • 面试考点
    • 对象锁 vs 类锁:同步代码块与静态同步方法的区别。
    • 原理:每个对象关联的 monitor 对象(EntryList, WaitSet)。
  • 应用场景:本项目的 GLTextureView 中用于状态机的全局调度。

2. ReentrantLock (显示锁)

  • 特性:基于 AQS(AbstractQueuedSynchronizer)实现。支持公平/非公平选型、可中断、支持超时获取、多条件变量(Condition)。
  • 面试考点
    • synchronized 区别:灵活性(tryLock)、性能(高竞争下更稳定)、可扩展性。
  • 应用场景:复杂的数据结构操作,或需要响应中断的长时间等待场景。

二、 读写与性能优化锁

3. ReentrantReadWriteLock (读写锁)

  • 核心逻辑读读共享、读写互斥、写写互斥
  • 面试考点
    • 锁降级:写锁可以降级为读锁,但读锁不能升级为写锁。
    • 饥饿问题:大量读操作可能导致写操作长时间无法获取锁。
  • 应用场景:App 的内存缓存(LruCache)查询频率远高于更新频率的场景。

4. StampedLock (乐观读锁)

  • 特性:Java 8 引入。提供了一种乐观读模式,读操作不会阻塞写操作。
  • 面试考点
    • 对比 ReadWriteLock:通过版本戳(Stamp)验证数据有效性,在读多写极少的极端场景下性能近乎无锁。
  • 应用场景:坐标点数据计算、频繁读取的配置信息。

三、 原子操作与轻量级同步

5. CAS (Compare And Swap)

  • 原理:利用 CPU 的 cmpxchg 指令实现无锁原子更新。
  • 面试考点
    • ABA 问题:如何通过 AtomicStampedReference 解决。
    • 自旋开销:高竞争下 CPU 占用率过高。
  • 应用场景:计数器(AtomicInteger)、状态标记位。

6. volatile 关键字

  • 作用可见性、有序性(禁止指令重排)。不保证原子性。
  • 面试考点
    • DCL(双重检查锁定)单例模式:为什么必须加 volatile?(防止对象初始化未完成就被引用)。
  • 应用场景:本项目的 mHasContentToDraw 标志位同步。

四、 Android 场景下的特有应用

7. 渲染链路同步 (本项目核心)

  • 痛点:GL 线程与 UI 线程的生命周期不同步。
  • 方案:使用 synchronized 配合 wait/notifyAll 构建保护性暂停(Guarded Suspension)模型。
  • 面试回答:参考 GLTEXTUREVIEW_LOCKING.md 中的握手协议描述。

8. 锁的选型指南 (横向对比)

维度 synchronized ReentrantLock Atomic/CAS
灵活性 低(自动) 高(手动控制) 极高(无锁)
性能 中(低中竞争优) 高(高竞争稳定) 极高(极短操作)
功能 基础 丰富(Condition/公平性) 仅限原子变量
风险 高(易忘释放) 容易出现 ABA 问题

五、 资深面试官进阶追问

Q1:如何定位 App 线上发生的死锁?

  • :1. 使用 Thread.getAllStackTraces() 获取堆栈。2. 寻找 BLOCKED 状态的线程。3. 分析锁的循环等待链(A 等 B,B 等 A)。

Q2:为什么 ConcurrentLinkedQueue 不需要加锁?

  • :它采用了 Michael-Scott 非阻塞队列算法,底层完全基于 CAS 操作头尾指针,实现了极高的并发吞吐量。本项目在 HybridNormalRenderer 中使用它来处理弹幕队列,避免了每一帧渲染都要持锁的开销。

Q3:什么是偏向锁和锁消除?

  • :锁消除是 JIT 编译器的优化,若发现对象只会在单线程访问,则直接去掉 synchronized。偏向锁是假设锁一直由同一线程持有,减少 CAS 操作开销。

📌 总结

锁不是越多越好,而是越轻量越好。在 Android 开发中,首选无锁结构(Concurrent 容器),次选轻量级标记(volatile/Atomic),最后才考虑重权重的 Monitor 或显示锁。