03-并发正确性与共享状态

O泡李华 6

并发正确性从哪里来?原子性、可见性与共享状态

给字段加 volatile,计数仍会丢;换成 ConcurrentHashMap,先判断再写入仍可能竞争。问题不在于工具名字是否带 Concurrent,而在于程序需要保护的是单次读写,还是由多次操作组成的业务条件。

并发正确性的分析顺序

1. 拆开一条看起来简单的语句

先看 count++ 的读、改、写,再选择能够保护整体操作的机制。

count++ 不是单独的一次读或写

从逻辑步骤看,递增包含:

读取旧值 → 计算旧值加一 → 写回新值

两个线程可以都读到 0,然后分别写回 1:

线程 A:读取 0 ───计算 1────写入 1
线程 B:读取 0 ───计算 1────写入 1
最终结果:1

这叫丢失更新。问题不在于某次读取有没有读到最新值,而在于整个“读、改、写”过程没有作为一个原子操作执行。

volatile 解决什么?

volatile 为变量的读写建立可见性和顺序方面的规则。对一个 volatile 变量的写,与同步顺序中后续对该变量的读建立 happens-before 关系。

因此它可以用作状态发布信号:先写普通数据,再写 volatile 标志;另一线程读到这个发布后的标志值时,可以据此观察之前的数据写入。JLS happens-before

但这个保证不会把多步计算自动合并。即使读写分别符合 volatile 规则,两个线程仍然可能先后读到同一个旧值,再各自写回结果。

“可见”与“原子”必须分别判断。

synchronized 如何保护一个临界区?

多个线程使用同一个监视器时,同一时刻只有一个线程进入对应同步区。把整个递增放进去,就能保护读、改、写的完整过程。

static synchronized void increment() {
    count++;
}

静态同步方法使用类对象的监视器;实例同步方法使用当前实例。两个不同实例的实例同步方法,不会因为代码相同就自动使用同一把锁。

同一个监视器上的释放与后续获取也建立可见性关系。锁不仅控制谁先进入,还帮助发布同步区内的状态。

AtomicInteger 如何完成递增?

AtomicInteger 提供原子更新 API,例如 incrementAndGet。调用方把“递增”交给这个原子操作,而不是自己拆成 get 后 set。

counter.incrementAndGet();

它适合独立计数等操作。其底层通常涉及 CAS 等机制,但不同方法的实现与内存语义应以具体 API 和 JDK 为准。AtomicInteger API

如果一个业务同时要求“余额不能为负”“库存减少”“订单只创建一次”,不能仅因为每个字段用了原子类,就认定整个业务过程原子。

完整示例:让丢失更新按确定顺序发生

普通竞态不一定每次都出现。下面用两个线程和同步器安排双方先读取,再写回,明确展示 volatile 无法合并复合操作。

实验里把递增拆开,是为了控制交错顺序;不是声称每次执行 count++ 都会得到相同错误结果。复制到 Java 17 的 Main.java 即可运行。

import java.util.concurrent.CountDownLatch;
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;
import java.util.concurrent.Future;
import java.util.concurrent.TimeUnit;
import java.util.concurrent.atomic.AtomicInteger;

public class Main {
    static volatile int visibleCounter;
    static int lockedCounter;

    static synchronized void incrementLocked() {
        lockedCounter++;
    }

    static void await(CountDownLatch latch) {
        try {
            if (!latch.await(5, TimeUnit.SECONDS)) {
                throw new IllegalStateException("wait timeout");
            }
        } catch (InterruptedException exception) {
            Thread.currentThread().interrupt();
            throw new IllegalStateException("interrupted", exception);
        }
    }

    static void runTwo(Runnable action) throws Exception {
        ExecutorService executor = Executors.newFixedThreadPool(2);
        try {
            Future<?> first = executor.submit(action);
            Future<?> second = executor.submit(action);
            first.get(5, TimeUnit.SECONDS);
            second.get(5, TimeUnit.SECONDS);
        } finally {
            executor.shutdownNow();
        }
    }

    public static void main(String[] args) throws Exception {
        CountDownLatch bothRead = new CountDownLatch(2);
        runTwo(() -> {
            int old = visibleCounter;
            bothRead.countDown();
            await(bothRead);
            visibleCounter = old + 1;
        });
        System.out.println("volatile read-modify-write = " + visibleCounter);

        runTwo(Main::incrementLocked);
        System.out.println("synchronized increment = " + lockedCounter);

        AtomicInteger atomic = new AtomicInteger();
        runTwo(() -> atomic.incrementAndGet());
        System.out.println("atomic increment = " + atomic.get());
    }
}

运行输出:

volatile read-modify-write = 1
synchronized increment = 2
atomic increment = 2

CountDownLatch 保证双方先保存旧值,再各自写回。主线程通过 Future.get 等待结束后检查结果,避免把“任务还没完成”误判成计数错误。CountDownLatch API、Future API

这组实验直接验证的是复合操作的原子性,不能拿它测量不同方案的性能,也不能从一次输出推导完整的内存模型。

2. 可见性的依据是同步关系

happens-before 描述程序保证,不是猜测线程执行先后。

happens-before 表达什么?

如果 A happens-before B,那么在对应规则下,A 的效果对 B 具有所需的可见性和顺序保证。

它不是要求 JVM 将每条指令严格按墙上时钟顺序执行。编译器和处理器仍可进行优化,但不能破坏程序依赖的合法保证。

它也有传递性:A happens-before B,B happens-before C,可以推导 A happens-before C。

这种关系可以连接多个步骤,例如:

普通数据写入 → volatile 标志写入 → 读取发布后的标志 → 读取数据

常见的同步关系

关系 可以推导的保证
同一线程的程序顺序 前面的动作 happens-before 后面的动作
同一监视器的解锁与后续加锁 释放前的状态可发布给获取者
volatile 写与后续读 按同步顺序建立相应关系
Thread.start 启动前的动作发布给新线程
线程结束与成功 join 等待者可以观察线程完成前的动作
任务提交与 Future.get 按执行器和 Future 契约传递任务结果

并发包中的同步器也有自己的内存一致性说明。需要根据具体操作连接关系,不能只看对象来自 concurrent 包就默认任何组合都安全。并发包的内存一致性说明

安全发布为什么重要?

对象创建后,把引用放进一个普通共享变量,并让其他线程无同步地读取,不足以保证所需的发布关系。

发布对象需要让接收线程正确观察其初始化状态,例如通过锁、volatile 引用、线程启动、并发集合或任务提交等明确机制。

对象自身如果继续发生并发修改,安全发布也不能替代后续同步。发布是“第一次如何交到别人手里”,状态维护是“交出去后怎样共同操作”。

final 字段还有专门的初始化保证,但它不是对任意对象图和后续修改的自动保护。构造过程中让 this 提前逸出,也会破坏应有的前提。

volatile 发布的推理方式

典型结构是先写数据,再写发布标志:

data = 42;
ready = true; // ready 为 volatile

另一个线程在确认读到这次发布对应的 ready 值后,再读取 data,可以利用传递性推导可见性。

但如果把 data 更新放在 ready 写入之后,原来的关系无法自动覆盖这次后续更新。若多个发布者反复复用同一个标志,或者读取者没有确认对应的状态,推理也需要重新分析。

volatile 是建立关系的机制,不是把整个对象所有操作自动变成一个事务。

如何分析一段并发代码?

先找到共享状态,再列出每个线程的读写操作,然后寻找它们之间明确的同步边:同一把锁、volatile、启动与结束、任务提交与完成等。

如果需要连接两个读写,却找不到足够的关系,就不能因为测试多次正常而判定安全。Thread.sleep、打印日志、单步调试都可能改变时机,掩盖问题。

还要分别检查原子性。即使某个字段访问具有可见性规则,多步更新仍可能交错;多个字段的不变量也需要完整的协调方式。

3. 线程安全容器仍有组合边界

把先查再改转换成适当的原子 API,并检查回调里的外部副作用。

它保证的是哪些操作?

ConcurrentHashMap 支持并发读取和更新,并提供 putIfAbsent、compute、merge 等具有特定原子语义的方法。

但下面两步是独立调用:

Integer old = map.get("count");
map.put("count", old + 1);

其他线程可以在它们之间操作同一个键。因此,集合内部结构安全,不代表组合起来的业务步骤不可分割。ConcurrentHashMap API

不同原子 API 如何选择?

API 适用需求
putIfAbsent 没有映射时放入一个已有值
replace(key, old, new) 仅当当前值匹配预期时替换
computeIfAbsent 缺少映射时计算并建立值
compute 根据当前映射计算新状态
merge 合并已有值与新值,例如累加

每种方法的缺失值、null 返回和异常规则不同。不能用方法名推断所有行为,更不能把多个键的变化当成一个整体事务。

例如 compute 返回 null 会移除或保持没有该映射;computeIfAbsent 的计算返回 null,则不会建立值。若计算失败,也不能假设该键永久只计算一次。

computeIfAbsent 不适合隐藏任意长任务

计算期间,相关更新可能等待。因此映射函数应简短,不应递归更新同一张 Map 的其他映射。

把很慢的远程请求、复杂数据库操作直接放进去,会把外部延迟带进集合更新过程。需要并发合并加载时,可以考虑单独的加载任务、Future 或缓存组件,但必须继续处理加载失败、取消和过期。

如果建立的 value 是 ArrayList,映射的创建安全,也不意味着后续多线程对该列表 add 安全。容器与值对象的并发职责要分别考虑。

遍历不是全表快照

ConcurrentHashMap 的迭代器可以与更新并存,不以 ConcurrentModificationException 的方式报告这种修改,但它不是一次原子的全表快照。

并发更新期间,size 和统计结果可能反映中间状态,不能据此实现严格容量检查:

检查 size 小于限制 → 再 put

这两个操作之间仍有竞争。需要严格业务限制时,应设计专门的原子状态或外部协调机制。

4. 设计判断与适用边界

并发设计先写出不变量,例如库存不能为负、同一个键只初始化一次。围绕不变量确定临界区,而不是每个字段分别加一个看起来安全的包装。需要保护多个字段时,单字段 CAS 不一定能组成正确事务。

锁与原子变量解决的层次不同。锁保护复合状态,原子 API 适合表达确定的单变量或单键变更;它们都不能自动保证远程调用和数据库之间的一致性。验证程序应协调竞争点并设置等待上限,不能只靠 sleep 碰运气。

5. 问答

问:volatile 能保证 count++ 吗?

不能。读与写的规则不等于整个读改写过程不可分割。

问:锁不同,代码相同,能互相保护吗?

不能。相关线程必须围绕同一个同步对象或协调机制维护同一份状态。

问:为什么并发 Map 的 get 加 put 会丢失更新?

单次调用安全,但两次调用之间存在交错,复合更新不自动原子。

问:computeIfAbsent 是否保证业务永远只执行一次?

不保证。删除、计算返回 null、计算失败等都可能导致后续再次计算,更没有跨进程保证。

问:happens-before 等于现实时间先执行吗?

不等于。它是模型定义的关系,需要用具体规则推导。

问:不用 volatile,普通字段就一定不能跨线程传递吗?

不是。锁、线程生命周期、任务提交和完成等也能建立正确关系。

参考资料