☕ 第十二章 多线程
📄 一句话概括

多个线程抢着改同一个共享资源,就会「干到一半被人插队」——解法是给桌子上上一把锁:干活前先拿锁,干完才还。工具就三件: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 跑通后:

  1. 删掉 synchronized (lock) 那对大括号——多跑几次,看到重复的票号甚至负数(-1 张)。事故现场亲眼看。
  2. 把锁对象换成每个线程各 new 一个(构造里 Object lock = new Object())——又乱了。各锁各的等于没锁,铁律现场演示。
  3. 把同步代码块改写成 synchronized 方法——功能一样,体会两种姿势的换算关系。

四、常见误区

  1. 「只要加了 synchronized 就绝对安全」——错。锁对了才安全:锁对象必须多线程共用同一把,各锁各的等于裸奔。
  2. 「锁住的是代码」——错。锁的是对象,代码只是凭锁进入的门。两个线程锁不同对象照样同时进同一块代码。
  3. 「synchronized 方法随便用,越包越多越好」——错。锁的范围越大,别人排队越久,并行的好处被锁吃掉了;只包真正的危险区。
  4. 「Lock 用完不 unlock 也没事」——大错。锁永远不还,别的线程全体永久 BLOCKED——手动锁忘记还,比不加锁还可怕。
  5. 「锁套锁很正常」——危险。两把锁互相等着对方先放(A 拿 1 号锁想要 2 号,B 拿 2 号锁想要 1 号),就死锁了:全员永久卡死。规矩:多把锁按统一顺序拿。

五、自测题(先自己想,再展开)

❓ Q1:12.3 的 BLOCKED 状态,到底在等什么?

等锁。目标对象的锁被别的线程拿着,拿不到锁就在门口排队,排上了才回 RUNNABLE——本节把它的谜底揭晓了。

❓ Q2:两个线程分别 `synchronized(o1)` 和 `synchronized(o2)` 改同一个 count,安全吗?

不安全。锁对象不同就不是同一把锁,互不干扰地同时进危险区——「各锁各的等于没锁」。

❓ Q3:静态 synchronized 方法的锁是谁?为什么不是 this?

是类的字节码对象 Xxx.class。静态方法属于类不属于对象,没有 this 可指,所以拿全类独一份的类对象当锁(顺带呼应 9.2:每类一份的 Class 对象又立功了)。

❓ Q4:11.4 说「连接不能存成员变量全局共用」,用本节的话解释一遍?

池子/连接就是共享资源,多个线程同时用就是同时修改共享状态,没人上锁就会互相插队把状态改乱。正经解法是每次从池里借(池子内部自带并发控制),而不是大家共用一根线。

❓ Q5(毕业题):一个账户余额 0,两个线程各存 1000 次 1 元,怎么保证最后正好 2000?

余额 balance 是共享资源 → 每次 balance++ 是危险区 → 用同一把锁包住:synchronized(lock){ balance++; }(或 synchronized 方法 / Lock)。跑完打印 2000 算毕业——反过来删掉锁跑一次,看看能少几块钱。

下一步学什么

相关笔记