04 | 互斥锁(下):如何用一把锁保护多个资源?
下载APP
关闭
渠道合作
推荐作者
04 | 互斥锁(下):如何用一把锁保护多个资源?
2019-03-07 王宝令 来自北京
《Java并发编程实战》
课程介绍
讲述:王宝令
时长09:32大小8.71M
在上一篇文章中,我们提到受保护资源和锁之间合理的关联关系应该是 N:1 的关系,也就是说可以用一把锁来保护多个资源,但是不能用多把锁来保护一个资源,并且结合文中示例,我们也重点强调了“不能用多把锁来保护一个资源”这个问题。而至于如何保护多个资源,我们今天就来聊聊。
当我们要保护多个资源时,首先要区分这些资源是否存在关联关系。
保护没有关联关系的多个资源
在现实世界里,球场的座位和电影院的座位就是没有关联关系的,这种场景非常容易解决,那就是球赛有球赛的门票,电影院有电影院的门票,各自管理各自的。
同样这对应到编程领域,也很容易解决。例如,银行业务中有针对账户余额(余额是一种资源)的取款操作,也有针对账户密码(密码也是一种资源)的更改操作,我们可以为账户余额和账户密码分配不同的锁来解决并发问题,这个还是很简单的。
相关的示例代码如下,账户类 Account 有两个成员变量,分别是账户余额 balance 和账户密码 password。取款 withdraw() 和查看余额 getBalance() 操作会访问账户余额 balance,我们创建一个 final 对象 balLock 作为锁(类比球赛门票);而更改密码 updatePassword() 和查看密码 getPassword() 操作会修改账户密码 password,我们创建一个 final 对象 pwLock 作为锁(类比电影票)。不同的资源用不同的锁保护,各自管各自的,很简单。
当然,我们也可以用一把互斥锁来保护多个资源,例如我们可以用 this 这一把锁来管理账户类里所有的资源:账户余额和用户密码。具体实现很简单,示例程序中所有的方法都增加同步关键字 synchronized 就可以了,这里我就不一一展示了。
但是用一把锁有个问题,就是性能太差,会导致取款、查看余额、修改密码、查看密码这四个操作都是串行的。而我们用两把锁,取款和修改密码是可以并行的。用不同的锁对受保护资源进行精细化管理,能够提升性能。这种锁还有个名字,叫细粒度锁。
保护有关联关系的多个资源
如果多个资源是有关联关系的,那这个问题就有点复杂了。例如银行业务里面的转账操作,账户 A 减少 100 元,账户 B 增加 100 元。这两个账户就是有关联关系的。那对于像转账这种有关联关系的操作,我们应该怎么去解决呢?先把这个问题代码化。我们声明了个账户类:Account,该类有一个成员变量余额:balance,还有一个用于转账的方法:transfer(),然后怎么保证转账操作 transfer() 没有并发问题呢?
相信你的直觉会告诉你这样的解决方案:用户 synchronized 关键字修饰一下 transfer() 方法就可以了,于是你很快就完成了相关的代码,如下所示。
在这段代码中,临界区内有两个资源,分别是转出账户的余额 this.balance 和转入账户的余额 target.balance,并且用的是一把锁 this,符合我们前面提到的,多个资源可以用一把锁来保护,这看上去完全正确呀。真的是这样吗?可惜,这个方案仅仅是看似正确,为什么呢?
问题就出在 this 这把锁上,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 元。
我们假设线程 1 执行账户 A 转账户 B 的操作,线程 2 执行账户 B 转账户 C 的操作。这两个线程分别在两颗 CPU 上同时执行,那它们是互斥的吗?我们期望是,但实际上并不是。因为线程 1 锁定的是账户 A 的实例(A.this),而线程 2 锁定的是账户 B 的实例(B.this),所以这两个线程可以同时进入临界区 transfer()。同时进入临界区的结果是什么呢?线程 1 和线程 2 都会读到账户 B 的余额为 200,导致最终账户 B 的余额可能是 300(线程 1 后于线程 2 写 B.balance,线程 2 写的 B.balance 值被线程 1 覆盖),可能是 100(线程 1 先于线程 2 写 B.balance,线程 1 写的 B.balance 值被线程 2 覆盖),就是不可能是 200。
并发转账示意图
使用锁的正确姿势
在上一篇文章中,我们提到用同一把锁来保护多个资源,也就是现实世界的“包场”,那在编程领域应该怎么“包场”呢?很简单,只要我们的锁能覆盖所有受保护资源就可以了。在上面的例子中,this 是对象级别的锁,所以 A 对象和 B 对象都有自己的锁,如何让 A 对象和 B 对象共享一把锁呢?
稍微开动脑筋,你会发现其实方案还挺多的,比如可以让所有对象都持有一个唯一性的对象,这个对象在创建 Account 时传入。方案有了,完成代码就简单了。示例代码如下,我们把 Account 默认构造函数变为 private,同时增加一个带 Object lock 参数的构造函数,创建 Account 对象时,传入相同的 lock,这样所有的 Account 对象都会共享这个 lock 了。
这个办法确实能解决问题,但是有点小瑕疵,它要求在创建 Account 对象的时候必须传入同一个对象,如果创建 Account 对象时,传入的 lock 不是同一个对象,那可就惨了,会出现锁自家门来保护他家资产的荒唐事。在真实的项目场景中,创建 Account 对象的代码很可能分散在多个工程中,传入共享的 lock 真的很难。
所以,上面的方案缺乏实践的可行性,我们需要更好的方案。还真有,就是用 Account.class 作为共享的锁。Account.class 是所有 Account 对象共享的,而且这个对象是 Java 虚拟机在加载 Account 类的时候创建的,所以我们不用担心它的唯一性。使用 Account.class 作为共享的锁,我们就无需在创建 Account 对象时传入了,代码更简单。
下面这幅图很直观地展示了我们是如何使用共享的锁 Account.class 来保护不同对象的临界区的。
总结
相信你看完这篇文章后,对如何保护多个资源已经很有心得了,关键是要分析多个资源之间的关系。如果资源之间没有关系,很好处理,每个资源一把锁就可以了。如果资源之间有关联关系,就要选择一个粒度更大的锁,这个锁应该能够覆盖所有相关的资源。除此之外,还要梳理出有哪些访问路径,所有的访问路径都要设置合适的锁,这个过程可以类比一下门票管理。
我们再引申一下上面提到的关联关系,关联关系如果用更具体、更专业的语言来描述的话,其实是一种“原子性”特征,在前面的文章中,我们提到的原子性,主要是面向 CPU 指令的,转账操作的原子性则是属于是面向高级语言的,不过它们本质上是一样的。
“原子性”的本质是什么?其实不是不可分割,不可分割只是外在表现,其本质是多个资源间有一致性的要求,操作的中间状态对外不可见。例如,在 32 位的机器上写 long 型变量有中间状态(只写了 64 位中的 32 位),在银行转账的操作中也有中间状态(账户 A 减少了 100,账户 B 还没来得及发生变化)。所以解决原子性问题,是要保证中间状态对外不可见。
课后思考
在第一个示例程序里,我们用了两把不同的锁来分别保护账户余额、账户密码,创建锁的时候,我们用的是:private final Object xxxLock = new Object();,如果账户余额用 this.balance 作为互斥锁,账户密码用 this.password 作为互斥锁,你觉得是否可以呢?
欢迎在留言区与我分享你的想法,也欢迎你在留言区记录你的思考过程。感谢阅读,如果你觉得这篇文章对你有帮助的话,也欢迎把它分享给更多的朋友。
分享给需要的人,Ta购买本课程,你将得18元
生成海报并分享
赞 76
提建议
© 版权归极客邦科技所有,未经许可不得传播售卖。 页面已增加防盗追踪,如有侵权极客邦将依法追究其法律责任。
上一篇
03 | 互斥锁(上):解决原子性问题
下一篇
05 | 一不小心就死锁了,怎么办?
精选留言(176)
- 少主江衫2019-03-07用this.balance 和this.password 都不行。在同一个账户多线程访问时候,A线程取款进行this.balance-=amt;时候此时this.balance对应的值已经发生变换,线程B再次取款时拿到的balance对应的值并不是A线程中的,也就是说不能把可变的对象当成一把锁。this.password 虽然说是String修饰但也会改变,所以也不行。老师所讲的例子中的两个Object无论多次访问过程中都未发生变化? 请老师指正。展开
作者回复: 正确,不能用可变对象做锁
共 6 条评论207 - 树森2019-03-07有个疑问,使用Account.class获得锁,那所有转账操作不是都成串行了,这里实践中可行吗?
作者回复: 不可行,下一期讲优化
共 8 条评论165 - senekis2019-03-07解决原子性问题,是要保证中间状态对外不见 太精辟了!共 6 条评论151
- 夜空中最亮的星2019-03-07我是一名普通的运维工程师,我是真看不懂java代码,我是来听思想的 。
作者回复: 那我就放心了
共 9 条评论80 - 老杨同志2019-03-07思考题: 我觉得不能用balance和password做为锁对象。这两个对象balance是Integer,password是String都是不可变变对象,一但对他们进行赋值就会变成新的对象,加的锁就失效了
作者回复: 是的
共 8 条评论80 - yuc2019-03-09是否可以在Account中添加一个静态object,通过锁这个object来实现一个锁保护多个资源,如下: class Account { private static Object lock = new Object(); private int balance; // 转账 void transfer(Account target, int amt){ synchronized(lock) { if (this.balance > amt) { this.balance -= amt; target.balance += amt; } } } }展开
作者回复: 这种方式比锁class更安全,因为这个缺是私有的。有些最佳实践要求必须这样做。👍
共 10 条评论56 - 别皱眉2019-03-13老师,很感谢有这个专栏,让我能够更加系统的学习并发知识。 对于思考题,之所以不可行是因为每次修改balance和password时都会使锁发生变化。 ----------------------------------------------------------------------- 以下只是我的猜想 比如有线程A、B、C 线程A首先拿到balance1锁,线程B这个时候也过来,发现锁被拿走了,线程B被放入一个地方进行等待。 当A修改掉变量balance的值后,锁由balance1变为balance2. 线程B也拿到那个balance1锁,这时候刚好有线程C过来,拿到了balance2锁。 由于B和C持有的锁不同,所以可以同时执行这个方法来修改balance的值,这个时候就有可能是线程B修改的值会覆盖掉线程C修改的值? ----------------------------------------------------------------------- 不知道到底是不是这样?老师可以详细讲下这个过程吗?谢谢展开
作者回复: 你分析的很仔细了,就是这样的,bc锁的不是一个对象。不能保证互斥性
共 2 条评论43 - wang2019-03-07不可以。因为balance为integer对象,当值被修改相当于换锁,还有integer有缓存-128到127,相当于同一个对象。
作者回复: 深刻!👍
34 - zhaozp2019-03-07可变对象不能作为锁
作者回复: 总结的到位
22 - 0bug2019-03-07思考题: 结论:不可行 原因:举个例子,假如this.balance = 10 ,多个线程同时竞争同一把锁this.balance,此时只有一个线程拿到了锁,其他线程等待,拿到锁的线程进行this.balance -= 1操作,this.balance = 9。 该线程释放锁, 之前等待锁的线程继续竞争this.balance=10的锁,新加入的线程竞争this.balance=9的锁,导致多个锁对应一个资源展开
作者回复: 分析的很仔细👍
共 3 条评论20 - 强哥2019-03-07文章里第二个例子根本无法用到实践中,锁力度太大,可以用乐观关锁解决,另外分布式的情况下,应该如何分析也应该讲讲?至于原子性其实跟数据库的原子性还是有差异的,例如虚拟机异常退出时,synchinzed也无法操作原子操作的。
作者回复: 分布式的不讲了,分支太多不好。下一期会讲优化
共 2 条评论12 - 多襄丸2019-03-07王老师, 您在第二讲中贴出的英文链接的地址很棒,看着您写过的专栏,再去看它,有种恍然大悟地感觉~! 恳请您还是在后续地专栏里,继续保持这种死磕并发基础地原汁原味地链接啊~! 您地专栏是您多年地理解与实战的营养,加上您亲自地朗读,当然也是原汁原味。但是我的意思是,我们应该有一批人很少看英文类的文档,所以才会有这种恳请~! 谢谢老师~!
作者回复: 感谢盛赞,我会继续保持的
10 - SnowsonZ2019-03-09老师,有个疑问,为什么作为互斥锁的对象一定要是final的?非final导致两个互斥锁的原因是什么?是工作内存从主内存拷贝的原因吗?
作者回复: 这是个最佳实践。只是为了防止一不小心改变了它。搞成final类型,就再也改变不了。只是最佳实践而已。
共 2 条评论9 - broccoli2020-05-07老师,我对评论区高赞同学@少主江衫的回答有不同的意见。 思考题的答案肯定是不能,原因是 balance 字段和 password 字段是可变的。但说它是可变的并不是指字段指向 Integer 对象和 String 对象是可变的(事实上在 openjdk 11 里实测 int 基本类型做引导锁并不会自动装箱,需要显式转换为包装类才能通过编译),而是指引用变量本身由于没有被 final 修饰所以是可变的,所以如果某处修改了引用指向的对象,就会出现 “多个锁管理同一个共享资源” 的问题。在这一阶段,问题的核心是 “引用可变”。 但在我们的例子中,哪怕字段被 final 修饰了,依然是不妥当的。这主要是因为 IntCache 和字符串常量池的存在,因此会出现不必要的锁竞争,从而降低系统性能。在这一阶段,问题的核心不是“锁对象本身可变”,而是可能存在 “锁复用问题”。 但本身用不会产生复用的 Integer 和 String 对象作为锁理论上应该是没有问题,比如用 new Integer() 或者 new String() 在堆上创建新的对象,和创建一个 Object 实例是一样的。 有什么问题还请老师指正,谢谢! 参考:https://wiki.sei.cmu.edu/confluence/display/java/LCK01-J.+Do+not+synchronize+on+objects+that+may+be+reused展开8
- 峰2019-03-07思考题,我的答案是不行,因为对象可变,所以导致加锁对象不一样。 然后感觉加锁的所有用户用同一个锁的粒度太大了,但如果每次转账操作,是不是可以同时加两个用户的锁,如果有先后顺序又可能有死锁问题。
作者回复: 下一期会讲这个
7 - 知行合一2019-07-29老师,写的时候加锁能理解,读的时候为啥要加锁呢
作者回复: 为了保证一定能读到最新的值
共 2 条评论6 - zyl2019-03-07请问这个画图软件是什么?谢谢
作者回复: 我已经确认过了,是PPT
6 - 2458649822019-09-29评论区都是大神,我在看的时候就在想,为什么不直接锁余额和密码字段要另外加个对象来锁。看了评论才明白。还有锁了整个class对象性能不是很低。令老师深懂我这种菜鸟的疑惑啊。很好
作者回复: 因为我也曾经很菜😂
4 - 不靠谱的琴谱2019-03-07之前评论可以忽略了一个因素,每次赋值都改变了这个对象。但是我有一个疑问 integer在-128~127之间 这个锁是不是有效的
作者回复: 不是有效的,其他功能也可能用到这个锁
4 - 忠艾一生2019-03-07这两把锁是会变的,所以无法保证互斥性。在第一个线程执行完之后,this.balance与this.password这两个对象锁都与第一个线程的对象锁是不一样的。 所以是不正确的。
作者回复: 回答正确
4