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

请工人有两条路:继承 Thread 重写 run(工人自带头脑)或 实现 Runnable 把任务交给它(任务与工人分离,4.7 Lambda 老主场)——最要命的坑是 start() 才是真上工,直接调 run() 是假上工。

12.1 说「Java 程序至少有一条 main 主线程」,工人不止一个才能叫多线程。这节就干一件事:亲手把第二个工人请上岗。

一、方式一:继承 Thread

Thread 类自带「工人」血统——继承它、重写 run(),这个类的对象就是工人:

public class Demo17 {
    public static void main(String[] args) {
        MyThread t = new MyThread();
        t.start();                       // 上工:开新线程跑 run
        for (int i = 0; i < 5; i++) {
            System.out.println("main 主线程 " + i);
        }
    }
}

class MyThread extends Thread {
    @Override
    public void run() {                  // @Override 收 4.1 的账
        for (int i = 0; i < 5; i++) {
            System.out.println(getName() + " 子线程 " + i);
        }
    }
}

期望输出(顺序每次都可能不一样——12.1 说的抢占式调度随机,这回亲眼看):

main 主线程 0
Thread-0 子线程 0
main 主线程 1
Thread-0 子线程 1
...

两条执行流交替出现,谁先谁后全看谁抢到 CPU——这就是多线程和单线程最直观的区别。

二、方式二:实现 Runnable(推荐)

继承 Thread 有个硬伤:占掉了类唯一的继承名额(4.1「Java 单继承」的账)。更聪明的做法是把「活儿」和「工人」分开——活儿写进 Runnable,工人只管把活儿带走:

public class Demo18 {
    public static void main(String[] args) {
        Runnable r = () -> {             // Lambda 三段式收 4.7 的账
            for (int i = 0; i < 5; i++) {
                System.out.println(Thread.currentThread().getName() + " 干活 " + i);
            }
        };
        new Thread(r, "工人A").start();   // 第二个参数起名,告别 Thread-0
    }
}

对比方式一的三点进步:

  1. 不占继承名额——任务类想继承谁继承谁
  2. 任务与工人分离——同一份 Runnable 可以丢给十个 Thread(一份活儿雇十个工人)
  3. Runnable 是函数式接口——Lambda 一行顶一个类,4.7 埋的「Lambda 老主场」正式开张
📝 起名是个好习惯

new Thread(r) 不起名就是 Thread-0、Thread-1;传第二个参数 "工人A",以后看日志一眼知道哪条线程在作妖。排查多线程问题,名字就是工人的工牌。

三、本节第一大坑:start() ≠ run()

MyThread t = new MyThread();
t.run();    // 假上工:主线程亲自把 run 跑完,才执行下一行
t.start();  // 真上工:JVM 开新线程,主线程立刻返回继续往下跑
t.start() t.run()
本质 通知 JVM 开新线程执行 run 普通方法调用
执行者 新开的子线程 还是 main 主线程
效果 两条流并发 和没写多线程一样
能调几次 一次(第二次 IllegalThreadStateException) 随便调(就是普通方法)

记忆口诀:start 是招聘上岗,run 只是背了一遍工作内容。背得再熟,人也还在主线程这间办公室里。

四、常用方法速览

方法 作用
start() 开新线程执行 run(只能调一次)
run() 工作内容本身,被 start 调用才有并发效果
getName() / setName() 取 / 改工人名(Thread-0、main...)
Thread.currentThread() 摸到「当前正在执行这行代码」的线程
Thread.sleep(毫秒) 让当前线程歇一会儿(本节只露脸,下节细讲)
🧪 三个 30 秒小实验

实验 1:交替输出亲眼看调度 Demo17 连跑三次 → 三次输出顺序都可能不同。第一次「看见」抢占式调度。

实验 2:start 改成 run Demo17 把 t.start() 改成 t.run() → 输出变成先打完 5 行子线程再打 main,永远单线程顺序。假上工实锤。

实验 3:start 两次 Demo17 里连写两行 t.start(); → 第二次直接 IllegalThreadStateException。一个工人不能上两次岗,想再干要 new 新工人。

五、常见误区

  1. 调 run() 以为开了线程——没有新线程,只有主线程在演独角戏
  2. start 调多次——一次上岗一条命,重复 start 抛 IllegalThreadStateException
  3. 重写 run 写成了重载——run(String s) 带参数的重载不是重写,Thread 根本不会调它(4.1 重写的签名规矩)
  4. 以为先 start 的线程先执行——抢占式调度完全随机,线程没有「先来后到」的保证
  5. 继承 Thread 后滥用——大多数场景用 Runnable:不占继承、可复用、能进线程池(11.5 的池子后面会重逢)

六、自测题

❓ Q1:两种创建方式各怎么写、推荐哪个?

方式一继承 Thread 重写 run;方式二实现 Runnable 把任务交给 Thread。推荐 Runnable:不占继承名额、任务可复用、Lambda 友好。

❓ Q2:start() 和 run() 的本质区别?

start 通知 JVM 开新线程执行 run(真并发);run 是普通方法调用,还在主线程里跑(假并发)。start 只能调一次。

❓ Q3:为什么输出顺序每次都不同?

抢占式调度——CPU 分配给哪个线程是随机的,程序员控制不了(12.1 的账)。

❓ Q4:`new Thread(r, "工人A")` 第二个参数干什么用的?

给线程起名。默认 Thread-0/1/2 没法看日志定位问题,工牌式的排查习惯。

❓ Q5(毕业题):用 Runnable + Lambda 开两条线程打印各自名字各 3 次,并说明这两条线程和 main 的关系。

要点:① 一个 Runnable 写打印 Thread.currentThread().getName();② new Thread(r, "A").start() 和 new Thread(r, "B").start();③ 说出「三条执行流并发(main + A + B),顺序随机」;④ 加分:解释同一个 Runnable 被两个 Thread 共用——任务与工人分离的直接受益。全对毕业。

下一步学什么

相关笔记