写点什么

【高并发】高并发环境下诡异的加锁问题(你加的锁未必安全)

作者:冰河
  • 2022 年 5 月 13 日
  • 本文字数:3834 字

    阅读完需:约 13 分钟

【高并发】高并发环境下诡异的加锁问题(你加的锁未必安全)

声明

特此声明:文中有关支付宝账户的说明,只是用来举例,实际支付宝账户要比文中描述的复杂的多。也与文中描述的完全不同。

前言

很多网友留言说:在编写多线程并发程序时,我明明对共享资源加锁了啊?为什么还是出问题呢?问题到底出在哪里呢?其实,我想说的是:你的加锁姿势正确吗?你真的会使用锁吗?错误的加锁方式不但不能解决并发问题,而且还会带来各种诡异的 Bug 问题,有时难以复现!


在上一篇《【高并发】如何使用互斥锁解决多线程的原子性问题?这次终于明白了!》一文中,我们知道在并发编程中,不能使用多把锁保护同一个资源,因为这样达不到线程互斥的效果,存在线程安全的问题。相反,却可以使用同一把锁保护多个资源。那么,如何使用同一把锁保护多个资源呢?又如何判断我们对程序加的锁到底是不是安全的呢?我们就一起来深入探讨这些问题!

分析场景

我们在分析多线程中如何使用同一把锁保护多个资源时,可以将其结合具体的业务场景来看,比如:需要保护的多个资源之间有没有直接的业务关系。如果需要保护的资源之间没有直接的业务关系,那么如何对其加锁;如果有直接的业务关系,那么如何对其加锁?接下来,我们就顺着这两个方向进行深入说明。

没有直接业务关系的场景

例如,我们的支付宝账户,有针对余额的付款操作,也有针对账户密码的修改操作。本质上,这两种操作之间没有直接的业务关系,此时,我们可以为账户的余额和账户密码分配不同的锁来解决并发问题。


例如,在支付宝账户 AlipayAccount 类中,有两个成员变量,分别是账户的余额 balance 和账户的密码 password。付款操作的 pay()方法和查看余额操作的 getBalance()方法会访问账户中的成员变量 balance,对此,我们可以创建一个 balanceLock 锁对象来保护 balance 资源;另外,更改密码操作的 updatePassword()方法和查看密码的 getPassowrd()方法会访问账户中的成员变量 password,对此,我们可以创建一个 passwordLock 锁对象来保护 password 资源。


具体的代码如下所示。


public class AlipayAccount{    //保护balance资源的锁对象    private final Object balanceLock = new Object();    //保护password资源的锁对象    private final Object passwordLock = new Object();    //账户余额    private Integer balance;    //账户的密码    private String password;        //支付方法    public void pay(Integer money){        synchronized(balanceLock){            if(this.balance >= money){                this.balance -= money;            }        }    }    //查看账户中的余额    public Integer getBalance(){        synchronized(balanceLock){            return this.balance;        }    }        //修改账户的密码    public void updatePassword(String password){        synchronized(passwordLock){            this.password = password;        }    }        //查看账户的密码    public String getPassword(){        synchronized(passwordLock){            return this.password;        }    }}
复制代码


这里,我们也可以使用一把互斥锁来保护 balance 资源和 password 资源,例如都使用 balanceLock 锁对象,也可以都使用 passwordLock 锁对象,甚至也都可以使用 this 对象或者干脆每个方法前加一个 synchronized 关键字。


但是,如果都使用同一个锁对象的话,那么,程序的性能就太差了。会导致没有直接业务关系的各种操作都串行执行,这就违背了我们并发编程的初衷。实际上,我们使用两个锁对象分别保护 balance 资源和 password 资源,付款和修改账户密码是可以并行的。

存在直接业务关系的场景

例如,我们使用支付宝进行转账操作。假设账户 A 给账户 B 转账 100,A 账户减少 100 元,B 账户增加 100 元。两个账户在业务中有直接的业务关系。例如,下面的 TansferAccount 类,有一个成员变量 balance 和一个转账的方法 transfer(),代码如下所示。


public class TansferAccount{    private Integer balance;    public void transfer(TansferAccount target, Integer transferMoney){        if(this.balance >= transferMoney){            this.balance -= transferMoney;            target.balance += transferMoney;        }    }}
复制代码


在上面的代码中,如何保证转账操作不会出现并发问题呢?很多时候我们的第一反应就是给 transfer()方法加锁,如下代码所示。


public class TansferAccount{    private Integer balance;    public synchronized void transfer(TansferAccount target, Integer transferMoney){        if(this.balance >= transferMoney){            this.balance -= transferMoney;            target.balance += transferMoney;        }    }}
复制代码


我们仔细分析下,**上面的代码真的是安全的吗?!**其实,在这段代码中,synchronized 临界区中存在两个不同的资源,分别是转出账户的余额 this.balance 和转入账户的余额 target.balance,这里只用到了一把锁 synchronized(this)。说到这里,大家有没有一种豁然开朗的感觉。没错,问题就出现在 synchronized(this)这把锁上,这把锁只能保护 this.balance 资源,而无法保护 target.balance 资源。


我们可以使用下图来表示这个逻辑。



从上图我们也可以发现,this 锁对象只能保护 this.balance 资源,而不能保护 target.balance 资源。


接下来,我们再看一个场景:假设存在 A、B、C 三个账户,余额都是 200,此时我们使用两个线程分别执行两个转账操作:账户 A 给账户 B 转账 100,账户 B 给账户 C 转账 100。理论上,账户 A 的余额为 100,账户 B 的余额为 200,账户 C 的余额为 300。


真的是这样吗?我们假设线程 A 和线程 B 同时在两个不同的 CPU 上执行,线程 A 执行账户 A 给账户 B 转账 100 的操作,线程 B 执行账户 B 给账户 C 转账 100 的操作。两个线程之间是互斥的吗?显然不是,按照 TansferAccount 的代码来看,线程 A 锁定的是账户 A 的实例,线程 B 锁定的是账户 B 的实例。所以,线程 A 和线程 B 能够同时进入 transfer()方法。此时,线程 A 和线程 B 都能够读取到账户 B 的余额为 200。两个线程都完成转账操作后,B 的账户余额可能为 300,也可能为 100,但是不可能为 200。


这是为什么呢?线程 A 和线程 B 同时读取到账户 B 的余额为 200,如果线程 A 的转账操作晚于线程 B 的转账操作对 balance 的写入,则账户 B 的余额为 300;如果线程 A 的转账操作早于线程 B 的转账操作对 balance 的写入,则账户 B 的余额为 100。无论如何账户 B 的余额都不会是 200。


综上所示,TansferAccount 的代码根本无法解决并发问题!

正确的加锁

如果我们希望对转账操作中涉及的多个资源加锁,那我们的锁就必须要覆盖所有需要保护的资源。


在前面的 TansferAccount 类中,this 是对象级别的锁,这就导致了线程 A 和线程 B 执行过程中所获取到的锁是不同的,那么如何让两个线程共享同一把锁呢?!


其中,方案有很多,一种简单的方式,就是在 TansferAccount 类的构造方法中传入一个 balanceLock 锁对象,以后在创建 TansferAccount 类对象的时候,每次传入相同的 balanceLock 锁对象,并在 transfer 方法中使用 balanceLock 锁对象加锁即可。这样,所有创建的 TansferAccount 类对象就会共享 balanceLock 锁。代码如下所示。


public class TansferAccount{    private Integer balance;    private Object balanceLock;    private TansferAccount(){}    public TansferAccount(Object balanceLock){        this.balanceLock = balanceLock;    }    public void transfer(TansferAccount target, Integer transferMoney){        synchronized(this.balanceLock){             if(this.balance >= transferMoney){                this.balance -= transferMoney;                target.balance += transferMoney;            }           }    }}
复制代码


那么,问题又来了:这样解决问题真的完美吗?!


上述代码虽然解决了转账操作的并发问题,但是它真的就完美了吗?!仔细分析后,我们发现,并不是想象中的那么完美。因为它要求创建 TansferAccount 对象的时候,必须传入同一个 balanceLock 对象,如果传入的不是同一个 balanceLock 对象,就不能保证并发带来的线程安全问题了!在实际的项目中,创建 TansferAccount 对象的操作可能被分散在多个不同的项目工程中,这样很难保证传入的 balanceLock 对象是同一个对象。


所以,在创建 TansferAccount 对象时传入同一个 balanceLock 锁对象的方案,虽然能够解决转账的并发问题,但是却无法在实际项目中被有效的采用!


还有没有其他的方案呢?答案是有!别忘了 JVM 在加锁类的时候,会为类创建一个 Class 对象,而这个 Class 对象对于类的实例对象来说是共享的,也就是说,无论创建多少个类的实例对象,这个 Class 对象都是同一个,这是由 JVM 来保证的。



说到这里,我们就能够想到使用如下方式对转账操作加锁。


public class TansferAccount{    private Integer balance;    public void transfer(TansferAccount target, Integer transferMoney){        synchronized(TansferAccount.class){          if(this.balance >= transferMoney){                this.balance -= transferMoney;                target.balance += transferMoney;            }           }    }}
复制代码


我们可以使用下图表示这个逻辑。



这样,无论创建多少个 TansferAccount 对象,都会共享同一把锁,解决了转账的并发问题。

写在最后

最后,附上并发编程需要掌握的核心技能知识图,祝大家在学习并发编程时,少走弯路。



发布于: 刚刚阅读数: 3
用户头像

冰河

关注

公众号:冰河技术 2020.05.29 加入

互联网资深技术专家,《深入理解分布式事务:原理与实战》,《海量数据处理与大数据技术实战》和《MySQL技术大全:开发、优化与运维实战》作者,mykit-data与mykit-transaction-message框架作者。【冰河技术】作者。

评论 (1 条评论)

发布
用户头像
原创不易,冰河在线求三连,不过分吧?
刚刚
回复
没有更多了
【高并发】高并发环境下诡异的加锁问题(你加的锁未必安全)_并发编程_冰河_InfoQ写作社区