现实中有很多多线程应用场景,我们以售票和银行转账为例,来完成场景的简单模拟。
1 售票
-
应用场景:假定当前山东泰山站有5个售票窗口开放,每个售票窗口有1名售票员,从山东泰安到北京的普快车票还有100张;旅客可能是一个人,也可能是几个人一起,那么买票的时候可能买多张票,这里限定1名旅客最多买5张票。
-
分析:5个售票窗口(5名售票员)共享这100张车票,我们把售票窗口建模为这100张票的容器。旅客和售票员用多线程简单模拟自动买票和卖票。旅客购买的票数作为参数传入卖票方法。建模如下,
- 车票:数字代表,作为窗口类的属性
- 窗口类:
- 属性:剩余车票数
- 方法:sell(int amount),售票
- 售票员,旅客:用多线程简单模拟
-
初级实现:
-
窗口类代码1-1如下:
-
public class TicketWindow { private int count; public TicketWindow(int count) { this.count = count; } /** * 获取剩余票数 * @return 剩余票数 */ public int getCount() { return count; } /** * 售票 * @param amount 售票数 * @return 实际售票数 */ public int sell(int amount) { if (count >= amount) { count -= amount; return amount; } else { return 0; } } }
-
测试类:如何验证多线程环境下的安全性呢?如果剩余票数加上卖出票数等于初始票数,那么可以验证是多线程安全的;否则就是多线程不安全的,代码如下
import java.util.ArrayList; import java.util.List; import java.util.Random; import java.util.Vector; /** * @author Administrator * @version 1.0 * @description 售票测试 * @date 2022-10-14 10:30 */ @Slf4j(topic = "c.TestSellTicket") public class TestSellTicket { public static void main(String[] args) throws InterruptedException { // 卖票窗口 TicketWindow ticketWindow = new TicketWindow(100); // 售票员线程列表 int initial = 2000; List<Thread> seller = new ArrayList<>(initial); // 每次销售的票数列表 List<Integer> sellTickets = new Vector<>(); for (int i = 0; i < initial; i++) { Thread t = new Thread(() -> { int amount = ticketWindow.sell(randomAmount() ); try { Thread.sleep(randomAmount() * 100); } catch (InterruptedException e) { e.printStackTrace(); } sellTickets.add(amount); }, "售票员" + i); seller.add(t); t.start(); } for (Thread thread : seller) { // thread.start(); thread.join(); } // 通过销售票数和剩余票数检查是否有问题 int remainders = ticketWindow.getCount(); log.debug("余票:{}", remainders); int sells = sellTickets.stream().mapToInt(i -> i).sum(); log.debug("卖出票数:{}", sells); } /** * Random线程安全 */ static Random random = new Random(); /** * 随机票数1~5 * @return 票数 */ public static int randomAmount() { return random.nextInt(5) + 1; } }
-
分析有哪些共享变量 变量组合 是否是临界区需要保护:
-
线程run方法涉及3个变量,
- seller:售票员线程列表,此变量由主线程使用,所以不存在安全性问题
- sellTickets:类型为Vector为线程安全类
- ticketWindow:调用了给实例的sell方法,此方法改变状态count的值,没有进行并发访问控制,存在线程安全问题
-
临界区:很显然,就是TicketWindow的sell方法
-
要得到预期的结果,需要我们多次运行,一下一下点很麻烦,这里我们通过cmd命令执行脚本的方法来多次执行,脚本如下:
for /L %n in (1,1,10) do java -cp ".;你自己的maven仓库路径\ch\qos\logback\logback-classic\1.2.3\logback-classic-1.2.3.jar;你自己的maven仓库路径\ch\qos\logback\logback-core\1.2.3\logback-core-1.2.3.jar;你自己的maven仓库路径\org\slf4j\slf4j-api\1.7.25\slf4j-api-1.7.25.jar" 你自己的包路径.TestSellTicket
- jar包路径为引入的日志打印相关类路径,当前执行脚本的路径为target/classes路径,用的Idea
-
解决方案:根据目前学习的知识,就是在TicketWindow的sell方法上加锁,改造TicketWindow的sell方法,TicketWindow的其他代码不变,代码如下
/** * 售票 * @param amount 售票数 * @return 实际售票数 */ public synchronized int sell(int amount) { if (count >= amount) { count -= amount; return amount; } else { return 0; } }
在此测试数据没有问题。
2 转账
-
应用场景:简单模拟银行转账。
-
以最简单2个储户之间的转账为例,建模如下:
- 金额money:数字代表,作为账户类的属性
- 账户类Account:
- 属性:money金钱
- 方法:
- getMoney:获取账户金额
- setMoney(int amount):设置账户金额
- transfer(Account a, int amount):当前账户给账户a转账amount元
- 多次相互转账:用多线程简单模拟
-
初级实现,代码如下
-
账户类:
public class Account { private int money; public int getMoney() { return money; } public void setMoney(int money) { this.money = money; } /** * 转账 * @param a 转入账户 * @param amount 转入金额 */ public void transfer(Account a, int amount) { // 判断当前金额是否大于等于转账金额 if (money >= amount) { // 当前账户减去转账金额 money -= amount; // 转入账户加上转账金额 a.setMoney(a.getMoney() + amount); } } }
-
测试类:
-
import lombok.extern.slf4j.Slf4j; import java.util.Random; /** * @author Administrator * @version 1.0 * @description 测试 * @date 2022-10-15 11:25 */ @Slf4j(topic = "c.TestTransfer") public class TestTransfer { public static void main(String[] args) throws InterruptedException { Account a = new Account(1000); Account b = new Account(1000); Thread t1 = new Thread(() -> { for (int i = 0; i < 100; i++) { a.transfer(b, randomAmount()); } }); Thread t2 = new Thread(() -> { for (int i = 0; i < 100; i++) { b.transfer(a, randomAmount()); } }); t1.start(); t2.start(); t1.join(); t2.join(); // 转账200次后的总金额 log.debug("total:{}", (a.getMoney() + b.getMoney())); } /** * Random线程安全 */ static Random random = new Random(); /** * 随机1~100 * @return 1~100随机整数 */ public static int randomAmount() { return random.nextInt(100) + 1; } }
-
多次测试结果不确定,有时大于2000,有时小于2000;大于2000银行不愿意啊,小于2000储户不愿意,这肯定不行啊。
-
-
分析:问题出在transfer转账方法中,那么这里面有哪些共享变量呢?
-
当前账户的金额:this.money
-
转入账户的金额:a.money
-
那么我们直接给transfer方法加synchronized行不行呢;测试并不行,因为这相当于只给当前对象加锁,并没有给转入账户加锁
-
那么我们能不能先给当前加锁在给转入对象加锁呢?这容易引起死锁问题,后面讲解。
-
那么以我们目前学习的知识,怎么解决呢?寻找共同点,2个或者多个对象都是同一个类的对象,我们给Account.class对象加锁,代码如下
/** * 转账 * @param a 转入账户 * @param amount 转入金额 */ public void transfer(SafeAccount a, int amount) { synchronized (Account.class) { // 判断当前金额是否大于等于转账金额 if (money >= amount) { // 当前账户减去转账金额 money -= amount; // 转入账户加上转账金额 a.setMoney(a.getMoney() + amount); } } }
-
-
思考:一个银行有很多储户,如果给Account.class加锁,虽然保证了线程安全性,但是极大的降低效率,有没有更好的解决方案呢?
3 后记
❓QQ:806797785
⭐️源代码仓库地址:https://gitee.com/gaogzhen/concurrent
参考:
[1]黑马程序员.黑马程序员深入学习Java并发编程,JUC并发编程全套教程[CP/OL].2020-01-18/2022-10-02.p71~p74.