java并发编程实战《四》互斥锁(下)
互斥锁(下):如何用一把锁保护多个资源?
一把锁可以保护多个资源,但是不能用多把锁来保护一个资源。
那如何保护多个资源?
当我们要保护多个资源时,首先要区分这些资源是否存在关联关系。
如下代码
1 class Account { 2 // 锁:保护账户余额 3 private final Object balLock = new Object(); 4 // 账户余额 5 private Integer balance; 6 // 锁:保护账户密码 7 private final Object pwLock = new Object(); 8 // 账户密码 9 private String password; 10 11 // 取款 12 void withdraw(Integer amt) { 13 synchronized(balLock) { 14 if (this.balance > amt){ 15 this.balance -= amt; 16 } 17 } 18 } 19 // 查看余额 20 Integer getBalance() { 21 synchronized(balLock) { 22 return balance; 23 } 24 } 25 26 // 更改密码 27 void updatePassword(String pw){ 28 synchronized(pwLock) { 29 this.password = pw; 30 } 31 } 32 // 查看密码 33 String getPassword() { 34 synchronized(pwLock) { 35 return password; 36 } 37 } 38 }
账户类 Account 有两个成员变量,分别是账户余额 balance 和账户密码 password。取款 withdraw() 和查看余额 getBalance() 操作会访问账户余额 balance,我们创建一个 final 对象 balLock 作为锁;而更改密码 updatePassword() 和查看密码 getPassword() 操作会修改账户密码 password,我们创建一个 final 对象 pwLock 作为锁。不同的资源用不同的锁保护,各自管各自的,很简单。
当然,我们也可以用一把互斥锁来保护多个资源,例如我们可以用 this 这一把锁来管理账户类里所有的资源:账户余额和用户密码。具体实现很简单,示例程序中所有的方法都增加同步关键字 synchronized 就可以了。
但是用一把锁有个问题,就是性能太差,会导致取款、查看余额、修改密码、查看密码这四个操作都是串行的。而我们用两把锁,取款和修改密码是可以并行的。用不同的锁对受保护资源进行精细化管理,能够提升性能。这种锁还有个名字,叫细粒度锁。
保护有关联关系的多个资源
在王老师写到的例子中有这样一个案例分析:
1 class Account { 2 private int balance; 3 // 转账 4 synchronized void transfer( 5 Account target, int amt){ 6 if (this.balance > amt) { 7 this.balance -= amt; 8 target.balance += amt; 9 } 10 } 11 }
假设有 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。 |
我把这个图重新画了一遍,应该能更贴合老师的意思:
注意红框处,代表线程执行结束,对应的也就是案例中加粗的位置。
为什么结果会是这个样子?
因为balance属于成员变量,被线程共享(线程在执行各自的方法时,对含有成员变量操作的方法会将成员变量拷贝到自己的工作内存<栈>进行操作),所以各自线程都只会操作各自的balance,而线程2的执行结果虽然写回到了主存(hp原则,解锁操作的结果对后续加锁操作可见),但是由于线程1执行完了后也会写回主存,所以导致线程2的balance被线程1的balance覆盖。
使用锁的正确姿势
所以该如何解决上述问题呢?
很简单,只要我们的锁能覆盖所有受保护资源就可以了。在上面的例子中,this 是对象级别的锁,所以 A 对象和 B 对象都有自己的锁,如何让 A 对象和 B 对象共享一把锁呢?
比如可以让所有对象都持有一个唯一性的对象,这个对象在创建 Account 时传入。
示例代码如下,我们把 Account 默认构造函数变为 private,同时增加一个带 Object lock 参数的构造函数,创建 Account 对象时,传入相同的 lock,这样所有的 Account 对象都会共享这个 lock 了。(怎么保证传入的这个lock是同一个lock?)
在真实的项目场景中,创建 Account 对象的代码很可能分散在多个工程中,传入共享的 lock 真的很难。
所以,上面的方案缺乏实践的可行性,我们需要更好的方案。比如用 Account.class 作为共享的锁。Account.class 是所有 Account 对象共享的,而且这个对象是 Java 虚拟机在加载 Account 类的时候创建的,所以我们不用担心它的唯一性。
使用 Account.class 作为共享的锁,我们就无需在创建 Account 对象时传入了,代码更简单。
1 class Account { 2 private int balance; 3 // 转账 4 void transfer(Account target, int amt){ 5 synchronized(Account.class) { 6 if (this.balance > amt) { 7 this.balance -= amt; 8 target.balance += amt; 9 } 10 } 11 } 12 }
但是,使用Account.class获得锁,那所有转账操作都成串行了,这里实践中不可行,下一篇笔记讲优化。
总结
如果资源之间没有关系,很好处理,每个资源一把锁就可以了。如果资源之间有关联关系,就要选择一个粒度更大的锁,这个锁应该能够覆盖所有相关的资源。除此之外,还要梳理出有哪些访问路径,所有的访问路径都要设置合适的锁。
“原子性”的本质是什么?其实不是不可分割,不可分割只是外在表现,其本质是多个资源间有一致性的要求,操作的中间状态对外不可见。
解决原子性问题,是要保证中间状态对外不可见。
课后思考
在第一个示例程序里,我们用了两把不同的锁来分别保护账户余额、账户密码,创建锁的时候,我们用的是:private final Object xxxLock = new Object();,如果账户余额用 this.balance 作为互斥锁,账户密码用 this.password 作为互斥锁,你觉得是否可以呢?
不行。this.balance 和this.password都属于可变对象,均不能作为锁。
引自极客时间用户
可以在Account中添加一个静态object,通过锁这个object来实现一个锁保护多个资源,如下:
1 class Account { 2 private static Object lock = new Object(); 3 private int balance; 4 // 转账 5 void transfer(Account target, int amt){ 6 synchronized(lock) { 7 if (this.balance > amt) { 8 this.balance -= amt; 9 target.balance += amt; 10 } 11 } 12 } 13 }
老师回复:这种方式比锁class更安全(???why),因为这个锁是私有的。有些最佳实践要求必须这样做。
摘自极客时间王宝令老师的课程