"我明明加了锁,为什么还是错?"背后通常是三件不同的事被当成了同一件:写进去的值别人看不见、"读-改-写"被打断、语句顺序被重排。三者的修复手段不通用——给可见性问题加锁是浪费,给原子性问题只加 volatile 无效。
决策起点不是挑类,而是先判断这段代码暴露了哪种问题,再选最小手段。
1. 三个问题独立存在
| 问题 | 一句话 | 典型表现 | 最小修复 |
|---|---|---|---|
| 可见性 | A 线程的写,B 线程看不见 | 循环里读到的标志永远不更新 | volatile 或加锁 |
| 原子性 | 复合操作中途被插入 | count++ 丢更新、先查再改被插队 |
锁、原子类、单线程化 |
| 有序性 | 指令重排让半成品被看到 | 双重检查拿到未初始化对象 | volatile、final、happens-before |
判断顺序:几个线程写 → 单变量还是多变量 → 是否依赖执行顺序。三个答案组合起来,手段基本唯一。
2. volatile 解决了什么,没解决什么
public class Worker implements Runnable {
private volatile boolean running = true; // 只是可见性 + 禁止重排
public void stop() { running = false; }
@Override public void run() {
while (running) { /* 干活 */ }
}
}
| 能力 | volatile 是否提供 |
说明 |
|---|---|---|
| 写后对读线程可见 | 是 | 写立即刷到主内存语义,读强制从主内存取 |
| 禁止与该变量相关的重排 | 是 | 这是 DCL 里它不可省的原因 |
| 复合操作原子性 | 否 | volatile int i; i++ 依然丢更新 |
| 多字段一致性 | 否 | 两个 volatile 字段之间没有原子性 |
适用面窄且清晰:一写多读的标志或引用,且不参与"读后算再写"。引用类型还有隐含条件——只保证引用本身的可见性,对象内部字段要靠 final 或安全发布。
3. synchronized 与 Lock:选择判据
| 维度 | synchronized |
Lock(ReentrantLock) |
|---|---|---|
| 释放 | 代码块结束或异常自动释放 | 必须 finally 释放,漏写即永久占用 |
| 可中断 | 否 | lockInterruptibly() |
| 超时 | 否 | tryLock(timeout) |
| 公平 | 否(可能饥饿) | 可选,代价是吞吐 |
| 条件队列 | 单一 wait/notifyAll |
多 Condition,可精确唤醒 |
| 性能 | JDK 6 后有偏向/轻量级锁优化 | 高竞争下略优,差异通常不是瓶颈 |
判据:需要超时、可中断、多条件队列、公平性中任一项,才用 Lock;否则用 synchronized。为了"显得专业"而换 Lock,收益通常是多一个漏掉 unlock 的故障点。
4. 原子类与 ABA
原子类只管单变量复合操作,代价是高竞争下自旋;多字段不变量与"读出来的值已过期"它都不管。
// 错的:两个字段各自原子,组合起来不原子
AtomicInteger x = new AtomicInteger(), y = new AtomicInteger();
// 对的:把不变量打包进一个不可变对象,用一次 CAS 换掉整体
record Point(int x, int y) {}
AtomicReference<Point> p = new AtomicReference<>(new Point(0, 0));
ABA 只需问一句:这段逻辑在不在意"中间被改过"。计数不在意;栈顶、状态机、带版本数据在意,用 AtomicStampedReference 或自带版本号。
5. ConcurrentHashMap 的复合操作陷阱
容器线程安全 ≠ 你的操作线程安全。"先查再改"在两次调用之间是裸露的。
// 错:containsKey 与 put 之间可以被插入,计数会丢
if (!map.containsKey(k)) map.put(k, 1);
// 对:用容器提供的原子复合方法
map.computeIfAbsent(k, key -> 1);
map.merge(k, 1, Integer::sum);
| 组合 | 是否原子 | 正确写法 |
|---|---|---|
containsKey + put |
否 | computeIfAbsent / putIfAbsent |
get + 计算 + put |
否 | compute |
get + remove |
否 | remove(k, v) 两参版本 |
同理,Collections.synchronizedMap 的复合操作要客户端加锁——这就是它很少是对的选择的原因。
6. 线程安全的三层定义
| 层 | 手段 | 代价 |
|---|---|---|
| 不可变 | final 字段、无 setter、防御性拷贝 |
变更要造新对象 |
| 线程封闭 | 局部变量、ThreadLocal、单事件循环 |
池化线程残留旧值 |
| 同步 | 锁、原子类、并发容器 | 竞争、死锁、吞吐下降 |
决策从上往下:能不可变就别共享,能封闭就别加锁,加锁是最后手段。多数并发问题的根因是"本不该共享的状态被共享了",加锁只是把问题挪到性能与死锁上。
7. 安全发布与 this 逸出
public final class Registry {
private final Map<String, String> conf; // final:构造完成后对其他线程可见
public Registry(Map<String, String> src) {
this.conf = Map.copyOf(src); // 拷贝,不持有外部可变引用
// 不要在这里注册监听器/启动线程 —— this 尚未构造完成
}
}
this 逸出有三种形态:构造器里 new Thread(this).start()、把 this 交给外部注册中心、调用可被重写的方法。三者都会让另一个线程在字段赋值前看到对象。修复不是加锁,而是把启动与注册挪到构造之外。
8. 死锁:四个条件与工程破法
四个必要条件(互斥、持有并等待、不可剥夺、循环等待)里,工程上能稳定打破的是循环等待。
| 破法 | 具体做法 | 可靠性 |
|---|---|---|
| 全局顺序 | 多把锁按固定 key 排序获取 | 高,需全团队守约定 |
| 超时放弃 | tryLock(timeout) 失败则回退 |
中,要写回退逻辑 |
| 单一锁 | 多个不变量收进一个锁 | 高,粒度变粗 |
| 无锁化 | 队列串行化、CAS、不可变快照 | 高,架构要改 |
jstack 报 Found one Java-level deadlock 只是证明,不是治疗。工程答案是让两种拿锁顺序压根不存在。
9. 锁粒度与吞吐是反向关系
粒度越细竞争越低,但锁管理、缓存行同步、跨锁一致性成本越高。三个档位:
| 粒度 | 例子 | 适用 | 失效点 |
|---|---|---|---|
| 粗(整对象) | 一个 synchronized 方法保护全部状态 |
高并发下排队成瓶颈 | |
| 中(分片) | 按 key 分桶各用一把锁 | 跨分片操作不再原子 | |
| 细(单变量) | 原子类、LongAdder |
多字段不变量失守 |
判据:先测排队时间,再决定是否细化。没测到竞争就拆锁,是用复杂度换一个不存在的瓶颈。
10. 阻塞队列:为什么必须有界
ThreadPoolExecutor pool = new ThreadPoolExecutor(
8, 32, 60, TimeUnit.SECONDS,
new ArrayBlockingQueue<>(200), // 有界:背压的前提
new ThreadPoolExecutor.CallerRunsPolicy()); // 满了让调用方跑,自然限速
| 队列 | 有界 | 何时选 |
|---|---|---|
ArrayBlockingQueue |
是 | 默认选择,容量可估 |
LinkedBlockingQueue |
可(默认近似无界) | 必须显式给容量 |
SynchronousQueue |
容量 0 | 线程数足够、不排队 |
PriorityBlockingQueue |
无界 | 有明确优先级且生产速率可控 |
DelayQueue |
无界 | 定时重试、延迟任务 |
无界队列的失效链条固定:堆积 → 内存上涨 → 延迟无上限 → OOM。四个内置拒绝策略里只有 CallerRunsPolicy 自带背压;DiscardPolicy 静默丢任务,是事故常见源头。
11. CompletableFuture 的线程池陷阱
ExecutorService io = Executors.newFixedThreadPool(16);
CompletableFuture
.supplyAsync(() -> remoteCall(), io) // 一定要显式给池
.thenApplyAsync(this::parse, io)
.exceptionally(e -> { log.warn("fallback", e); return fallback(); })
.join();
三处坑:不给 Executor 就用 ForkJoinPool.commonPool()(并行度是核数减一),一次阻塞调用就能拖死全 JVM 的并行流;thenApply 这类非 Async 变体在完成线程上跑,落点不可控;不带 exceptionally 的异常被吞,调用方只看到"没结果"。
12. 弱一致迭代与 happens-before
并发容器迭代器弱一致:不抛 ConcurrentModificationException,但可能看不到迭代开始后的修改。"遍历即最新全集"这个假设本身就是错的。
happens-before 的用处是回答**"我的代码是否依赖它"**:解锁先于加锁、volatile 写先于读、start() 先于线程内动作、join() 返回先于后续读。依赖这四条之外的顺序就是数据竞争。
13. 怎么测并发代码
| 手段 | 能发现 | 局限 |
|---|---|---|
| 高并发压测 | 丢更新、池耗尽、死锁 | 复现率低 |
| jcstress | 内存模型级竞争、重排 | 需单独工程 |
| 注入延迟 | 放大交错窗口(Thread.yield、随机 sleep) |
只能证明有问题 |
| 静态检查 | 漏锁、双检写法 | 误报多 |
| 代码评审 | 共享状态清单、锁顺序 | 依赖评审者意识 |
结论:并发正确性靠设计,不靠测试。测试只提高发现概率。
14. 排错:四种"加了锁还错"
| 现象 | 真实原因 | 处理 |
|---|---|---|
| 加锁后计数仍少 | 锁保护的对象和修改的对象不是同一个 | 确认锁与状态同一实例 |
| 单例里状态串了 | 成员变量被当请求上下文 | 改局部变量或 ThreadLocal |
| 本地复现不了、线上偶发 | 少了一次 happens-before | 补 volatile/锁,别靠 sleep |
thenApply 里阻塞导致全线卡住 |
落在 commonPool | 显式传入独立线程池 |
15. 边界:什么时候不需要并发控制
| 场景 | 结论 | 理由 |
|---|---|---|
| 无共享可变状态 | 不需要任何同步 | 局部变量与方法参数天然线程封闭 |
| 单线程模型(事件循环、Actor) | 不需要锁 | 串行化已由架构保证 |
| 单核、低 QPS 的内部工具 | 过度设计有害 | 复杂度换不到吞吐,只换来故障面 |
| 分布式并发(多实例抢资源) | 本地锁无效 | 需要分布式锁或数据库约束,问题性质不同 |
一句话:并发控制是给"共享可变状态"付的税。最好的优化不是把税算准,是让这块状态不存在。
16. 自测
volatile int i; i++;为什么仍然会丢更新?- 什么情况下必须用
Lock而不是synchronized?给出两个判据。 containsKey+put在ConcurrentHashMap上为什么不是原子的?正确写法是什么?- 构造器里
new Thread(this).start()会造成什么问题? - 无界阻塞队列在生产上的失效链条是什么?
CompletableFuture.supplyAsync不传Executor时,阻塞调用会拖垮什么?