Appearance
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-lock | VolatileVsAtomicVsLock.java |
volatile 在 DCL 单例中的安全发布作用 | singleton-dcl | SingletonDcl.java |
为什么需要
只要多个线程会同时碰到同一份状态,你就必须先回答三个问题:
- 要不要互斥?——同一时刻能不能允许两个线程都进来改?
- 要不要最新值可见?——一个线程改完,另一个线程什么时候能看到?
- 要不要更强控制?——是否需要超时尝试、可中断等待、公平性、多条件等待?
这也是为什么“我给变量加个 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++;
}
}它提供:
- 互斥:同一把锁保护的临界区,一次只允许一个线程进入
- 可见性:释放锁前的写入,对随后获取同一把锁的线程可见
- 结构安全:锁在异常时也会自动释放,不容易漏
unlock()
适合:
- 临界区短
- 锁边界清晰
- 不需要超时尝试、公平策略、可中断获取
不适合:
- 临界区里包着 IO、RPC、数据库访问等长耗时操作
- 需要多个等待条件队列的复杂协调
2. Lock / ReentrantLock:当你需要更强控制
java
ReentrantLock lock = new ReentrantLock();
void update() {
lock.lock();
try {
doUpdate();
} finally {
lock.unlock();
}
}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();
}
}volatile 适合:
- 单写多读的状态标记位:停止开关、熔断开关、初始化完成标志
- 不可变快照引用切换:整体替换配置对象,而不是逐字段修改
- DCL 单例:禁止“引用先可见、构造尚未完成”的重排序
volatile 不适合:
count++这类读改写复合操作- 多字段必须一起保持一致的状态更新
- 想用它代替真正互斥的场景
经典反例:
java
private volatile int count = 0;
void wrong() {
count++; // ❌ 仍然不是原子操作
}4. 默认选型顺序
先问自己要解决什么:
| 你要解决的问题 | 优先选型 |
|---|---|
| 只有可见性 / 开关位 / 快照引用替换 | volatile |
| 一个短小临界区的互斥与可见性 | synchronized |
| 要超时、可中断、条件队列 | ReentrantLock |
| 单变量原子计数 / 状态切换 | 看 Atomic* / CAS / LongAdder |
5. Android 场景下的额外边界
- 主线程拿锁前,先假设自己绝不能等很久。
- 不要把网络、磁盘、大 JSON 解析放进锁内。
- 如果你在 Kotlin 业务层主要用协程,很多时候更适合用不可变状态对象 +
StateFlow/Mutex,但底层 Java SDK、旧模块仍要看得懂synchronized/Lock/volatile。
Android / Flutter / Web / Backend 对照
| 维度 | synchronized / Lock / volatile | Android | Flutter | Web | Backend |
|---|---|---|---|---|---|
| 共享模型 | 共享内存 | 主线程 + 后台线程共享对象很常见 | 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;
}适合条件:
- 一个线程写,多线程读
- 状态本身独立,不和其他字段要求原子联动
不适合条件:
- 取消时还必须同时更新多个字段,并保证一致性
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);
}
}如果你把 isLoading、items、error 分成多个独立变量分别更新,就很容易让别的线程读到“不成套”的中间态。
3. 需要超时尝试拿锁的后端状态机
java
if (lock.tryLock(50, TimeUnit.MILLISECONDS)) {
try {
flushBatch();
} finally {
lock.unlock();
}
} else {
metrics.recordBusyDrop();
}适合:
- 不能无限等锁
- 锁拿不到时允许降级 / 丢弃 / 延后
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;
}
}这里的 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-lock | volatile-vs-atomic-vs-lock | VolatileVsAtomicVsLock.java |
| singleton-dcl | singleton-dcl | SingletonDcl.java |
当前仍缺:
ReentrantLock/Condition/tryLock(timeout)/ 公平锁吞吐差异的专属实验。
复习检查题
volatile为什么能保证“看见最新值”,却保证不了count++正确?答:因为
volatile只保证读写可见性和部分有序性,不保证“读-改-写”这三个步骤合成一个原子动作。count++在并发下仍可能发生更新丢失。什么情况下
synchronized往往比ReentrantLock更合适?答:当需求只是一个短小、边界清晰的互斥临界区,而且不需要超时尝试、可中断等待或多个条件队列时,
synchronized语义更直接、出错面更小。为什么说
Lock的优势不在“更高级”,而在“更可控”?答:因为它提供
tryLock()、lockInterruptibly()、Condition、公平策略等控制能力,能表达synchronized不方便表达的协调需求;但同时也增加了忘记释放锁和控制流复杂化的风险。Android 主线程上使用锁时最应该警惕什么?
答:最该警惕长时间等待和把耗时操作包进锁里。主线程一旦被后台线程持有的锁阻塞,就会出现掉帧、卡顿甚至 ANR。
DCL 单例为什么一定要给
instance加volatile?答:因为对象创建可能被重排序成“先把引用写出去,再慢慢初始化对象内部”。没有
volatile时,其他线程可能读到非空但尚未初始化完成的半成品对象。
速记
- 先问自己要的是互斥、可见性,还是更强控制。
- 默认先想
synchronized,复杂协调再上Lock。 volatile不是轻量锁;它最擅长标志位和快照引用。- 主线程不要长时间等锁,锁里不要包耗时 IO。