0. 多线程实战概述
多线程不是单纯地"同时运行多段代码",而是在 JVM 线程模型、操作系统调度器、共享内存模型以及同步机制 共同作用下完成的一种程序执行方式。实际开发中,多线程常被用于提升吞吐量、改善响应速度、隐藏 I/O 等待时间,但它同时也会引入 共享数据一致性、锁竞争、线程调度不确定性、结果汇总困难 等问题。
核心 :这 7 道题表面上是在完成"卖票、送礼品、打印奇数、抢红包、抽奖箱"等业务练习,本质上是在训练并发编程中最重要的四类能力:识别共享数据、保护临界区、缩小锁范围、汇总线程结果。
1. 基础并发与精度控制
1.1 练习 1:卖票案例

卖票案例是最典型的共享数据并发访问 场景。两个窗口并发领取同一批电影票,本质上是多个线程共同修改同一个 tickets 字段。如果没有同步控制,就可能出现 重票 或 超卖。
注意 :
tickets--并不是不可分割的原子操作,它至少包含读取、计算、写回三个阶段。线程一旦在中间阶段失去 CPU 执行权,就可能造成数据状态被破坏。

1.1.1 TicketTask
下面的任务类通过实现 Runnable 接口,将"票数"作为任务对象内部的共享数据 ,并使用 synchronized (this) 保护临界区。
java
package text1;
public class TicketTask implements Runnable {
// 共享数据:总共 1000 张票
private int tickets = 1000;
@Override
public void run() {
while (true) {
// 使用 this 作为唯一的锁对象,保证两个窗口不会发生重票和超卖
synchronized (this) {
if (tickets > 0) {
try {
// 模拟每次领取的时间为 3000 毫秒 (3秒)
// 注意:sleep 不会释放锁,此时另一个窗口只能在门外等待
Thread.sleep(3000);
} catch (InterruptedException e) {
e.printStackTrace();
}
// 票数减 1
tickets--;
// 打印是哪个窗口领取的,以及剩余的数量
System.out.println(Thread.currentThread().getName() + " 领取了一张票,剩余电影票的数量为:" + tickets);
} else {
// 票领完了,跳出循环,结束线程
System.out.println(Thread.currentThread().getName() + " 发现票已领完,下班了!");
break;
}
}
}
}
}
1.1.2 Demo
测试类只创建一个 TicketTask 对象,然后将同一个任务对象交给两个线程执行。这样两个线程访问的是同一份 tickets 数据。
java
package text1;
public class Demo {
public static void main(String[] args) {
// 1. 创建唯一的任务对象,里面包含了那 1000 张票的数据
TicketTask task = new TicketTask();
// 2. 创建两个线程对象,模拟两个窗口,并将同一个任务对象传递给它们
Thread t1 = new Thread(task, "窗口1");
Thread t2 = new Thread(task, "窗口2");
// 3. 启动线程,开始并发抢票
t1.start();
t2.start();
}
}

总结 :练习 1 的重点不在于"卖票"业务,而在于理解 多个线程共享同一个 Runnable 任务对象时,任务对象中的成员变量天然成为共享数据。
1.2 练习 2:送礼品案例

送礼品案例与卖票案例结构高度相似,区别在于终止条件发生变化:当剩余礼品小于 10 份时停止发送。这类题目训练的是对 边界条件与共享变量修改顺序 的控制能力。

1.2.1 GiftTask
在该任务中,gifts 是共享数据;判断剩余数量、休眠模拟耗时、扣减数量以及打印结果,都被放在同步代码块中执行。
java
package text2;
public class GiftTask implements Runnable {
// 共享数据:总共 100 份礼品
private int gifts = 100;
@Override
public void run() {
while (true) {
// 使用 this 作为唯一的锁对象,保证两人发送礼品时的数据安全
synchronized (this) {
// 判断条件:当剩下的礼品小于 10 份的时候则不再送出
if (gifts < 10) {
System.out.println(Thread.currentThread().getName() + " 发现剩余礼品不足10份,停止发送!");
break;
} else {
try {
// 稍微休眠一下,模拟发送礼品耗时,也能让控制台的交替打印效果更明显
Thread.sleep(10);
} catch (InterruptedException e) {
e.printStackTrace();
}
// 礼品数减 1
gifts--;
// 打印是哪个人送出的,以及剩余的数量
System.out.println(Thread.currentThread().getName() + " 送出了一份礼品,剩余礼品数量为:" + gifts);
}
}
}
}
}
1.2.2 Demo
两个线程共同持有同一个 GiftTask 实例,因此二者并发争抢的是同一个礼品库存。
java
package text2;
public class Demo {
public static void main(String[] args) {
// 1. 创建唯一的任务对象,里面包含了那 100 份礼品的数据
GiftTask task = new GiftTask();
// 2. 创建两个线程对象,模拟"两人同时发送",并将同一个任务对象传递给它们
Thread t1 = new Thread(task, "员工A");
Thread t2 = new Thread(task, "员工B");
// 3. 启动线程,开始并发发送礼品
t1.start();
t2.start();
}
}

注意 :同步代码块能够保证数据安全,但并不意味着同步块越大越好。同步范围越大,线程等待时间越长,并发性能越低。这种把耗时操作(如
Thread.sleep)包裹在锁内的写法,在实际开发中是一种严重影响性能的"反模式"。我们在这里先这样写是为了优先保证基础的数据安全,在后续的【练习 5】和【练习 6】中,我们将重点展示如何通过"缩小锁的粒度"将耗时操作移出锁外,从而彻底优化程序的并发性能。
1.3 练习 3:打印奇数案例

打印奇数案例看似只涉及输出,实际仍然是一个共享计数器并发推进问题。两个线程共同推进 number,只有当数字为奇数时才打印。

1.3.1 OddNumberTask
该题中最容易忽视的细节是:无论当前数字是不是奇数,number 都必须自增。否则线程可能在某个偶数上反复判断,导致循环无法按预期推进。
java
package text3;
public class OddNumberTask implements Runnable {
// 共享数据:从 1 开始获取数字
private int number = 1;
@Override
public void run() {
while (true) {
// 使用 this 作为锁对象,保证两个线程争抢数字时的绝对安全
synchronized (this) {
// 判断条件:只要数字还在 1~100 之间,就继续处理
if (number <= 100) {
// 判断是否为奇数,是奇数则打印
if (number % 2 != 0) {
System.out.println(Thread.currentThread().getName() + " 打印了奇数:" + number);
}
// 【核心注意点】:无论刚才是不是奇数,数字用完后都必须自增!
// 这样才能保证两个线程共同推进这 100 个数
number++;
} else {
// 数字超过 100,跳出循环,结束线程
break;
}
}
try {
// 稍微休眠一下,模拟处理数字耗时,也能让控制台的交替打印效果更明显
Thread.sleep(10);
} catch (InterruptedException e) {
e.printStackTrace();
}
}
}
}
1.3.2 Demo
两个线程共享同一个 OddNumberTask,共同消费 1~100 这一段数字范围。
java
package text3;
public class Demo {
public static void main(String[] args) {
// 1. 创建唯一的任务对象,里面包含了那 1~100 的共享数字状态
OddNumberTask task = new OddNumberTask();
// 2. 创建两个线程对象,并将同一个任务对象传递给它们
Thread t1 = new Thread(task, "线程A");
Thread t2 = new Thread(task, "线程B");
// 3. 启动线程,共同获取数字
t1.start();
t2.start();
}
}

核心 :练习 1、练习 2、练习 3 的共同点是:都采用 Runnable + 共享成员变量 + synchronized 临界区 的基本模型。该模型适合训练线程安全的基本判断,但也会暴露锁范围过大、耗时操作被锁包裹等性能问题。
1.4 练习 4:抢红包实战

抢红包案例在基础并发之上引入了一个更接近真实业务的关键问题:金额计算精度 。在 Java 中,double 与 float 都属于二进制浮点数,无法精确表示很多十进制小数。如果在红包、订单、支付等场景中直接使用 double 处理金额,就可能产生精度丢失。
注意 :涉及货币计算时,应优先使用
BigDecimal,并尽量使用字符串构造方式,例如new BigDecimal("100.00")。直接使用new BigDecimal(0.1)仍然可能把二进制浮点误差带入高精度对象中。

1.4.1 RedPacketTask
该任务类中,红包总金额 与最小金额 均使用 BigDecimal 表示。多个用户线程共同争抢同一个红包任务对象,因此 totalMoney 和 count 都是共享数据,必须放入同步代码块中统一保护。
java
package text4;
import java.math.BigDecimal;
import java.math.RoundingMode;
import java.util.Random;
public class RedPacketTask implements Runnable {
// 共享数据 1:总金额 100 元 (使用字符串构造 BigDecimal 避免精度丢失)
private BigDecimal totalMoney = new BigDecimal("100.00");
// 共享数据 2:红包个数
private int count = 3;
// 常量:最小中奖金额 0.01 元
private final BigDecimal MIN_AMOUNT = new BigDecimal("0.01");
@Override
public void run() {
// 使用 this 作为唯一的锁对象,保证多个人抢红包时的数据安全
synchronized (this) {
// 1. 判断红包是否已经被抢完了
if (count == 0) {
System.out.println(Thread.currentThread().getName() + "没抢到");
} else {
// 2. 还有红包可以抢
BigDecimal prize;
if (count == 1) {
// 如果是最后一个红包,无需随机,剩下的钱全给这个人
prize = totalMoney;
} else {
// 如果不是最后一个,则需要随机计算金额
// 核心逻辑:本次能抢的最大金额 = 剩下的总钱数 - 剩下的红包个数需要的保底钱数
// 比如剩 100元 3个包,这次最大只能抢 100 - (3-1)*0.01 = 99.98 元
BigDecimal bounds = totalMoney.subtract(MIN_AMOUNT.multiply(new BigDecimal(count - 1)));
// 生成随机金额
Random r = new Random();
// nextDouble() 生成 0.0 ~ 1.0 的小数,乘以上限 bounds
double randomVal = r.nextDouble() * bounds.doubleValue();
// 将随机出来的 double 值转成 BigDecimal,并保留 2 位小数,四舍五入
prize = new BigDecimal(randomVal).setScale(2, RoundingMode.HALF_UP);
// 如果随机出来的金额不到 0.01(比如 0.00),则强制置为 0.01
if (prize.compareTo(MIN_AMOUNT) < 0) {
prize = MIN_AMOUNT;
}
}
// 3. 从总金额中扣除本次抢到的金额
totalMoney = totalMoney.subtract(prize);
// 4. 红包个数减 1
count--;
// 5. 打印抢红包的结果
System.out.println(Thread.currentThread().getName() + "抢到了" + prize + "元");
}
}
}
}
从并发角度看,该实现有三个核心控制点:
- 共享数据 :
totalMoney与count同属于任务对象状态,多个线程会共同访问。- 唯一锁对象 :
synchronized (this)使用同一个RedPacketTask实例作为锁,保证一次只有一个线程能完成红包扣减。- 边界控制:最后一个红包直接领取剩余金额,避免随机计算后产生余额残留。
1.4.2 Demo
测试类创建 5 个线程模拟 5 个用户,但红包任务对象只有一个,因此多个线程争抢的是同一个红包池。
java
package text4;
public class Demo {
public static void main(String[] args) {
// 1. 创建唯一的任务对象,里面包含了共享的红包数据
RedPacketTask task = new RedPacketTask();
// 2. 创建 5 个线程对象,模拟 5 个人,并将同一个任务对象传递给它们
Thread t1 = new Thread(task, "小A");
Thread t2 = new Thread(task, "小QQ");
Thread t3 = new Thread(task, "小哈哈");
Thread t4 = new Thread(task, "小诗诗");
Thread t5 = new Thread(task, "小丹丹");
// 3. 5 个人同时开始抢红包
t1.start();
t2.start();
t3.start();
t4.start();
t5.start();
}
}

总结 :抢红包案例的本质是 共享状态的一次性消费 。线程安全保证的是不会出现"红包数量被重复消费 "或"总金额扣减不一致 ";
BigDecimal保证的是金额计算不会被二进制浮点误差污染。
2. 容器并发安全与底层内存模型
2.1 练习 5:抽奖箱并发抽取

抽奖箱案例开始从简单数值变量过渡到集合容器。ArrayList 本身不是线程安全容器,如果多个线程同时对同一个集合执行 remove 操作,就可能产生数据错乱或运行时异常。因此,抽奖箱案例的第一个重点是:对共享集合的复合操作必须加锁。

2.1.1 LotteryTask
该任务类接收外部传入的奖池集合 ,并在同步代码块中完成"判断奖池是否为空、生成随机索引、删除并获取奖项"这一组不可拆分的逻辑。
java
package text5;
import java.util.List;
import java.util.Random;
public class LotteryTask implements Runnable {
// 共享数据:奖池集合
private List<Integer> prizePool;
// 通过构造方法将外部初始化好的奖池传进来
public LotteryTask(List<Integer> prizePool) {
this.prizePool = prizePool;
}
@Override
public void run() {
Random r = new Random();
while (true) {
// 使用 this 作为唯一的锁对象,保证两个抽奖箱操作集合时的绝对安全
synchronized (this) {
// 1. 判断奖池中是否还有奖项
if (prizePool.isEmpty()) {
break; // 奖池空了,跳出循环结束线程
} else {
// 2. 奖池还有钱,随机抽取一个
// 随机生成一个集合的合法索引
int index = r.nextInt(prizePool.size());
// 使用 remove(index) 方法不仅能获取到奖金,还能同时将其从集合中剔除,防止重复抽取
int prize = prizePool.remove(index);
System.out.println(Thread.currentThread().getName() + " 又产生了一个 " + prize + " 元大奖");
}
} // 注意:同步代码块在这里结束,锁被释放了!
try {
// 【核心细节】:休眠必须放在同步代码块外面!
// 这样线程在这个短暂的休眠期间是不持有锁的,另一个抽奖箱就能趁机抢到锁去抽奖,从而实现交替效果。
Thread.sleep(10);
} catch (InterruptedException e) {
e.printStackTrace();
}
}
}
}
注意 :
isEmpty()与remove(index)必须被同一把锁保护。否则某个线程刚判断集合非空,另一个线程就可能先一步删除元素,导致当前线程使用过期的集合大小继续访问。
2.1.2 Demo
主线程负责初始化奖池,再将同一个奖池集合交给抽奖任务对象。
java
package text5;
import java.util.ArrayList;
import java.util.Collections;
import java.util.List;
public class Demo {
public static void main(String[] args) {
// 1. 创建并初始化共享的奖池数据
List<Integer> prizePool = new ArrayList<>();
Collections.addAll(prizePool, 10, 5, 20, 50, 100, 200, 500, 800, 2, 80, 300, 700);
// 2. 创建唯一的任务对象,并将装满奖金的奖池传递进去
LotteryTask task = new LotteryTask(prizePool);
// 3. 创建两个线程对象,模拟两个抽奖箱
Thread t1 = new Thread(task, "抽奖箱1");
Thread t2 = new Thread(task, "抽奖箱2");
// 4. 同时开启两个抽奖箱
t1.start();
t2.start();
}
}

从性能角度看,练习 5 已经体现出一个非常重要的实践原则:Thread.sleep(10) 被放在同步代码块外部。这样线程休眠时不会持有锁,另一个抽奖箱可以继续进入临界区完成抽奖。
核心 :锁只应该保护真正需要互斥的数据操作。打印、休眠、统计、格式化等不影响共享数据一致性的动作,应尽量从同步代码块中剥离。
2.2 练习 6:抽奖箱统计与栈封闭优化

练习 6 在练习 5 的基础上继续演进:不仅要抽取奖项,还要分别统计每个抽奖箱获得了哪些奖项、最高奖项是多少、总金额是多少。此时如果把每个抽奖箱的统计集合设计成 static 或共享成员变量,就会重新引入集合并发安全问题。
解决该问题的关键技术是:栈封闭。
核心 :栈封闭是指对象只在某一个线程的局部作用域中创建和使用,不被其他线程共享。 只要对象引用没有逃逸到其他线程,那么该对象即使本身不是线程安全的,也可以被当前线程安全使用。
2.2.1 穿透 JVM 内存看"栈封闭"
为了彻底搞懂为什么 run() 方法里的局部变量天然线程安全,我们先不急着看练习 6 的抽奖代码,而是借助一段更小的线程代码,把 JVM 中的栈、堆、局部变量、成员变量逐步拆开看。
示例代码中,main 方法创建两个 MyThread 对象,分别命名为"线程1""线程2",然后调用 start() 启动它们;MyThread 类中有一个成员变量 a,run() 方法中还有一个局部变量 b。
2.2.1.1 第一步:代码结构先定下来

这张图先告诉我们两件事:
main方法本身由main线程执行。t1.start()和t2.start()被调用后,JVM 会真正开启两条新的执行线程,分别去执行各自的run()方法。
注意,这里不是 main 线程直接调用 run()。如果直接写 t1.run(),那只是普通方法调用,仍然在 main 线程里执行;只有 start() 才会让 JVM 为新线程准备独立的执行栈。
2.2.1.2 第二步:new MyThread() 先在堆里创建线程对象

当程序启动时,JVM 首先为主线程(main 线程)分配了属于它自己的虚拟机栈。
执行下面两行代码时:
java
MyThread t1 = new MyThread();
MyThread t2 = new MyThread();
内存中会形成两个位置:
- main 线程的栈中 :保存局部变量
t1和t2。它们不是对象本身,而是两个引用,可以理解为指向对象的地址。 - 堆内存中 :保存两个真正的
MyThread对象。对象里的成员变量,比如String name、int a = 1,都属于对象的一部分,因此也跟着对象放在堆中。
这里先得到一个关键结论:局部变量引用在栈里,对象本身通常在堆里。
2.2.1.3 第三步:setName() 修改的是堆中对象的成员数据

继续执行:
java
t1.setName("线程1");
t2.setName("线程2");
t1 和 t2 仍然是 main 栈帧中的两个引用;真正被修改的是堆中两个 MyThread 对象内部的 name 成员。
所以要分清楚:
t1、t2是main方法里的局部变量,存放在main线程栈中。name、a是线程对象的成员变量,跟着对象存放在堆中。
堆是所有线程都能访问的共享区域。如果多个线程拿到了同一个堆对象的引用,并同时修改该对象的成员变量,就可能产生线程安全问题。
2.2.1.4 第四步:start() 之后,每个线程都有自己的栈

当 t1.start() 和 t2.start() 被调用后,JVM 会让"线程1"和"线程2"分别进入自己的 run() 方法。此时内存中不再只有 main 线程的栈,还会多出两块新的线程栈:
main线程有自己的栈,里面有main方法的栈帧。- 线程 1 有自己的栈,里面有线程 1 执行
run()方法时产生的栈帧。 - 线程 2 也有自己的栈,里面有线程 2 执行
run()方法时产生的栈帧。
因此,run() 方法中的局部变量 int b = 2; 会出现两份:
- 线程 1 执行
run(),在线程 1 的栈帧中创建一个b = 2。 - 线程 2 执行
run(),在线程 2 的栈帧中创建另一个b = 2。
这两个 b 名字相同、值也相同,但它们位于不同线程的栈帧里,是两份完全独立的数据。线程 1 改自己的 b,线程 2 看不见;线程 2 改自己的 b,线程 1 也看不见。
这就是最直观的栈封闭 :局部变量被封闭在线程自己的调用栈中,没有被其他线程共享,所以天然线程安全。
2.2.1.5 第五步:局部变量是 ArrayList 时,为什么仍然安全?

这是初学者最容易产生困惑的地方。如果我们在 run() 方法中不是定义 int,而是 new 了一个 ArrayList 集合呢?
重点剖析:
- 引用的位置: 集合的引用(指针)依然存储在各自线程的栈帧中。
- 对象的位置: 因为使用了
new关键字,ArrayList对象本身是分配在**堆内存(共享区域)**中的。
既然对象在共享的堆中,它还安全吗?
答案是:绝对安全! 因为虽然这两个 ArrayList 躺在共享的堆内存里,但能够找到它们的"钥匙"(即引用)仅仅存在于各自线程的私有栈中。
- 只要我们没有把这个集合赋值给
static变量; - 只要我们没有把它作为返回值
return出去给别的线程; - 只要它没有逃逸出当前线程的作用域。
那么,线程 1 永远只能操作堆中的"集合A",线程 2 永远只能操作堆中的"集合B"。它们在逻辑上形成了完美的隔离。
结合练习 6 中的代码,boxList 定义在 run() 方法内部:
- 线程
抽奖箱1调用run()时,会在自己的线程栈中创建一份局部变量引用。- 线程
抽奖箱2调用run()时,也会在自己的线程栈中创建另一份局部变量引用。- 两个
boxList指向的集合对象只被各自线程持有,不会成为共享容器。
因此,boxList.add(prize)、统计最大值、统计总金额等操作都可以在锁外执行。
2.2.2 MyThread
下面的代码将"共享奖池"与"线程私有统计集合"明确拆分:奖池需要加锁,抽奖箱自己的统计集合不需要加锁。
java
package text6;
import java.util.ArrayList;
import java.util.List;
public class MyThread extends Thread {
// 共享数据:所有线程争抢的同一个奖池
private List<Integer> prizePool;
// 通过构造方法,将主线程的奖池传进来,顺便设置线程名称
public MyThread(List<Integer> prizePool, String name) {
super(name);
this.prizePool = prizePool;
}
@Override
public void run() {
// 【优化点1】:栈封闭!
// 定义在 run 方法内部的局部变量,每个线程都会在自己的栈内存中 new 一个全新的集合。
// 绝对物理隔离,彻底消除了使用 static 集合带来的多线程共享隐患!
List<Integer> boxList = new ArrayList<>();
// ================== 第一阶段:疯狂抢夺阶段 ==================
while (true) {
// 【优化点2:精准锁对象选择(锁的细粒度控制)】
// 思考:为什么用 prizePool 而不是 MyThread.class?
// 1. 语义对口:多线程争抢的核心资源是这个具体的奖池实例,锁应该加在被保护的资源本身。
// 2. 细化粒度:如果主程序同时举办两场独立的抽奖(传入不同的 prizePool),
// 使用类锁 (MyThread.class) 会导致所有活动全局串行;而锁 prizePool
// 能让两场抽奖活动互不干扰,实现真正的并行。
synchronized (prizePool) { // 锁住那个唯一的奖池
if (prizePool.isEmpty()) {
break; // 奖池抽干了,跳出循环
}
//【优化3:前置打乱,无脑取第一个】:
// 因为主程序已经把集合打乱过了,这里每次只需要无脑 remove(0) 即可,性能 O(1)
int prize = prizePool.remove(0);
// 将抽到的奖金塞进当前线程自己的小钱包里
boxList.add(prize);
} // 锁在这里就释放了!绝不在锁里做耗时操作!
// 【优化4:细化锁粒度】:
// 休眠必须放在 synchronized 外面!让当前线程短暂释放 CPU,给另一个抽奖箱抢锁的机会。
try {
Thread.sleep(10);
} catch (InterruptedException e) {
e.printStackTrace();
}
}
// ================== 第二阶段:锁外统计与打印阶段 ==================
// 【优化5:职责分离】:
// 代码走到这里,说明 while 循环结束(奖池空了)。
// 此时不会再有抢夺发生,各自拿着自己的 boxList 慢慢统计,完全不需要加锁!
printResult(boxList);
}
// 将繁杂的统计和打印逻辑抽离出来,保持 run 方法的主干清晰
private void printResult(List<Integer> boxList) {
if (boxList.isEmpty()) {
System.out.println("在此次抽奖过程中," + getName() + " 运气太差,什么都没抽到。");
return;
}
int max = boxList.get(0);
int sum = 0;
for (int prize : boxList) {
if (prize > max) {
max = prize;
}
sum += prize;
}
// 严格按照题目要求的格式输出(利用 replace 去掉 List 默认打印的中括号)
System.out.println("在此次抽奖过程中," + getName() + "总共产生了" + boxList.size() + "个奖项。");
System.out.println(" 分别为:" + boxList.toString().replace("[", "").replace("]", "")
+ "最高奖项为" + max + "元,总计额为" + sum + "元\n");
}
}
注意 :
prizePool是共享集合,必须通过synchronized (prizePool)保护;boxList是局部变量,每个线程独有,因此属于栈封闭对象,不需要额外同步。
2.2.3 Demo
主线程先完成奖池初始化与洗牌,然后创建两个抽奖箱线程。
java
package text6;
import java.util.ArrayList;
import java.util.Collections;
import java.util.List;
public class Demo {
public static void main(String[] args) {
// 1. 初始化唯一共享的奖池
List<Integer> prizePool = new ArrayList<>();
Collections.addAll(prizePool, 10, 5, 20, 50, 100, 200, 500, 800, 2, 80, 300, 700);
// 【优化:只打乱一次】:
// 在发车前把牌洗好,避免在多线程的 while 循环里反复去 shuffle 拖垮性能
Collections.shuffle(prizePool);
// 2. 创建两个抽奖箱线程,并将唯一的奖池传给它们
MyThread t1 = new MyThread(prizePool, "抽奖箱1");
MyThread t2 = new MyThread(prizePool, "抽奖箱2");
// 3. 启动线程
t1.start();
t2.start();
// 拓展:如果你现在想加"抽奖箱3",只需加下面两行,MyThread 类一行都不用改!
// MyThread t3 = new MyThread(prizePool, "抽奖箱3");
// t3.start();
}
}

2.2.4 前置洗牌与锁粒度细化
练习 6 中有两个非常重要的性能优化点。
| 优化点 | 代码位置 | 技术含义 |
|---|---|---|
| 前置洗牌 | Collections.shuffle(prizePool) |
在单线程准备阶段完成随机化,避免在多线程临界区中反复 shuffle |
| 无脑取首位 | prizePool.remove(0) |
洗牌后直接取首位,降低同步块中的计算复杂度 |
| 休眠移出锁外 | Thread.sleep(10) 位于同步块外 |
避免线程休眠时继续占有锁,提高另一个线程获得锁的机会 |
| 锁外统计 | printResult(boxList) |
统计对象是线程私有数据,不需要阻塞其他线程 |
总结:并发优化不是简单地"加更多线程",而是减少线程在共享资源上的等待时间。临界区越短,锁竞争越轻;共享数据越少,线程安全问题越容易控制。
3. 带返回值的并发任务
3.1 Runnable 的局限性

前 6 个练习主要使用 Runnable 或直接继承 Thread。这类方式可以启动线程执行任务,但 run() 方法的返回值类型是 void,无法自然地把子线程计算结果交还给主线程。
在练习 7 中,主线程需要比较两个抽奖箱各自抽到的最高奖项 。此时如果继续使用 Runnable,就必须额外设计共享变量、回调接口或其他结果容器,代码复杂度会明显上升。
核心 :当线程任务不仅要执行过程,还要向主线程返回计算结果时,应使用
Callable<V>表示任务,并通过FutureTask<V>管理异步执行结果。

3.2 LotteryCallable
该任务类实现 Callable<Integer>,表示每个抽奖线程最终会返回一个 Integer 类型的最高奖项。
java
package text7;
import java.util.ArrayList;
import java.util.Collections;
import java.util.List;
import java.util.concurrent.Callable;
/**
* 抽奖任务类(基于 Callable 实现)
* 支持多线程并发抽奖,并在结束后返回本线程抽到的最高奖金
*/
public class LotteryCallable implements Callable<Integer> {
// 所有线程共享的初始奖池
private final List<Integer> prizePool;
// 通过构造方法将外部初始化好的奖池传进来
public LotteryCallable(List<Integer> prizePool) {
this.prizePool = prizePool;
}
@Override
public Integer call() throws Exception {
// 【栈封闭优化】局部变量:当前线程专属的抽奖记录,天然线程安全
List<Integer> boxList = new ArrayList<>();
// ================== 阶段一:并发抢夺奖项 ==================
while (true) {
synchronized (prizePool) {
// 边界控制:奖池抽空则结束抢夺
if (prizePool.isEmpty()) {
break;
}
// 【性能优化】前置打乱策略:主程序已提前洗牌,此处直接 O(1) 提取首位元素
int prize = prizePool.remove(0);
boxList.add(prize);
}
// 【锁粒度优化】休眠置于锁外:模拟耗时,同时强制出让 CPU 调度权,避免单线程霸占锁
Thread.sleep(10);
}
// ================== 阶段二:锁外局部统计 ==================
// 此时奖池已空,多线程竞争结束,后续统计操作各自在栈内进行,无需加锁拖累性能
if (boxList.isEmpty()) {
return 0; // 兜底处理:若运气极差未抽中任何奖项
}
// 借助 Collections 工具类快速求最大值
int max = Collections.max(boxList);
// 统计该抽奖箱的总收益
int sum = 0;
for (int prize : boxList) {
sum += prize;
}
// ================== 阶段三:格式化输出 ==================
String name = Thread.currentThread().getName();
// 清洗 List 默认的 toString() 格式,满足题意要求的紧凑输出结构
String listStr = boxList.toString().replace("[", "").replace("]", "").replace(" ", "");
System.out.println("在此次抽奖过程中," + name + "总共产生了" + boxList.size() + "个奖项,分别为:"
+ listStr + " 最高奖项为" + max + "元,总计额为" + sum + "元");
// 将本抽奖箱的最大奖金交接给 FutureTask 供主线程汇总
return max;
}
}
该实现同时继承了练习 6 的关键优化思想:
- 共享奖池加锁 :只有
prizePool.remove(0)这一类共享集合操作需要进入同步块。- 局部集合栈封闭 :
boxList只属于当前执行call()的线程。- 锁外统计:最大值、总金额、格式化输出都不需要占用共享奖池锁。
- 返回最高奖项 :
return max将子线程结果交给FutureTask保存。
3.3 Demo
主线程通过两个 FutureTask<Integer> 分别管理两个线程的执行结果,随后使用 get() 阻塞获取最高奖项并完成比较。
java
package text7;
import java.util.ArrayList;
import java.util.Collections;
import java.util.List;
import java.util.concurrent.ExecutionException;
import java.util.concurrent.FutureTask;
/**
* 多线程抽奖启动与结果汇总类
*/
public class Demo {
public static void main(String[] args) {
// 1. 数据准备与预处理
List<Integer> prizePool = new ArrayList<>();
Collections.addAll(prizePool, 10, 5, 20, 50, 100, 200, 500, 800, 2, 80, 300, 700);
// 【性能优化】在单线程阶段完成洗牌,彻底避免在多线程同步块中反复 shuffle 带来的性能灾难
Collections.shuffle(prizePool);
// 2. 任务分发
// 创建唯一的任务实例(内部持有共享的 prizePool)
LotteryCallable task = new LotteryCallable(prizePool);
// 使用 FutureTask 包装 Callable,用于后续拦截并获取线程执行的异步结果
FutureTask<Integer> ft1 = new FutureTask<>(task);
FutureTask<Integer> ft2 = new FutureTask<>(task);
// 3. 线程启动
Thread t1 = new Thread(ft1, "抽奖箱1");
Thread t2 = new Thread(ft2, "抽奖箱2");
t1.start();
t2.start();
// 4. 阻塞聚合结果
try {
// 注意:get() 为阻塞调用,主线程会在此停顿,等待子线程计算完毕并返回 max 结果
int max1 = ft1.get();
int max2 = ft2.get();
// 5. 最终胜负判定与输出
System.out.println("==================================================");
if (max1 > max2) {
System.out.println("在此次抽奖过程中,抽奖箱1中产生了最大奖项,该奖项金额为" + max1 + "元");
} else if (max2 > max1) {
System.out.println("在此次抽奖过程中,抽奖箱2中产生了最大奖项,该奖项金额为" + max2 + "元");
} else {
System.out.println("在此次抽奖过程中,两个抽奖箱产生的最大奖项金额并列,均为" + max1 + "元");
}
} catch (InterruptedException | ExecutionException e) {
System.out.println("多线程执行异常:" + e.getMessage());
}
}
}

3.4 主线程阻塞汇总结果
练习 7 的核心执行链路可以拆解为以下步骤:
LotteryCallable定义真正的并发任务,并通过call()返回最高奖项。FutureTask<Integer>包装Callable,负责保存异步执行结果。Thread接收FutureTask并启动线程。- 主线程调用
ft1.get()与ft2.get(),等待两个线程结束。- 主线程比较
max1与max2,输出最终胜者。
总结 :Callable解决的是"任务有返回值"的表达问题;FutureTask解决的是"异步结果如何被主线程安全获取"的管理问题。二者配合使用,构成了 Java 基础并发模型中最直接的结果回收方案。
4. 总结
这 7 道多线程练习题形成了一条非常清晰的技术演进路线。
| 阶段 | 代表练习 | 核心能力 |
|---|---|---|
| 基础共享数据 | 练习 1:卖票 | 识别共享成员变量,使用唯一锁保护临界区 |
| 边界条件控制 | 练习 2:送礼品 | 在同步块中完成判断与修改,避免条件失效 |
| 状态推进 | 练习 3:打印奇数 | 保证共享计数器持续推进,避免逻辑停滞 |
| 金额精度 | 练习 4:抢红包 | 使用 BigDecimal 处理货币计算,避免浮点误差 |
| 集合并发 | 练习 5:抽奖箱 | 对共享集合的判断与删除操作进行互斥保护 |
| ==栈封闭优化 == | 练习 6:抽奖统计 | 用局部变量隔离线程私有数据,缩小锁范围 |
| 结果汇总 | 练习 7:Callable | 使用 Callable + FutureTask 返回并聚合子线程结果 |
从工程实践角度看,这些案例背后隐藏着几条非常重要的开发规范:
规范一:避免不必要的全局状态。 能放在局部变量中的数据,不要设计成
static或共享成员变量。共享范围越大,线程安全成本越高。
规范二:同步块只包裹真正的临界区。 判断共享状态、修改共享状态、删除共享集合元素需要加锁;休眠、统计、格式化、打印等耗时操作应尽量移出锁外。
规范三:货币计算必须重视精度模型。double适合科学计算或近似计算,不适合订单、红包、余额等精确金额场景。
规范四:线程私有数据优先使用栈封闭。 局部变量天然归属于当前线程的调用栈,只要引用不逃逸,就可以有效避免额外同步。
规范五:需要返回结果时,不要强行滥用 Runnable。Runnable适合无返回值任务;当主线程需要聚合子线程结果时,Callable与FutureTask更符合语义。