上午 3 小时 线程安全 & 同步代码块 & 同步方法

1 线程安全问题产生原理(0.5h 精讲)

核心根源(背死)

共享资源 + 多线程抢占式调度 + 没有加锁保护

  1. 多个线程操作同一个共享变量
  2. CPU 线程随机切换,执行到一半就被抢走时间片
  3. 多条线程同时修改数据,造成数据覆盖、重复售卖、超卖、负数数据

经典案例:售票问题

总票数 100 张,3 个售票窗口(3 条线程)同时卖票不安全现象:

  • 同一张票被多个窗口卖出
  • 卖出 0 张、-1、-2 负票数
  • 票数错乱、超卖

不安全完整代码(复现问题)

java

运行

// 售票任务:多个线程共享同一个任务对象
public class TicketRunnable implements Runnable {
    // 共享资源:100张票
    private int ticket = 100;

    @Override
    public void run() {
        // 一直卖票
        while (true) {
            if (ticket > 0) {
                // 模拟售票延迟,放大不安全现象
                try {
                    Thread.sleep(100);
                } catch (InterruptedException e) {
                    e.printStackTrace();
                }
                // 卖票
                System.out.println(Thread.currentThread().getName()
                        + " 正在售卖第 " + ticket + " 张票");
                ticket--;
            } else {
                System.out.println("票已售罄");
                break;
            }
        }
    }
}

// 测试类
public class TicketTest {
    public static void main(String[] args) {
        // 同一个任务对象,多线程共享
        TicketRunnable task = new TicketRunnable();

        // 3个窗口线程
        Thread t1 = new Thread(task, "窗口1");
        Thread t2 = new Thread(task, "窗口2");
        Thread t3 = new Thread(task, "窗口3");

        t1.start();
        t2.start();
        t3.start();
    }
}

代码解析

  1. ticket=100共享资源,三个线程共用;
  2. sleep 放大线程切换时机,更容易出现重复卖票、负票
  3. 没加任何同步锁,CPU 随机切换,线程安全问题直接暴露。

为什么会出现负数票?

多个线程同时判断 ticket>0,都满足条件;还没执行 ticket-- 就切换线程,最后一起自减,票数被扣成负数。


2 同步代码块(核心重点 1.2h 精讲 + 代码)

格式

java

运行

synchronized(锁对象){
    // 操作共享资源的核心代码(临界区)
}

核心规则

  1. 锁对象必须唯一:多个线程必须用同一把锁才有效;
  2. 同一时刻只能一个线程进入代码块
  3. 其他线程在外阻塞等待,直到锁被释放;
  4. 锁范围越小越好,只锁操作共享资源的代码,不要锁整段。

常用锁对象

  • 任意自定义对象 new Object()
  • this
  • 类名.class(字节码对象,全局唯一)

用同步代码块解决售票安全问题 完整代码

java

运行

public class TicketSyncBlock implements Runnable {
    private int ticket = 100;
    // 定义唯一锁对象
    private final Object lock = new Object();

    @Override
    public void run() {
        while (true) {
            // 同步代码块,加唯一锁
            synchronized (lock) {
                if (ticket > 0) {
                    try {
                        Thread.sleep(100);
                    } catch (InterruptedException e) {
                        e.printStackTrace();
                    }
                    System.out.println(Thread.currentThread().getName()
                            + " 售卖第 " + ticket + " 张票");
                    ticket--;
                } else {
                    System.out.println("票已售罄");
                    break;
                }
            }
        }
    }
}

逐行关键解析

  1. private final Object lock = new Object();手动创建唯一锁对象,多线程共用同一把锁;
  2. synchronized (lock)进入代码块自动获取锁,执行完自动释放锁
  3. 保证同一时间只有一个线程能判断票数、卖票、自减,彻底解决重复票、负票。

3 同步方法(1.3h 精讲 + 代码)

格式

在方法修饰符上加 synchronized

java

运行

public synchronized void 方法名(){
    // 操作共享资源代码
}

隐含锁对象(必考)

  1. 非静态同步方法锁对象默认是 this 当前对象
  2. 静态同步方法锁对象默认是 类名.class 字节码对象,全局唯一

同步方法 解决售票问题 完整代码

java

运行

public class TicketSyncMethod implements Runnable {
    private int ticket = 100;

    @Override
    public void run() {
        while (true) {
            // 调用同步卖票方法
            boolean flag = saleTicket();
            if (!flag) {
                break;
            }
        }
    }

    // 同步方法:非静态,锁是this
    public synchronized boolean saleTicket() {
        if (ticket > 0) {
            try {
                Thread.sleep(100);
            } catch (InterruptedException e) {
                e.printStackTrace();
            }
            System.out.println(Thread.currentThread().getName()
                    + " 售卖第 " + ticket + " 张票");
            ticket--;
            return true;
        } else {
            System.out.println("票已售罄");
            return false;
        }
    }
}

同步代码块 vs 同步方法 对比(必背表格)

表格

对比项 同步代码块 同步方法
锁范围 可自定义范围,精准控制 锁住整个方法,范围固定偏大
锁对象 可以自定义任意对象 非静态默认 this,静态默认类名.class
灵活性 高,推荐使用 低,写法简单但粒度粗
性能 好,只锁核心代码 稍差,容易锁住多余代码

下午 2.5h Lock 锁 & 等待唤醒机制

1 Lock 可重入锁(1h 精讲 + 标准模板)

核心区别

  • synchronized隐式锁,自动加锁、自动释放锁
  • Lock显式锁,手动上锁、手动解锁,可控性更强

常用实现类

ReentrantLock 可重入锁

核心方法

  • lock():手动获取锁
  • unlock():手动释放锁

标准固定模板(必背)

java

运行

锁对象.lock(); // 手动上锁
try{
    // 操作共享资源的代码
}finally{
    锁对象.unlock(); // 无论是否异常,最终一定释放锁
}

Lock 锁 解决售票问题 完整代码

java

运行

import java.util.concurrent.locks.Lock;
import java.util.concurrent.locks.ReentrantLock;

public class TicketLock implements Runnable {
    private int ticket = 100;
    // 创建显式锁对象
    private final Lock lock = new ReentrantLock();

    @Override
    public void run() {
        while (true) {
            // 手动加锁
            lock.lock();
            try {
                if (ticket > 0) {
                    Thread.sleep(100);
                    System.out.println(Thread.currentThread().getName()
                            + " 售卖第 " + ticket + " 张票");
                    ticket--;
                } else {
                    System.out.println("票已售罄");
                    break;
                }
            } catch (InterruptedException e) {
                e.printStackTrace();
            } finally {
                // 必须在finally中手动解锁,保证一定释放
                lock.unlock();
            }
        }
    }
}

解析重点

  • finally 中写 unlock():哪怕代码异常,也必然释放锁,不会死锁;
  • Lock 比 synchronized 更灵活:可尝试获取锁、可超时、可中断等待。

2 线程等待与唤醒(1.5h 精讲 + 代码)

三个核心方法(都来自 Object 类)

  1. wait():当前线程无限等待释放锁,进入阻塞池
  2. notify():唤醒随机一个等待中的线程
  3. notifyAll()唤醒所有等待中的线程

强制使用规则(必考)

  1. 必须放在 同步代码块 / 同步方法 中;
  2. 必须由 锁对象 调用;
  3. wait 会释放锁,其他线程可以抢到锁执行。

基础等待唤醒案例 完整代码

java

运行

public class WaitNotifyDemo {
    // 唯一锁对象
    private final Object lock = new Object();

    // 等待线程
    public void waitTask() {
        synchronized (lock) {
            System.out.println("等待线程:进入等待");
            try {
                // 释放锁,无限等待
                lock.wait();
            } catch (InterruptedException e) {
                e.printStackTrace();
            }
            System.out.println("等待线程:被唤醒,继续执行");
        }
    }

    // 唤醒线程
    public void notifyTask() {
        synchronized (lock) {
            System.out.println("唤醒线程:准备唤醒");
            // 唤醒一个等待线程
            lock.notify();
        }
    }
}

// 测试
class Test {
    public static void main(String[] args) throws InterruptedException {
        WaitNotifyDemo demo = new WaitNotifyDemo();

        // 开启等待线程
        new Thread(demo::waitTask).start();
        Thread.sleep(2000);
        // 2秒后唤醒
        new Thread(demo::notifyTask).start();
    }
}

3 生产者消费者模型入门

场景

生产一个资源 → 消费一个资源不能生产过剩、不能重复消费利用 wait() 满了就等待,notify() 有资源就唤醒消费

核心逻辑

  • 生产者:有产品就等待,没产品就生产,生产完唤醒消费者
  • 消费者:没产品就等待,有产品就消费,消费完唤醒生产者

晚上 1.5h 综合练习 + 复盘

必做实操清单

  1. 运行无锁售票代码,复现重复票、负票
  2. 分别用 同步代码块、同步方法、Lock 锁 三种方式改造解决安全问题;
  3. 手写 wait/notify 等待唤醒案例,记住使用规则;
  4. 对比:synchronized 隐式锁 和 Lock 显式锁 优缺点。

三种同步方式优缺点总结

  1. 同步代码块:灵活、粒度可控、性能好,日常最常用;
  2. 同步方法:写法简单,锁范围大,适合整个方法都操作共享资源;
  3. Lock 锁:手动加解锁、功能更强、可中断 / 可超时,高并发推荐。

原计划缺失 重要知识点补充(Day27 必须掌握)

原大纲没写,但必考、后续必用,全部补上:

  1. 线程死锁多个线程互相持有对方需要的锁,都不释放,无限等待,程序卡死;产生条件 + 如何规避。
  2. wait () 带参重载wait(毫秒):限时等待,时间到自动唤醒,不用别人 notify。
  3. ReentrantLock 可重入含义同一线程可多次获取同一把锁,不会自己卡死自己。
  4. 虚假唤醒wait 可能被意外唤醒,开发规范必须 while 循环判断条件,不能用 if。
  5. 锁的范围与性能损耗锁范围越大,并发越低,尽量缩小临界区代码。

Day27 验收标准(强化版)

  1. 能说出线程安全问题产生根本原因,能复现售票错乱现象;
  2. 熟练手写同步代码块、同步方法,分清各自默认锁对象;
  3. 会用 ReentrantLock 标准 try-finally 模板加锁解锁;
  4. 熟记 wait/notify/notifyAll 作用 + 三条强制使用规则;
  5. 能区分三种同步方式特点、适用场景;
  6. 了解死锁产生原因、虚假唤醒规范写法。

Day27 学习总结(带对比表格 + 核心结论)

一、线程安全问题根源

多线程共享同一个资源 + 抢占式调度 + 无锁保护导致:重复卖票、超卖、卖出负数票。

二、三种加锁方式核心对比表

表格

对比维度 同步代码块 synchronized 同步方法 synchronized Lock 锁 ReentrantLock
锁类型 隐式锁,自动加锁自动释放 隐式锁,自动加锁自动释放 显式锁,手动 lock ()、手动 unlock ()
锁对象 自己指定任意唯一对象 非静态:this静态:类名.class 自己 new ReentrantLock
锁范围 灵活,只锁核心代码 锁住整个方法,范围偏大 灵活,自己控制加锁解锁范围
写法复杂度 中等 最简单 稍复杂,必须 try-finally 保证解锁
性能并发 较好,锁粒度小 一般,容易锁多余代码 更好,功能更强、可扩展
可控性 不可手动控制 不可手动控制 可中断、可超时、可尝试拿锁
解决霸占问题 锁外循环加 sleep (10) 锁外循环加 sleep (10) 锁外循环加 sleep (10)

三、三个方式共性(必背)

  1. 都能解决线程安全,杜绝重复票、负数票;
  2. 同一时间只能一个线程进临界区
  3. 不加「锁外 sleep」,先启动的线程会一直抢占霸占
  4. 统一解决霸占方案:while 循环里、锁代码外面 加 Thread.sleep (10),让出 CPU,实现多窗口交替执行。

四、三者区别关键

  1. 同步代码块:灵活,推荐日常用,可精准控制锁范围;
  2. 同步方法:写法最懒,但锁整个方法,粒度粗、并发低;
  3. Lock 锁:手动加解锁,功能最强,高并发项目首选。

五、今天踩坑总结

  1. 线程启动顺序决定谁第一张票先卖
  2. 代码执行太快,同一线程立刻循环抢锁,会一直霸占;
  3. sleep 加在锁里面是模拟业务耗时;加在锁外面是礼让其他线程,实现交替;
  4. 同步方法默认锁是 this,静态同步方法锁是 类名.class

更多推荐