Skip to content

synchronized / Lock / volatile ​

在总览中的位置:Java 并发模型总览 §3
前置基础:CAS 与无锁原子基础
相关主题:Atomic* / CAS / LongAdder · JMM:happens-before / 安全发布

一句话定位 ​

这三者不是同义词:synchronized 解决互斥 + 可见性,Lock 提供更强的等待/中断/条件控制,volatile 解决可见性 + 有序性约束,但不提供复合操作原子性。

代码索引 ​

主题Lab 说明源码
volatile vs AtomicInteger vs synchronized 行为对比volatile-vs-atomic-vs-lockVolatileVsAtomicVsLock.java
volatile 在 DCL 单例中的安全发布作用singleton-dclSingletonDcl.java

为什么需要 ​

只要多个线程会同时碰到同一份状态,你就必须先回答三个问题:

  1. 要不要互斥?——同一时刻能不能允许两个线程都进来改?
    • 一句话答:只要同一份状态会被多个线程并发写入,就必须用锁 / 原子类 / CAS 互斥,否则会出现丢失更新与中间态不一致。
  2. 要不要最新值可见?——一个线程改完,另一个线程什么时候能看到?
    • 一句话答:跨线程的修改必须靠 volatile、锁或原子类建立 happens-before,否则另一个线程可能长时间看到旧值。
  3. 要不要更强控制?——是否需要超时尝试、可中断等待、公平性、多条件等待?
    • 一句话答:当基础互斥不够(限时获取、可中断、公平性、多条件队列)时,用 Lock/Condition 等显式锁替代 synchronized。

这也是为什么“我给变量加个 volatile 就线程安全了”几乎总是错的。真实工程里:

  • Android 主线程和后台线程共用状态时,不仅要避免脏数据,还要避免主线程被锁卡住。
  • Backend 多线程更新缓存 / 状态机时,不仅要保证结果对,还要保证延迟可控。
  • Kotlin/Flutter 团队切到 Java 老代码时,最容易把共享内存问题误解成“像协程/Promise 一样只关心异步顺序”。

底层机制 ​

1. synchronized:默认首选的内置监视器锁 ​

Synchronized 的价值在于:语义直接、自动释放、JVM 优化成熟。

java
private final Object lock = new Object();
private int cacheVersion = 0;

void bumpVersion() {
    synchronized (lock) {
        cacheVersion++;
    }
}
  • 可能执行顺序:多个线程竞争同一把 lock;同一时刻只有一个线程能进入临界区完成 cacheVersion++;退出后下一个线程再继续。
  • 预期现象:版本号不会因为并发自增而丢更新;后进入同一把锁的线程能看见前一个线程对 cacheVersion 的写入。
  • 观察重点:锁保护的是“短小共享状态修改”;若临界区里塞进 IO,等待时间会被急剧放大。

它提供:

  • 互斥:同一把锁保护的临界区,一次只允许一个线程进入
  • 可见性:释放锁前的写入,对随后获取同一把锁的线程可见
  • 结构安全:锁在异常时也会自动释放,不容易漏 unlock()

适合:

  • 临界区短
  • 锁边界清晰
  • 不需要超时尝试、公平策略、可中断获取

不适合:

  • 临界区里包着 IO、RPC、数据库访问等长耗时操作
  • 需要多个等待条件队列的复杂协调

2. Lock / ReentrantLock:当你需要更强控制 ​

java
ReentrantLock lock = new ReentrantLock();

void update() {
    lock.lock();
    try {
        doUpdate();
    } finally {
        lock.unlock();
    }
}
  • 可能执行顺序:线程先显式拿锁,执行 doUpdate(),最后在 finally 中释放;如果中途抛异常,也会走到 unlock()。
  • 预期现象:互斥效果与 synchronized 类似,但控制流由你自己负责,所以忘记释放锁会直接制造卡死风险。
  • 观察重点:Lock 的优势不在“更快”,而在“更可控”;只要用了就必须坚持 try/finally 释放。

ReentrantLock 相比 synchronized 多出来的能力包括:

  • tryLock():尝试拿锁,拿不到就降级或返回
  • tryLock(timeout, unit):有超时地等待
  • lockInterruptibly():拿锁等待过程中可响应中断
  • Condition:比 wait/notify 更明确的条件队列
  • 可选公平锁

什么时候值得上 Lock:

  • 你必须避免无限等待
  • 需要在等待锁时可取消
  • 需要多个条件队列协调生产者 / 消费者 / 状态机

什么时候别为了“高级感”硬上:

  • 只是简单互斥
  • 团队维护成本高于收益
  • 没有充足测试去覆盖复杂控制流

3. volatile:轻量可见性与重排序约束,不是轻量锁 ​

java
private volatile boolean stopped = false;

void stop() {
    stopped = true;
}

void loop() {
    while (!stopped) {
        doOneRound();
    }
}
  • 可能执行顺序:工作线程持续读取 stopped;控制线程调用 stop() 把值改成 true;工作线程下一次读到新值后跳出循环。
  • 预期现象:停止信号对其他线程应较快可见,不需要额外加锁;但 doOneRound() 本身若很慢,退出仍然可能滞后一个周期。
  • 观察重点:这里只解决“看见停止信号”,不解决复杂状态协同,也不保证循环体内部操作原子。

volatile 适合:

  • 单写多读的状态标记位:停止开关、熔断开关、初始化完成标志
  • 不可变快照引用切换:整体替换配置对象,而不是逐字段修改
  • DCL 单例:禁止“引用先可见、构造尚未完成”的重排序

volatile 不适合:

  • count++ 这类读改写复合操作
  • 多字段必须一起保持一致的状态更新
  • 想用它代替真正互斥的场景

经典反例:

这里的关键概念是:volatile 只能保证这个字段的写入对其他线程更快可见,不能把 count++ 这种“读旧值 → 加一 → 写回”的复合动作合成一次原子提交。所以只要多个线程同时做自增,就仍然会发生丢更新。

java
private volatile int count = 0;

void wrong() {
    count++; // ❌ 仍然不是原子操作
}
  • 可能执行顺序:线程 A 和线程 B 先后都读到同一个旧值 count=10;两边各自在本地算出 11;随后分别把 11 写回,导致一次加一被覆盖。
  • 可能输出:多线程并发执行很多次后,最终 count 往往小于理论值。
  • 预期现象:即使字段加了 volatile,最终结果仍可能偏小,因为问题不在“看不见最新值”,而在“多个线程基于同一个旧值重复写回”。
  • 观察重点:count++ 包含读、加、写三步;volatile 只能让每一步更可见,不能把三步合成一个不可分割的原子动作。

4. 默认选型顺序 ​

先问自己要解决什么:

你要解决的问题优先选型
只有可见性 / 开关位 / 快照引用替换volatile
一个短小临界区的互斥与可见性synchronized
要超时、可中断、条件队列ReentrantLock
单变量原子计数 / 状态切换看 Atomic* / CAS / LongAdder

5. Android 场景下的额外边界 ​

  • 主线程拿锁前,先假设自己绝不能等很久。
  • 不要把网络、磁盘、大 JSON 解析放进锁内。
  • 如果你在 Kotlin 业务层主要用协程,很多时候更适合用不可变状态对象 + StateFlow / Mutex,但底层 Java SDK、旧模块仍要看得懂 synchronized / Lock / volatile。

Android / Flutter / Web / Backend 对照 ​

维度synchronized / Lock / volatileAndroidFlutterWebBackend
共享模型共享内存主线程 + 后台线程共享对象很常见isolate 默认不共享内存主线程 JS 单线程,Worker 用消息多线程共享缓存/队列/连接池
典型原语锁、条件队列、可见性标志同上,额外叠加生命周期和 ANR 风险更多用消息、Future、Stream更多靠事件顺序和 AbortController锁、CAS、并发容器是日常
最大误区把 volatile 当线程安全万能钥匙在主线程持有锁做耗时工作把 Java 锁思维硬套到 isolate把事件顺序错当共享内存同步把锁范围放到 RPC / DB 外围

常见场景 ​

1. Android 后台任务停止开关 ​

对应 Lab:volatile-vs-atomic-vs-lock

java
private volatile boolean cancelled = false;

void cancel() {
    cancelled = true;
}
  • 可能执行顺序:后台任务周期性读取 cancelled;上层线程调用 cancel() 后,后续读取会观察到最新值并结束后续处理。
  • 预期现象:简单取消开关可以不加锁地传播到工作线程。
  • 观察重点:如果取消还伴随其他共享状态切换,只靠一个 volatile 布尔值往往不够。

适合条件:

  • 一个线程写,多线程读
  • 状态本身独立,不和其他字段要求原子联动

不适合条件:

  • 取消时还必须同时更新多个字段,并保证一致性

2. 多字段状态一起更新 ​

比如“列表数据 + loading 状态 + 错误文案”要一起切换,不能只靠 volatile。

java
private final Object lock = new Object();
private UiState state = UiState.idle();

void onDataLoaded(List<Item> items) {
    synchronized (lock) {
        state = new UiState(false, items, null);
    }
}
  • 可能执行顺序:数据线程在同一临界区里一次性构造并替换完整 UiState;其他线程要么看到旧状态,要么看到新状态,不容易看到半更新组合。
  • 预期现象:loading、items、error 这组字段作为一个整体切换,比拆开多次赋值更容易保持一致。
  • 观察重点:多字段一致性问题通常已经超出 volatile 的适用边界,应该把状态收拢成整体同步或不可变快照。

如果你把 isLoading、items、error 分成多个独立变量分别更新,就很容易让别的线程读到“不成套”的中间态。

3. 需要超时尝试拿锁的后端状态机 ​

java
if (lock.tryLock(50, TimeUnit.MILLISECONDS)) {
    try {
        flushBatch();
    } finally {
        lock.unlock();
    }
} else {
    metrics.recordBusyDrop();
}
  • 可能执行顺序:线程先在 50ms 内尝试拿锁;拿到就执行批量刷新;拿不到就直接走降级分支记录忙碌丢弃。
  • 预期现象:系统不会无限等锁,而是把“等不到”显式转成可观测降级事件。
  • 观察重点:这类代码的关键不是互斥本身,而是“允许失败并快速返回”的业务语义。

适合:

  • 不能无限等锁
  • 锁拿不到时允许降级 / 丢弃 / 延后

4. DCL 单例里的 volatile ​

对应 Lab:singleton-dcl

java
class Singleton {
    private static volatile Singleton instance;

    static Singleton getInstance() {
        if (instance == null) {
            synchronized (Singleton.class) {
                if (instance == null) {
                    instance = new Singleton();
                }
            }
        }
        return instance;
    }
}
  • 可能执行顺序:第一个进入的线程通过同步块完成实例创建;后续线程大多直接走外层 if 读到已发布实例并返回。
  • 预期现象:实例只会被初始化一次;其他线程拿到的应是已构造完成的对象,而不是半初始化引用。
  • 观察重点:这里的 volatile 重点在禁止有害重排序,不在互斥;去掉它后 DCL 语义就不完整了。

这里的 volatile 不是为了互斥,而是为了安全发布:防止别的线程读到半初始化对象。

常见坑 ​

坑现象修法
volatile 修饰计数器后仍然 count++结果偶发偏小用 AtomicInteger / 锁
把 IO / RPC / 磁盘操作放进 synchronized线程长时间阻塞,主线程容易卡顿缩小临界区,只保护共享状态
用 Lock 却忘记 finally unlock()偶发永久卡死始终 try/finally
多把锁获取顺序不一致线上偶发死锁统一锁顺序,必要时用 tryLock + 超时
一上来就用公平锁吞吐下降、延迟抖动默认先用非公平,除非业务真的需要强公平
DCL 漏掉 volatile偶发半初始化对象DCL 必须 volatile,或改用静态内部类 / enum

与相近概念对比 ​

概念保证什么最适合最不适合
volatile可见性 + 部分有序性标志位、快照引用切换、DCL读改写原子操作、多字段一致性
synchronized互斥 + 可见性短临界区、默认互斥需求超时获取、复杂条件队列
ReentrantLock互斥 + 可中断/超时/条件队列更复杂协调、精细控制简单锁也硬上,导致复杂度过高
ReadWriteLock读写分离读多写少、临界区较大写多或读写转换复杂场景
Atomic*单变量原子更新计数器、状态位切换多字段事务性一致性

对应实验 ​

Lab说明源码
volatile-vs-atomic-vs-lockvolatile-vs-atomic-vs-lockVolatileVsAtomicVsLock.java
singleton-dclsingleton-dclSingletonDcl.java

当前仍缺:ReentrantLock / Condition / tryLock(timeout) / 公平锁吞吐差异的专属实验。

复习检查题 ​

  1. volatile 为什么能保证“看见最新值”,却保证不了 count++ 正确?

    答:因为 volatile 只保证读写可见性和部分有序性,不保证“读-改-写”这三个步骤合成一个原子动作。count++ 在并发下仍可能发生更新丢失。

  2. 什么情况下 synchronized 往往比 ReentrantLock 更合适?

    答:当需求只是一个短小、边界清晰的互斥临界区,而且不需要超时尝试、可中断等待或多个条件队列时,synchronized 语义更直接、出错面更小。

  3. 为什么说 Lock 的优势不在“更高级”,而在“更可控”?

    答:因为它提供 tryLock()、lockInterruptibly()、Condition、公平策略等控制能力,能表达 synchronized 不方便表达的协调需求;但同时也增加了忘记释放锁和控制流复杂化的风险。

  4. Android 主线程上使用锁时最应该警惕什么?

    答:最该警惕长时间等待和把耗时操作包进锁里。主线程一旦被后台线程持有的锁阻塞,就会出现掉帧、卡顿甚至 ANR。

  5. DCL 单例为什么一定要给 instance 加 volatile?

    答:因为对象创建可能被重排序成“先把引用写出去,再慢慢初始化对象内部”。没有 volatile 时,其他线程可能读到非空但尚未初始化完成的半成品对象。

速记 ​

  • 先问自己要的是互斥、可见性,还是更强控制。
  • 默认先想 synchronized,复杂协调再上 Lock。
  • volatile 不是轻量锁;它最擅长标志位和快照引用。
  • 主线程不要长时间等锁,锁里不要包耗时 IO。

站点构建时间:2026/8/24 23:43:17