防御性编程:@Volatile修饰

8 阅读5分钟

@Volatile 确实涉及到指令重排(Instruction Reordering),但在这种场景下,主要原因是为了保证内存可见性(Memory Visibility)

这里加上它是一个“防御性编程”的好习惯,以及它解决了什么问题

1. 核心原因:内存可见性 (Visibility)

在 Java/Kotlin (JVM) 的内存模型中,每个线程都有自己的工作内存(CPU Cache/Registers)

  • 写操作:当 CallHangUpService 在主线程(UI线程)运行 onCreate 并修改 isRunning = true 时,这个值可能只被更新在主线程的工作内存(缓存)中,而没有立即同步回主内存(RAM)
  • 读操作:如果你在另一个线程(例如:网络请求回调、IO 线程、或者 Dispatchers.IO 的协程中)调用了 startFloatingServiceInternal 并读取 isRunning,这个线程可能会直接读取它自己缓存里的旧值(false)。

结果:虽然服务已经启动了,但后台线程仍然认为它没启动,导致逻辑出错。

@Volatile 的作用:它强制要求对该变量的读写必须直接操作主内存

  • 写:立刻刷新到主内存。
  • 读:强制从主内存读取,而不是读缓存。 这就保证了任何线程看到的值都是最新的。

2. 是否一定需要?

这取决于调用 startFloatingServiceInternal 的位置:

  • 情况 A:只在主线程(Activity/Fragment)调用 Android 的 Service 生命周期方法(onCreate/onDestroy)也是在主线程运行的。 如果你的 check(读取)和 set(写入)都在同一个线程(主线程),那么 不需要 @Volatile。因为同一个线程内的操作是天然有序且可见的。

  • 情况 B:可能在后台线程调用 例如:

    // 假设你在协程或者子线程里判断要不要启动服务
    CoroutineScope(Dispatchers.IO).launch {
        // 如果这里没有 volatile,可能读到过期的 false
        startFloatingServiceInternal(...) 
    }
    

    在这种情况下,必须@Volatile

3. 为什么默认加上了?

作为通用的代码建议,加上 @Volatile 是一种防御性编程

虽然你现在可能只在 ActivityonClick 里调用(主线程),但未来你(或者你的同事)可能会在接收到 FCM 推送、WebSocket 消息或者后台任务完成时尝试启动这个 Service。

如果不加 @Volatile,这种潜在的“多线程可见性 Bug”非常难复现和调试。加上它开销极小,但能消除这个隐患。

总结

  • 如果确信只在主线程(UI线程)使用该变量:可以去掉 @Volatile,代码完全正常
  • 为了代码的健壮性(防止未来在子线程调用出错):建议保留

如果不加 @Volatile,在多线程环境下出现的问题不仅仅是“更新不及时”(即延迟几毫秒),更糟糕的情况是“根本看不到更新”或者“看到的是过期的假象”。

具体表现取决于 CPU 缓存的刷新时机,这在物理真机上是不可预测的。以下是两种最典型的“故障现象”:

现象一:重复启动("False Negative" - 假阴性)

场景

  1. 主线程:启动了 Service,执行 onCreate,将 isRunning 设为 true。但这个值留在了主核的 L1/L2 缓存中,还没同步到主内存。
  2. 子线程:紧接着(哪怕过了几百毫秒),试图调用 startFloatingServiceInternal
  3. 问题:子线程从自己的缓存里读取 isRunning,读到的依然是初始值 false

后果

  • 代码判断 if (isRunning) 失败。
  • 子线程再次调用 startService(...)
  • Android 系统行为:虽然 Service 不会重建(不会走 onCreate),但会再次触发 onStartCommand
  • 用户感知
    • 悬浮窗可能闪烁一下(如果逻辑里有移除再添加)。
    • 通知栏可能重新弹出或刷新(Notify 再次被调用)。
    • 原本只需执行一次的初始化逻辑(如连接 Socket、重置状态)被执行了两次,可能导致逻辑错误。

现象二:无法启动("False Positive" - 假阳性 / 僵尸状态)

这是更隐蔽且危险的情况。

场景

  1. 主线程:用户关闭了 Service,onDestroy 执行,isRunning 被设为 false
  2. 子线程:在之前的某次操作中,它缓存了 isRunning = true 的状态,并且一直没刷新(CPU 认为这个变量没人改,没必要去主内存重新拉取)。
  3. 操作:用户想再次开启功能,子线程调用 startFloatingServiceInternal
  4. 问题:子线程以为 isRunning 还是 true

后果

  • 代码命中 if (CallHangUpService.isRunning) return
  • 直接 return,没有去调用 startService
  • 用户感知:点击了“开启”按钮,但没有任何反应。服务死活起不来,必须杀掉 App 重启才能恢复。

为什么会这样?(通俗解释)

可以把 主内存 想象成教室前方的 “黑板”,把 线程的工作内存(缓存) 想象成学生桌上的 “笔记本”

  1. 没有 @Volatile

    • 主线程(老师)在黑板上把 isRunning 改成了 true
    • 子线程(学生)一直在低头看自己的笔记本,笔记本上记录的还是 false
    • 除非学生偶然抬头看黑板(CPU 缓存随机刷新,或者发生上下文切换),否则他永远不知道状态变了。
  2. 加上 @Volatile

    • 这就好比规定:每次要读写这个变量,必须抬头看黑板,不准只看笔记本。
    • 这样虽然抬头(访问主内存)比看笔记本(访问缓存)慢一点点(纳秒级差异),但保证了全班看到的信息是绝对同步的。

总结

在项目中,如果没有 @Volatile且涉及子线程访问:

  • 最好的情况:运气好,CPU 缓存刷新快,无事发生。
  • 一般的情况:状态更新有几百毫秒的延迟,导致短时间内快速点击可能触发两次逻辑。
  • 最坏的情况状态永久不同步(尤其在高性能手机的多核 CPU 上),导致服务在该启动时没启动,或者在该停止时被误判为运行中。