多个线程抢着改同一个共享资源,就会「干到一半被人插队」——解法是给桌子上上一把锁:干活前先拿锁,干完才还。工具就三件:synchronized 代码块、synchronized 方法、Lock 手动锁,铁律是多线程必须用同一把锁。
一、先把事故现场摆出来
12.1 埋的那笔账今天正式清:线程多了抢一张桌子,会出事。Demo20 里两个工人算半段账的钩子也是它的伏笔。看最小的事故模型:
count++; // 看着是一行,背后是三步:读出来 → 加 1 → 写回去
两条线程各执行一次 count++,正确答案应该是 3,实际可能得到 2——工人 A 刚读出 1,工人 B 插队也读出 1,两人各自加完都写回 2,一次更新凭空蒸发。放到现实里就是:卖票系统同一张票卖两次、银行转账转丢钱。这就是线程安全问题。
①有共享资源(成员变量、同一个对象)+ ②多条线程同时碰 + ③至少有一条在修改。缺一个都安全——只读不写不炸,各用各的局部变量更不炸(12.2 里每个工人自己的循环变量从来没事)。
二、解法:锁(synchronized)
锁的核心思想:危险区(操作共享资源的那段代码)门口挂把锁——先拿到锁的线程进去干活,没拿到的在门口排队(这正是 12.3 里 BLOCKED 状态的谜底),干完出来还锁,下一个才能进。排队期间一次只许一个人摸桌子,插队从物理上被禁止。
姿势一:同步代码块(最灵活,推荐先学这个)
public class SellTicket implements Runnable {
static int count = 100; // 共享资源:100 张票
static Object lock = new Object(); // 一把专属钥匙
@Override
public void run() {
while (true) {
synchronized (lock) { // 拿锁进门
if (count <= 0) {
System.out.println("票卖完了");
break;
}
System.out.println(Thread.currentThread().getName()
+ " 卖出第 " + count + " 张");
count--;
} // 出门还锁
}
}
}
锁对象理论上任意对象都行(锁的是对象不是代码),习惯用 new Object() 当专属钥匙;括号里还可以写 Xxx.class(字节码对象,全 JVM 独一份)。只包危险区,锁越小效率越高。
姿势二:同步方法
private synchronized void sell() { ... } // 整个方法都是危险区,锁 = this
给方法加 synchronized,等于把整个方法体包进「以 this 为锁」的同步代码块。省心但锁得粗;静态同步方法的锁是 Xxx.class(没有 this 可锁,拿类对象顶上)。
姿势三:Lock 手动档(JDK5 后的新秀)
private static final ReentrantLock l = new ReentrantLock();
l.lock(); // 手动上锁
try {
count--; // 危险区
} finally {
l.unlock(); // 手动还锁——必须放 finally!
}
synchronized 是自动挡(出代码块自动还锁),Lock 是手动档:上锁解锁自己来,好处是更灵活(能试锁、能限时等)。unlock 放 finally 里保证任何情况下都还得上——5.3 的 finally 在这正式上岗。
三、动手:Demo21 与三个 30 秒实验
Demo21 跑通后:
- 删掉
synchronized (lock)那对大括号——多跑几次,看到重复的票号甚至负数(-1 张)。事故现场亲眼看。 - 把锁对象换成每个线程各 new 一个(构造里
Object lock = new Object())——又乱了。各锁各的等于没锁,铁律现场演示。 - 把同步代码块改写成 synchronized 方法——功能一样,体会两种姿势的换算关系。
四、常见误区
- 「只要加了 synchronized 就绝对安全」——错。锁对了才安全:锁对象必须多线程共用同一把,各锁各的等于裸奔。
- 「锁住的是代码」——错。锁的是对象,代码只是凭锁进入的门。两个线程锁不同对象照样同时进同一块代码。
- 「synchronized 方法随便用,越包越多越好」——错。锁的范围越大,别人排队越久,并行的好处被锁吃掉了;只包真正的危险区。
- 「Lock 用完不 unlock 也没事」——大错。锁永远不还,别的线程全体永久 BLOCKED——手动锁忘记还,比不加锁还可怕。
- 「锁套锁很正常」——危险。两把锁互相等着对方先放(A 拿 1 号锁想要 2 号,B 拿 2 号锁想要 1 号),就死锁了:全员永久卡死。规矩:多把锁按统一顺序拿。
五、自测题(先自己想,再展开)
等锁。目标对象的锁被别的线程拿着,拿不到锁就在门口排队,排上了才回 RUNNABLE——本节把它的谜底揭晓了。
不安全。锁对象不同就不是同一把锁,互不干扰地同时进危险区——「各锁各的等于没锁」。
是类的字节码对象 Xxx.class。静态方法属于类不属于对象,没有 this 可指,所以拿全类独一份的类对象当锁(顺带呼应 9.2:每类一份的 Class 对象又立功了)。
池子/连接就是共享资源,多个线程同时用就是同时修改共享状态,没人上锁就会互相插队把状态改乱。正经解法是每次从池里借(池子内部自带并发控制),而不是大家共用一根线。
余额 balance 是共享资源 → 每次 balance++ 是危险区 → 用同一把锁包住:synchronized(lock){ balance++; }(或 synchronized 方法 / Lock)。跑完打印 2000 算毕业——反过来删掉锁跑一次,看看能少几块钱。
下一步学什么
- ☑ 12.6 本章小结 ✅ —— 点此复习:五节串线收官,毕业大题三窗口卖票
- 想温故:12.3 六状态里 BLOCKED 的谜底在本节兑现
相关笔记
- 第十二章 多线程/12.4 线程操作的相关方法:join 等结果、Demo20 双工人的钩子在本节收线
- 第十二章 多线程/12.2 线程的创建:卖票案例用的 Runnable + Lambda 写法老家
- 第十二章 多线程/12.1 进程与线程:抢桌子的伏笔源头
- 第十一章 JDBC/11.4 数据库连接池:「连接不能存成员变量共用」正式清算
- 第五章 异常/5.3 异常处理及语法:Lock 的 unlock 必须放 finally,靠的就是它