Java学习笔记(12)多线程通关秘籍:用售票系统讲透 Java 线程创建与 synchronized 锁魔法

61 阅读5分钟

 一、当火车站遇见多线程:售票大厅的热闹日常​

        想象一个春运期间的火车站售票大厅:10 个售票窗口同时开放,每个窗口都在疯狂出售通往家乡的车票。这就是现实世界中的 "多线程" 场景 —— 多个任务(售票窗口)同时运行,共享同一批资源(车票)。在 Java 的世界里,线程就是这样的 "售票员",它们能让程序像热闹的售票大厅一样高效运转。​

        但如果管理不当,就会出现魔幻场景:明明只剩 10 张票,却卖出了 15 张;甚至出现负数车票的情况。这就是线程安全问题!今天我们就用这个真实场景,揭开 Java 多线程的神秘面纱。​

二、线程创建的两种姿势:继承派 vs 接口派​

1. 继承 Thread 类:简单直接的 "单线程体"

// 第一种姿势:继承Thread类
class TicketSeller extends Thread {
    private int tickets = 100; // 初始100张票
    
    @Override
    public 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; // 重点!共享同一批车票
    
    @Override
    public 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 步:​

  1. 读取 tickets 值(比如 1)​
  2. 执行减 1 操作(得到 0)​
  3. 写回 tickets 变量​

        如果两个线程同时执行到第一步,都读取到 1,就会各自减 1,最终得到 - 1,这就是经典的线程安全问题

2. synchronized 关键字:给操作加锁​

class SafeTicketSeller implements Runnable {
    private int tickets = 100;
    private Object lock = new Object(); // 锁对象,相当于票箱的钥匙
    
    @Override
    public 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);
                }
            } // 离开代码块自动释放锁
        }
    }
}

锁的工作原理:​

  1. 当线程 A 进入synchronized代码块,会获取 lock 对象的锁,其他线程只能在门外排队​
  2. 线程 A 执行完毕或异常退出时,自动释放锁,下一个线程才能进入​
  3. 确保同一时间只有一个线程操作共享资源(票箱),就像每次只有一个售票员能打开票箱数票

3. 锁的高级用法:直接锁方法​

class MethodLockSeller implements Runnable {
    private int tickets = 100;
    
    @Override
    public 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 类就像让售票员只能当售票员,扩展性太差。​

五、总结:多线程世界的生存法则​

  1. 线程创建:用 Runnable 实现共享资源,避免继承 Thread 的单继承局限​
  2. 线程安全:遇到共享资源(如票箱、账户余额),记得用 synchronized 加锁​
  3. 锁的范围:尽量缩小锁的作用域(只锁关键代码),提高并发效率​
  4. 调试技巧:用Thread.currentThread().getName()定位问题线程,用jstack命令查看线程堆栈​

        下次当你在火车站看到多个售票窗口时,不妨想想 Java 的多线程:每个窗口就是一个线程,票箱就是共享资源,而 synchronized 就是那个确保秩序的神奇锁。掌握这些,你就能在并发编程的世界里畅通无阻!