03-并发正确性与共享状态
并发正确性从哪里来?原子性、可见性与共享状态
给字段加 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,普通字段就一定不能跨线程传递吗?
不是。锁、线程生命周期、任务提交和完成等也能建立正确关系。
参考资料
- JLS 17:线程、锁与内存模型
- Java 17 AtomicInteger
- Java 17 CountDownLatch
- Java 17 Future
- Java 17 LongAdder
- JLS 17:Java 内存模型
- JLS 17:happens-before
- JLS 17:final 字段语义
- Java 17 并发包:内存一致性
- Java 17 Thread API
- Java 17 ExecutorService API
- Java 17 ConcurrentHashMap API
- Java 17 ConcurrentMap API
- OpenJDK 17u ConcurrentHashMap 源码