用Account.class作为互斥锁,来解决银行业务里面的转账问题,虽然这个方案不存在并发问题,但是所有账户的转账操作都是串行的,性能太差。账户A转账户B、账户C转账户D这两个转账操作现实世界中是可以并行的。
向现实世界要答案
现实世界中,账户转账操作是支持并发的,而且绝对是真正的并行,银行所有的窗口都可以做转账操作。
在古代没有信息化,账户的存在形式就是一个账本,而且每个账户都有一个账本,这些账本都统一存放在文件架上。银行柜员在做转账时,要去文件架上把转出账本和转入账本都拿到手,然后做转账。
- 文件架上恰好有转出账本和转入账本,那就同时拿走;
- 如果文件架上只有转出账本和转入账本之一,那这个柜员就先把文件架上有的账本拿到手,同时等着其他柜员把另外一个账本送回来;
- 转出账本和转入账本都没有,那这个柜员就等着两个账本都被送回来。
在编程的世界,用两把锁实现,转出账本一把,转入账本一把。在transfer()方法内部,首先尝试锁定转出账户this(先把转出账本拿到手),然后尝试锁定转入账户target(再把转入账本拿到手),只有当两者都成功时,才执行转账操作。
1 | class Account { |
没有免费的午餐
上述实现相当于用Account.class作为互斥锁,锁定的范围太大,而锁定两个账户范围就小得多-细粒度锁。使用细粒度锁可以提高并行度,是性能优化的一个重要手段。
使用细粒度锁的代价是,可能会导致死锁。
死锁的一个比较专业的定义是:一组互相竞争资源的线程因互相等待,导致“永久”阻塞的现象。(线程T1执行账户A转账给账户B,线程T2执行账户B转账给账户A。线程A拿到了账户A的锁,线程T2拿到了账户B的锁。线程T1等待线程T2释放账户B的锁,线程T2等待线程T1释放账户A的锁)
如何预防死锁
并发程序一旦死锁,一般没有特别好的方法,很多时候只能重启应用。解决死锁问题最好的方法还是规避死锁。
出现死锁的四个条件(Coffman):只要破话其中一个,就可以成功避免死锁的发生。
互斥,共享资源X和Y只能被一个线程占用;
占有且等待,线程T1已经取得共享资源X,在等待共享资源Y的时候,不释放共享资源X;
不可抢占,其他线程不能强行抢占线程T1占有的资源;
循环等待,线程T1等待线程T2占有的资源,线程T2等待线程T1占有的资源,就是循环等待。
“互斥”无法破坏,用锁为的就是互斥。
“占用且等待”,可以一次性申请所有的资源,这样就不存在等待了。
“不可抢占”,占用部分资源的线程进一步申请其他资源时,如果申请不到,可以主动释放它占有的资源,这样不可抢占这个条件就破坏掉了。
“循环等待”,可以靠按序申请资来预防。所谓按序申请,是指资源是有线性顺序的,申请的时候可以先申请资源序号小的,再申请资源序号大的,这样线程话后自然就不存在循环了。
破坏占用且等待条件
从理论上将,要破坏这个条件,可以一次性申请所有资源。在实现世界中,转账操作需要的资源有两个,一个是转出账户,另一个是转入账户,当着两个账户同时被申请时,可以增加一个账本管理员,然后只允许账本管理员从文件架上拿账本,也就是说柜员不能直接在文件架上拿账本,必须通过账本管理员才能拿到想要的账本。只有需要的两个账本都在文件架上才会拿出来,这样就保证了一次性申请所有资源。
对应到编程领域,“同时申请”这个操作是一个临界区,也需要有个角色(Java里面的类)来管理这个临界区,把这个角色定为Allocator。它有两个重要功能,分别是:同时申请资源apply()和同时释放资源free()。账户Account类里面持有一个Allocator的单例(必须是单例,只能由一个人来分配资源)。当账户Account在执行转账操作的时候,首先向Allocator同时申请转出账户和转入账户这两个资源,成功后再锁定这两个资源;当转账操作执行完,释放锁之后,需要通知Allocator同时释放转出账户和转入账户这两个资源。
1 | class Allocator { |
该方案与synchronized(Account.class)相比,synchronized(Account.class)锁了Account类相关的所有操作,只要与Account有关联,通通需要等待当前线程操作完成。而该方案则锁了一个全局对象(申请资源时),锁定了当前操作的两个相关的对象(转账时,如果不锁this和target时,不影响当前两个账户的其他操作,如改密等)。两种影响到的范围不同。
破坏不可抢占条件
破坏不可抢占条件的核心是要能够主动释放它占有的资源,这一点synchronized是做不到的。原因是synchronized申请资源的时候,如果申请不到,线程直接进入阻塞状态了,而线程进入阻塞状态,啥都干不了,也释放不了线程已经占有的资源。
Java在语言层次没有解决这个问题,在SDK层面解决了,java.util.concurrent这个包下面提供的Lock是可以轻松解决这个问题。
破坏循环等待条件
破坏这个条件,需要对资源进行排序,然后按序申请资源。
1 | //假设每个账户都有不同的属性id,这个id可以作为排序字段,申请的时候,可以按照从小到大的顺序来申请并锁定账户,这样就不存在循环等待了。 |
总结
编程世界遇到的问题,可以利用现实世界的模型来构思解决方案。但要仔细对比现实世界和编程世界里的各角色之间的差异。
用细粒度锁来锁定多个资源时,要注意死锁的问题。
预防死锁主要是破坏三个条件中的一个,有时预防死锁的成本也很高。转账的例子,破坏占用且等待条件的成本比破坏循环等待条件的成本高。破坏占用且等待条件,也是锁了所有的账户,而且还是用了死循环的方法,这里破坏循环等待条件是成本最低的一个方案。选择具体方案的时候,需要评估操作成本,选择成本最低的方案。