【java进阶】------ 多线程【实际案例分析】

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 中,doublefloat 都属于二进制浮点数,无法精确表示很多十进制小数。如果在红包、订单、支付等场景中直接使用 double 处理金额,就可能产生精度丢失。

注意 :涉及货币计算时,应优先使用 BigDecimal,并尽量使用字符串构造方式,例如 new BigDecimal("100.00")。直接使用 new BigDecimal(0.1) 仍然可能把二进制浮点误差带入高精度对象中。


1.4.1 RedPacketTask

该任务类中,红包总金额最小金额 均使用 BigDecimal 表示。多个用户线程共同争抢同一个红包任务对象,因此 totalMoneycount 都是共享数据,必须放入同步代码块中统一保护。

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 + "元");
            }
        }
    }
}

从并发角度看,该实现有三个核心控制点:

  • 共享数据totalMoneycount 同属于任务对象状态,多个线程会共同访问。
  • 唯一锁对象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 类中有一个成员变量 arun() 方法中还有一个局部变量 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 线程的栈中 :保存局部变量 t1t2。它们不是对象本身,而是两个引用,可以理解为指向对象的地址。
  • 堆内存中 :保存两个真正的 MyThread 对象。对象里的成员变量,比如 String nameint a = 1,都属于对象的一部分,因此也跟着对象放在堆中。

这里先得到一个关键结论:局部变量引用在栈里,对象本身通常在堆里。


2.2.1.3 第三步:setName() 修改的是堆中对象的成员数据

继续执行:

java 复制代码
t1.setName("线程1");
t2.setName("线程2");

t1t2 仍然是 main 栈帧中的两个引用;真正被修改的是堆中两个 MyThread 对象内部的 name 成员。

所以要分清楚:

  • t1t2main 方法里的局部变量,存放在 main 线程栈中。
  • namea 是线程对象的成员变量,跟着对象存放在堆中。

堆是所有线程都能访问的共享区域。如果多个线程拿到了同一个堆对象的引用,并同时修改该对象的成员变量,就可能产生线程安全问题。


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 集合呢?

重点剖析:

  1. 引用的位置: 集合的引用(指针)依然存储在各自线程的栈帧中。
  2. 对象的位置: 因为使用了 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(),等待两个线程结束。
  • 主线程比较 max1max2,输出最终胜者。
    总结Callable 解决的是"任务有返回值"的表达问题;FutureTask 解决的是"异步结果如何被主线程安全获取"的管理问题。二者配合使用,构成了 Java 基础并发模型中最直接的结果回收方案。

4. 总结

这 7 道多线程练习题形成了一条非常清晰的技术演进路线。

阶段 代表练习 核心能力
基础共享数据 练习 1:卖票 识别共享成员变量,使用唯一锁保护临界区
边界条件控制 练习 2:送礼品 在同步块中完成判断与修改,避免条件失效
状态推进 练习 3:打印奇数 保证共享计数器持续推进,避免逻辑停滞
金额精度 练习 4:抢红包 使用 BigDecimal 处理货币计算,避免浮点误差
集合并发 练习 5:抽奖箱 对共享集合的判断与删除操作进行互斥保护
==栈封闭优化 == 练习 6:抽奖统计 用局部变量隔离线程私有数据,缩小锁范围
结果汇总 练习 7:Callable 使用 Callable + FutureTask 返回并聚合子线程结果

从工程实践角度看,这些案例背后隐藏着几条非常重要的开发规范:

规范一:避免不必要的全局状态。 能放在局部变量中的数据,不要设计成 static 或共享成员变量。共享范围越大,线程安全成本越高。
规范二:同步块只包裹真正的临界区。 判断共享状态、修改共享状态、删除共享集合元素需要加锁;休眠、统计、格式化、打印等耗时操作应尽量移出锁外。
规范三:货币计算必须重视精度模型。 double 适合科学计算或近似计算,不适合订单、红包、余额等精确金额场景。
规范四:线程私有数据优先使用栈封闭。 局部变量天然归属于当前线程的调用栈,只要引用不逃逸,就可以有效避免额外同步。
规范五:需要返回结果时,不要强行滥用 Runnable。 Runnable 适合无返回值任务;当主线程需要聚合子线程结果时,CallableFutureTask 更符合语义。

相关推荐
用户298698530141 小时前
Java 中的 Word 变量管理:添加、统计、获取与删除
java·后端
郭龙_Jack1 小时前
Java 17 到 Java 25:LTS 升级的全面收益与迁移指南
java·开发语言·python
要开心吖ZSH1 小时前
Java AI Agent 开发中的 RAG 实现方案及小白入门指南
java·ai·agent·rag
掉鱼的猫1 小时前
Java 流程编排新范式 Solon Flow:一个引擎,七种节点,覆盖规则/任务/工作流/AI 编排全场景
java·workflow
Aaa111114431 小时前
四类地址 逻辑地址 线性地址 虚拟地址 物理地址
java
小则又沐风a1 小时前
深入了解进程概念 第二章
java·linux·服务器·前端
程序猿进阶2 小时前
OpenClaw Mac 安装教程
java·macos·ai·架构·agent·openclaw
凯瑟琳.奥古斯特2 小时前
信号分类与特性解析
java·开发语言·职场和发展
JAVA面经实录9172 小时前
JVM 性能监控 + 全链路分析实战 + 性能优化(完整版)
java·jvm