竞争条件与重入锁

竞争条件与重入锁

既然考虑到了多线程,事情就变得复杂起来了,因为处理一个人的问题很好解决,无论这个事情有多难,总能按着顺序逻辑串行下来,但是现在要考虑多个人,显然多个人能提高生产的效率,但是同样的多个人也会产生各种各样的矛盾。

举个最简单的例子,也是刚学编程的时候幻想的一个问题:老王和老王他媳妇有一个银行账户的正卡和副卡,这个账户里面有600¥,老王和他媳妇同时去取钱,在一个银行的不同ATM机上,假如两个人输入相同的取钱金额500¥,同时点了取钱,一切都是同时进行的,最终会不会两个人都取出500¥,有生活常识的人肯定知道,如果银行系统这样搞,那么写这个系统的程序员应该会被fire了,那么考虑一下,如何实现这样的可能?

很简单就是正常的编写服务器代码:

int amount = input; //amount = 500
int restMoney = loadMoney(); // restMoney = 600
if(restMoney < amount){
	return;
}
restMonry = restMoney - amount; //减去取出的钱
storeMoney(restMoney); //存入取出的前
popMoney(amount); //弹出钱

这里,如果系统两个线程几乎没有时差(也就是卡的好),那么同时的情况下restMoney 被同时的赋值,然后同时时的存入系统,结果就是老王和他媳妇同时取到了500,还剩下100,白赚了银行500。

因为并发的同时访问(存取)相同资源的情况下,根据线程访问的次数,可能会产生错误,这种错误成为讹误,这样的一个情况被称为**竞争条件**

2.1再一个竞争条件的例子

UnSynchBankTest.java

package test.concurrent.synch.unsynch;

/**
 * @BelongsProject: Java_Source_Learn
 * @BelongsPackage: test.concurrent.synch
 * @Author: Wang Haipeng
 * @CreateTime: 2021-12-10 14:15
 * @Description: 未处理的竞争例子
 */
public class UnSynchBankTest {

    /**
     * 银行中的账户数
     */
    public static final int NACCOUNTS  = 100;

    /**
     * 银行账户初始金额
     */
    public static final double INITIAL_BALANCE = 1000;

    /**
     * 每次转账最大数量
     */
    public static final double MAX_AMOUNT = 1000;

    /**
     * 睡眠时间
     */
    public static final int DELAY = 10;


    public static void main(String[] args) {
        Bank bank = new Bank(NACCOUNTS,INITIAL_BALANCE);
        for (int i = 0; i < NACCOUNTS ;i++){
            int fromAccount = i;
            Runnable r = ()->{
                while (true){
                    /**
                     * Math.Random 生成一个大于或等于0.0且小于1.0的伪随机double精度值
                     * 乘一个size 再强转就能获取一个范围内的随机数([0,size))
                     */
                    int toAccount = (int) (bank.size()*Math.random());
                    double amount = MAX_AMOUNT*Math.random();
                    bank.transfer(fromAccount,toAccount,amount);
                    try {
                        /*让给其他线程执行的机会*/
                        Thread.sleep((long) (DELAY*Math.random()));
                    } catch (InterruptedException e) {
                        e.printStackTrace();
                    }
                }
            };
            Thread thread = new Thread(r);
            thread.start();
        }
    }
}

Bank.java

package test.concurrent.synch.unsynch;

import java.util.Arrays;

/**
 * @BelongsProject: Java_Source_Learn
 * @BelongsPackage: test.concurrent.synch.unsynch
 * @Author: Wang Haipeng
 * @CreateTime: 2021-12-10 14:16
 * @Description: 银行
 */
public class Bank {

    /**
     * 银行账户数
     */
    private final double[] account;

    /**
     * 初始化银行
     * @param n 账户数
     * @param initialBalance 每个账户中初始金额数
     */
    public Bank(int n , double initialBalance) {
        account = new double[n];
        /*
        *  底层是一个for循环,将数组循环放入数组中
        *  public static void fill(double[] a, double val) {
        *     for (int i = 0, len = a.length; i < len; i++)
        *     a[i] = val;
            }
        * */
        Arrays.fill(account,initialBalance);
    }

    public void transfer(int from,int to,double amount){
        if (account[from] < amount){
            /*账户固有金额小于转账金额,无法转账,退出函数*/
            return;
        }
        System.out.println(Thread.currentThread());
        account[from] -= amount;
        /*打印转账金额,转出账户和转入账户*/
        System.out.printf(" %10.2f from %d to %d",amount,from,to);
        account[to] += amount;
        System.out.printf(" Total Balance: %10.2f%n",getTotalBalance());
    }

    /**
     * 获取银行的总金额数
     * @return 银行总金额
     */
    private double getTotalBalance() {
        double sum = 0;
        for (double d: account){
            sum += d;
        }
        return sum;
    }

    /**
     * 获取银行账户数
     * @return 账户数
     */
    public int size(){
        return account.length;
    }
}

这块代码看着没啥,运行起来也很简单,但是随着不断的运行就会发现:

image-20211210155037466

TotalBalance 无论是从哪取到哪,都应该是固定的100000¥,但是很显然在程序的运行过程中出现了讹误,导致了整体的钱数发生了改变,而且是不可预知的,不可预料的变化,这显然对于程序来说是毁灭性的灾难,没有什么比不可预料计算的问题更难解决了。

而这里很显然就是因为对共享数据的同步存取导致了讹误现象的产生,可能是在线程调度的过程中某处线程运行先后次序的问题导致。

2.2竞争条件

2.2.1产生竞争条件的原因和示例

在这段代码中为什么会产生竞争条件?当两个线程同时更新同一个共享数据的时候,问题就产生了?那为什么会在访问共享数据(更新同一块内存区域的数据)的时候产生呢?

关于竞争条件,写者觉得可以引入一点CPU的知识来进行解释。在计算机底层称类似的冲突问题为**数据相关**(用编写Verilog 的思想考虑多线程就会很舒服,因为在CPU流水线中一切都是并发执行的这里看不懂的同学可以直接跳过并不影响)

常见的数据相关的问题就是:写后读的问题,就是值还未写入到寄存器当中就被后面的流水级读了出来。

如果发生了,显然这就是一个明显的讹误,因为后一级的读出来的值是旧的而不是新的值,而从串行执行的顺序上来说(在CPU中运行的程序都是指令序列)这显然是错误的,而解决这样的讹误的情况就是让后面的人等一等,等等前面的完成了再继续执行。

从本质上来说:产生讹误最大的原因就是因为操作不具有原子性。还记得在哪学过原子性的概念吗?我提醒一句,数据库的事务管理。

为什么说不具有原子性就会产生相关的讹误呢?因为在线程运行的期间很容易发生中断被其他的线程抢占CPU时间片,当发生这样的事情的时候很大概率就会出现讹误的情况。

假定两个线程同时执行指令:

account[to] += amount

这个指令就会被处理为:

  1. 将account[to] 加载到寄存器
  2. 增加amount
  3. 将结果写回account[to]

现在假定第一个线程执行了步骤1和2,然后它就被剥夺了运行权,这个时候唤醒了线程2,而线程2修改了account 的同一项,然后线程1唤醒完成第三步,最终的结果就是线程1 的动作抹去了线程2 的更新,于是总金额不再正确。

可以通过运行命令:

javap -c -v Bank 对Bank.class文件进行反编译,得到类的字节码文件:

image-20211210163228782

jvm虚拟机指令的含义其实无关紧要,但是要了解的是实际上,在指令的任意一个位置都可以发生中断,转换CPU的使用权。

这里load是取值的意思

store是存值的意思

image-20211210163337605

我们都知道,线程调度程序会根据线程的任务内容的不同分配不同大小的CPU时间片,可能这个时间片正好能把这个任务执行完毕,但也有可能在某一条指令处中断,从整体的上面来说,如果减少任务的负重,也就是删除打印操作,一定程度上能降低任务在执行中间就被中断的可能,也就能降低讹误的产生机率,但是显然这种不可靠的因素并没有本质上的解决竞争的问题。

2.2.2解决讹误的方法

从上述我们可以清楚的了解到,产生讹误的主要原因是因为访问共享资源的操作不具有原子性,那么从这个角度上,如果能保证线程在失去控制之前方法运行完成,那么就不会出现讹误的情况

在这里我们先介绍几个概念:

  • 临界资源:
    • 这里的临界资源就可以理解为上述的共享资源,这样的资源同时只能让一个线程去访问,只有这线程放弃了访问权,才能由其他的线程去访问
  • 临界区
    • 对于临界资源操作的一段指令序列(就是一段代码)

在Java中提供了两种机制防止代码块受并发访问的干扰:

  • synchronized 关键字
    • 提供一个锁以及相关的条件
  • ReentranLock 类

2.2.3可重入锁

基本结构:

myLock.lock();  //a ReentrantLock object
try{
	critical sections
}finally{
	/*make sure the lock is unlocked even if an exception is thrown*/
	myLock.unlock(); 
}

这段程序的目的是:确保任意一个线程进入临界区,封锁了锁对象,其他任何线程无法通过lock语句去访问,因而被阻塞,直到第一个线程释放了锁对象。

这里一定要注意开锁和释放锁的操作,在临界区,如果发生了异常,就一定会发生中断结束程序,或者进行线程的转换,如果这里没有finally 释放锁,那么其他访问临界区的线程就都会一直被阻塞下去。

其次就是如果带锁,就不能去使用带资源的try 语句,首先解锁的方法不是close ,即使它被重命名,带资源的try 也无法正常工作,它的首部需要的是一个新变量,而往往使用了锁的目的是为了使用多个线程访问的那个变量并不是一个新变量。

用锁保护Bank类的transfer方法:

    /**
     * ReentrantLock 实现了Lock接口
     */
    private Lock bankLock = new ReentrantLock();
    
    public void transfer(int from,int to,double amount){
        bankLock.lock();
        try {
            if (account[from] < amount){
                /*账户固有金额小于转账金额,无法转账,退出函数*/
                return;
            }
            System.out.println(Thread.currentThread());
            account[from] -= amount;
            /*打印转账金额,转出账户和转入账户*/
            System.out.printf(" %10.2f from %d to %d",amount,from,to);
            account[to] += amount;
            System.out.printf(" Total Balance: %10.2f%n",getTotalBalance());   
        }finally {
            bankLock.unlock();
        }
    }

bankLock.lock(); 和 finally 块之间的就是临界区,从时间图的角度来理解一下这个过程:

image-20211210170252609

这样每次访问transfer 方法的只有一个线程,并不会发生因为中断导致讹误产生的情况,因为此时的没有持有锁的线程都处于阻塞的状态。

这里的锁是可重入的,就是说线程可以重复的获得已经持有的锁,锁会维持一个计数器来跟踪对lock 方法的嵌套调用,因为线程每一次调用相同的锁的lock方法都需要调用unlock来释放锁,由于这一个特性,被一个锁保护的代码可以调用另一个使用相同的锁的方法。

2.3API

java.util.concurrent.locks.lock

  • void lock()
    • 获得这个锁,如果锁同时被另一个线程拥有则发生阻塞
  • void unlock()
    • 释放这个锁

java.util.concurrent.locks.ReentrantLock

  • ReentrantLock
  • ReentrantLock(boolean fair)
    • 构建一个公平锁,一个公平锁偏爱等待时间更长的线程,但是这一公平的保证将大大降低性能,所以默认情况下,锁没有被强制为公平的。
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值