当前位置: 首页 > article >正文

【Java 并发编程】解决多线程中数据错乱问题

前言


        承接上回,我们已经理解了线程的一些相关操作,本篇内容将会讲到如何去解决线程竞争导致的数据错乱。


前期回顾:线程操作


目录

前言

线程竞争的场景

竞态条件的详解

synchronized 关键字

 ReentrantLock 类

 

 

线程竞争的场景

概念:

        在大多数实际的多线程运用中,两个或者两个以上的线程需要共享存储相同的数据。如果两个线程存取同一个对象,并且每个线程分别调用了一个修改该对象状态的方法,那么会发生什么呢?这两个线程会相互覆盖。取决于线程访问数据的次序,可能会导致线程被破坏。这种情况通常称为 “竞态条件”。

        为了让大家更好的理解,这里先举两个例子


 例子一: 

        一个银行账户中有 1000 元,现在有 用户A、用户B分别从这个账户中取钱:那么必然包括两个操作:A向银行取钱,B向银行取钱,如果两次取钱都与银行存款相对应则证明银行的取钱操作是成功。

 ​​​

         试想一下,如果这两个操作不具备原子性,假设从 用户A  取走 100 元之后,操作突然终止了,那么 用户A 把钱取走了,但是银行账户的钱并没有减少。这种情况是很严重的,可能会致使银行破产。

 

上述操作有两个步骤,出现意外后导致取钱失败,说明没有原子性。 

原子性的解释:

  • 原子性:即一个操作或者多个操作,要么全部执行并且执行的过程不会被任何因素打断,要么就都不执行。
  • 原子操作:即不会被线程调度机制打断的操作,没有上下文切换。

        现在我们可以用代码实现一下以上操作:

class Bank {
    // 一个账户有1000块钱
    static int money = 1000;
    // 柜台 Counter 取钱的方法
    public void Counter(int money) {// 参数是每次取走的钱
        Bank.money -= money;        // 取钱后总数减少
        System.out.println("A取走了" + money + "还剩下" + (Bank.money));
    }

    // ATM取钱的方法
    public void ATM(int money) {// 参数是每次取走的钱
        Bank.money -= money;    // 取钱后总数减少
        System.out.println("B取走了" + money + "还剩下" + (Bank.money));
    }
}

class PersonA extends Thread {
    // 创建银行对象
    Bank bank;

    // 通过构造器传入银行对象,确保两个人进入的是一个银行
    public PersonA(Bank bank) {
        this.bank = bank;
    }

    //重写run方法,在里面实现使用柜台取钱
    @Override
    public void run() {
        while (Bank.money >= 100) {
            bank.Counter(100);// 每次取100块
            try {
                sleep(100);    // 取完休息0.1秒
            } catch (InterruptedException e) {
                e.printStackTrace();
            }
        }
    }
}

class PersonB extends Thread {
    // 创建银行对象
    Bank bank;

    // 通过构造器传入银行对象,确保两个人进入的是一个银行
    public PersonB(Bank bank) {
        this.bank = bank;
    }

    // 重写run方法,在里面实现使用柜台取钱
    @Override
    public void run() {
        while (Bank.money >= 200) {
            bank.ATM(200);// 每次取200块
            try {
                sleep(100);// 取完休息0.1秒
            } catch (InterruptedException e) {
                e.printStackTrace();
            }
        }

    }
}

class MainClass {
    public static void main(String[] args) {
        System.out.println("一个账户总共有1000块钱");
        // 实力化一个银行对象
        Bank bank = new Bank();
        // 实例化两个人,传入同一个银行的对象
        PersonA pA = new PersonA(bank);
        PersonB pB = new PersonB(bank);
        // 两个人开始取钱
        pA.start();
        pB.start();
    }
}

打印结果:

一个账户总共有1000块钱
B取走了200还剩下700
A取走了100还剩下900
A取走了100还剩下600
B取走了200还剩下600
B取走了200还剩下300
A取走了100还剩下500
B取走了200还剩下100
A取走了100还剩下0

        可以看到这里出现了几处错误:比如 B 是第一个取钱的,但是取完之后只剩下 700。程序在运行一段时间,我们发现有一段 A、B 取完钱之后,银行账户余额没有发生改变。如果一个银行采用的是这样的系统,你还会将钱存进这个银行吗?

 例子二: 

class Test {
    public static int Count = 0;
    public static void main(String[] args) throws InterruptedException {
        Thread t1 = new Thread(()->{
            for(int i = 0;i<50000;i++){
                Count++;
            }
        });
        Thread t2 = new Thread(()->{
            for(int i = 0;i<50000;i++){
                Count++;
            }
        });
        t1.start();
        t2.start();

        t1.join();
        t2.join();
        System.out.println(Count);
    }

        以上程序运行的结果是什么?如果是单线程的话,估计你会立刻说出答案 -- 100000。但是这里是多线程,我们打印的结果往往是小于 100000 的数字

        关于以上问题的答案我们现在就来揭晓 ~

竞态条件的详解

        我们以第二个简单的例子进行讲解,由于 t1 线程在执行时会受到 t2 线程的干扰,所以这不是原子操作。那么 Count++ 这条语句可能就有如下三条指令

(1)把内存中的数据,读取到 cpu 寄存器里
(2)把 CPU 寄存器里的数据 +1
(3)把寄存器的值,写回内存

         现在假定 t1 线程执行步骤1和步骤2,然后,它的运行权被强占。这时 t1 寄存器刚从内存中读取到数据并加 1,只差修改内存数据了。再假设现在 t2 线程现在被唤醒,并完美的执行三个步骤:由于 t1 线程并没有修改内存所以 t2 寄存器从内存中读取到的值仍然是 0,然后加 1 修改内存,此时内存的值为 1。最后终于运行 t1 的步骤3,这个时候 t1 寄存器的值就会覆盖 t2 线程所作出的修改,其结果仍然是 1。这相当于运行了两次 for 循环,但是 Count 只增加了 1。

        那么如何解决上述问题呢? -- 通常有三种方法:

        方法一:由于程序是并发执行的,所以关联线程(两个线程共用一个变量)之间势必会发生竞争,那么我们只需让一个线程等待等一个线程执行完即可。使用 join() 就可以解决问题。

        上述代码是让线程 t2 阻塞等待 t1线程结束后再开始运行。结果表明这种方法是可行的。

        方法二:可以设置两个变量在不同的线程中运行,最后在整合起来。

class Test {
    public static int Count1 = 0;
    public static int Count2 = 0;
    public static void main(String[] args) throws InterruptedException {
        Thread t1 = new Thread(()->{
            for(int i = 0;i<50000;i++){
                Count1++;
            }
        });
        Thread t2 = new Thread(()->{
            for(int i = 0;i<50000;i++){
                Count2++;
            }
        });
        t1.start();
        t2.start();

        t1.join();
        t2.join();
        System.out.println(Count1+Count2);
    }
}

synchronized 关键字

        第三种方法是本章节重点要求掌握的 -- 同步锁。 Java 提供了一个关键字 synchronized 可以防止并发访问一个代码块。

synchronized 的介绍

        它的作用域默认是当前对象,这时锁就是对象,谁拿到这个锁谁就可以运行它所控制的那段代码如果这个对象有多个 synchronized 方法,其它线程就不能同时访问这个对象中任何一个 synchronized 方法。

class Test {
    public static int Count = 0;
    public static void main(String[] args) throws InterruptedException {
        Thread t1 = new Thread(()->{
            for(int i = 0;i<50000;i++){
                synchronized (Test.class) {
                    Count++;
                }
            }
        });
        Thread t2 = new Thread(()->{
              for(int i = 0;i<50000;i++){
                    synchronized (Test.class) {
                        Count++;
                    }
                }
        });
        t1.start();
        t2.start();

        t1.join();
        t2.join();
        System.out.println(Count);
    }
}

        Test.class 的意思是引用当前类的对象。此时程序运行结果就是 100000,我们回去看看给例子一加锁答案将会如何:

程序运行结果:

一个账户总共有1000块钱
A取走了100还剩下900
B取走了200还剩下700
A取走了100还剩下600
B取走了200还剩下400
A取走了100还剩下300
B取走了200还剩下100
A取走了100还剩下0

 

 ReentrantLock 类

        synchronized 关键字用于加锁,但这种锁一是很重,二是获取时必须一直等待,没有额外的尝试机制。除了 synchronized 能锁住线程外,Java 5还引入了 ReentrantLock 类。

        上述代用如果用 ReentrantLock 写的话,可以这么写:

class Test {
    public static int Count = 0;
    public static void main(String[] args) throws InterruptedException {
        ReentrantLock myLock = new ReentrantLock();
        Thread t1 = new Thread(()->{
            for(int i = 0;i<50000;i++){
                myLock.lock();
                try{
                    Count++;
                }finally {
                    myLock.unlock();
                }
            }
        });
        Thread t2 = new Thread(()->{
                for(int i = 0;i<50000;i++){
                    myLock.lock();
                    try{
                        Count++;
                    }finally {
                        myLock.unlock();
                    }
                }
        });
        t1.start();
        t2.start();

        t1.join();
        t2.join();
        System.out.println(Count);
    }
}

使用  ReentrantLock 保护代码块的基本结构如下:

              myLock.lock();
                try{
                    //Count++;
                }finally {
                    myLock.unlock();
                }

        这个结构确保了任何时刻只有一个线程进入临界状态。一旦一个线程锁定了对象,任何其他线程都无法通过 lock 语句。当其他线程调用 lock 语句时,它们会处于阻塞状态,直到第一个线程释放这个锁对象

        注意:因为 synchronized 是Java语言层面提供的语法,所以我们不需要考虑异常,而ReentrantLock 是Java代码实现的锁,我们就必须先获取锁,然后在 finally 中正确释放锁(也就是将 unlock 操作包在 finally 语句中),否则其他线程将永远阻塞

         关于更多的锁操作,后续将继续介绍~

 


http://www.kler.cn/news/340447.html

相关文章:

  • 前端vue-安装pinia,它和vuex的区别
  • Vue中watch监听属性的一些应用总结
  • 微信小程序启动不起来,报错凡是以~/包名/*.js路径的文件,都找不到,试过网上一切方法,最终居然这么解决的,【避坑】命运的齿轮开始转动
  • 【Linux】man手册安装使用
  • 以一个B站必剪应用Bug过一下CVSS 4.0评分
  • TCP网络通信——多线程
  • 重学SpringBoot3-集成Redis(二)之注解驱动
  • 408算法题leetcode--第26天
  • Kubernetes--深入理解Pod资源管理
  • (Linux驱动学习 - 9).设备树下platform的LED驱动
  • 如何通过jupyter调用服务器端的GPU资源
  • 微信小程序流量主
  • 字节青训营-技术训练营报名啦!!!
  • 外包干了6天,技术明显退步。。。
  • 项目-坦克大战学习-爆炸特效消除
  • 九大排序之交换排序
  • 九APACHE
  • 基于vue框架的大学生在线教育jp6jw(程序+源码+数据库+调试部署+开发环境)系统界面在最后面。
  • C++、Ruby和JavaScript
  • No.7 笔记 | 数据库基础(含端口号)