两个 Java 线程修改同一个变量,写入可能悄无声息地丢失。看清 count++ 为什么会竞态,synchronized、volatile、原子类和锁怎样修好它,以及怎样强制制造并检测死锁。
线程和程序的其他部分同时运行代码。只碰自己数据的线程很好对付。麻烦出在两个线程修改同一个变量的时候:程序不崩溃,也不报警,但有些修改就这么没了。
本文先启动线程,展示这个 bug,再让它每次运行都必然发生。接着讲 synchronized、volatile、原子类、死锁、ReentrantLock、线程安全的集合和不可变数据。下面每个程序都在 Java 25 上运行过,输出直接从运行结果复制而来。想自己运行,就把代码保存为 Main.java,然后执行 java Main.java。
启动线程:用 start,不是 run
Thread 对象在你调用 start() 之前什么都不做。start() 向 JVM 申请一个新线程,并在上面运行你的代码。调用 run() 看起来差不多,但它只是在你当前所在的线程上调用这个方法:
void main() throws InterruptedException {
Thread mainThread = Thread.currentThread();
boolean[] onMain = new boolean[2];
Thread first = new Thread(() -> onMain[0] = Thread.currentThread() == mainThread);
first.run();
Thread second = new Thread(() -> onMain[1] = Thread.currentThread() == mainThread);
second.start();
second.join();
IO.println("run() ran the task on the main thread: " + onMain[0]);
IO.println("start() ran the task on the main thread: " + onMain[1]);
IO.println("state of the thread we called run() on: " + first.getState());
}
输出:
run() ran the task on the main thread: true
start() ran the task on the main thread: false
state of the thread we called run() on: NEW
first.run() 在主线程上运行了 lambda 表达式,first 根本没有变成一个真正的线程,它的状态仍然是 NEW。误调 run() 能编译,能运行,却悄悄地让你失去了并发。
second.join() 让主线程一直等到 second 结束。没有它,main 可能在另一个线程写入 onMain[1] 之前就去读它。
守护线程(daemon thread)是 JVM 不会等待的线程:只剩守护线程时,程序就退出。下面的死锁示例会用到这一点。
大多数代码不会手动创建线程。讲 java.util.concurrent 的那一部分会介绍执行器,讲虚拟线程的那一部分会介绍 Java 21 加入的廉价线程。本文关于共享状态的内容对两者都适用。
只用自己数据的线程不需要锁
构建器 Thread.ofPlatform().start(...) 从 Java 21 起成为正式特性,一次调用就能创建并启动线程。下面四个线程各自写入数组中属于自己的槽位:
void main() throws InterruptedException {
String[] words = {"apple", "banana", "cherry", "date"};
int[] lengths = new int[words.length];
var threads = new ArrayList<Thread>();
for (int i = 0; i < words.length; i++) {
int slot = i;
threads.add(Thread.ofPlatform().start(() -> lengths[slot] = words[slot].length()));
}
for (Thread t : threads) {
t.join();
}
IO.println(Arrays.toString(lengths));
}
输出:
[5, 6, 6, 4]
没有两个线程写同一个槽位,所以不需要任何协调。读取之前先 join 每个线程,结果就是可靠的。
抓不住的丢失更新
两个线程修改同一个变量,一个修改覆盖了另一个,这就是丢失更新。下面四个线程各自给共享的 count 加 1,各加十万次:
int count = 0;
void main() throws InterruptedException {
var threads = new ArrayList<Thread>();
for (int i = 0; i < 4; i++) {
threads.add(Thread.ofPlatform().start(() -> {
for (int n = 0; n < 100_000; n++) {
count++;
}
}));
}
for (Thread t : threads) {
t.join();
}
IO.println("count = " + count);
}
你会以为结果是 400000。我们连续运行了五次,每次的数字都不一样,所以下面的输出只是一个例子,你不会得到完全相同的结果:
$ for i in 1 2 3 4 5; do java Main.java; done
count = 135423
count = 149972
count = 120394
count = 165135
count = 199862
一半以上的自增不见了。换一台机器,或者运气好的时候,可能一次都不丢,所以测试通过什么也证明不了。
Go 有竞态检测器,能标出这类代码。Java 没有自带。所以与其指望 bug 自己冒出来,下一个程序让它每次都发生。
强制制造丢失更新
count++ 不是一步。它先读取 count,加 1,再把结果写回去。CountDownLatch 能把两个线程都卡在读和写之间:
int count = 5;
void main() throws InterruptedException {
var bothHaveRead = new CountDownLatch(2);
Runnable increment = () -> {
int seen = count; // 1. read
bothHaveRead.countDown();
awaitQuietly(bothHaveRead); // wait until the other thread has read too
count = seen + 1; // 2. add one and write
};
Thread t1 = Thread.ofPlatform().start(increment);
Thread t2 = Thread.ofPlatform().start(increment);
t1.join();
t2.join();
IO.println("two increments ran, starting from 5");
IO.println("count = " + count);
}
void awaitQuietly(CountDownLatch latch) {
try {
latch.await();
} catch (InterruptedException e) {
throw new IllegalStateException(e);
}
}
输出:
two increments ran, starting from 5
count = 6
CountDownLatch(2) 从 2 开始。countDown() 让它减一,await() 一直阻塞到它变成 0。每个线程读到 5,倒数一次,然后等另一个线程也倒数。所以两个线程总是在任何一方写入之前都拿着 5,两个都写入 6。我们运行了 20 次,每次都是 6。
老实说,闭锁(latch)把问题放大了。没有它,调度器只是偶尔在读和写之间暂停线程。可在 400,000 次自增里,“偶尔”就是经常,前面五次运行已经证明了这一点。闭锁故意选中了糟糕的时机,好让你每次都能看到。
看着更新丢失
两个线程在一个初始值为 5 的共享 count 上各执行一次 count++:
两个线程在值为 5 的共享 count 上各执行一次 count++。两者都在对方写入之前读到 5,于是都写入 6,丢了一次自增。上面程序里的闭锁强制了这个顺序;没有闭锁时,调度器只是有时会排出这个顺序。
如果动画没有播放,下面用文字说明这几步:
count是 5。T1 和 T2 各执行一次count++。- T1 读取
count,把 5 存进自己的局部副本。 - T1 还没写入任何东西,T2 也读取
count,存下 5。 - T1 给它的 5 加 1,写入 6。
- T2 给它自己的 5 加 1,同样写入 6,覆盖了 T1 的 6。
- 执行了两次自增,
count却只从 5 变成 6。丢了一次自增。
用十岁孩子能懂的话说
两个小朋友在同一块记分牌上记分。每当自己的队得分,小朋友就看一眼记分牌,在心里加 1,再把新数字写上去。
两个队同时得分。小朋友 A 看到 5,心想“6”。小朋友 B 也看到 5,也心想“6”。A 写上 6。B 擦掉它,也写上 6。明明得了两分,记分牌只涨了一分。一分凭空消失,记分牌看起来却一切正常。
synchronized 就是唯一的一支记号笔。只有拿着笔的小朋友才能看记分牌、在上面写字。另一个小朋友等着拿笔,拿到后看到 6,写上 7。
准确的说法
字段上的 count++ 编译成三步字节码:读取字段、加 1、存回字段。任意两步之间,都可能有别的线程插进来运行。两段“读取—修改—写入”重叠时,第二次存储会覆盖第一次,而且它依据的是过时的读取结果。
Java 不保证线程怎样交错执行,所以这样的程序没有固定的答案。JIT 编译器还可能把这个空隙拉得更大。它可以在一段迭代里把 count 放在 CPU 寄存器中,过一阵再存回去。一次运行丢失的自增远超你的预料,这就是原因之一。
这个比喻的局限:小朋友能看见对方伸手去够记分牌,线程看不见。count++ 从不检查有没有别的线程,只有你加了锁它才会等待。而且两个小朋友用的必须是同一支笔:两个线程各拿一把不同的锁,根本不会互相等待。
synchronized:一次只让一个线程进
每个 Java 对象都有一把内置锁,叫作监视器(monitor)。synchronized 方法在运行前获取 this 的监视器,返回时释放它,因异常退出时也会释放。第二个线程在同一个对象上调用它,就得等着。
还是那个强制制造问题的程序,这次把 increment 标成 synchronized。闭锁现在最多只等 300 毫秒,因为另一个线程进不来,没法倒数:
int count = 5;
int timedOut = 0;
synchronized void increment(CountDownLatch bothHaveRead) {
int seen = count;
bothHaveRead.countDown();
if (!awaitBriefly(bothHaveRead)) {
timedOut++; // the other thread never got in to read
}
count = seen + 1;
}
void main() throws InterruptedException {
var bothHaveRead = new CountDownLatch(2);
Thread t1 = Thread.ofPlatform().start(() -> increment(bothHaveRead));
Thread t2 = Thread.ofPlatform().start(() -> increment(bothHaveRead));
t1.join();
t2.join();
IO.println("count = " + count);
IO.println("waits that gave up: " + timedOut);
}
boolean awaitBriefly(CountDownLatch latch) {
try {
return latch.await(300, TimeUnit.MILLISECONDS);
} catch (InterruptedException e) {
throw new IllegalStateException(e);
}
}
输出:
count = 7
waits that gave up: 1
先拿到监视器的线程读到 5,倒数一次。然后它等待第二次读取,可这次读取不可能发生,因为另一个线程被挡在 increment 外面。300 毫秒后它放弃等待,写入 6。直到这时,第二个线程才能进入。它读到 6,发现闭锁已经是 0,于是写入 7。糟糕的交错现在不是不太可能,而是根本不可能。
如果用不带超时的 latch.await(),这个程序会永远挂起。第一个线程拿着锁等第二个线程。这就是死锁,下面有专门的一节来讲。
static synchronized 方法锁的是这个类的 Class 对象,而不是 this。监视器还是可重入的:持有它的线程可以调用同一个对象上的另一个 synchronized 方法,不会把自己堵住。
选择锁对象
synchronized 块要写明获取哪个对象的监视器。通常的做法是用一个私有的 final 字段,它存在的唯一目的就是被锁:
class Counter {
private final Object lock = new Object();
private int count;
void increment() {
synchronized (lock) {
count++;
}
}
int get() {
synchronized (lock) {
return count;
}
}
}
void main() throws InterruptedException {
var counter = new Counter();
var threads = new ArrayList<Thread>();
for (int i = 0; i < 4; i++) {
threads.add(Thread.ofPlatform().start(() -> {
for (int n = 0; n < 100_000; n++) {
counter.increment();
}
}));
}
for (Thread t : threads) {
t.join();
}
IO.println("count = " + counter.get());
}
输出:
count = 400000
这就是前面那个结果飘忽不定的程序,修好了。我们运行了 20 次,每次都是 400000。
私有锁比 synchronized 方法更好,有三个理由:
- 别人拿不到它。 任何持有
Counter的代码都能写synchronized (counter),把你的方法堵住。外部代码却碰不到lock。 - 锁住的块可以很小。 只锁住访问共享状态的那几行,慢的活放到锁外面做。
- 读也要加锁。
get()同样加锁,所以它永远不会看到做了一半的更新,而且总能看到最新的写入。
不要锁一个会变的对象。在 Integer 字段上写 synchronized (count),每次 count++ 之后锁的都是另一个对象,因为装箱会创建新的 Integer。javac 能发现这个问题:warning: [identity] attempt to synchronize on an instance of a value-based class。这是 Java 25 里的类别名,加上 -Werror 会把这个警告变成构建失败。
volatile:看见其他线程的写入
可见性是共享状态的第二个问题。一个线程写了字段,另一个线程可能一直看到旧值。volatile 字段解决了这个问题:每次读取都能看到最近一次写入。经典用法是停止标志:
volatile boolean running = true;
void main() throws InterruptedException {
var started = new CountDownLatch(1);
long[] loops = new long[1];
Thread worker = Thread.ofPlatform().start(() -> {
started.countDown();
while (running) {
loops[0]++;
}
});
started.await();
running = false;
worker.join();
IO.println("worker stopped: " + !worker.isAlive());
}
输出:
worker stopped: true
工作线程一直空转,直到 running 变为 false,它一察觉到,join 就返回。
让我们意外的是:我们去掉 volatile,让 main 在清除标志前先睡半秒,设五秒超时运行了五次。工作线程一次都没有停下来。最可能的原因是 JIT:循环里没有任何代码写 running,所以编译后的循环可以不再读取这个字段。没有 volatile 或锁,这样做是合法的。
不过,volatile 并不能让 count++ 变安全。volatile int count 让每次读取都看到最新值,但读取、加法和写入仍然是三步。两个线程照样可以都读到 5、都写入 6,和动画里一模一样。volatile 解决的是可见性,不是原子性。把它用在一个线程设置、其他线程读取的标志上。
原子类:比较并设置
java.util.concurrent.atomic 里的类,每次更新都是单个不可分割的步骤。它们建立在比较并设置(compare-and-set)之上:“把值设为 6,但前提是它仍然是 5”。如果中间有别的线程改了它,调用就失败并返回 false,你再试一次。下面还是那个强制交错的程序,这次用 AtomicInteger:
void main() throws InterruptedException {
var count = new AtomicInteger(5);
var retries = new AtomicInteger();
var bothHaveRead = new CountDownLatch(2);
Runnable increment = () -> {
int seen = count.get();
bothHaveRead.countDown();
awaitQuietly(bothHaveRead);
while (!count.compareAndSet(seen, seen + 1)) {
retries.incrementAndGet(); // someone changed it: read again
seen = count.get();
}
};
Thread t1 = Thread.ofPlatform().start(increment);
Thread t2 = Thread.ofPlatform().start(increment);
t1.join();
t2.join();
IO.println("count = " + count.get());
IO.println("failed compare-and-sets: " + retries.get());
}
void awaitQuietly(CountDownLatch latch) {
try {
latch.await();
} catch (InterruptedException e) {
throw new IllegalStateException(e);
}
}
输出:
count = 7
failed compare-and-sets: 1
两个线程仍然都读到 5。一个线程的 compareAndSet(5, 6) 成功了。另一个线程的 compareAndSet(5, 6) 失败,因为值现在是 6。它重新读到 6,设为 7。什么都没丢,也没有谁在锁上等待。
这个循环你很少需要自己写。incrementAndGet() 内部做的正是这件事,updateAndGet(x -> x * 2) 则对任意函数都这么做:
void main() throws InterruptedException {
var count = new AtomicInteger();
var total = new LongAdder();
var threads = new ArrayList<Thread>();
for (int i = 0; i < 4; i++) {
threads.add(Thread.ofPlatform().start(() -> {
for (int n = 0; n < 100_000; n++) {
count.incrementAndGet();
total.increment();
}
}));
}
for (Thread t : threads) {
t.join();
}
IO.println("AtomicInteger: " + count.get());
IO.println("LongAdder: " + total.sum());
}
输出:
AtomicInteger: 400000
LongAdder: 400000
LongAdder 适合被许多线程不停累加的计数器。许多线程争抢同一个 AtomicInteger 时,比较并设置会不断失败、不断重试。LongAdder 给各个线程分开的单元去累加,调用 sum() 时再把各单元加起来。自增变便宜了,读取稍微变贵了。而且线程还在累加时,sum() 并不是一个快照,所以要等它们结束后再读,或者只在近似值够用时读。
原子类保护的是一个值。当两个字段必须一起改变,比如余额和交易次数,就用锁。
死锁:两把锁,顺序相反
两个线程各自持有对方需要的锁,于是都永远等下去,这就是死锁。常见的原因是两把锁按相反的顺序获取。下面的程序用闭锁强制制造死锁,检测到它,然后退出:
import java.lang.management.ManagementFactory;
final Object accountA = new Object();
final Object accountB = new Object();
void main() throws InterruptedException {
var bothHoldOne = new CountDownLatch(2);
Thread t1 = Thread.ofPlatform().daemon().start(() -> {
synchronized (accountA) {
bothHoldOne.countDown();
awaitQuietly(bothHoldOne);
synchronized (accountB) {
IO.println("t1 moved money from A to B");
}
}
});
Thread t2 = Thread.ofPlatform().daemon().start(() -> {
synchronized (accountB) {
bothHoldOne.countDown();
awaitQuietly(bothHoldOne);
synchronized (accountA) {
IO.println("t2 moved money from B to A");
}
}
});
var threadBean = ManagementFactory.getThreadMXBean();
long[] stuck = threadBean.findDeadlockedThreads();
while (stuck == null) {
Thread.sleep(10);
stuck = threadBean.findDeadlockedThreads();
}
IO.println("deadlock detected: " + stuck.length + " threads");
IO.println("t1 state: " + t1.getState() + ", t2 state: " + t2.getState());
}
void awaitQuietly(CountDownLatch latch) {
try {
latch.await();
} catch (InterruptedException e) {
throw new IllegalStateException(e);
}
}
输出:
deadlock detected: 2 threads
t1 state: BLOCKED, t2 state: BLOCKED
t1 持有 A,等待 B。t2 持有 B,等待 A。两条转账消息都不会打印。findDeadlockedThreads() 向 JVM 查询陷入这种循环的线程,没有时返回 null。循环不断轮询,直到循环等待形成。
阻塞在 synchronized 上的线程无法被中断,所以没有什么能让这两个线程脱身。它们是守护线程,所以 main 返回时 JVM 照样退出。我们运行了 20 次:每次都打印同样的两行,大约一秒半后退出。
在真实的服务器上,你会从外部看到它。jcmd <pid> Thread.print 会转储每个线程,对这个程序,输出里包含 Found one Java-level deadlock:,后面列出哪个线程持有哪个监视器。
修复靠一条规则:每个线程都按同样的顺序获取锁。
final Object accountA = new Object();
final Object accountB = new Object();
int transfers = 0;
void transfer() {
synchronized (accountA) { // always A first
synchronized (accountB) { // then B
transfers++;
}
}
}
void main() throws InterruptedException {
Runnable work = () -> {
for (int n = 0; n < 50_000; n++) {
transfer();
}
};
Thread t1 = Thread.ofPlatform().start(work);
Thread t2 = Thread.ofPlatform().start(work);
t1.join();
t2.join();
IO.println("transfers = " + transfers);
}
输出:
transfers = 100000
不可能有线程拿着 B 去等 A,所以循环等待无法形成。对真实的账户,要根据某个稳定的东西来定顺序,比如账户 id:先锁 id 小的那个。
ReentrantLock:可以放弃的锁
java.util.concurrent.locks.ReentrantLock 做的事和 synchronized 一样,只是要显式调用 lock() 和 unlock()。解锁总是放在 finally 里,这样异常就不会让锁一直被占着:
class Counter {
private final ReentrantLock lock = new ReentrantLock();
private int count;
void increment() {
lock.lock();
try {
count++;
} finally {
lock.unlock();
}
}
int get() {
lock.lock();
try {
return count;
} finally {
lock.unlock();
}
}
}
void main() throws InterruptedException {
var counter = new Counter();
Runnable work = () -> {
for (int n = 0; n < 100_000; n++) {
counter.increment();
}
};
Thread t1 = Thread.ofPlatform().start(work);
Thread t2 = Thread.ofPlatform().start(work);
t1.join();
t2.join();
IO.println("count = " + counter.get());
}
输出:
count = 200000
同样的结果,代码比 synchronized 多。ReentrantLock 的价值在于做 synchronized 做不到的事,最主要的一件就是放弃。带超时的 tryLock 只等待有限的时间,如果锁一直没空出来,就返回 false:
void main() throws InterruptedException {
var lock = new ReentrantLock();
boolean[] gotIt = new boolean[1];
lock.lock(); // main holds the lock for the whole test
try {
Thread other = Thread.ofPlatform().start(() -> {
try {
gotIt[0] = lock.tryLock(100, TimeUnit.MILLISECONDS);
if (gotIt[0]) {
lock.unlock();
}
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
}
});
other.join();
} finally {
lock.unlock();
}
IO.println("other thread got the lock: " + gotIt[0]);
}
输出:
other thread got the lock: false
在死锁程序里,对第二把锁用 tryLock,线程就能退一步,释放第一把锁再重试,而不是永远等下去。lockInterruptibly() 提供一种可以被 interrupt() 结束的等待,这是 synchronized 永远做不到的。
ReadWriteLock(通常是 ReentrantReadWriteLock)有两面。任意多个线程可以同时持有读锁,但写锁是独占的,要等所有读者离开。读远多于写、而且每次读都要花点时间时,它才有帮助。临界区很短时,它往往并不更快,所以先从普通的锁开始。
线程安全的集合,以及先检查后执行
线程安全的集合让每一次单独的调用变安全,但一连串调用并不安全。Collections.synchronizedList 把列表包装起来,让每个方法都获取同一把锁:
void main() throws InterruptedException {
List<Integer> numbers = Collections.synchronizedList(new ArrayList<>());
var threads = new ArrayList<Thread>();
for (int i = 0; i < 4; i++) {
threads.add(Thread.ofPlatform().start(() -> {
for (int n = 0; n < 1_000; n++) {
numbers.add(n);
}
}));
}
for (Thread t : threads) {
t.join();
}
long sum = 0;
synchronized (numbers) { // iterating needs the list's own lock
for (int n : numbers) {
sum += n;
}
}
IO.println("size = " + numbers.size() + ", sum = " + sum);
}
输出:
size = 4000, sum = 1998000
每次 add 都是安全的。但遍历不是一次调用,而是许多次 next() 调用,所以你得自己持有这个列表的锁,文档里也是这么说的。我们换成普通的 ArrayList 运行了 30 次。29 次结果不到 4000,其中两次还在线程里抛出了 ArrayIndexOutOfBoundsException。
ConcurrentHashMap 更进一步。读取不阻塞,写入者只锁住映射的一小部分,遍历也从不抛出 ConcurrentModificationException。但它仍然保护不了分两次调用、先检查再执行的代码:
void main() throws InterruptedException {
var sessions = new ConcurrentHashMap<String, String>();
var created = new AtomicInteger();
var bothHaveChecked = new CountDownLatch(2);
Runnable login = () -> {
if (!sessions.containsKey("ana")) { // check
bothHaveChecked.countDown();
awaitQuietly(bothHaveChecked);
int n = created.incrementAndGet();
sessions.put("ana", "session-" + n); // then act
}
};
Thread t1 = Thread.ofPlatform().start(login);
Thread t2 = Thread.ofPlatform().start(login);
t1.join();
t2.join();
IO.println("sessions created for ana: " + created.get());
IO.println("entries in the map: " + sessions.size());
}
void awaitQuietly(CountDownLatch latch) {
try {
latch.await();
} catch (InterruptedException e) {
throw new IllegalStateException(e);
}
}
输出:
sessions created for ana: 2
entries in the map: 1
闭锁把两个线程都卡在检查和 put 之间,所以两个线程都看到没有会话,都创建了一个。映射里只有一个条目,却创建了两个会话,第二次 put 悄悄替换了第一次。这和 count++ 是同一种丢失更新,只是高了一层。
修复办法是把整个决定交给映射,在一次调用里完成:
void main() throws InterruptedException {
var sessions = new ConcurrentHashMap<String, String>();
var created = new AtomicInteger();
var wordCounts = new ConcurrentHashMap<String, Integer>();
var words = List.of("red", "blue", "red", "green", "red", "blue");
var threads = new ArrayList<Thread>();
for (int i = 0; i < 8; i++) {
threads.add(Thread.ofPlatform().start(() -> {
sessions.computeIfAbsent("ana", user -> "session-" + created.incrementAndGet());
for (String w : words) {
wordCounts.merge(w, 1, Integer::sum);
}
}));
}
for (Thread t : threads) {
t.join();
}
IO.println("sessions created for ana: " + created.get());
IO.println("word counts: " + new TreeMap<>(wordCounts));
}
输出:
sessions created for ana: 1
word counts: {blue=16, green=8, red=24}
八个线程都来要 Ana 的会话,结果只创建了一个。ConcurrentHashMap.computeIfAbsent 对每个键最多运行一次函数,函数运行期间,其他更新这个键的线程可能被迫等待。merge(w, 1, Integer::sum) 就是原子的“加一,或者从一开始”。程序打印前先把映射复制进 TreeMap,所以键按顺序输出。
我们在这里也试着强制制造糟糕的时机,在函数里放了一个闭锁并设了超时。五次运行中,第二个线程一次都没进到函数里。它先等待,然后发现 Ana 的会话已经在了。正因为其他线程可能要等它,这个函数要写得短,也不要在函数里更新同一个映射。
不可变数据不需要锁
最省事的线程安全,就是不会改变的数据。没有写入,就没有竞态可言。在紧凑构造器里用了 List.copyOf 的 record,可以放心交给任意多个线程:
record Order(String customer, List<String> items) {
Order {
items = List.copyOf(items);
}
}
void main() throws InterruptedException {
var items = new ArrayList<>(List.of("tea", "cake"));
var order = new Order("ana", items);
items.add("soup"); // changes our list, not the order's copy
int[] sizes = new int[4];
var threads = new ArrayList<Thread>();
for (int i = 0; i < sizes.length; i++) {
int slot = i;
threads.add(Thread.ofPlatform().start(() -> sizes[slot] = order.items().size()));
}
for (Thread t : threads) {
t.join();
}
IO.println("every thread saw: " + Arrays.toString(sizes));
IO.println(order);
}
输出:
every thread saw: [2, 2, 2, 2]
Order[customer=ana, items=[tea, cake]]
record 的字段是 final 的,List.copyOf 给了它一个属于自己的不可修改列表。调用方后来的 add 碰不到它。讲 record 的那一部分解释了这份副本为什么重要。要“修改”一个不可变的值,就构建一个新值,通过单个 AtomicReference 或 volatile 字段发布出去。这样剩下的共享状态就只有一个引用。
要点
start()在新线程上运行代码,run()只是在你当前的线程上调用它。读取线程的结果之前,用join()等它结束。count++是读取、加一、写入三步。两个线程可能交错执行这些步骤而丢失更新,Java 也没有竞态检测器来发现它。synchronized让同一时刻只有一个线程持有对象的监视器。锁在私有的 final 对象上,读和写都要加锁。volatile让写入对其他线程可见,但不会让count++变成原子操作。AtomicInteger用比较并设置来更新一个值。繁忙的计数器用LongAdder,几个值必须一起改变时用锁。- 死锁来自按不同顺序获取锁。始终按同一个顺序获取,需要退路时用带超时的
tryLock。 - 线程安全的集合让单次调用变安全。先检查后执行照样会竞态,所以用
computeIfAbsent和merge,能用不可变数据时就用。
共享的可变状态需要一条规则,规定谁可以碰它;没有这条规则,就由调度器说了算。