Android 与通用编程中的 Multi thread 和 thread safe 问题
结论
变量通常不需要默认做成 thread safe。只有下面两个条件同时成立,才需要考虑线程安全:
- 多个线程可以访问同一份状态。
- 至少一个线程会修改这份状态,或者多个操作之间有必须保持的约束。
线程不会主动“发现”变量。不同线程能访问同一个变量,是因为代码把同一个对象引用、单例、静态字段、闭包或回调上下文交给了它们。
在 Android 中,即使没有直接调用 Thread(),协程、线程池、网络回调、数据库操作、系统回调和第三方库也可能让代码在不同线程上运行。框架不需要知道变量名。只要它调用了你的对象或回调,而其中的代码访问了这个变量,共享就已经发生。
哪些情况不需要 thread safe
下面的代码只有一个线程访问,通常不需要同步:
fun calculateTotal(): Int {
var total = 0
for (i in 1..100) {
total += i
}
return total
}
total 是局部变量,每次调用都会创建独立的一份。只要函数没有把 total 或包含它的可变对象暴露出去,其他线程就无法访问它。
如果一个对象严格限定在 Android 主线程使用,也可以靠线程约束保证安全:
class ScreenState {
// 约定所有读写都只发生在主线程
var selectedItemId: Long? = null
}
这里依靠的是“只允许主线程访问”的约束,不需要使用 synchronized。不过,这项约束必须真正落实。仅写一条注释并不能阻止后台线程访问。
线程如何获得同一个变量
线程通常共享的是同一个对象引用,而不是变量名。
class Counter {
var value = 0
}
val counter = Counter()
val thread1 = Thread {
counter.value++
}
val thread2 = Thread {
counter.value++
}
thread1.start()
thread2.start()
thread1.join()
thread2.join()
两个线程都捕获了 counter,所以都能访问同一个 Counter 实例。它们既不需要查询变量是否存在,也不需要知道变量名。
共享引用通常来自这些情况:
- 对象被传给多个任务或回调。
- 对象保存在
companion object、Javastatic字段或 Kotlinobject中。 - 多个组件持有同一个 repository、cache 或 service。
- lambda 捕获了外部对象。
- 依赖注入容器把同一个实例交给多个调用者。
- framework 在不同线程调用同一个 listener 或 callback。
- 多个 coroutine 在不同 dispatcher 上访问同一个对象。
为什么简单的 value++ 也会出错
value++ 不是不可分割的操作,大致可以分成三步:
读取 value
计算 value + 1
写回 value
假设初始值是 0,两个线程可能按照下面的顺序运行:
线程 A 读取到 0
线程 B 读取到 0
线程 A 写入 1
线程 B 写入 1
虽然执行了两次自增,最终结果却只有 1。这种问题称为 lost update。
可以用同一把锁保护读取、修改和写回的完整过程:
class Counter {
private val lock = Any()
private var value = 0
fun increment() {
synchronized(lock) {
value++
}
}
fun get(): Int {
synchronized(lock) {
return value
}
}
}
访问共享状态的代码都要遵守同一套同步规则。如果写操作加了锁,读操作却绕过锁直接读取,仍可能出现可见性或一致性问题。
对于单一计数器,在 JVM/Android 环境中也可以使用原子类:
import java.util.concurrent.atomic.AtomicInteger
class Counter {
private val value = AtomicInteger(0)
fun increment() {
value.incrementAndGet()
}
fun get(): Int = value.get()
}
external library 如何带来跨线程访问
library 不需要知道你的字段。它只要保存并调用你提供的 callback 即可:
class DataLoader {
private var lastResult: String? = null
fun load(client: NetworkClient) {
client.fetch { result ->
lastResult = result
}
}
fun currentResult(): String? = lastResult
}
这里假设 NetworkClient.fetch 接受一个 callback。callback 具体在哪个线程执行,要看该 API 的契约。
如果 callback 在后台线程执行,而主线程调用了 currentResult(),那么两个线程都会访问 lastResult。library 只知道 callback,并不知道 lastResult,但 callback 内部会访问它。
具体采用哪种处理方式,要看实际需要:
- 先把结果切换到主线程,再修改只属于 UI 的状态。
- 用锁保护共享状态。
- 使用
AtomicReference等并发工具。 - 通过不可变对象传递快照。
- 使用 coroutine confinement,让指定的 dispatcher 或 actor 统一管理状态。
Android UI 状态通常应该回到主线程更新,例如:
class DataLoader(
private val mainExecutor: java.util.concurrent.Executor
) {
private var lastResult: String? = null
fun load(client: NetworkClient) {
client.fetch { result ->
mainExecutor.execute {
lastResult = result
// 在这里更新 UI 或主线程限定的状态
}
}
}
}
这段代码能够保证安全的前提是,lastResult 的所有读写都限定在主线程。
coroutine 也可能产生并发
使用 coroutine 并不会自动获得线程安全。多个 coroutine 可以同时访问同一个对象,也可能在不同线程上恢复执行。
class Repository {
var requestCount = 0
}
val repository = Repository()
coroutineScope {
repeat(1000) {
launch(Dispatchers.Default) {
repository.requestCount++
}
}
}
这里的 requestCount++ 仍然存在竞争,可以使用 Mutex 保护:
import kotlinx.coroutines.sync.Mutex
import kotlinx.coroutines.sync.withLock
class Repository {
private val mutex = Mutex()
private var requestCount = 0
suspend fun incrementRequestCount() {
mutex.withLock {
requestCount++
}
}
suspend fun getRequestCount(): Int {
return mutex.withLock {
requestCount
}
}
}
Mutex 适合 coroutine 的挂起式同步。普通 JVM 线程之间的短临界区,可以考虑使用 synchronized、Lock 或原子类。具体选择取决于状态结构和调用环境。
volatile 不能替代所有同步
在 JVM/Android 场景中,@Volatile 主要解决可见性问题:
@Volatile
private var stopped = false
一个线程写入 stopped 后,其他线程可以按照 JVM 内存模型的规则看到更新。不过,@Volatile 无法让复合操作自动变成原子操作:
@Volatile
var count = 0
count++ // 仍然不是原子操作
count++ 依然包含读取、计算和写回,因此仅靠 @Volatile 无法防止 lost update。
companion object 为什么会使用同步
companion object 的成员属于类级别,所有实例和调用者看到的都是同一份状态,所以它比普通实例字段更容易成为共享状态。
延迟创建单例就是一个常见例子:
class Database private constructor() {
companion object {
@Volatile
private var instance: Database? = null
fun getInstance(): Database {
return instance ?: synchronized(this) {
instance ?: Database().also { instance = it }
}
}
}
}
假设两个线程第一次同时调用 getInstance():
线程 A 发现 instance 为 null
线程 B 也发现 instance 为 null
线程 A 创建实例
线程 B 也创建实例
同步可以保证初始化临界区内只有一个线程创建对象,@Volatile 则保证其他线程能正确看到已经发布的引用。上面的代码是 JVM/Android 中常见的 double-checked locking 写法。
并非所有 companion object 都需要同步。如果能够证明初始化只发生在主线程,或者 Kotlin object、lazy、依赖注入容器等已经提供了所需的初始化保证,就没有必要为了“保险”随意加锁。
下面几个容易混淆的写法也需要区分:
class Example {
@Synchronized
fun update() {
// 锁住当前 Example 实例
}
companion object {
@Synchronized
fun create() {
// 锁住 companion object 对应的监视器
}
}
}
@Synchronized 用于修饰函数。如果只需要保护一部分代码,可以写成:
synchronized(lock) {
// 临界区
}
锁住哪个对象很重要。不同线程使用不同的锁时,彼此之间无法形成互斥。
一个实际的 Android 场景
假设应用中有一个共享 token 管理器:
class TokenManager {
var token: String? = null
}
它可能同时被下面这些代码使用:
- 主线程读取 token,用于显示登录状态。
- 网络拦截器在线程池中读取 token,并添加到请求头。
- token 刷新任务在后台线程写入新值。
- 注销操作在主线程把 token 设为
null。
即使开发者没有显式创建 Thread,网络库的执行器、coroutine dispatcher 或系统 callback 也可能提供多个执行线程。只要多个组件持有同一个 TokenManager,它们就已经共享了这份状态。
如果只需要原子地读写一个引用,可以考虑:
import java.util.concurrent.atomic.AtomicReference
class TokenManager {
private val token = AtomicReference<String?>(null)
fun get(): String? = token.get()
fun set(value: String?) {
token.set(value)
}
}
如果一次更新涉及多个字段,而且这些字段必须始终保持一致,可以把整个状态作为不可变快照进行原子替换,也可以用同一把锁保护完整操作。例如,token 和 expiresAt 必须匹配时,就不能在没有保护的情况下分别修改。
data class TokenState(
val token: String?,
val expiresAt: Long?
)
class TokenManager {
private val state = AtomicReference(
TokenState(token = null, expiresAt = null)
)
fun get(): TokenState = state.get()
fun set(token: String, expiresAt: Long) {
state.set(TokenState(token, expiresAt))
}
fun clear() {
state.set(TokenState(token = null, expiresAt = null))
}
}
如何判断是否需要线程安全
检查一个变量时,可以依次确认:
- 这个状态是否可能离开当前函数或当前线程?
- 是否有多个 thread、executor task、callback 或 coroutine 持有同一个对象?
- 它们的执行时间是否可能重叠?
- 是否存在写操作?
- 单次操作是否包含多个步骤?
- 多个字段之间是否必须保持一致?
- API 是否明确规定了 callback 所在线程?
- 能否把访问限制在一个线程中,从而避免共享?
状态没有被共享,或者所有访问都严格串行时,不需要同步。如果无法证明这一点,就要设计明确的并发策略。
注意事项
- thread safe 是对象或操作在特定使用方式下具备的性质,不能靠给变量随意添加一个关键字实现。
synchronized只能协调使用同一把锁的代码。- 线程安全涉及同时写入、内存可见性、安全发布和操作顺序。
- 不可变对象更容易在线程之间传递,但对象引用不可变,并不代表它指向的内容也不可变。
- 过度加锁会增加复杂度,还可能引发死锁、阻塞或性能问题。
- Android 的
View和大多数 UI 操作受主线程约束。后台工作完成后,应按照相应 API 的线程规则切回主线程更新 UI。 - 第三方 API 的 callback 在哪个线程执行不能靠猜测,应查看该 API 的文档或调用约定。
判断时,先确认状态是否真的被共享,再确定由哪个线程负责管理它。能把可变状态限制在单一线程时,通常会比到处添加 synchronized 更容易维护。
备注:内容仅供参考。
