缘博客
发布日期

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 实例,所以 remainingMoneyremainingCount 是共同状态,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() 等写法,可以集中练习当节知识。把案例整理成可复用代码时,还可以做几项改进:

  1. 任务实现 Callable,通过返回值交付统计结果,不依赖外部读取线程内部变量。
  2. 共享奖池由构造方法显式传入,不隐藏在静态字段中。
  3. 每个线程的抽奖结果使用局部列表,减少共享状态。
  4. 锁住奖池对象本身,直接表达“保护的是这份奖池”。
  5. 用随机下标取代每次打乱整个列表。
  6. 正确响应中断,不把中断简单包装成无限继续运行的异常。

这些调整没有改变课程要训练的核心:识别共享数据、保证复合操作原子性、收集线程结果并进行跨线程比较。

十一、可以继续验证的测试点

只观察一两次控制台输出并不足以证明线程安全。可以把案例重复执行许多次,并在每次结束后验证:

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("奖池总额不一致");
}

还可以把两个结果列表合并后排序,与原奖池排序结果逐项比较。测试不能穷尽所有线程交错方式,但明确的不变式断言远比“看起来输出正常”更可靠。

十二、本篇小结

抢红包案例要求把“检查剩余个数、计算金额、扣减余额和个数”作为一个原子操作;金额最好用整数分保存,避免浮点误差。抽奖案例要求把“检查奖池、随机选择、移除奖项”放进同一临界区,而每个线程自己的统计列表应尽量保持私有。

当线程需要向主线程返回结果时,CallableFutureTask 和结果对象的组合比依赖静态变量更清楚。并发程序的正确性也不应只靠输出顺序判断,而应围绕“总额不变、奖项不重复、结果完整”等不变式验证。

下一篇将进入本系列最后一部分:线程池。我们会分析为什么不应为每个短任务都创建新线程,以及自定义 ThreadPoolExecutor 的七个核心参数和任务处理流程。

参考资料