2811 字
约 9 分钟
22
S04-A05 · Java 并发设计:可见性、原子性与顺序

"我明明加了锁,为什么还是错?"背后通常是三件不同的事被当成了同一件:写进去的值别人看不见、"读-改-写"被打断、语句顺序被重排。三者的修复手段不通用——给可见性问题加锁是浪费,给原子性问题只加 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. 自测

  1. volatile int i; i++; 为什么仍然会丢更新?
  2. 什么情况下必须用 Lock 而不是 synchronized?给出两个判据。
  3. containsKey + put 在 ConcurrentHashMap 上为什么不是原子的?正确写法是什么?
  4. 构造器里 new Thread(this).start() 会造成什么问题?
  5. 无界阻塞队列在生产上的失效链条是什么?
  6. CompletableFuture.supplyAsync 不传 Executor 时,阻塞调用会拖垮什么?
S04-A05 · Java 并发设计:可见性、原子性与顺序
https://www.clxhxhhr.top/posts/6165/
作者
clxstart
发布于
2024-12-31
许可协议
CC BY-NC-SA 4.0
评论
0 条
还没有评论,先写一条吧。