必刷|Java 多线程同步两大经典题——含 synchronized/Lock/AtomicInteger 三种实现,小白也能懂
目录
解法2使用 Lock + ReentrantLock 实现(更灵活,面试高频)
2. AtomicInteger 的 CAS 操作解决了什么?
1. 多线程交替打印 (15 分)
编写一个程序,启动三个线程,三个线程的 ID 分别是 A、B、C。每个线程将自己的 ID 在屏幕上打印 10 遍,打印顺序必须严格按照 ABCABC… 的顺序来打印。即输出结果为:
plaintext
A
B
C
A
B
C
…
(总共 30 次输出)
要求:使用 wait/notify 或 Lock/Condition 来实现线程间的通信。
思考:
- 在
main里写new PrintA().start(); new PrintB().start(); new PrintC().start();,只是告诉裁判 “这三个人要上场跑步”,并不是让他们按顺序跑。 - 裁判(CPU)会随机决定先喊谁起跑,而且跑一会儿还会把人拽下来,换另一个人跑(这叫线程调度)。谁先跑、跑多久,完全不由我们控制。
start()只是启动线程,不是按顺序执行线程。- 线程的执行顺序由 CPU 调度算法 决定,是随机的。(操作系统的特性)
- 要想严格按
ABCABC...打印,必须用锁 /wait/notify/Lock/Condition来做线程间的协调通信,强制它们按顺序 “接力”。
解法1:使用wait/notify
// 主类:控制 ABC 打印顺序
public class PrintABC {
// 核心:用一个变量标记「当前该谁打印」
// 0=该A打印,1=该B打印,2=该C打印
private static int flag = 0;
// 锁对象:所有线程共用这一把锁
private static final Object lock = new Object();
// 打印轮数:比如打印10轮
private static final int ROUND = 10;
// ========== 1. 定义打印A的线程 ==========
static class PrintA extends Thread {
@Override
public void run() {
// 要打印10轮,循环10次
for (int i = 0; i < ROUND; i++) {
// 加锁:同一时间只有一个线程能进来
synchronized (lock) {
// 循环等待:只要不是自己的轮次,就等着
while (flag != 0) {
try {
lock.wait(); // 线程A“睡觉”,等别人喊它
} catch (InterruptedException e) {
e.printStackTrace();
}
}
// 轮到自己了,打印A
System.out.print("A ");
// 标记:接下来该B打印了
flag = 1;
// 唤醒所有等待的线程(喊B:该你了!)
lock.notifyAll();
}
}
}
}
// ========== 2. 定义打印B的线程 ==========
static class PrintB extends Thread {
@Override
public void run() {
for (int i = 0; i < ROUND; i++) {
synchronized (lock) {
// 不是B的轮次,就等
while (flag != 1) {
try {
lock.wait();
} catch (InterruptedException e) {
e.printStackTrace();
}
}
// 打印B
System.out.print("B ");
// 标记:接下来该C打印
flag = 2;
// 唤醒所有线程(喊C:该你了!)
lock.notifyAll();
}
}
}
}
// ========== 3. 定义打印C的线程 ==========
static class PrintC extends Thread {
@Override
public void run() {
for (int i = 0; i < ROUND; i++) {
synchronized (lock) {
// 不是C的轮次,就等
while (flag != 2) {
try {
lock.wait();
} catch (InterruptedException e) {
e.printStackTrace();
}
}
// 打印C
System.out.print("C ");
// 标记:接下来该A打印(完成一轮,重置)
flag = 0;
// 唤醒所有线程(喊A:该你了!)
lock.notifyAll();
}
}
}
}
// ========== 4. 启动线程 ==========
public static void main(String[] args) {
new PrintA().start();
new PrintB().start();
new PrintC().start();
}
}
- 按顺序打印 ABC 的核心是线程间等待 / 唤醒,通过
flag标记轮次、lock加锁、wait()/notifyAll()通信; synchronized保证同一时间只有一个线程执行,while防止虚假唤醒;- 所有线程共用同一个
lock和flag,才能实现顺序控制。 - 只要用 await ()/wait (),判断条件就必须用 while——线程可能被误唤醒,while会再检查一次flag,确保真的轮到自己了。虚假唤醒不是代码写错导致的,而是 JVM / 操作系统层面的 “偶发情况”(比如 Linux 内核的 pthread_cond_wait 机制就可能触发)。虽然概率低,但只要出现一次,程序逻辑就会彻底乱掉,所以必须用 while 防御。
三个关键点
-
锁的是 “对象”,不是代码
- 修饰静态方法时:锁的是 “类对象”(整个类只有一把锁);
- 修饰普通方法 / 代码块时:锁的是你指定的对象(比如代码里的
lock); - 核心:多个线程必须抢同一把锁,synchronized 才生效。
比如打印 ABC 的代码里,A、B、C 三个线程都抢同一个
lock对象的锁,才能按顺序执行;如果各抢各的锁,就会乱序。 -
自动加锁、自动释放锁
- 线程进入
synchronized修饰的代码 → 自动 “拿到锁”; - 线程执行完代码 / 抛出异常 → 自动 “释放锁”;
- 不用手动操作,新手不容易出错(这也是它比
Lock简单的原因)。
- 线程进入
-
互斥性
- 同一把锁,同一时间只能被一个线程持有;
- 其他线程想拿这把锁,只能等(阻塞),直到锁被释放。
解法2:Lock/Condition
先搞懂核心概念(大白话版)
| 组件 | 类比(生活例子) | 作用 |
|---|---|---|
Lock |
厕所的大门锁(可手动开关) | 替代 synchronized,实现更灵活的加锁 / 解锁 |
Condition |
厕所门口的 “排队叫号器”(A/B/C 各一个) | 替代 wait/notify,实现精准的线程等待 / 唤醒 |
核心优势:wait/notify 是 “广播式唤醒”(喊所有线程来抢),而 Condition 是 “精准唤醒”(只喊指定线程),效率更高、逻辑更清晰。
import java.util.concurrent.locks.Condition;
import java.util.concurrent.locks.Lock;
import java.util.concurrent.locks.ReentrantLock;
// 用Lock/Condition实现ABC顺序打印
public class PrintABCWithLock {
// 1. 创建一把可重入锁(替代synchronized的锁对象)
private static final Lock lock = new ReentrantLock();
// 2. 为每个线程创建专属的Condition(叫号器)
private static final Condition conditionA = lock.newCondition(); // A的叫号器
private static final Condition conditionB = lock.newCondition(); // B的叫号器
private static final Condition conditionC = lock.newCondition(); // C的叫号器
// 3. 标记当前该谁打印(0=A,1=B,2=C)
private static int flag = 0;
// 打印轮数
private static final int ROUND = 10;
// ========== 打印A的线程 ==========
static class PrintA extends Thread {
@Override
public void run() {
for (int i = 0; i < ROUND; i++) {
// ① 手动加锁(替代synchronized代码块)
lock.lock();
try {
// 循环等待:不是A的轮次,就等A的叫号器
while (flag != 0) {
// 替代lock.wait():A线程等待,直到被唤醒
conditionA.await();
}
// 打印A
System.out.print("A ");
// 标记:该B打印了
flag = 1;
// 替代lock.notify():精准唤醒B线程(只喊B,不喊C)
conditionB.signal();
} catch (InterruptedException e) {
e.printStackTrace();
} finally {
// ② 手动解锁(必须放finally,防止死锁)
lock.unlock();
}
}
}
}
// ========== 打印B的线程 ==========
static class PrintB extends Thread {
@Override
public void run() {
for (int i = 0; i < ROUND; i++) {
lock.lock();
try {
while (flag != 1) {
// B线程等待,直到被A唤醒
conditionB.await();
}
System.out.print("B ");
flag = 2;
// 精准唤醒C线程
conditionC.signal();
} catch (InterruptedException e) {
e.printStackTrace();
} finally {
lock.unlock();
}
}
}
}
// ========== 打印C的线程 ==========
static class PrintC extends Thread {
@Override
public void run() {
for (int i = 0; i < ROUND; i++) {
lock.lock();
try {
while (flag != 2) {
// C线程等待,直到被B唤醒
conditionC.await();
}
System.out.print("C ");
flag = 0;
// 精准唤醒A线程,开始下一轮
conditionA.signal();
} catch (InterruptedException e) {
e.printStackTrace();
} finally {
lock.unlock();
}
}
}
}
// ========== 启动线程 ==========
public static void main(String[] args) {
new PrintA().start();
new PrintB().start();
new PrintC().start();
}
}
关键步骤对比(和 synchronized 版比)
| 操作 | synchronized + wait/notify | Lock + Condition |
|---|---|---|
| 加锁 | 自动加锁(进入代码块) | 手动调用 lock.lock() |
| 解锁 | 自动解锁(退出代码块) | 手动调用 lock.unlock()(必须放 finally) |
| 线程等待 | lock.wait() |
conditionX.await() |
| 线程唤醒 | lock.notifyAll()(广播) |
conditionX.signal()(精准唤醒) |
代码执行流程(以 A→B 为例)
- 线程 A 加锁 → 检查
flag=0→ 打印 A → 改flag=1→ 调用conditionB.signal()(只唤醒 B)→ 解锁; - 线程 B 之前在
conditionB.await()等待 → 被唤醒后加锁 → 检查flag=1→ 打印 B → 改flag=2→ 调用conditionC.signal()(只唤醒 C)→ 解锁; - 线程 C 同理,最后唤醒 A,完成一轮循环。
为什么必须放 finally 解锁
如果线程执行中抛出异常,lock.unlock() 放在 finally 里能保证锁一定被释放,避免死锁(这是Lock和synchronized最大的区别:synchronized 自动解锁,Lock 必须手动解锁)。
Lock/Condition 的核心优势(面试必答)
- 精准唤醒:不用唤醒所有线程,只唤醒需要的线程(比如 A 只唤醒 B,不唤醒 C),减少无效竞争,效率更高;
- 灵活的锁控制:支持超时加锁(
lock.tryLock(1, TimeUnit.SECONDS))、可中断加锁,解决了synchronized锁无法中断的问题; - 多个等待队列:一个 Lock 可以创建多个 Condition,对应多个等待队列(比如 A/B/C 各一个),而
synchronized只有一个等待队列。
2. 模拟售票系统 (15 分)
某电影院正在上映一部电影,共有 100 张票。现有 3 个售票窗口(模拟 3 个线程)同时对外售票。请设计一个程序,模拟这 3 个窗口的售票过程,直到所有票售完。
要求:
- 必须保证线程安全,不能出现多卖、超卖(卖出第 101 张票)的情况。
- 每卖出一张票,打印出当前售票窗口的名称和票号,例如:“窗口 1 售出第 1 号票,还剩 99 张”。
- 票售完后,每个窗口应停止售票,程序正常结束。
- 请至少给出两种不同的实现方式(例如,使用
synchronized、Lock或AtomicInteger等)
核心需求
- 3 个线程模拟 3 个售票窗口;
- 100 张票,线程安全(不超卖、不多卖);
- 打印格式:
窗口X 售出第N号票,还剩M张; - 票售完后所有窗口停止,程序正常结束。
解法1 使用 synchronized 实现
public class TicketSellerWithSync {
// 总票数(static保证3个窗口共享这100张票)
private static int totalTickets = 100;
// 同步售票方法:同一时间只能一个线程调用
private static synchronized boolean sellTicket(String windowName) {
// 1. 检查是否有票
if (totalTickets <= 0) {
return false; // 无票可售,返回false
}
// 2. 卖票(当前票号 = 剩余票数)
int ticketNo = totalTickets;
totalTickets--; // 票数减1
// 3. 按要求打印
System.out.printf("%s 售出第 %d 号票,还剩 %d 张%n", windowName, ticketNo, totalTickets);
return true; // 售票成功
}
// 售票窗口线程类
static class TicketWindow extends Thread {
// 窗口名称(比如“窗口1”)
private String windowName;
public TicketWindow(String windowName) {
this.windowName = windowName;
}
@Override
public void run() {
// 循环售票,直到无票可售
while (true) {
// 调用同步售票方法
boolean success = sellTicket(windowName);
if (!success) {
// 票售完,打印结束信息并退出循环
System.out.println(windowName + ":票已售完,停止售票");
break;
}
// 模拟售票耗时(可选,让效果更直观)
try {
Thread.sleep(10);
} catch (InterruptedException e) {
e.printStackTrace();
}
}
}
}
// 主方法:启动3个售票窗口
public static void main(String[] args) {
new TicketWindow("窗口1").start();
new TicketWindow("窗口2").start();
new TicketWindow("窗口3").start();
}
}
方法级 synchronized 的作用:把整个方法变成 “不可分割的原子操作”
给静态方法加 synchronized,相当于给整个类对象加锁(因为静态方法属于类,不是对象),效果是:
- 同一时间,只有一个线程能进入这个方法执行;
- 线程 A 进入方法后,会 “锁住” 这个方法,线程 B/C 必须等 A 执行完、退出方法后,才能进来;
- ①②③④步会被完整执行,不会被其他线程打断 → 彻底避免线程安全问题。
解法2使用 Lock + ReentrantLock 实现(更灵活,面试高频)
import java.util.concurrent.locks.Lock;
import java.util.concurrent.locks.ReentrantLock;
public class TicketSellerWithLock {
// 总票数
private static int totalTickets = 100;
// 创建可重入锁(替代synchronized)
private static final Lock lock = new ReentrantLock();
// 售票方法(手动加锁/解锁)
private static boolean sellTicket(String windowName) {
// 1. 手动加锁
lock.lock();
try {
// 2. 检查是否有票
if (totalTickets <= 0) {
return false;
}
// 3. 卖票并打印
int ticketNo = totalTickets;
totalTickets--;
System.out.printf("%s 售出第 %d 号票,还剩 %d 张%n", windowName, ticketNo, totalTickets);
return true;
} finally {
// 4. 手动解锁(必须放finally,防止死锁)
lock.unlock();
}
}
// 售票窗口线程类(和方案一完全一致)
static class TicketWindow extends Thread {
private String windowName;
public TicketWindow(String windowName) {
this.windowName = windowName;
}
@Override
public void run() {
while (true) {
boolean success = sellTicket(windowName);
if (!success) {
System.out.println(windowName + ":票已售完,停止售票");
break;
}
// 模拟售票耗时
try {
Thread.sleep(10);
} catch (InterruptedException e) {
e.printStackTrace();
}
}
}
}
// 主方法:启动3个窗口
public static void main(String[] args) {
new TicketWindow("窗口1").start();
new TicketWindow("窗口2").start();
new TicketWindow("窗口3").start();
}
}
ReentrantLock = 可重复进入的锁,简单说:
同一个线程已经拿到这把锁后,再次去抢这把锁时,不会被自己锁住(可以直接进入),还会记录 “抢锁的次数”,解锁时要对应次数才能彻底释放。
不过这道题目的整个过程中,线程只抢了 1 次锁、解了 1 次锁,没有任何 “嵌套加锁” 的场景 —— 所以哪怕用的是 “不可重入锁”(假设存在),结果也完全一样。
这也是为什么我说:在这个简单场景下,ReentrantLock 的 “可重入” 特性是 “多余的”,它的价值体现在更复杂的场景里。
什么时候 “可重入” 特性才会发挥作用?
只有当代码出现「同一线程嵌套调用需要同一把锁的方法」时,“可重入” 才是必须的 —— 否则会导致线程 “自己锁自己”(死锁)。
举个例子:给售票系统加个 “退票 + 售票” 的嵌套逻辑(模拟复杂场景):
import java.util.concurrent.locks.ReentrantLock;
public class ReentrantLockDemo {
private static final ReentrantLock lock = new ReentrantLock();
private static int totalTickets = 100;
// 售票方法(需要加锁)
private static void sellTicket(String windowName) {
lock.lock(); // 第一次加锁(计数=1)
System.out.println(windowName + ":开始售票");
// 嵌套调用退票方法(也需要加锁)
refundTicket(windowName, 1);
lock.unlock(); // 第一次解锁(计数=0)
}
// 退票方法(也需要加锁)
private static void refundTicket(String windowName, int num) {
lock.lock(); // 第二次加锁(可重入,计数=2)
totalTickets += num;
System.out.println(windowName + ":退了" + num + "张票,剩余:" + totalTickets);
lock.unlock(); // 第二次解锁(计数=1)
}
public static void main(String[] args) {
sellTicket("窗口1");
}
}
运行结果(正常执行,无死锁)
窗口1:开始售票
窗口1:退了1张票,剩余:101
核心逻辑:
- 线程调用
sellTicket,第一次lock()→ 计数 = 1; sellTicket里调用refundTicket,第二次lock()→ 因为是同一个线程,ReentrantLock 允许 “重入”,计数变成 2;refundTicket执行unlock()→ 计数 = 1;sellTicket执行unlock()→ 计数 = 0,锁彻底释放。
如果是 “不可重入锁”(假设):
步骤 2 会卡死 —— 线程已经拿到锁了,又去抢同一把锁,结果就是 “自己等自己释放锁”,永远等不到(死锁)。
这就是 “可重入” 的核心价值:解决同一线程嵌套加锁的死锁问题。
那售票题里为什么还推荐用 ReentrantLock?
虽然 “可重入” 没用到,但 ReentrantLock 还有其他关键优势,比 synchronized 更灵活:
1. 支持 “公平锁”(可选)
如果想让 3 个窗口 “排队售票”(先等先得,避免某个窗口一直抢不到锁),只需改一行代码:
// 创建公平锁(按排队顺序拿锁)
private static final Lock lock = new ReentrantLock(true);
而 synchronized 永远是 “非公平锁”(随机抢锁),无法做到公平。
2. 支持 “超时拿锁”(避免死锁)
如果担心某个窗口拿锁后卡死,可以加超时逻辑:
private static boolean sellTicket(String windowName) {
try {
// 尝试拿锁1秒,拿不到就放弃
if (!lock.tryLock(1, TimeUnit.SECONDS)) {
System.out.println(windowName + ":1秒没拿到锁,放弃本次售票");
return false;
}
} catch (InterruptedException e) {
return false;
}
try {
if (totalTickets <= 0) return false;
int ticketNo = totalTickets;
totalTickets--;
System.out.printf("%s 售出第 %d 号票,还剩 %d 张%n", windowName, ticketNo, totalTickets);
return true;
} finally {
lock.unlock();
}
}
synchronized 做不到这一点 —— 拿不到锁就一直等,直到拿到为止,容易死锁。
3. 支持 “可中断拿锁”
如果需要手动停止某个窗口的售票线程,可以用 lock.lockInterruptibly(),线程等待锁时能被中断,而 synchronized 的等待是无法中断的。
- 基础版售票题里,ReentrantLock 的 “可重入” 特性确实没用 —— 因为没有嵌套加锁的场景;
- 用 ReentrantLock,不是因为 “可重入”,而是因为它的其他高级特性(公平锁、超时锁、可中断),这些是 synchronized 没有的;
- “可重入” 是 ReentrantLock 的基础特性(名字都带 Reentrant),虽然简单场景用不到,但复杂场景(嵌套加锁)是必须的,也是面试必问的考点。
一句话记住:ReentrantLock 的核心价值不只是 “可重入”,更是比 synchronized 更灵活的锁控制能力—— 哪怕简单场景用不到全部特性,也是学习和面试的重点。
解法3 使用AtomicInteger
AtomicInteger 是解决售票题的无锁方案,和 synchronized/ReentrantLock 的 “加锁排队” 思路完全不同 —— 它靠 CPU 指令级的原子操作保证线程安全,性能更高,而且不用写任何锁代码。
AtomicInteger = 原子整数,它的所有操作(比如自减、比较赋值)都是「不可分割的原子操作」—— 多个线程同时操作时,不会出现 “中间态”,天然保证线程安全,不用加锁。
售票题用 AtomicInteger 的核心逻辑:用 AtomicInteger 替代普通 int 存储票数 → 用 compareAndSet(CAS) 实现 “查票 + 减票” 的原子操作 → 全程无锁,线程安全。
import java.util.concurrent.atomic.AtomicInteger;
public class TicketSellerWithAtomic {
// 核心:用AtomicInteger替代普通int,初始值100
private static final AtomicInteger totalTickets = new AtomicInteger(100);
// 售票方法(无锁!)
private static boolean sellTicket(String windowName) {
// 循环CAS操作:保证“查票+减票”原子性
while (true) {
// 1. 获取当前剩余票数(原子操作,拿到的是最新值)
int current = totalTickets.get();
// 2. 没票了,返回false
if (current <= 0) {
return false;
}
// 3. CAS核心操作:尝试把票数从current改成current-1
// 原理:只有当当前票数还是current时,才会修改成功(避免被其他线程抢先)
boolean success = totalTickets.compareAndSet(current, current - 1);
// 4. CAS成功=售票成功,打印并返回;失败=被其他线程抢先,重新循环
if (success) {
System.out.printf("%s 售出第 %d 号票,还剩 %d 张%n",
windowName, current, current - 1);
return true;
}
// CAS失败:不做任何操作,回到循环开头重新尝试
}
}
// 窗口线程类(和锁方案完全一样)
static class TicketWindow extends Thread {
private String windowName;
public TicketWindow(String windowName) {
this.windowName = windowName;
}
@Override
public void run() {
while (true) {
boolean success = sellTicket(windowName);
if (!success) {
System.out.println(windowName + ":票已售完,停止售票");
break;
}
// 模拟售票耗时
try {
Thread.sleep(10);
} catch (InterruptedException e) {
e.printStackTrace();
}
}
}
}
// 启动3个窗口
public static void main(String[] args) {
new TicketWindow("窗口1").start();
new TicketWindow("窗口2").start();
new TicketWindow("窗口3").start();
}
}
核心原理拆解
1. 为什么普通 int 会出问题?
普通 int totalTickets-- 其实是 3 步操作:① 读取当前值 → ② 减 1 → ③ 写回新值。多线程时,可能出现 “线程 A 读了 100,线程 B 也读了 100,都减 1 后写回 99” → 超卖。
2. AtomicInteger 的 CAS 操作解决了什么?
compareAndSet(current, current-1) 是CPU 级的原子指令,相当于:“我要把值从 current 改成 current-1,只有当内存里的值还是 current 时,才改成功;如果已经被其他线程改了,就不改,返回 false。”
整个过程不可分割,没有中间态 —— 彻底避免了 “多线程同时改值” 的问题。
3. 循环的作用是什么?
while (true) 是 “自旋”:如果 CAS 失败(说明被其他线程抢先卖了这张票),就重新获取最新的票数,再试一次。
总结
这道多线程售票题的核心目标是保证 100 张票被 3 个窗口安全售卖(不超卖、不多卖),售完后程序正常结束,三种实现方法从原理、写法、特点上各有侧重,核心总结如下:
| 实现方法 | 核心原理 | 核心代码特点 | 优点 | 缺点 |
|---|---|---|---|---|
| 1. synchronized(同步锁) | 给售票方法加锁,让 “查票 - 卖票 - 减票 - 打印” 成为原子操作,同一时间仅一个线程执行 | 静态方法加 synchronized,自动加锁 / 解锁,无需手动控制 |
最简单、新手易上手,自动解锁不易死锁 | 功能单一(非公平锁、不可中断、无法超时) |
| 2. ReentrantLock(可重入锁) | 手动加锁 / 解锁,通过锁保证核心操作原子性,支持更灵活的锁控制 | lock.lock() 加锁,finally 中 lock.unlock() 解锁,可指定公平锁 / 超时锁 |
灵活(公平锁、超时拿锁、可中断),面试高频 | 需手动解锁(忘写 finally 会导致死锁) |
| 3. AtomicInteger(原子整数) | 基于 CPU 指令级的 CAS(比较并交换)操作,无锁实现单个变量的原子修改 | 自旋 +compareAndSet 实现 “查票 - 减票” 原子性,全程无锁 |
性能最高(非阻塞式),无死锁风险 | 仅适合单个变量操作,自旋重试可能耗 CPU |
AtomGit 是由开放原子开源基金会联合 CSDN 等生态伙伴共同推出的新一代开源与人工智能协作平台。平台坚持“开放、中立、公益”的理念,把代码托管、模型共享、数据集托管、智能体开发体验和算力服务整合在一起,为开发者提供从开发、训练到部署的一站式体验。
更多推荐


所有评论(0)