- 发布日期
Java多线程(五):抢红包、抽奖与线程结果比较实战
- 作者
- 姓名
- 缘
- 社交账号
文章目录
前言
前面几篇分别学习了线程创建、线程安全和线程协作。本篇进入综合练习:先模拟多名用户争抢有限红包,再让两个抽奖箱从同一个奖池中并发抽奖,最后统计每个线程的抽奖结果并比较最大奖项。
这些案例真正考查的不是随机数 API,而是能否回答三个问题:哪些数据被多个线程共享?哪一组操作必须保持原子性?线程执行完后,怎样把结果安全地交给汇总方?
一、抢红包案例的需求分析
假设红包总金额为 100 元,共 3 个红包,有 5 名用户同时抢:
- 每名用户只能尝试一次。
- 红包抢完后,后来的用户提示“没有抢到”。
- 每个红包至少 0.01 元。
- 非最后一个红包随机分配,但要给剩余红包保留最低金额。
- 最后一个红包获得剩余全部金额。
- 无论线程怎样调度,发出的总额不能超过 100 元。
共享状态包括“剩余金额”和“剩余个数”。一次抢红包包含检查个数、计算金额、扣减余额、扣减个数等多个步骤,这些步骤必须作为一个整体加锁。只锁住 money -= prize 还不够,因为多个线程可能基于同一个旧状态分别计算金额。
二、金额为什么不建议使用 double
课程入门案例常用 double 表示金额,便于集中学习线程知识。但 double 使用二进制浮点数,许多十进制小数无法被精确表示,连续运算后可能出现 0.30000000000000004 之类结果。
更稳妥的做法是使用“分”作为最小单位:100 元保存为 10000 分,1 分可以被整数精确表示。涉及复杂财务计算时也可以使用 BigDecimal,但本案例用整数分最清晰。
三、线程安全的抢红包实现
import java.util.concurrent.ThreadLocalRandom;
public class RedPacketTask implements Runnable {
private int remainingMoney = 10_000;
private int remainingCount = 3;
private static final int MIN_MONEY = 1;
@Override
public void run() {
int prize;
synchronized (this) {
if (remainingCount == 0) {
System.out.println(
Thread.currentThread().getName() + " 没有抢到红包"
);
return;
}
if (remainingCount == 1) {
prize = remainingMoney;
} else {
int maxAvailable = remainingMoney
- (remainingCount - 1) * MIN_MONEY;
prize = ThreadLocalRandom.current()
.nextInt(MIN_MONEY, maxAvailable + 1);
}
remainingMoney -= prize;
remainingCount--;
}
System.out.printf(
"%s 抢到了 %.2f 元%n",
Thread.currentThread().getName(),
prize / 100.0
);
}
}
测试类:
public class RedPacketDemo {
public static void main(String[] args) {
RedPacketTask task = new RedPacketTask();
for (int i = 1; i <= 5; i++) {
new Thread(task, "用户" + i).start();
}
}
}
五个线程共享同一个 RedPacketTask 实例,所以 remainingMoney 和 remainingCount 是共同状态,synchronized (this) 竞争的也是同一把锁。
四、随机金额上限是怎样计算的
这段代码是案例的核心:
int maxAvailable = remainingMoney
- (remainingCount - 1) * MIN_MONEY;
假设还剩 10000 分、3 个红包。当前线程不能把 10000 分全部拿走,必须给后面两个红包各留下至少 1 分。因此当前红包最大可取:
10000 - (3 - 1) × 1 = 9998 分
随机范围为 [1, 9998]。如果还剩 2 个红包和 500 分,则当前最多取 500 - 1 = 499 分。最后一个红包不再随机,直接拿走所有余额,这样可以保证总额恰好发完。
注意,这个算法只满足“金额合法”,并不保证红包金额分布符合现实平台使用的随机策略。它的教学重点是维护并发条件下的金额不变式。
五、为什么抢红包不需要循环
一个用户线程代表一次抢红包行为:抢到或没抢到后,任务都应该结束。如果在 run() 外层写无限循环,同一名用户可能连续获得多次机会,甚至把多个红包全部抢走。
是否需要循环,取决于线程所代表的业务任务:
- 抢红包:每个用户只尝试一次,不循环。
- 售票窗口:一个窗口持续卖票,直到票卖完,需要循环。
- 抽奖箱:一个箱子持续从奖池抽奖,直到奖池为空,需要循环。
不要因为“多线程案例常见 while”就机械地添加循环,先明确一个线程在业务上代表什么。
六、抽奖箱案例的需求分析
现在有一个奖池:
10、5、20、50、100、200、500、800、2、80、300、700
创建“抽奖箱1”和“抽奖箱2”两个线程。每个线程不断随机抽取一个奖项,奖项一旦被抽走就不能再次出现,直到奖池为空。
共享数据是奖池。一次抽奖至少包括:判断是否为空、随机选择、移除奖项。这些步骤必须在同一个临界区中完成。如果只对 remove() 加锁,两个线程仍可能基于同一个大小生成下标,或者一个线程判断非空后,另一个线程抢先取走最后一个元素,前者随后发生越界。
每个抽奖箱自己的结果列表只由所属线程写入,可以保存在任务对象内部,不必与另一个线程共用静态列表。
七、实现并发抽奖
import java.util.ArrayList;
import java.util.List;
import java.util.concurrent.Callable;
import java.util.concurrent.ThreadLocalRandom;
public class LotteryTask implements Callable<LotteryResult> {
private final List<Integer> prizePool;
public LotteryTask(List<Integer> prizePool) {
this.prizePool = prizePool;
}
@Override
public LotteryResult call() {
List<Integer> prizes = new ArrayList<>();
while (true) {
Integer prize;
synchronized (prizePool) {
if (prizePool.isEmpty()) {
break;
}
int index = ThreadLocalRandom.current()
.nextInt(prizePool.size());
prize = prizePool.remove(index);
}
prizes.add(prize);
System.out.println(
Thread.currentThread().getName()
+ " 产生了一个 " + prize + " 元大奖"
);
try {
Thread.sleep(10);
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
break;
}
}
return LotteryResult.from(
Thread.currentThread().getName(), prizes
);
}
}
锁内只保留共享奖池的检查和移除。把打印、结果统计、短暂休眠放在锁外,可以减少持锁时间。
这里没有必要先 Collections.shuffle() 再移除第一个元素。直接生成合法随机下标并移除,意图更清楚,也避免每抽一次就重新打乱整个列表。不过如果学习目标正是练习 Collections.shuffle(),也可以在锁内执行“打乱后移除第一个”,线程安全边界不变。
八、使用结果对象统计数量、总额和最大值
定义一个不可变结果对象:
import java.util.List;
public record LotteryResult(
String boxName,
List<Integer> prizes,
int total,
int max
) {
public LotteryResult {
prizes = List.copyOf(prizes);
}
public static LotteryResult from(
String boxName, List<Integer> prizes) {
int total = prizes.stream()
.mapToInt(Integer::intValue)
.sum();
int max = prizes.stream()
.mapToInt(Integer::intValue)
.max()
.orElse(0);
return new LotteryResult(boxName, prizes, total, max);
}
public void printSummary() {
System.out.printf(
"%s 共抽到 %d 个奖项,分别为 %s,"
+ "最高奖项为 %d 元,总额为 %d 元%n",
boxName, prizes.size(), prizes, max, total
);
}
}
使用 List.copyOf() 生成不可修改的列表副本,避免任务结束后外部代码继续改变统计结果。orElse(0) 处理某个线程一个奖项都没抽到的情况,否则直接调用 max().getAsInt() 可能抛出异常。
如果你的 JDK 版本低于 16,不能使用 record,可以改写为普通类,并提供构造方法和 getter。并发设计不受影响。
九、FutureTask 获取两个线程的结果
import java.util.ArrayList;
import java.util.Collections;
import java.util.List;
import java.util.concurrent.ExecutionException;
import java.util.concurrent.FutureTask;
public class LotteryDemo {
public static void main(String[] args)
throws ExecutionException, InterruptedException {
List<Integer> pool = new ArrayList<>();
Collections.addAll(
pool, 10, 5, 20, 50, 100, 200,
500, 800, 2, 80, 300, 700
);
FutureTask<LotteryResult> future1 =
new FutureTask<>(new LotteryTask(pool));
FutureTask<LotteryResult> future2 =
new FutureTask<>(new LotteryTask(pool));
Thread box1 = new Thread(future1, "抽奖箱1");
Thread box2 = new Thread(future2, "抽奖箱2");
box1.start();
box2.start();
LotteryResult result1 = future1.get();
LotteryResult result2 = future2.get();
result1.printSummary();
result2.printSummary();
LotteryResult winner = result1.max() >= result2.max()
? result1 : result2;
System.out.printf(
"%s 产生了本次最大奖项:%d 元%n",
winner.boxName(), winner.max()
);
}
}
两个 LotteryTask 对象各自保存自己的结果,但都引用同一个 pool,因此对奖池加锁时能竞争同一个锁。主线程调用两个 get() 等待结果,最后统一打印统计并进行比较。
每次运行时,两个抽奖箱拿到的奖项数量和内容可能不同,这是并发调度和随机选择共同造成的。我们真正要保证的是:
- 每个奖项最多被抽一次。
- 两个结果列表合并后恰好包含原奖池全部奖项。
- 两个结果总额之和等于原奖池总额。
- 汇总发生在两个任务都结束以后。
十、课程写法与改进写法的关系
学习视频时,跟着课程使用继承 Thread、静态共享列表、Collections.shuffle() 等写法,可以集中练习当节知识。把案例整理成可复用代码时,还可以做几项改进:
- 任务实现
Callable,通过返回值交付统计结果,不依赖外部读取线程内部变量。 - 共享奖池由构造方法显式传入,不隐藏在静态字段中。
- 每个线程的抽奖结果使用局部列表,减少共享状态。
- 锁住奖池对象本身,直接表达“保护的是这份奖池”。
- 用随机下标取代每次打乱整个列表。
- 正确响应中断,不把中断简单包装成无限继续运行的异常。
这些调整没有改变课程要训练的核心:识别共享数据、保证复合操作原子性、收集线程结果并进行跨线程比较。
十一、可以继续验证的测试点
只观察一两次控制台输出并不足以证明线程安全。可以把案例重复执行许多次,并在每次结束后验证:
int expectedTotal = List.of(
10, 5, 20, 50, 100, 200,
500, 800, 2, 80, 300, 700
).stream().mapToInt(Integer::intValue).sum();
int actualTotal = result1.total() + result2.total();
if (actualTotal != expectedTotal) {
throw new AssertionError("奖池总额不一致");
}
还可以把两个结果列表合并后排序,与原奖池排序结果逐项比较。测试不能穷尽所有线程交错方式,但明确的不变式断言远比“看起来输出正常”更可靠。
十二、本篇小结
抢红包案例要求把“检查剩余个数、计算金额、扣减余额和个数”作为一个原子操作;金额最好用整数分保存,避免浮点误差。抽奖案例要求把“检查奖池、随机选择、移除奖项”放进同一临界区,而每个线程自己的统计列表应尽量保持私有。
当线程需要向主线程返回结果时,Callable、FutureTask 和结果对象的组合比依赖静态变量更清楚。并发程序的正确性也不应只靠输出顺序判断,而应围绕“总额不变、奖项不重复、结果完整”等不变式验证。
下一篇将进入本系列最后一部分:线程池。我们会分析为什么不应为每个短任务都创建新线程,以及自定义 ThreadPoolExecutor 的七个核心参数和任务处理流程。