13.多线程通关秘籍:用售票系统讲透 Java 线程创建与 synchronized 锁魔法
一、当火车站遇见多线程:售票大厅的热闹日常
想象一个春运期间的火车站售票大厅:10 个售票窗口同时开放,每个窗口都在疯狂出售通往家乡的车票。这就是现实世界中的 "多线程" 场景 —— 多个任务(售票窗口)同时运行,共享同一批资源(车票)。在 Java 的世界里,线程就是这样的 "售票员",它们能让程序像热闹的售票大厅一样高效运转。
但如果管理不当,就会出现魔幻场景:明明只剩 10 张票,却卖出了 15 张;甚至出现负数车票的情况。这就是线程安全问题!今天我们就用这个真实场景,揭开 Java 多线程的神秘面纱。
二、线程创建的两种姿势:继承派 vs 接口派
1. 继承 Thread 类:简单直接的 "单线程体"
// 第一种姿势:继承Thread类
class TicketSeller extends Thread {private int tickets = 100; // 初始100张票@Overridepublic void run() { // 线程执行体,相当于售票员的工作while (tickets > 0) {try {Thread.sleep(50); // 模拟售票操作耗时} catch (InterruptedException e) {e.printStackTrace();}System.out.println(Thread.currentThread().getName() + "卖出1张票,剩余:" + --tickets);}}
}public class ThreadDemo {public static void main(String[] args) {// 创建3个售票窗口(线程)new TicketSeller().start(); // 窗口1开始工作new TicketSeller().start(); // 窗口2开始工作new TicketSeller().start(); // 窗口3开始工作}
}
运行结果可能出现 "剩余:-5" 这样的魔幻场景!因为每个售票员(线程)都有自己的独立票箱(tickets 变量),相当于开了 3 个独立窗口卖 300 张票,这显然不符合现实场景!
2. 实现 Runnable 接口:共享资源的正确打开方式
// 第二种姿势:实现Runnable接口(推荐!)
class SharedTicketSeller implements Runnable {private int tickets = 100; // 重点!共享同一批车票@Overridepublic void run() {while (tickets > 0) {try {Thread.sleep(50);} catch (InterruptedException e) {e.printStackTrace();}System.out.println(Thread.currentThread().getName() + "卖出1张票,剩余:" + --tickets);}}
}public class RunnableDemo {public static void main(String[] args) {SharedTicketSeller seller = new SharedTicketSeller(); // 唯一的票箱// 3个窗口共享同一个票箱new Thread(seller, "窗口A").start();new Thread(seller, "窗口B").start();new Thread(seller, "窗口C").start();}
}
这次 3 个窗口共享同一批车票,但运行后依然可能出现负数!因为多个线程同时操作 tickets 变量时,出现了 "非原子性操作"—— 就像两个售票员同时看到剩余 1 张票,都认为自己能卖出,结果卖出 2 张票。
三、线程同步:给票箱加把 "原子锁"
1. 问题根源:魔幻操作的三步曲
当线程执行--tickets时,实际分为 3 步:
- 读取 tickets 值(比如 1)
- 执行减 1 操作(得到 0)
- 写回 tickets 变量
如果两个线程同时执行到第一步,都读取到 1,就会各自减 1,最终得到 - 1,这就是经典的线程安全问题!
2. synchronized 关键字:给操作加锁
class SafeTicketSeller implements Runnable {private int tickets = 100;private Object lock = new Object(); // 锁对象,相当于票箱的钥匙@Overridepublic void run() {while (tickets > 0) {synchronized (lock) { // 只有拿到钥匙才能操作if (tickets > 0) { // 二次检查(双重校验锁雏形)try {Thread.sleep(50);} catch (InterruptedException e) {e.printStackTrace();}System.out.println(Thread.currentThread().getName() + "卖出1张票,剩余:" + --tickets);}} // 离开代码块自动释放锁}}
}
锁的工作原理:
- 当线程 A 进入synchronized代码块,会获取 lock 对象的锁,其他线程只能在门外排队
- 线程 A 执行完毕或异常退出时,自动释放锁,下一个线程才能进入
- 确保同一时间只有一个线程操作共享资源(票箱),就像每次只有一个售票员能打开票箱数票
3. 锁的高级用法:直接锁方法
class MethodLockSeller implements Runnable {private int tickets = 100;@Overridepublic synchronized void run() { // 等价于synchronized(this)while (tickets > 0) {try {Thread.sleep(50);} catch (InterruptedException e) {e.printStackTrace();}System.out.println(Thread.currentThread().getName() + "卖出1张票,剩余:" + --tickets);}}
}
这里锁的是当前对象(this),适合锁实例方法的场景,但要注意:静态方法锁的是类对象,普通方法锁的是实例对象。
四、两种创建方式对比:选对工具很重要
特性 | 继承 Thread 类 | 实现 Runnable 接口 |
资源共享 | 每个线程独立对象 | 多个线程共享同一实例 |
扩展性 | 单继承限制(Java 不支持多继承) | 可同时继承其他类 / 实现多个接口 |
设计模式 | 面向对象(is-a 关系) | 面向接口(has-a 关系,推荐) |
适用场景 | 简单独立任务 | 多线程共享资源场景 |
最佳实践:永远优先使用 Runnable 接口!就像现实中售票员可以同时是收银员(实现多个接口),而继承 Thread 类就像让售票员只能当售票员,扩展性太差。
五、总结:多线程世界的生存法则
- 线程创建:用 Runnable 实现共享资源,避免继承 Thread 的单继承局限
- 线程安全:遇到共享资源(如票箱、账户余额),记得用 synchronized 加锁
- 锁的范围:尽量缩小锁的作用域(只锁关键代码),提高并发效率
- 调试技巧:用Thread.currentThread().getName()定位问题线程,用jstack命令查看线程堆栈
下次当你在火车站看到多个售票窗口时,不妨想想 Java 的多线程:每个窗口就是一个线程,票箱就是共享资源,而 synchronized 就是那个确保秩序的神奇锁。掌握这些,你就能在并发编程的世界里畅通无阻!