当前位置: 首页 > 科技观察

高并发环境下奇怪的加锁问题(你加的锁可能不安全)

时间:2023-03-19 10:22:56 科技观察

作者个人开发了一个高并发场景下简单、稳定、可扩展的延迟消息队列框架,有精准的定时任务和延迟队列处理功能。开源半年多以来,已成功为十几家中小企业提供精准定时调度解决方案,经受住了生产环境的考验。为了造福更多童鞋,这里提供框架开源地址:https://github.com/sunshinelyz/mykit-delay。在此声明声明:文章中对支付宝账户的描述仅为举例,实际的支付宝账户要比文章中的描述复杂得多。也和文中描述的完全不一样。前言很多网友留言说:写多线程并发程序的时候,我明明锁定了共享资源?为什么还有问题?哪里有问题?其实我想说的是:你的锁定姿势正确吗?你真的知道如何使用锁吗?错误的加锁方式不仅不能解决并发问题,还会带来各种诡异的bug,有时很难重现!我们知道,在并发编程中,不能使用多个锁来保护同一个资源,因为这样达不到线程互斥的效果,存在线程安全问题。相反,您可以使用同一个锁来保护多个资源。那么,如何使用同一个锁来保护多个资源呢?而如何判断我们给程序加的锁是否安全呢?让我们一起探讨这些问题!分析场景我们分析在多线程中如何使用同一个锁当一个锁保护多个资源时,可以结合具体的业务场景来看,比如:需要保护的多个资源之间是否有直接的业务关系保护。如果要保护的资源之间没有直接的业务关系,如何加锁;如果有直接业务关系,如何锁定?接下来,我们将沿着这两个方向进行深入的讲解。没有直接业务关系的场景比如我们的支付宝账户有余额的支付操作和账户密码的修改操作。本质上,这两个操作之间没有直接的业务关系。这时候我们可以给账户余额和账户密码分配不同的锁来解决并发问题。例如,在支付宝账户的AlipayAccount类中,有两个成员变量,分别是账户余额balance和账户密码password。支付操作的pay()方法和余额查询操作的getBalance()方法都会访问账户中的成员变量balance。为此,我们可以创建一个balanceLock锁对象来保护余额资源;另外,修改密码操作的updatePassword()方法和查看密码的getPassowrd()方法都会访问账户中的成员变量password。为此,我们可以创建一个passwordLock锁对象来保护密码资源。具体代码如下。publicclassAlipayAccount{//保护余额资源的锁对象privatefinalObjectbalanceLock=newObject();//保护密码资源的锁对象privatefinalObjectpasswordLock=newObject();//账户余额privateIntegerbalance;//账户密码privateStringpassword;//支付方式publicvoidpay(Integermoney){synchronized(balanceLock){if(this.balance>=money){this.balance-=money;}}}//查询账户余额publicIntegergetBalance(){synchronized(balanceLock){returnthis.balance;}}//修改账户密码publicvoidupdatePassword(Stringpassword){synchronized(passwordLock){this.password=password;}}//查看账户密码publicStringgetPassword(){synchronized(passwordLock){returnthis.password;}}}在这里,我们还可以使用互斥量来保护余额资源和密码资源。比如都使用balanceLock锁对象,或者都使用passwordLock锁对象,甚至使用this对象或者干脆在每个方法前加一个synchronized关键字。但是,如果使用同一个锁对象,程序的性能就太差了。会导致各种没有直接业务关系的操作串行执行,违背了我们并发编程的初衷。实际上,我们使用了两个锁对象来分别保护余额资源和密码资源。支付和修改账户密码可以并行进行。有直接业务关系的场景比如我们使用支付宝进行转账操作。假设A账户转100给B账户,A账户减少100元,B账户增加100元。这两个账户在业务上有直接的业务关系。比如下面的TransferAccount类有一个成员变量balance和一个transfer方法transfer(),代码如下所示。publicclassTansferAccount{privateIntegerbalance;publicvoidtransfer(TansferAccounttarget,IntegertransferMoney){if(this.balance>=transferMoney){this.balance-=transferMoney;target.balance+=transferMoney;}}}在上面的代码中,如何保证转账操作会没有出现并发问题呢?很多时候我们的第一反应是锁定transfer()方法,如下代码所示。publicclassTansferAccount{privateIntegerbalance;publicsynchronizedvoidtransfer(TansferAccounttarget,IntegertransferMoney){if(this.balance>=transferMoney){this.balance-=transferMoney;target.balance+=transferMoney;}}}仔细分析一下,上面的代码真的安全吗?!实际上,在这段代码中,同步临界区中有两个不同的资源,分别是转出账户的余额this.balance和转入账户的余额target.balance。这里只用了一把锁synchronized。(这)。说到这里,是不是有一种豁然开朗的感觉?没错,问题出在synchronized(this)锁上,它只能保护this.balance资源,不能保护target.balance资源。我们可以用下图来表示这个逻辑。从上图我们也可以发现this锁对象只能保护this.balance资源,不能保护target.balance资源。接下来我们再看另一种场景:假设有A、B、C三个账户,余额为200,此时我们用两个线程进行两次转账操作:A账户转100到B账户,A账户转账100到B账户。B转100到B账户,C账户转100。理论上,A账户余额100,B账户余额200,C账户余额300,真的是这样吗?我们假设线程A和线程B同时在两个不同的CPU上执行。线程A执行从A账户转100到B账户的操作,线程B执行从B账户转100到C账户的操作,这两个线程是否互斥?显然不是,根据TransferAccount的代码,线程A锁定了账户A的实例,线程B锁定了账户B的实例。因此,线程A和线程B可以同时进入transfer()方法。此时,线程A和线程B都可以读到B账户余额为200。两个线程完成转账操作后,B账户余额可能是300,也可能是100,但不能是200,这是为什么呢?线程A和线程B同时读取到账户B的余额为200。如果线程A的转账操作晚于线程B的转账操作写入余额,则账户B的余额为300;如果线程A的转账操作早于线程B的转账操作写入余额,那么账户B的余额为100。无论如何,账户B的余额都不会是200。综上所述,TransferAccount的代码解决不了并发问题!正确加锁如果我们要加锁转账操作涉及的多个资源,那么我们的锁必须覆盖所有需要保护的资源。在之前的TransferAccount类中,这是一个对象级的锁,导致线程A和线程B在执行过程中获取的锁不同,那么如何让两个线程共享同一个锁呢?!其中,有很多解决方案。一种简单的方法是在TransferAccount类的构造函数中传入一个balanceLock锁对象。在创建TransferAccount类对象时,每次传入同一个balanceLock锁对象,在transfer方法中使用。balanceLock锁对象可以上锁。这样所有创建的TransferAccount类对象都会共享balanceLock锁。代码如下所示。publicclassTansferAccount{privateIntegerbalance;privateObjectbalanceLock;privateTansferAccount(){}publicTansferAccount(ObjectbalanceLock){this.balanceLock=balanceLock;}publicvoidtransfer(TansferAccounttarget,IntegertransferMoney){同步(this.balanceLock){if(this.Mobalanceney)>=transfer{-=transferMoney;target.balance+=transferMoney;}}}}那么,问题又来了:真的完美解题了吗?!上面的代码虽然解决了转账操作的并发问题,但是真的完美吗?!经过仔细分析,我们发现并没有想象中的那么完美。因为它要求在创建TansferAccount对象时,必须传入同一个balanceLock对象,如果不传入同一个balanceLock对象,并发带来的线程安全问题就得不到保证!在实际项目中,创建一个TansferAccount对象的操作可能会分散在多个不同的project项目中,因此很难保证传入的balanceLock对象是同一个对象。因此,虽然在创建TansferAccount对象时传入同一个balanceLock锁对象的方案可以解决转账的并发问题,但在实际项目中并不能有效采用!还有其他方案吗?答案是肯定的!别忘了JVM在给一个类加锁的时候,会为这个类创建一个Class对象,这个Class对象是和这个类的实例对象共享的,也就是说不管创建了多少个类的实例对象,Class对象也是一样的,这是JVM保证的。说到这里,我们可以想到使用下面的方法来锁定传输操作。publicclassTansferAccount{privateIntegerbalance;publicvoidtransfer(TansferAccounttarget,IntegertransferMoney){synchronized(TansferAccount.class){if(this.balance>=transferMoney){this.balance-=transferMoney;target.balance+=transferMoney;}}}}我们可以使用下面的图表达了这个逻辑。这样无论创建多少个TransferAccount对象,它们都会共享同一个锁,解决了transfer的并发问题。写在最后,如果觉得文章对你有帮助,请在微信搜索并关注“冰河科技”微信公众号,向冰河学习高并发编程技术。最后附上并发编程需要掌握的核心技能知识图谱。祝大家在学习并发编程的时候少走弯路。后记记住:让你比别人强的,不是你做了多少年的CRUD工作,而是你掌握了比别人更深入的技能。不要总是停留在CRUD的表面,了解和掌握底层原理并熟悉源码实现,形成自己的抽象思维能力,才能灵活运用。这是你突破瓶颈,脱颖而出的重要方向!你在刷抖音,玩游戏的时候,别人在这里学习,成长,提高。人与人之间最大的差距,其实就是思维。你可能不相信,优秀的人总是在一起。.本文转载自微信公众号“冰河科技”,可通过以下二维码关注。转载本文请联系冰川科技公众号。