ToB企服应用市场:ToB评测及商务社交产业平台

标题: 多线程 05:线程同步,三大不安全案例分析,synchronized 和 Lock 的应用, [打印本页]

作者: 愛在花開的季節    时间: 2024-11-12 14:23
标题: 多线程 05:线程同步,三大不安全案例分析,synchronized 和 Lock 的应用,
一、概述

   纪录时间 [2024-11-11]
  前置知识:Java 基础篇;Java 面向对象
多线程 01:Java 多线程学习导航,线程简介,线程相干概念的整理
多线程 02:线程实现,创建线程的三种方式,通过多线程下载图片案例分析异同(Thread,Runnable,Callable)
多线程 03:知识增补,静态代理与 Lambda 表达式的相干先容,及其在多线程方面的应用
多线程 04:线程状态,线程的五大根本状态及状态转换,以及线程利用方法、优先级、守护线程的相干知识
Java 多线程学习主要模块包括:线程简介;线程实现;线程状态;线程同步;线程通讯问题;拓展高级主题。
本文讲述线程同步相干知识,包括线程同步机制,线程同步涉及的三大不安全案例(不安全买票 / 取款 / 集合),及这些案例的美满方法。同时,通过 synchronized(同步)和 Lock(锁),我们能解决多线程修改共享资源引起的访问冲突,实现线程同步。
此外,文章还先容了死锁的知识,如死锁产生的条件,死锁的案例,以及如何克制死锁等。

二、线程同步机制

1. 相干概念

并发(Concurrency)是指计算机系统中多个使命在同一时间段内交织实行的能力。固然这些使命大概不是真正同时举行的(即并行,Parallelism),但从宏观上看,它们似乎是在同一时间实行的。并发编程的目标是提高程序的服从和响应性,尤其是在处理 I/O 密集型使命或多用户环境时。
多线程是实现并发的一种常见方式。每个历程可以包含多个线程,这些线程共享历程的资源(如内存地址空间),但每个线程有自己的栈和程序计数器。在带来方便的同时,也带来了访问冲突的问题。
线程同步是多线程编程中的一个重要概念,它主要用于确保多个线程在访问共享资源(如变量或数据结构)时不会发生冲突。
简言之,多个线程操作同一个资源
如果多个线程同时修改同一个资源而没有适当的同步机制,大概会导致数据损坏或其他不可预测的举动。
队列和锁是多线程编程中非常重要的同步机制。联合利用队列和锁可以有用地管理和协调多个线程之间的使命分配和资源共享,提高程序的可靠性和性能。
在 Java 中,为了保证数据在方法中被访问时的正确性,在访问时加入锁机制(Synchronized),当一个线程获取了某个对象的锁之后,它就拥有了对该资源的独占访问权,此时其他试图访问同一资源的线程将不得不等候。一旦该线程完成了对资源的操作并释放了锁,其他等候中的线程就可以继承尝试获取锁以访问资源。
需要注意的是,一个线程持有锁会导致其他所有需要此锁的线程挂起;且加锁 / 释放锁会导致比力多的上下文切换和调度延时,引起性能问题;如果优先级高的线程等候优先级低的线程释放锁,会导致性能倒置。属于是牺牲部门性能来保证安全性

2. 举例说明

在现实生存中,多个线程同时操作同一个资源的例子有许多。
比方,抢票时,当后台仅剩 1 张票时,所有用户都能看到这张票。如果不对接入的抢票举动举行有用控制,每个用户都大概同时尝试抢票,从而导致超售现象。这种环境不仅会影响用户体验,还会给系统带来安全隐患。
在银行取款的场景中,当账户余额只剩下最后一笔资金时,所有持有账户的客户都能看到这笔余额。如果不对接入的取款哀求举行有用的控制,每个客户都大概同时尝试取款,从而导致账户余额不敷的问题。这种环境不仅会影响客户的体验,还大概引发金融风险和系统的不稳定性。
为了防止这种环境发生,银行系统通常会接纳各种同步机制来确保每次取款操作的原子性和同等性。比方,利用互斥锁来保证在同一时间内只有一个取款哀求能够访问账户余额,别的用户会进入等候队列,有序排队以免引起混乱。
抢票系统亦然。

三、不安全案例

下面例举三个多线程的并发问题案例,这些问题都指出了——线程同步机制的重要性。
分别是不安全买票、不安全取钱,以及不安全集合。
在多线程环境下,多个线程共享历程的资源,同时每个线程在自己的工作内存交互。它们会把历程共享的信息复制一份到自己的工作内存,然后处理这些东西,大概同时对历程的资源举行修改,从而导致了数据不同等问题。

1. 不安全买票

多线程抢票中存在的并发问题主要包括以下几点:


通过编写测试代码来更好地理解。
买票操作类


  1. // 1. 买票操作类
  2. class BuyTicket implements Runnable {
  3.     // 2. 设置票数,私有属性安全
  4.     private int ticketNums = 10;
  5.     // 4. 设置线程停止标志位
  6.     private boolean flag = true;
  7.     @Override
  8.     public void run() {
  9.         // 7. 多线程买票的入口
  10.         while (flag) {
  11.             try {
  12.                 buy();
  13.             } catch (InterruptedException e) {
  14.                 e.printStackTrace();
  15.             }
  16.         }
  17.     }
  18.     // 3. 具体的买票操作逻辑
  19.     private void buy() throws InterruptedException {
  20.         // 5. 如果票卖完了就结束
  21.         if (ticketNums <= 0) {
  22.             flag = false;
  23.             return;
  24.         }
  25.         // 8. 设置延时,增加问题的发生性
  26.         Thread.sleep(100);
  27.         // 6. 有票就卖
  28.         System.out.println(Thread.currentThread().getName() + "买到了票" + ticketNums--);
  29.     }
  30. }
复制代码

多线程启动类


  1. /*
  2.     不安全的买票
  3.     线程不安全,可能有负数,有拿到同一张票的情况
  4.     每个线程在自己的工作内存交互,内存控制不当会造成数据不一致
  5. */
  6. public class UnsafeBuyTicket {
  7.     public static void main(String[] args) {
  8.         // 9. 创建三类多线程抢票用户
  9.         BuyTicket station = new BuyTicket();
  10.         new Thread(station,"小明").start();
  11.         new Thread(station,"元元").start();
  12.         new Thread(station,"黄牛党").start();
  13.     }
  14. }
复制代码

测试结果

从他们的抢票结果中不难发现,有买到重复票的,也有买到不正常的票的,此时的抢票程序是不安全的。
  1. # 不安全,结果不唯一
  2. 元元买到了票10
  3. 黄牛党买到了票9
  4. 小明买到了票10
  5. 黄牛党买到了票8
  6. 元元买到了票6
  7. 小明买到了票7
  8. 小明买到了票5
  9. 黄牛党买到了票3
  10. 元元买到了票4
  11. 元元买到了票2
  12. 黄牛党买到了票1
  13. 小明买到了票0
复制代码

2. 不安全取钱

在多线程环境中,不安全取钱的问题主要源于并发访问和修改共享资源(如银行账户余额)时缺乏适当的同步机制。这些问题大概导致数据不同等、资金丢失或重复扣款等严峻结果。
比方,一个线程正在更新账户余额,而另一个线程在此期间读取了旧的余额值,并基于这个旧值举行进一步的操作。

通过编写测试代码来更好地理解。
银行账户类

银行账户中包含了账户名、卡内余额。
  1. // 1. 银行账户
  2. class Account {
  3.     // 卡里余额
  4.     private int money;
  5.     // 卡名
  6.     private String name;
  7.     public Account(int money, String name) {
  8.         this.money = money;
  9.         this.name = name;
  10.     }
  11.     public int getMoney() {
  12.         return money;
  13.     }
  14.     public void setMoney(int money) {
  15.         this.money = money;
  16.     }
  17.     public String getName() {
  18.         return name;
  19.     }
  20.     public void setName(String name) {
  21.         this.name = name;
  22.     }
  23. }
复制代码

取钱业务类

银行取钱业务类,美满取款逻辑。

  1. // 2. 银行取钱业务
  2. class Drawing extends Thread {
  3.     // 银行账户
  4.     private Account account;
  5.     // 待取金额
  6.     private int drawMoney;
  7.     // 手里的金额
  8.     private int nowMoney;
  9.     public Drawing(Account account, int drawMoney, String name) {
  10.         // 调用父类的构造函数,传入子类的名字
  11.         // 而这里的父类是线程 Thread 类,所以获取的线程名就是子类传入的名字
  12.         // getName() 是父类 Thread 的方法,子类使用父类的方法
  13.         // this.getName() == Thread.currentThread().getName(),表示线程名
  14.         super(name);
  15.         this.account = account;
  16.         this.drawMoney = drawMoney;
  17.     }
  18.     // 3. 取钱的逻辑,多线程取钱的入口
  19.     @Override
  20.     public void run() {
  21.         // 判断余额
  22.         if (account.getMoney() - drawMoney < 0) {
  23.             // this.getName() == Thread.currentThread().getName(),表示线程名
  24.             System.out.println(Thread.currentThread().getName() + "取款失败,余额不足");
  25.             return;
  26.         }
  27.         // 延时操作
  28.         try {
  29.             Thread.sleep(1000);
  30.         } catch (InterruptedException e) {
  31.             e.printStackTrace();
  32.         }
  33.         // 取钱
  34.         // 账户余额
  35.         account.setMoney(account.getMoney() - this.drawMoney);
  36.         // 手里的金额
  37.         this.nowMoney = this.nowMoney + this.drawMoney;
  38.         System.out.println(this.getName() + "取了" + this.drawMoney);
  39.         System.out.println(Thread.currentThread().getName() + "手里有" + this.nowMoney);
  40.         System.out.println(this.account.getName() + "账户余额为" + this.account.getMoney());
  41.     }
  42. }
复制代码

多线程启动类

模拟多个用户同时同账户取款。如你和委托人同时取出一张卡里的余额。
  1. /*
  2.     不安全的取钱
  3.     两个人对同一个银行账户取钱
  4. */
  5. public class UnsafeBank {
  6.     // 4. 两个用户对同一个账户取钱
  7.     public static void main(String[] args) {
  8.         Account account = new Account(100, "基础账户");
  9.         Drawing you = new Drawing(account, 50, "你");
  10.         Drawing another = new Drawing(account, 100, "委托人");
  11.         you.start();
  12.         another.start();
  13.     }
  14. }
复制代码

测试结果

观察取款后的到手金额、账户余额,不难发现金额总数对不上,是不安全的。
  1. # 不安全,结果不唯一
  2. 委托人取了100
  3. 委托人手里有100
  4. 你取了50
  5. 基础账户账户余额为50
  6. 你手里有50
  7. 基础账户账户余额为50
复制代码

3. 不安全集合

在多线程环境中,线程不安全的集合类大概会导致各种并发问题,如数据不同等、死锁、竞态条件等。
比方,启动 10000 个线程,每个线程尝试将自身的名称存入某个集合中,但终极集合中的内容数目少于 10000 个。原因在于多个线程同时运行时,存在重复写入的环境,把内容添加到了同一个位置,导致部门线程的写入操作被覆盖或忽略。
  1. /*
  2.     线程不安全的集合
  3.     启动 10000 个线程,往某个集合中存入线程名称,实际集合内容小于 10000 个
  4.     原因:线程同时运行,存在重复写入的情况
  5. */
  6. public class UnsafeList {
  7.     public static void main(String[] args) throws InterruptedException {
  8.         // 1. 新建集合
  9.         List<String> list = new ArrayList<String>();
  10.         // 2. 启动 1000 个线程
  11.         for (int i = 0; i < 10000; i++) {
  12.             new Thread(()->{
  13.                 // 往某个集合中存入线程名称,追加数据
  14.                 list.add(Thread.currentThread().getName());
  15.             }).start();
  16.         }
  17.         // 延时
  18.         Thread.sleep(5000);
  19.         // 3. 计算集合实际容量
  20.         System.out.println(list.size());
  21.     }
  22. }
复制代码

四、Synchronized(同步)

1. 相干概念

synchronized 是 Java 中用于线程同步的关键字。它提供了一种简朴且有用的方法来确保多个线程在访问共享资源时不会发生冲突。
synchronized 关键字可以用于方法或代码块,确保在同一时间只有一个线程可以实行被标志为 synchronized 的代码段。
被 synchronized 声名为同步方法 / 同步代码块后,每个对象有一把锁,当一个线程获取了某个对象的锁之后,它就拥有了对该资源的独占访问权,此时其他试图访问同一资源的线程将不得不等候(阻塞)。一旦该线程完成了对资源的操作并释放了锁,其他等候中的线程就可以继承尝试获取锁以访问资源。
相当于原来是一窝蜂上去抢,用了 synchronized 就要排队,等上一个人用完了才能用。
注意,方法中需要修改的内容才需要锁,只读部门不用。锁太多了会造成资源浪费。

2. 利用方式


  1. // 同步方法默认锁的是 this,就是这个对象本身,或者是 class
  2. private synchronized void buy() {}
复制代码

  1. synchronized (obj) {
  2.     // obj 同步监视器,代表需要被监视的对象,推荐使用共享资源
  3.     // 中间包裹修改资源的代码(不安全的代码)
  4. }
复制代码
同步监视器(obj)的实行过程:

3. 美满不安全案例

接下来,我们通过 synchronized 同步来美满一下上面的三大不安全案例。
安全买票

将买票方法 buy() 变成同步方法即可。
  1. /*
  2.     synchronized 同步方法
  3.     同步方法无需指定同步监视器
  4.     同步方法的同步监视器是 this,就是这个对象本身,或者是 class
  5. */
  6. private synchronized void buy() throws InterruptedException {
  7.     // 5. 如果票卖完了就结束
  8.     if (ticketNums <= 0) {
  9.         flag = false;
  10.         return;
  11.     }
  12.     // 8. 设置延时,增加问题的发生性
  13.     Thread.sleep(100);
  14.     // 6. 有票就卖
  15.     System.out.println(Thread.currentThread().getName() + "买到了票" + ticketNums--);
  16. }
复制代码

安全的环境下的出票结果:
  1. # 安全的情况(不唯一)
  2. # 出票结果是正常的
  3. 元元买到了票10
  4. 元元买到了票9
  5. 小明买到了票8
  6. 小明买到了票7
  7. 小明买到了票6
  8. 黄牛党买到了票5
  9. 黄牛党买到了票4
  10. 小明买到了票3
  11. 小明买到了票2
  12. 小明买到了票1
复制代码

安全取款


  1. /*
  2.     synchronized (obj)
  3.     锁的对象默认是本身 synchronized (this)
  4.     实际需要锁的是变化的对象,需要增删改操作的对象
  5.     obj 可以是任何对象,推荐使用共享资源作为同步监视器
  6.     所以这里我们需要锁住账户,而不是银行
  7. */
  8. // 3. 取钱的逻辑,多线程取钱的入口
  9.     @Override
  10.     public void run() {
  11.         synchronized (account) {
  12.             // 判断余额
  13.             if (account.getMoney() - drawMoney < 0) {
  14.                 // this.getName() == Thread.currentThread().getName(),表示线程名
  15.                 System.out.println(Thread.currentThread().getName() + "取款失败,余额不足");
  16.                 return;
  17.             }
  18.             // 延时操作
  19.             try {
  20.                 Thread.sleep(1000);
  21.             } catch (InterruptedException e) {
  22.                 e.printStackTrace();
  23.             }
  24.             // 取钱
  25.             // 账户余额
  26.             account.setMoney(account.getMoney() - this.drawMoney);
  27.             // 手里的金额
  28.             this.nowMoney = this.nowMoney + this.drawMoney;
  29.             System.out.println(this.getName() + "取了" + this.drawMoney);
  30. //            System.out.println(Thread.currentThread().getName() + "手里有" + this.nowMoney);
  31.             System.out.println(this.account.getName() + "账户余额为" + this.account.getMoney());
  32.         }
  33.     }
复制代码

安全集合


  1. new Thread(()->{
  2.     synchronized (list) {
  3.         // 往某个集合中存入线程名称,追加数据
  4.         list.add(Thread.currentThread().getName());
  5.     }
  6. }).start();
复制代码

固然,Java 中提供了另一种方式(JUC),来确保多线程操作集合的安全性。
需要用到 Java 的并发包,前面线程创建利用的 Callable 接口也用到了这个包。
编写代码,测试 JUC 安全类型的集合。
  1. import java.util.concurrent.CopyOnWriteArrayList;
  2. // 测试 JUC 安全类型的集合
  3. public class TestJUC {
  4.     public static void main(String[] args) throws InterruptedException {
  5.         CopyOnWriteArrayList<String> list = new CopyOnWriteArrayList<String>();
  6.         for (int i = 0; i < 10000; i++) {
  7.             new Thread(()->{
  8.                 list.add(Thread.currentThread().getName());
  9.             }).start();
  10.         }
  11.         Thread.sleep(3000);
  12.         System.out.println(list.size());
  13.     }
  14. }
复制代码

五、Lock(锁)

从 JDK 5.0 开始,Java 提供了更强大的线程同步机制——通过显式定义同步锁对象来实现同步。同步锁利用 Lock 对象充当。
Lock 是 Java 中 java.util.concurrent.locks 包提供的一个接口,用于实现更灵活和高级的锁机制,控制多个线程对共享资源举行访问。锁提供了对共享资源的独占访问,每次只能有一个线程对 Lock 对象加锁,线程开始访问共享资源之前,应先得到 Lock 对象。
与传统的 synchronized 关键字相比,Lock 接口提供了更多的功能和更好的性能特性。以下是 Lock 接口的主要特性和利用方法。

1. Lock 接口的主要特性



2. 常用的 Lock 实现


利用方法

定义 Look 锁,加锁和解锁。
需要用到 try-catch-finally 代码块。
  1. // 定义 look 锁
  2. private final ReentrantLock lock = new ReentrantLock();
  3. try {
  4.     // 加锁
  5.     lock.lock();
  6.     {
  7.         // 不安全代码块
  8.     }
  9. } finally {
  10.     // 解锁
  11.     lock.unlock();
  12. }
复制代码

3. 案例分析

给不安全的买票案例加上 Lock 锁,实现线程同步。
  1. public class TestLock {
  2.     public static void main(String[] args) {
  3.         // 注意要给同一个对象创建多线程,不同对象用的资源可能不是同一份
  4.         TestLock2 testLock2 = new TestLock2();
  5.         for (int i = 0; i < 3; i++) {
  6.             new Thread(testLock2).start();
  7.         }
  8.     }
  9. }
  10. // 依旧是买票的例子
  11. class TestLock2 implements Runnable {
  12.     int ticketNums = 10;
  13.     // 定义 look 锁
  14.     private final ReentrantLock lock = new ReentrantLock();
  15.     @Override
  16.     public void run() {
  17.         while (true) {
  18.             try {
  19.                 // 加锁
  20.                 lock.lock();
  21.                 if (ticketNums > 0) {
  22.                     System.out.println(ticketNums--);
  23.                     Thread.sleep(1000);
  24.                 } else {
  25.                     break;
  26.                 }
  27.             } catch (InterruptedException e) {
  28.                 e.printStackTrace();
  29.             } finally {
  30.                 // 解锁
  31.                 lock.unlock();
  32.             }
  33.         }
  34.     }
  35. }
复制代码

4. Synchronized / Lock



六、死锁

死锁(Deadlock)是指两个或多个线程在实行过程中由于竞争资源而造成的一种僵局,这些线程都在等候对方释放资源,结果导致所有涉及的线程都无法继承实行。
死锁是多线程编程中常见的问题之一,严峻影响程序的性能和可靠性。
1. 四个必要条件

发存亡锁需要同时满意四个条件,即死锁发生的四个必要条件


2. 案例分析

某一个同步块同时拥有两个以上对象的锁时,就有大概发存亡锁问题。
比方,模拟一下化妆过程,假设有两个线程灰姑凉和白雪公主,分别需要访问两个资源口红和镜子

  1. // 死锁:多个线程抱着对方需要的资源,然后形成僵持
  2. public class DeadlockExample2 {
  3.     public static void main(String[] args) {
  4.         Makeup girl1 = new Makeup(0, "灰姑凉");
  5.         Makeup girl2 = new Makeup(1, "白雪公主");
  6.         girl1.start();
  7.         girl2.start();
  8.     }
  9. }
  10. // 1. 口红类
  11. class Lipstick {
  12. }
  13. // 2. 镜子类
  14. class Mirror {
  15. }
  16. // 3. 化妆类
  17. class Makeup extends Thread {
  18.     // 资源只有一份,用 static 来保证只有一份
  19.     static Lipstick lipstick = new Lipstick();
  20.     static Mirror mirror = new Mirror();
  21.     // 选择
  22.     int choice;
  23. //    String girlName;
  24.     Makeup(int choice, String girlName) {
  25.         // 使用化妆品的人
  26.         super(girlName);
  27.         this.choice = choice;
  28.     }
  29.     @Override
  30.     public void run() {
  31.         try {
  32.             makeup();
  33.         } catch (InterruptedException e) {
  34.             e.printStackTrace();
  35.         }
  36.     }
  37.     // 化妆方法,互相持有对方的锁,就是要拿到对方的资源
  38.     void makeup() throws InterruptedException {
  39.         if (choice == 0) {
  40.             synchronized (lipstick) {
  41.                 System.out.println(this.getName() + "获得了口红的锁");
  42.                 Thread.sleep(1000);
  43.                 System.out.println(this.getName() + "等待镜子的锁......");
  44.                 synchronized (mirror) {
  45.                     System.out.println(this.getName() + "获得了镜子的锁");
  46.                 }
  47.             }
  48.         } else {
  49.             synchronized (mirror) {
  50.                 System.out.println(this.getName() + "获得了镜子的锁");
  51.                 Thread.sleep(2000);
  52.                 System.out.println(this.getName() + "等待口红的锁......");
  53.                 synchronized (lipstick) {
  54.                     System.out.println(this.getName() + "获得了口红的锁");
  55.                 }
  56.             }
  57.         }
  58.     }
  59. }
复制代码

观察测试结果:

  1. 灰姑凉获得了口红的锁
  2. 白雪公主获得了镜子的锁
  3. 灰姑凉等待镜子的锁......
  4. 白雪公主等待口红的锁......
复制代码

3. 克制死锁的策略

只要粉碎四个必要条件中的任意一个或多个,就能克制死锁发生。

比方,我们对上述死锁案例举行修改,粉碎占据并等候条件,要求线程在申请新资源之前释放已持有的资源。
当灰姑凉释放了口红锁之后,才能哀求镜子的锁;当白雪公主释放了镜子锁之后,才能哀求口红的锁。
  1. // 当灰姑凉释放了口红锁之后,才能请求镜子的锁;当白雪公主释放了镜子锁之后,才能请求口红的锁。
  2. void makeup() throws InterruptedException {
  3.     if (choice == 0) {
  4.         synchronized (lipstick) {
  5.             System.out.println(this.getName() + "获得了口红的锁");
  6.             Thread.sleep(1000);
  7.             System.out.println(this.getName() + "等待镜子的锁......");
  8.         }
  9.         synchronized (mirror) {
  10.             System.out.println(this.getName() + "获得了镜子的锁");
  11.         }
  12.     } else {
  13.         synchronized (mirror) {
  14.             System.out.println(this.getName() + "获得了镜子的锁");
  15.             Thread.sleep(2000);
  16.             System.out.println(this.getName() + "等待口红的锁......");
  17.         }
  18.         synchronized (lipstick) {
  19.             System.out.println(this.getName() + "获得了口红的锁");
  20.         }
  21.     }
  22. }
复制代码

结果如下,克制了死锁。
  1. 灰姑凉获得了口红的锁
  2. 白雪公主获得了镜子的锁
  3. 灰姑凉等待镜子的锁......
  4. 白雪公主等待口红的锁......
  5. 白雪公主得到了口红的锁灰姑凉得到了镜子的锁
复制代码

参考资料

狂神说 Java 多线程:https://www.bilibili.com/video/BV1V4411p7EF
TIOBE 编程语言走势: https://www.tiobe.com/tiobe-index/
Typora 官网:https://www.typoraio.cn/
Oracle 官网:https://www.oracle.com/
Notepad++ 下载地址:https://notepad-plus.en.softonic.com/
IDEA 官网:https://www.jetbrains.com.cn/idea/
Java 开辟手册:https://developer.aliyun.com/ebook/394
Java 8 帮助文档:https://docs.oracle.com/javase/8/docs/api/
MVN 堆栈:https://mvnrepository.com/

免责声明:如果侵犯了您的权益,请联系站长,我们会及时删除侵权内容,谢谢合作!更多信息从访问主页:qidao123.com:ToB企服之家,中国第一个企服评测及商务社交产业平台。




欢迎光临 ToB企服应用市场:ToB评测及商务社交产业平台 (https://dis.qidao123.com/) Powered by Discuz! X3.4