Challenge
Sam was trying to make a program to show how 1337 he is. But the
output isn't always correct. Help him fix his program so he can impress
his friends.
Sam 写了个程序想显得自己很 1337,但输出总是不对;帮他把程序修好。
关卡页给出完整的类源码:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40
| package org.hackthissite.missions.extbasic;
import java.util.concurrent.ExecutorService; import java.util.concurrent.Executors;
public class ExtBasic14 {
private final ExecutorService executorService = Executors.newFixedThreadPool(100); private static final int MAX = 1337; private int timeToGetLeet = 0;
ExtBasic14() throws InterruptedException { for (int i = 0; i < MAX; i++) { executorService.execute(new Runnable() { public void run() { incrementLeetness(); } }); } executorService.shutdown(); while (!executorService.isTerminated()) { Thread.sleep(500); } System.out.println(timeToGetLeet); }
private void incrementLeetness() { int obfusticatedIncremental = timeToGetLeet; obfusticatedIncremental = obfusticatedIncremental + 1; timeToGetLeet = obfusticatedIncremental; }
public static void main(String[] args) throws InterruptedException { new ExtBasic14(); }
}
|
Solution
MAX = 1337,构造函数把 1337 个任务提交进一个 100
线程的固定池,每个任务只调用一次
incrementLeetness(),shutdown 等池终止后打印
timeToGetLeet。程序想要的输出是 1337。
被点名的 incrementLeetness() 是一个三步的读-改-写:
1 2 3 4 5
| private void incrementLeetness() { int obfusticatedIncremental = timeToGetLeet; obfusticatedIncremental = obfusticatedIncremental + 1; timeToGetLeet = obfusticatedIncremental; }
|
而且 timeToGetLeet 是普通实例字段,没有
volatile,整个方法也没有任何锁。
多线程交错执行这段代码时会出现经典的丢失更新:
1 2 3 4 5 6
| 线程 A: obfusticatedIncremental = timeToGetLeet // 读到 0 线程 B: obfusticatedIncremental = timeToGetLeet // 也读到 0 线程 A: obfusticatedIncremental = 0 + 1 线程 A: timeToGetLeet = 1 线程 B: obfusticatedIncremental = 0 + 1 线程 B: timeToGetLeet = 1
|
A、B 各做了一次自增,计数却只从 0 走到 1:B 读到的旧值 0 在 A
写回之后仍然被写回,A 的那次增量被覆盖。100
个线程争抢同一个三段序列,丢失的更新累积起来,System.out.println
打出的值就稳定地低于 1337。
用 OpenJDK 17.0.20.1:原始类原样编为
broken,只把方法声明改成
private synchronized void 编为 fixed,各跑 10
次。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24
| $ javac -d broken/out broken/org/hackthissite/missions/extbasic/ExtBasic14.java $ javac -d fixed/out fixed/org/hackthissite/missions/extbasic/ExtBasic14.java $ for i in $(seq 1 10); do java -cp broken/out org.hackthissite.missions.extbasic.ExtBasic14; done 1333 1336 1332 1335 1337 1335 1335 1335 1337 1335 $ for i in $(seq 1 10); do java -cp fixed/out org.hackthissite.missions.extbasic.ExtBasic14; done 1337 1337 1337 1337 1337 1337 1337 1337 1337 1337
|
broken 十次落在 1332–1337
之间(只有两次偶然凑满),fixed 十次全是
1337。两份源码的唯一差异是那个 synchronized:
1 2 3 4 5
| private synchronized void incrementLeetness() { int obfusticatedIncremental = timeToGetLeet; obfusticatedIncremental = obfusticatedIncremental + 1; timeToGetLeet = obfusticatedIncremental; }
|
synchronized 加在实例方法上,等价于整段方法体在
this
的监视器锁内执行:同一时刻只有一个线程能进入读-改-写序列,丢失的更新随之消失。
Key points
- 递增(
x = x + 1)不是原子操作,多线程下必须用锁或原子类型保护;synchronized
方法等价于用 this 做互斥。
- 只加
volatile
不够:它保证可见性与有序性,但不会让读-改-写变成原子操作,两个线程仍能交错读走同一个旧值。
AtomicInteger.incrementAndGet() 用 CAS
免锁达到同样的原子性;本例只需修 incrementLeetness,加
synchronized 是最小改动。
- 竞争窗口越小越难复现:
broken 十次里有两次恰好是
1337,判断修好没有要多跑几次,不能只看单次输出。