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) }

深度解析 Android 并发锁机制与应用实践

Android 开发中的并发编程:从关键字到锁机制

在 Kotlin/Android 开发中,处理多线程并发主要有两个核心目标:可见性(Visibility)原子性(Atomicity)。为了达成这些目标,Java 语言及其并发包(JUC)提供了从简单到复杂的多种工具。


1. 基础关键字:原子性与可见性的保障

volatile

volatile 是最轻量级的同步机制。

  • 作用: 保证变量的可见性禁止指令重排序
  • 原理: 当一个线程修改了 volatile 变量,新值会立即同步到主内存;其他线程读取时,会强制从主内存读取。
  • 局限: 不保证原子性。例如 i++ 操作在多线程下依然是不安全的。

典型案例:为什么 DCL 单例必须加 Volatile?
以下是你代码中的问题分析及修正方案:

❌ 错误代码示例

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
public class Singleton {
// 1. volatile 必须加:保证可见性,禁止指令重排序
private static volatile Singleton instance = null;

// 2. 锁对象必须是静态的:确保全局唯一,拦截所有线程的访问
private static final Object lock = new Object();

// 私有构造函数:防止外部 new
private Singleton() {}

public static Singleton getInstance() {
// 第一次检查:为了效率。如果 instance 已经创建,直接返回,避免进入锁竞争
if (instance == null) {
// 这里使用静态锁对象。如果 lock 不是 static 的,每个 Singleton 实例都有自己的锁,
// 而 getInstance 是静态方法,无法保证多个线程竞争的是同一把锁。
synchronized (lock) {
// 第二次检查:为了安全性。确保在获取锁的瞬间,没有其他线程抢先创建了实例
if (instance == null) {
instance = new Singleton();
}
}
}
return instance;
}
}

在 Java 5 之后,volatile 语义得到了增强。它不仅保证了线程 A 对 instance 的修改对线程 B 可见,更重要的是它在 new Singleton() 时建立了一个内存屏障。
如果没有 volatile,instance = new Singleton(); 可能会被分解为:

  • allocate (分配内存)
  • instance = memory (设置引用,此时对象还没初始化)
  • ctorSingleton (执行构造函数)

如果 2 和 3 发生重排序,其他线程就会拿到一个“半成品”对象。

在单例模式的 DCL (Double Check Locking) 中,volatile 的核心作用其实是禁止指令重排序。
如果没有 volatile,对象可能在构造函数还没执行完时,就把内存地址赋给了变量。另一个线程拿到的就是一个“半成品”对象,直接崩溃。

用途: 确保“在变量赋值之前,之前所有的初始化工作已经全部完成”。
场景: 刚才提到的单例模式赋值,或者作为“完成信号”标记。

再深入理解一下
作为“触发器 / 内存栅栏” (Memory Barrier)
当线程 A 写入一个 volatile 变量时,JMM (Java 内存模型) 会把该线程之前所有的普通变量修改也一起刷新到主内存中。

1
2
3
4
5
6
7
8
9
10
11
12
13
  var data = 0
@Volatile var ready = false

// 线程 A
data = 42
ready = true // 写入 volatile 变量,这会像“存盘”一样把 data 也刷过去

------------------------- 这里好似有一层屏障------------------------------

// 线程 B
if (ready) {
println(data) // 此时 data 保证一定是 42
}

所以 volatile 仅用于变量,且有能力影响其他的变量

2. 现代并发原语:CAS (Compare And Swap)

为什么不叫“修改”而叫“交换”?
这源于底层 CPU 指令(如 x86 的 LOCK CMPXCHG)。在硬件层面,这个操作通常是原子的,它会将寄存器里的新值与内存位置的值进行交换(或者说覆盖),以此确保在多核环境下,这一系列动作不会被其他处理器中途干扰。

CAS 是实现无锁编程(Lock-Free)的核心算法。

  • 原理: 它包含三个操作数:内存地址 V、旧的预期值 A、即将更新的新值 B。只有当内存值 V 等于 A 时,才会将 V 修改为 B。
  • 优点: 避免了线程切换的开销,效率极高。
  • 缺点: 1. ABA 问题(可用版本号解决)。
    1. 自旋时间长会消耗 CPU。
  • 应用: Kotlin 中的 AtomicInteger, AtomicBoolean, AtomicReference 等都是基于 CAS 实现的。
特性 AtomicInteger AtomicReference
操作目标 具体的 Int 数值 对象的引用地址
原子范围 数值的加减、替换 整个对象的切换/替换
性能 极高(直接操作底层数字) 高(操作指针),但对象内部属性不保证原子性
Kotlin 示例 AtomicInteger(0) AtomicReference(User("Gemini"))

CAS 完整日志流 (含冲突重试)

第一轮:遭遇冲突

  • [读取] 内存值 V = 10。
  • [准备] 预期值 A = 10,拟更新值 B = 11。
  • [验证] 比较 V 是否为 A?
  • [冲突] 硬件返回 No(此时变量已被其他线程改成了 12)。
  • [动作] 写入失败,触发**自旋 (Retry)**。

第二轮:重试命中

  • [读取] 重新获取内存值 V = 12。
  • [准备] 预期值 A = 12,拟更新值 B = 13。
  • [验证] 比较 V 是否为 A?
  • [命中] 硬件返回 Yes(值未被改动)。
  • [结束] 成功写入 13,更新完成。

3. 基础锁机制:Synchronized

synchronized 是 Java 中最常用的同步机制,它是一种隐式锁(由 JVM 自动管理获取和释放),也是一种互斥锁

核心用法

  • 修饰实例方法:锁住当前实例对象 (this)。
  • 修饰静态方法:锁住当前类的 Class 对象。
  • 修饰代码块:锁住指定的对象实例(最灵活,建议缩小锁的范围)。

底层原理:Monitor(管程)

每个 Java 对象天生就带了一把“锁”,在 JVM 中通过 Monitor 机制实现。

  • **Enter (入锁)**:执行 monitorenter 指令,线程尝试获取对象的 Monitor。
  • **Exit (释放)**:执行 monitorexit 指令,线程释放 Monitor(即使发生异常,JVM 也会保证释放)。

JVM 对它的“黑科技”优化

在早期 Java 版本中,synchronized 是笨重的重量级锁。但从 Java 6 开始,引入了锁升级路径以提升性能:

  1. **偏向锁 (Biased Locking)**:锁会偏向于第一个访问它的线程,无竞争时几乎无开销。
  2. **轻量级锁 (Lightweight Locking)**:当出现少量竞争时,通过 CAS(自旋)来获取锁,避免线程挂起。
  3. **重量级锁 (Heavyweight Locking)**:当竞争激烈时,升级为重量级锁,线程进入阻塞状态,交给操作系统调度。

4. 高级锁机制:ReentrantLock 家族

synchronized 的灵活性不足时,我们会使用 java.util.concurrent.locks 中的工具。

ReentrantLock (可重入锁)

  • 对比 synchronized: * 手动控制: 必须手动调用 lock()unlock()(建议配合 try-finally)。
    • 公平性: 支持公平锁(按等待顺序获取)。
    • 可中断: 支持 lockInterruptibly()
    • 超时: 支持 tryLock(timeout),避免死锁。

ReentrantReadWriteLock (读写锁)

  • 核心逻辑: 读读共享,读写互斥,写写互斥
  • 场景: 适用于“读多写少”的情况。
  • 特点: 允许多个线程同时读取资源,但只有一个线程能进行写入。如果已有线程在读,写线程必须等待。

synchronized 和 ReentrantLock 的区别

编写一个程序,开启三个线程(分别为 Thread A, Thread B, Thread C):

  1. Thread A 负责打印字母 A
  2. Thread B 负责打印字母 B
  3. Thread C 负责打印字母 C

要求:

  • 这三个线程必须顺序执行,即控制台输出的结果必须是 ABCABCABC... 的形式。
  • 每个线程各自循环打印 $n$ 次(例如你代码中的 loops = 5)。

1. 使用 synchronized

这是最基础的实现方式,依靠 wait() 让出锁,并依靠 notifyAll() 唤醒其他正在等待该锁的线程。

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
36
37
38
39
40
import kotlin.concurrent.thread

fun synchronizedExample() {
val lock = Object()
var state = 0 // 0:A, 1:B, 2:C
val loops = 5

val t1 = thread {
repeat(loops) {
synchronized(lock) {
while (state != 0) lock.wait()
print("A")
state = 1
lock.notifyAll()
}
}
}

val t2 = thread {
repeat(loops) {
synchronized(lock) {
while (state != 1) lock.wait()
print("B")
state = 2
lock.notifyAll()
}
}
}

val t3 = thread {
repeat(loops) {
synchronized(lock) {
while (state != 2) lock.wait()
print("C ")
state = 0
lock.notifyAll()
}
}
}
}

2. 使用 ReentrantLock

ReentrantLock 配合 Condition 的好处是它可以精准唤醒。在上面的 synchronized 例子中,notifyAll() 会唤醒所有线程,但其实只有下一个顺序的线程才是真正需要的。使用 Condition 可以直接定向唤醒。

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
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
import java.util.concurrent.locks.ReentrantLock
import kotlin.concurrent.thread

fun reentrantLockExample() {
val lock = ReentrantLock()
// 为每个线程创建特定的“等待间”
val conditionA = lock.newCondition()
val conditionB = lock.newCondition()
val conditionC = lock.newCondition()

var state = 0
val loops = 5

thread(name = "A") {
repeat(loops) {
lock.lock()
try {
if (state != 0) conditionA.await()
print("A")
state = 1
conditionB.signal() // 精准唤醒 B
} finally {
lock.unlock()
}
}
}

thread(name = "B") {
repeat(loops) {
lock.lock()
try {
if (state != 1) conditionB.await()
print("B")
state = 2
conditionC.signal() // 精准唤醒 C
} finally {
lock.unlock()
}
}
}

thread(name = "C") {
repeat(loops) {
lock.lock()
try {
if (state != 2) conditionC.await()
print("C ")
state = 0
conditionA.signal() // 精准唤醒 A
} finally {
lock.unlock()
}
}
}
}

3. 关键区别点

特性 synchronized ReentrantLock
灵活性 自动加锁/释放,代码简洁 需手动 lock()unlock(),更安全需配合 finally
唤醒机制 notifyAll() 唤醒所有,由线程竞争 Condition 可以实现精准“点名”唤醒,性能更优
等待响应 不可中断 支持 lockInterruptibly(),可中断等待

4. 终极性能优化:StampedLock

StampedLock 是 Java 8 引入的,是对读写锁的改进。

  • 核心改进: 引入了乐观读(Optimistic Reading)
  • 原理: 读操作时不会阻塞写操作。读完后检查一个“戳(Stamp)”,如果期间没有写操作发生,则读取成功;如果有写操作,再退化为悲观读锁。
  • 注意: 它不可重入,且不支持 Condition

5. Kotlin 线程安全的数据结构

深入分类解读

数据结构 底层原理 特点 最佳场景
ConcurrentHashMap CAS + synchronized (分段锁定) 高并发读写,允许完全并发的读 缓存、全局状态管理
CopyOnWriteArrayList 写时复制副本 (Copy-On-Write) 读操作无锁,写操作极慢且耗内存 监听器列表、配置读取 (读多写极少)
CopyOnWriteArraySet 基于 CopyOnWriteArrayList 去重,且具备写时复制特性 白名单、去重的回调通知
ConcurrentLinkedQueue CAS (无锁算法) 非阻塞、高性能、无界 高频率、高并发的任务调度
LinkedBlockingQueue 独占锁 (ReentrantLock) 阻塞式,可选有界/无界 线程池任务队列 (ExecutorService)
ArrayBlockingQueue 独占锁 + 数组 阻塞式,必须指定容量 生产者-消费者(防止内存溢出)

1. “快读” 阵营:写时复制 (Copy-On-Write)

  • 成员CopyOnWriteArrayList, CopyOnWriteArraySet
  • 原理:当你修改集合时,它不直接在原数组上改,而是复制一个新数组,改完后再把引用指向新的。
  • 优点:遍历(Iterator)时不需要加锁,绝对不会抛出 ConcurrentModificationException
  • 缺点:写操作代价极高(内存占用翻倍)。

2. “高频” 阵营:无锁/细粒度锁 (CAS/Segment)

  • 成员ConcurrentHashMap, ConcurrentLinkedQueue
  • 原理:使用硬件层面的 CAS (Compare And Swap) 原子操作。ConcurrentHashMap 在 Java 8+ 之后对桶位(Bucket)加锁,而不是整个 Map。
  • 优点:吞吐量极大,多个线程可以同时往里塞数据而不需要互相排队。

3. “同步” 阵营:阻塞队列 (Blocking)

  • 成员LinkedBlockingQueue, ArrayBlockingQueue
  • 原理:利用 Condition 实现“等待-唤醒”机制。如果队列满了,写线程会停下等;如果队列空了,读线程会停下等。
  • 区别
    • Array 是预先分配好内存的,适合对内存控制严格的场景。
    • Linked 是动态分配的,吞吐量通常略高于 Array,但要注意如果不设置容量限制,可能会导致 OOM。

1. ConcurrentHashMap 原理 (Java 8+)

ConcurrentHashMap 的核心目标是在保证线程安全的同时,提供接近 HashMap 的高性能。

核心架构:由“分段锁”进化为“桶位锁”

在 Java 8 之后,它摒弃了 Java 7 的 Segment 方案,改用 CAS + synchronized + 红黑树 的混合结构。

A. 关键组件
  • Node 数组:存储数据的核心数组。
  • **CAS (Compare And Swap)**:用于无锁地插入新节点或更新状态。
  • synchronized:仅锁定当前正在操作的 桶(Bucket)的头节点
  • 红黑树:当链表长度超过 8 时,转化为红黑树以保证查询效率为 O(\log n)。

我们可以从以下几个维度来深度理解这个设计:

1. 为什么要锁“头节点”?

在散列表中,数组的每一个下标(Bucket)都是一个入口。如果你要往某个链表里增加节点,或者修改某个节点,你必须保证在这个过程中没有其他人也在动这根链表。

  • 头节点就是“守门员”:头节点是访问整个桶位的唯一入口。通过 synchronized(f)(这里的 f 就是头节点对象),Java 实际上是把这个桶位变成了一个同步块
  • 最小化粒度:只要两个线程访问的是不同的数组下标(比如线程 A 在下标 1,线程 B 在下标 5),它们之间完全没有锁竞争。这也就是为什么它的并发性能极高。

2. 为什么不用 CAS 到底,非要用 synchronized

这是一个很棒的工程权衡。你会发现:

  • 当桶位为空时:使用 CAS。因为这时候只需要把新节点挂上去,不涉及复杂逻辑,CAS 失败了重试即可。
  • 当桶位有数据时:情况变复杂了。你可能需要遍历链表看 Key 是否重复,或者需要旋转红黑树。
    • 如果用 CAS 实现复杂的链表插入,逻辑会变得异常复杂(需要处理各种中间态)。
    • synchronized 的进化:在 Java 8 中,synchronized 已经过优化(偏向锁、轻量级锁、锁消除等),在只有少量线程竞争时,性能非常出色。
    • 代码可读性与稳定性:使用 synchronized 锁定头节点,可以让后续的链表/红黑树操作变成“单线程化”,确保了逻辑的简单和安全。
B. 读写流程
  1. 写操作(put)
    • 计算 Hash 值,定位到数组下标。
    • 如果目标位置为空,利用 CAS 尝试插入。
    • 如果目标位置已有数据且正在扩容,则协助扩容。
    • 如果目标位置有数据且未扩容,使用 synchronized 锁定该位置的 头节点,然后进行链表或红黑树的插入。
  2. 读操作(get)
    • 完全无锁。Node 节点的 valuenext 指针使用了 volatile 关键字,确保了多线程间的可见性。
C. 扩容机制 (Multiphase Transfer)

它是并发扩容的。当一个线程发现需要扩容时,它会参与进来协助数据的迁移,每个线程负责处理一小部分桶位,通过 ForwardingNode 标记已处理过的区域。

ConcurrentLinkedQueue 原理

ConcurrentLinkedQueue 是 Java 并发包(java.util.concurrent)中性能最高的队列之一。它是一个基于链接节点的无界线程安全队列,采用了 Michael & Scott 非阻塞队列算法

其核心思想是:不使用锁(synchronized 或 ReentrantLock),而是完全通过 CAS(Compare-And-Swap)原子操作来实现。


1. 核心架构与设计

ConcurrentLinkedQueue 内部维护了两个关键指针:head(头节点)和 tail(尾节点)。每个节点(Node)包含数据 item 和指向下一个节点的 next 指针。

  • 原子性保障itemnext 都被声明为 volatile,确保了多线程间的内存可见性。
  • 哨兵节点:初始化时,队列会创建一个空的“伪节点”,headtail 最初都指向它。这简化了边界条件判断。

2. 关键算法:延迟更新(HOPS 策略)

这是 ConcurrentLinkedQueue 最精妙的地方。在传统的无锁算法中,每次插入都要更新一次 tail,但 CAS 是昂贵的(涉及缓存一致性流量)。为了提高吞吐量,该队列并不保证 tail 永远指向最后一个节点。

A. 入队 (offer) 的逻辑

  1. 寻找真正的尾部:由于 tail 可能是“旧的”,程序会从 tail 开始往后遍历 next,直到找到真正的最后一个节点(即 next == null 的节点)。
  2. CAS 插入:使用 CAS 将真正的尾节点的 next 指向新节点。
  3. 延迟更新 tail:只有当 tail 偏离真正末尾的距离超过一个阈值(通常是 1 个节点长度)时,才会通过 CAS 将 tail 指向新节点。

直观理解:就像接力赛,并不是每一棒都要把旗子插在终点,而是跑几棒后再统一更新终点标志的位置。

B. 出队 (poll) 的逻辑

  1. 寻找真正的头部head 也不一定指向第一个有效元素。它可能指向一个已经被弹出的节点。
  2. CAS 提取:找到第一个 item 不为 null 的节点,尝试用 CAS 将其 item 置为 null
  3. 延迟更新 head:同理,只有当 head 偏离位置较远时,才会更新 head 指针。

3. CAS 如何解决竞争?

假设两个线程同时尝试入队:

  1. 线程 A 执行 CAS 成功,将新节点挂载到了队列末尾。
  2. 线程 B 的 CAS 会失败(因为预期的 null 变成了线程 A 插入的新节点)。
  3. 自旋重试:线程 B 发现失败后,不会挂起,而是进入下一轮循环,重新定位新的尾节点并再次尝试 CAS。
优点
  • 无锁化:避免了线程切换和阻塞的开销。
  • 高性能:在高并发场景(尤其是生产者和消费者都很频繁时)下,吞吐量远超 LinkedBlockingQueue
  • 永不失效的迭代器:它的迭代器是弱一致性的,不会抛出 ConcurrentModificationException
缺点/局限
  • size() 开销大:由于是链表结构且没有全局计数器(维护计数器本身需要同步),计算 size 必须遍历全表,复杂度为 $O(n)$。
  • 内存压力:无界队列如果不加控制,在生产者远快于消费者时可能会导致 OOM。

ConcurrentLinkedQueue 是通过 CAS 原子指令 + 延迟指针更新 实现的。它通过牺牲一定的实时准确性(如 tail 指针的滞后)换取了极高的并发处理能力,是实现高性能异步处理任务的理想选择。

以下是触发 tail 偏离并重新校准的具体场景分析:

1. 典型的“跳跃更新”场景(Hops = 2)

为了平衡 CAS 的性能开销,Doug Lea(JUC 作者)设计了让 tail 滞后。

  • 初始状态headtail 都指向同一个哨兵节点(Node 0)。
  • 第一次插入(Thread A)
    1. 发现 tail.next == null
    2. CAS 将 tail.next 指向新节点 Node 1。
    3. 不更新 tail。此时 tail 依然指向 Node 0,但 Node 0 指向 Node 1。这就产生了偏离(距离为 1)。
  • 第二次插入(Thread B 或 A)
    1. 检查 tail.next,发现不为 null(指向了 Node 1)。
    2. 顺着指针找到真正的末尾 Node 1。
    3. CAS 将 Node 1 的 next 指向 Node 2。
    4. 触发阈值:因为此时 tail 距离真正的末尾已经跳了 2 步,代码会执行 compareAndSetTail,将 tail 直接指向 Node 2。
2. 多线程竞争导致的“大幅偏离”

在极端高并发下,tail 偏离的距离可能远超 2 个节点:

  • 竞速状态:当线程 A 正在寻找末尾准备插入时,线程 B、C、D 已经快速完成了三次插入,但它们还没来得及更新 tail 指针。
  • 被迫寻根:此时线程 A 发现 tail.next 后面跟着一串节点(Node 1 -> Node 2 -> Node 3)。线程 A 必须沿着 next 链条一直往后走,直到找到真正的末尾。
  • 校准:一旦线程 A 完成了它的插入,它会尝试一次性将 tail 移动到它刚插入的那个最新节点上,瞬间完成“大幅度校准”。
3. 节点被删除导致的特殊偏离(Dangling Tail)

有一种特殊情况:tail 指向的节点已经被出队(poll)并从链表中逻辑删除了。

  • 原理:当一个节点出队后,为了帮助 GC,它的 next 可能会指向它自己(p.next = p)。
  • 处理:当 offer 操作发现 tail.next == tail 时,说明 tail 已经由于落后太多,掉进了“时空裂缝”(指向了一个已失效的节点)。此时,线程会直接跳到 head 指针处,从头开始重新寻找末尾。

CopyOnWriteArrayList

CopyOnWriteArrayList 是 Java 并发包中设计最独特的集合之一。它的核心思想正如其名:写时复制(Copy-on-Write, COW)

其设计初衷是解决在多线程环境下,对 ArrayList 进行遍历时如果发生修改会抛出 ConcurrentModificationException 的问题,同时提供比完全加锁(Vector)更高的读取性能。

核心设计原理

CopyOnWriteArrayList 的核心逻辑可以概括为:读写分离,空间换时间

A. 内部结构

它内部维护了一个 volatile 修饰的数组 arrayvolatile 确保了当数组引用指向一个新的数组时,所有线程都能立即看到。

B. 写操作(修改、添加、删除)

当你执行 add()remove() 时,它不会直接修改当前数组,而是执行以下步骤:

  1. 加锁:获取一把独占锁(ReentrantLock),确保同一时刻只有一个线程在修改。
  2. 复制:创建一个与当前数组内容完全相同的新数组。
  3. 修改:在新数组上执行添加或删除操作。
  4. 覆盖:将内部的 array 引用指向这个新数组。
  5. 释放锁:操作完成。
C. 读操作(get、遍历)

读操作是完全无锁的。线程直接读取当前的 array

  • 由于写操作是在副本上进行的,读线程即使在写操作过程中进行遍历,读取的也是旧数组的快照(Snapshot),因此不会产生冲突,也不会抛出异常。
优点
  1. 线程安全且无锁读:读操作性能极高,适合高频读取。
  2. 一致性快照:遍历时不需要加锁,且不会受到修改操作的影响。
缺点
  1. 内存开销:每次写操作都要复制整个数组。如果数组很大,会频繁触发 GC,甚至导致 OOM。
  2. 数据延迟(最终一致性):读线程可能会在短时间内读取到旧数据,因为写操作还没把引用指向新数组。
  3. 写操作慢:因为涉及数组拷贝,写性能远低于 ArrayList
最佳实践场景:
  • 读多写极少:例如白名单、黑名单配置、系统属性配置。
  • 监听器列表:在 Android 中存储 ListenerObserver 集合。通常这些监听器在初始化时注册,运行期间频繁触发读取,极少修改。
Collections.synchronizedList 的区别
特性 CopyOnWriteArrayList SynchronizedList
读性能 极高(无锁) 一般(有锁)
写性能 低(复制数组) 一般(锁竞争)
迭代器 弱一致性(Snapshot),不会报错 强一致性,修改会抛 Exception
内存占用 较高(写时翻倍) 低(原地修改)

附录

Java 7 ConcurrentHashMap Segment 分段锁

在 Java 7 的 ConcurrentHashMap 中,Segment 分段锁的实现本质上是“化整为零”的锁管理策略。它通过将一个巨大的哈希表拆分为多个独立维护的小型哈希表,从而实现真正的并发写入。

以下是其核心设计与实现逻辑:

1. 核心数据结构:分层架构

Segment 的实现依赖于一个三层嵌套结构:

  • 外层 ConcurrentHashMap:持有一个 Segment 数组。
  • 中层 Segment:每个 Segment 内部包含一个小型 HashEntry 数组。最关键的是,**Segment 类继承自 ReentrantLock**,这使得每个分段本身就是一把锁。
  • 内层 HashEntry:具体的键值对节点。

2. 写入流程(put 操作):双重定位

当你向 Map 中插入数据时,会经历两次定位过程:

  1. 定位 Segment:通过对 Key 的 hashCode 进行再哈希,计算出该数据属于哪一个 Segment。
  2. 加锁操作:调用该 Segment 的 lock() 方法。此时,只有操作同一个 Segment 的线程才会被阻塞。操作不同 Segment 的线程可以并行执行。
  3. 定位 HashEntry:在 Segment 内部的数组中找到具体的桶位置,执行插入逻辑。
  4. 释放锁:操作完成后通过 unlock() 释放。

3. 读取流程(get 操作):无锁化设计

Segment 设计的一个精妙之处在于 get 操作通常是不需要加锁的。

  • 实现原理HashEntryvalue 字段和 next 指针都使用了 volatile 关键字
  • 效果volatile 保证了内存可见性,即一个线程修改了数据,另一个线程能立刻看到最新值。这使得读取操作可以在不获取锁的情况下安全进行,极大提升了吞吐量。

4. 关键技术点:为什么是 2 的幂次方?

正如之前提到的,Segment 数组的大小(并发级别)会被调整为 2 的幂次方。

  • 掩码运算:假设 Segment 数量为 16(即 $2^4$),寻址时只需执行 (hash >>> segmentShift) & segmentMask
  • 效率:这种位运算比传统的取模运算(%)要快得多,确保了在高并发下定位 Segment 的性能损耗降到最低。

5. 总结:Segment 的本质

Segment 实际上是对锁的物理隔离

  • 它解决了 Hashtable 中“一人持锁,全家等待”的尴尬局面。
  • 限制:一旦初始化完成,Segment 的数量就固定了,这意味着它的最大并发度在整个生命周期内无法动态扩展。这也是为什么 Java 8 最终转向了基于 CAS 的桶级(Bucket-level)锁,实现了更细粒度的控制。

CAS原子操作原语

无锁软件通常依赖硬件提供的原子读取-修改-写入(Read-Modify-Write)原语。

在较老一代的 ARM64 CPU 上,原子操作使用 LL/SC 循环(Load-Link/Store-Conditional)。CPU 加载一个值并标记该地址。如果另一个线程写入了该地址,存储操作会失败,循环会重试。因为线程可以不断尝试并在不等待其他线程的情况下成功,所以这一操作是无锁的。

1
2
3
4
5
6
asmretry:
ldxr x0, [x1] // 从地址 x1 独占加载到 x0
add x0, x0, #1 // 值加 1
stxr w2, x0, [x1] // 独占存储
// w2 为 0 表示成功,1 表示失败
cbnz w2, retry // 如果 w2 非零(失败),跳转到 retry

(在 Compiler Explorer 中查看)

更新的 ARM 架构(ARMv8.1)支持大型系统扩展(Large System Extensions, LSE),包括 CAS(Compare-And-Swap)和 Load-And-Add 等指令。在 Android 17 中,我们为 ART 编译器添加了 LSE 检测支持,能够在支持 LSE 的设备上生成优化指令:

1
2
3
asm// ARMv8.1 LSE 原子操作示例
ldadd x0, x1, [x2] // 原子 load-add
// 更快,无需循环

在谷歌的基准测试中,使用 CAS 的高竞争代码比 LL/SC 变体实现了约 3 倍加速

Java 语言通过 java.util.concurrent.atomic 提供原子操作原语,底层依赖这些特殊的 CPU 指令。

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 是一个可变大小的数组双端队列,可以在队列的两端进行高效的插入和删除操作,同时也支持随机访问。

Java 锁的膨胀机制(偏向锁、轻量级锁、重量级锁)

这份合并后的深度分析涵盖了从底层存储到自动化流程,以及程序员如何通过代码逻辑“间接”干预锁命运的完整机制。


一、 锁状态的核心载体:Mark Word

在 Java(包括 Android ART 和 HotSpot JVM)中,synchronized 的性能优化主要依赖于膨胀(Inflation)机制。锁的状态信息存储在对象头(Object Header)的 Mark Word 字段中:

  • 空间复用:Mark Word 会根据锁状态的变化,动态存储偏向线程 ID、指向栈中锁记录的指针、或指向互斥量(Monitor)的指针。
  • 四个阶段:锁会自动在 无锁 -> 偏向锁 -> 轻量级锁 -> 重量级锁 这四种状态间转换。

二、 四种锁状态详解:从“极致偏爱”到“失控挂起”

1. 无锁 (Unlocked)

对象的初始状态,Mark Word 中没有线程竞争的相关信息。

2. 偏向锁 (Biased Locking)

  • 核心思想:假设锁总是由同一个线程多次获得,从而消除同步操作(如 CAS)的开销。
  • 实现:JVM 在 Mark Word 中记录当前线程 ID。该线程再次进入时,只需检查 ID 是否一致,性能接近无锁。
  • 潜规则:JVM 启动初期通常有约 4s 的延迟开启时间,以避开启动阶段大量类加载导致的线程竞争。

3. 轻量级锁 (Lightweight Locking)

  • 触发时机:当有第二个线程尝试获取锁,但竞争并不激烈时。
  • 实现:线程在自己的虚拟机栈中创建“锁记录(Lock Record)”,尝试通过 CAS 将 Mark Word 指向该记录。
  • 适应性自旋:抢不到锁的线程会在门口“打转”(自旋)。JVM 会根据历史成功率决定自旋时长,避免立即挂起线程。

4. 重量级锁 (Heavyweight Locking)

  • 触发时机:竞争激烈。当自旋次数过多,或又有第三个线程加入抢锁。
  • 实现:Mark Word 指向堆中的 Monitor(监视器),依赖操作系统的 mutex 指令。
  • 代价:未能抢到锁的线程会被挂起(Park),涉及内核态与用户态的上下文切换,开销巨大。

三、 程序员如何“间接”控制锁的命运?

虽然锁膨胀是 JVM 的“自动挡”,但你的逻辑决定了系统走哪条优化路径:

优化维度 做法与建议 对 JVM 的影响
锁的粒度 尽量使用 synchronized(lockObj) 缩小同步块。 减少虚假竞争,让锁尽可能维持在轻量级状态。
持锁时长 同步块内只做核心状态变更,严禁长时间 IO。 防止自旋线程等不及而强行触发重量级锁膨胀。
逃逸分析 利用局部变量加锁(对象不逃逸出方法)。 JIT 编译器会触发锁消除,运行时完全不加锁。
锁粗化 避免在循环体内高频加锁。 编译器会将多次加锁合并,减少开关锁的性能损耗。

🎙 面试官进阶 Q&A

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

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

Q:在 Android 开发中,这套机制有什么实践意义?

  • :1. 对于高频调用的代码,优先考虑 Atomic 原子类或无锁结构,避免一旦膨胀到重量级锁后,移动端有限的算力导致明显的卡顿。2. 意识到 Android ART 在高版本中可能会弱化偏向锁,因此减少竞争和缩小临界区才是王道。