PAYATHON 2026

Android 与通用编程中的 Multi thread 和 thread safe 问题

技术老齐

结论

变量通常不需要默认做成 thread safe。只有下面两个条件同时成立,才需要考虑线程安全:

  1. 多个线程可以访问同一份状态。
  2. 至少一个线程会修改这份状态,或者多个操作之间有必须保持的约束。

线程不会主动“发现”变量。不同线程能访问同一个变量,是因为代码把同一个对象引用、单例、静态字段、闭包或回调上下文交给了它们。

在 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、Java static 字段或 Kotlin object 中。
  • 多个组件持有同一个 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))
    }
}

如何判断是否需要线程安全

检查一个变量时,可以依次确认:

  1. 这个状态是否可能离开当前函数或当前线程?
  2. 是否有多个 thread、executor task、callback 或 coroutine 持有同一个对象?
  3. 它们的执行时间是否可能重叠?
  4. 是否存在写操作?
  5. 单次操作是否包含多个步骤?
  6. 多个字段之间是否必须保持一致?
  7. API 是否明确规定了 callback 所在线程?
  8. 能否把访问限制在一个线程中,从而避免共享?

状态没有被共享,或者所有访问都严格串行时,不需要同步。如果无法证明这一点,就要设计明确的并发策略。

注意事项

  • thread safe 是对象或操作在特定使用方式下具备的性质,不能靠给变量随意添加一个关键字实现。
  • synchronized 只能协调使用同一把锁的代码。
  • 线程安全涉及同时写入、内存可见性、安全发布和操作顺序。
  • 不可变对象更容易在线程之间传递,但对象引用不可变,并不代表它指向的内容也不可变。
  • 过度加锁会增加复杂度,还可能引发死锁、阻塞或性能问题。
  • Android 的 View 和大多数 UI 操作受主线程约束。后台工作完成后,应按照相应 API 的线程规则切回主线程更新 UI。
  • 第三方 API 的 callback 在哪个线程执行不能靠猜测,应查看该 API 的文档或调用约定。

判断时,先确认状态是否真的被共享,再确定由哪个线程负责管理它。能把可变状态限制在单一线程时,通常会比到处添加 synchronized 更容易维护。

备注:内容仅供参考。