Blog

Java 线程与共享状态:竞态条件、synchronized 与原子类

两个 Java 线程修改同一个变量,写入可能悄无声息地丢失。看清 count++ 为什么会竞态,synchronized、volatile、原子类和锁怎样修好它,以及怎样强制制造并检测死锁。

线程和程序的其他部分同时运行代码。只碰自己数据的线程很好对付。麻烦出在两个线程修改同一个变量的时候:程序不崩溃,也不报警,但有些修改就这么没了。

本文先启动线程,展示这个 bug,再让它每次运行都必然发生。接着讲 synchronizedvolatile、原子类、死锁、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++

T1 count T2 接着 count++ 读到 5 写入 6 接着 count++ 读到 5 写入 6 5 6 seen = 5 seen = 5 6 6 count 是 6 不是 7:丢了一次自增 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

两个线程在值为 5 的共享 count 上各执行一次 count++。两者都在对方写入之前读到 5,于是都写入 6,丢了一次自增。上面程序里的闭锁强制了这个顺序;没有闭锁时,调度器只是有时会排出这个顺序。

如果动画没有播放,下面用文字说明这几步:

  1. count 是 5。T1 和 T2 各执行一次 count++
  2. T1 读取 count,把 5 存进自己的局部副本。
  3. T1 还没写入任何东西,T2 也读取 count,存下 5。
  4. T1 给它的 5 加 1,写入 6。
  5. T2 给它自己的 5 加 1,同样写入 6,覆盖了 T1 的 6。
  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 的那一部分解释了这份副本为什么重要。要“修改”一个不可变的值,就构建一个新值,通过单个 AtomicReferencevolatile 字段发布出去。这样剩下的共享状态就只有一个引用。

要点

  • start() 在新线程上运行代码,run() 只是在你当前的线程上调用它。读取线程的结果之前,用 join() 等它结束。
  • count++ 是读取、加一、写入三步。两个线程可能交错执行这些步骤而丢失更新,Java 也没有竞态检测器来发现它。
  • synchronized 让同一时刻只有一个线程持有对象的监视器。锁在私有的 final 对象上,读和写都要加锁。
  • volatile 让写入对其他线程可见,但不会让 count++ 变成原子操作。
  • AtomicInteger 用比较并设置来更新一个值。繁忙的计数器用 LongAdder,几个值必须一起改变时用锁。
  • 死锁来自按不同顺序获取锁。始终按同一个顺序获取,需要退路时用带超时的 tryLock
  • 线程安全的集合让单次调用变安全。先检查后执行照样会竞态,所以用 computeIfAbsentmerge,能用不可变数据时就用。

共享的可变状态需要一条规则,规定谁可以碰它;没有这条规则,就由调度器说了算。

这篇文章对你有帮助吗?

点一颗爱心来评分!

平均评分 0 / 5. 投票总数: 0

还没有人投票。来做第一个评分的人吧。