本节摘要:线程是进程里独立执行的窗口,创建方式有两种——继承 Thread 重写 run、实现 Runnable 交给 Thread 执行,工程上推荐后者(规程与窗口分离、规程可复用)。线程有六种状态,sleep 与 wait 的差异在锁与唤醒方式。多窗口共享数据会互相踩踏,同步代码块用锁保证一次只有一个窗口操作。本节先复现计数丢数事故再修复。
到第 7 章为止,我们的程序都是单窗口:从 main 入口进,一行一行执行到尾。一旦某一步慢(读大文件、调外部服务),整条队伍全堵着。多线程就是同时开几个窗口:每个线程有自己独立的执行路径,共享同一个进程的内存——共享是福利(数据不用来回拷贝),也是祸根(多个窗口同时改一份档案就会互相覆盖)。
两种开窗口的方式:
// 方式一:继承 Thread 把窗口本体当规程 class WindowA extends Thread { @Override public void run() { // run 是窗口的办事规程 for (int i = 1; i <= 3; i++) { System.out.println("窗口A办理第 " + i + " 笔"); } } } // 方式二:实现 Runnable 规程与窗口分离(推荐) class Duty implements Runnable { @Override public void run() { for (int i = 1; i <= 3; i++) { System.out.println("窗口B办理第 " + i + " 笔"); } } } public class ThreadDemo { public static void main(String[] args) { new WindowA().start(); // start 开窗口 不是 run new Thread(new Duty()).start(); // 规程交给窗口执行 System.out.println("主窗口也在办事"); // 三个窗口的输出交错出现 } }
运行输出三路交错(每次顺序不同)——这就是并发的直观样子。两个必踩的知识点:start 与 run 的区别——调 run 只是普通方法调用(还是在原窗口排队办事),调 start 才是开新窗口并由系统调度执行 run;为什么推荐 Runnable——规程与窗口分离后,同一份规程可以被多个窗口共用(下面计数案例马上用),且任务类不需要占用唯一的继承名额(第 3 章的老结论在并发场景再次生效)。

看图记住主干即可:新建后 start 进可运行;抢不到锁进阻塞、拿到锁回来;主动让位有两种——等到点自动回(sleep 计时等待)、等通知才回(wait 等待);run 跑完进结束。sleep 与 wait 的三差异是常考也常用的判断题:sleep 是线程的静态方法、抱着锁睡;wait 是对象的方法、必须先拿到锁才能调、调了就放手并把线程挂到该对象的等待区,等别人 notify。
背景:全楼用同一个静态计数器登记来访数(第 1 章 static 的并发版),开两个窗口各登记一万次,总数应该两万。操作:
public class RaceDemo { static int count = 0; // 共享门牌:两个窗口同时改 public static void main(String[] args) throws InterruptedException { Runnable task = () -> { for (int i = 0; i < 10000; i++) { count++; // 看似一步 实际是读 加 写 三步 } }; Thread w1 = new Thread(task); Thread w2 = new Thread(task); w1.start(); w2.start(); w1.join(); w2.join(); // join:主窗口等两个窗口办完再往下走 System.out.println("实际总数:" + count); // 实测输出:小于 20000 且每次不同 } }
结果:实际总数小于两万,且每次运行都不一样。解读:count++ 不是一步——先读旧值、加一、写回。窗口甲读到五万、窗口乙也读到五万,各自加一写回五万零一,两次登记只涨了一。这类"读改写被插队"就是竞态条件,静态可变字段(第 1 章的提醒)正是高发区。变式(修复):用同步代码块把三步锁成一个不可分割的整体:
public class SyncDemo { static int count = 0; static final Object LOCK = new Object(); // 专用的锁对象 public static void main(String[] args) throws InterruptedException { Runnable task = () -> { for (int i = 0; i < 10000; i++) { synchronized (LOCK) { // 进门先拿锁 拿不到的窗口在此排队 count++; // 锁内三步 不会被插队 } // 出门还锁 } }; Thread w1 = new Thread(task), w2 = new Thread(task); w1.start(); w2.start(); w1.join(); w2.join(); System.out.println("同步后总数:" + count); // 输出:同步后总数:20000 每次都准 } }
锁的纪律三条:锁对象要唯一——两个窗口必须抢同一把锁才互斥,各拿各的锁等于没锁;别锁字面量——字符串字面量在常量池里共享(第 4 章),锁它可能意外跟别人共锁;锁范围尽量小——只包住必须互斥的读改写,锁太粗会把并发退化成排队。同步方法(方法签名加 synchronized)是代码块的简写:锁的是实例自身,静态同步方法锁的是类对象。更精细的工具(显式锁、原子类)是下一节线程池话题之外的高级内容,先记住"共享可变状态加同步"这条底线。
窗口怎么开、怎么锁清楚了,下一节解决"开多少个窗口"与"窗口之间怎么递材料":线程池的调度台逻辑、生产者消费者的叫号协调,以及 Callable 与 Future 的办结回执。