互斥锁: 解决原子性问题

锁模型
image.png

synchronized关键字

synchronized 里的加锁 lock() 和解锁 unlock() 锁定的对象:

  • 修饰代码块;
  • 当修饰静态方法的时候,锁定的是当前类的 Class 对象,在上面的例子中就是 Class X;
  • 当修饰非静态方法的时候,锁定的是当前实例对象 this。 ```java

class X { // 修饰非静态方法 synchronized void foo() { // 临界区 } // 修饰静态方法 synchronized static void bar() { // 临界区 } // 修饰代码块 Object obj = new Object(); void baz() { synchronized(obj) { // 临界区 } } }

  1. <a name="4Nd41"></a>
  2. ### 锁和受保护资源之间的关系
  3. 受保护资源和锁之间的关联关系是 N:1 的关系, 即可以用一把锁来保护多个资源, 而不应该对于同一资源使用多把锁
  4. 示例: 这个受保护的资源就是静态变量 value,两个锁分别是 this 和 SafeCalc.class, 由于临界区 get() 和 addOne() 是用两个锁保护的,因此这两个临界区没有互斥关系,临界区 addOne() 对 value 的修改对临界区 get() 也没有可见性保证,这就导致并发问题了。
  5. ```java
  6. class SafeCalc {
  7. static long value = 0L;
  8. synchronized long get() {
  9. return value;
  10. }
  11. synchronized static void addOne() {
  12. value += 1;
  13. }
  14. }

image.png

保护没有关联关系的多个资源

用不同的锁对受保护资源进行精细化管理,能够提升性能。这种锁还有个名字,叫细粒度锁

示例: 此处用两把锁,取款和修改密码是可以并行的

  1. class Account {
  2. // 锁:保护账户余额
  3. private final Object balLock
  4. = new Object();
  5. // 账户余额
  6. private Integer balance;
  7. // 锁:保护账户密码
  8. private final Object pwLock
  9. = new Object();
  10. // 账户密码
  11. private String password;
  12. // 取款
  13. void withdraw(Integer amt) {
  14. synchronized(balLock) {
  15. if (this.balance > amt){
  16. this.balance -= amt;
  17. }
  18. }
  19. }
  20. // 查看余额
  21. Integer getBalance() {
  22. synchronized(balLock) {
  23. return balance;
  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. }

保护有关联关系的多个资源

示例:
假设有 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。

  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. }

image.png

改进方案: 使用Account.class作锁, 能够覆盖所有受保护资源

  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作为锁的话, 所有账户的转账工作都是串行的, 性能太差
优化如下: 使用两把锁, 先锁this, 再锁target

  1. class Account {
  2. private int balance;
  3. // 转账
  4. void transfer(Account target, int amt){
  5. // 锁定转出账户
  6. synchronized(this) {
  7. // 锁定转入账户
  8. synchronized(target) {
  9. if (this.balance > amt) {
  10. this.balance -= amt;
  11. target.balance += amt;
  12. }
  13. }
  14. }
  15. }
  16. }

使用细粒度锁可以提高并行度,是性能优化的一个重要手段,
但, 使用细粒度锁是有代价的,这个代价就是可能会导致死锁。

image.png
死锁: 一组互相竞争资源的线程因互相等待, 导致”永久”阻塞的现象
**
并发程序一旦死锁,一般没有特别好的方法,很多时候我们只能重启应用。因此,解决死锁问题最好的办法还是规避死锁。

发生死锁的条件:
1.互斥,共享资源 X 和 Y 只能被一个线程占用;
2.占有且等待,线程 T1 已经取得共享资源 X,在等待共享资源 Y 的时候,不释放共享资源 X;
3.不可抢占,其他线程不能强行抢占线程 T1 占有的资源;
4.循环等待,线程 T1 等待线程 T2 占有的资源,线程 T2 等待线程 T1 占有的资源,就是循环等待。
从破坏条件入手, 来规避死锁:
1.互斥==>无法破坏
2.占用且等待==>可以一次性申请所有资源, 则不存在等待问题
image.png

  1. class Allocator {
  2. private List<Object> als =
  3. new ArrayList<>();
  4. // 一次性申请所有资源
  5. synchronized boolean apply(
  6. Object from, Object to){
  7. if(als.contains(from) ||
  8. als.contains(to)){
  9. return false;
  10. } else {
  11. als.add(from);
  12. als.add(to);
  13. }
  14. return true;
  15. }
  16. // 归还资源
  17. synchronized void free(
  18. Object from, Object to){
  19. als.remove(from);
  20. als.remove(to);
  21. }
  22. }
  23. class Account {
  24. // actr应该为单例
  25. private Allocator actr;
  26. private int balance;
  27. // 转账
  28. void transfer(Account target, int amt){
  29. // 一次性申请转出账户和转入账户,直到成功
  30. while(!actr.apply(this, target))
  31. try{
  32. // 锁定转出账户
  33. synchronized(this){
  34. // 锁定转入账户
  35. synchronized(target){
  36. if (this.balance > amt){
  37. this.balance -= amt;
  38. target.balance += amt;
  39. }
  40. }
  41. }
  42. } finally {
  43. actr.free(this, target)
  44. }
  45. }
  46. }

3.不可抢占==>占用部分资源的线程进一步申请其他资源时,如果申请不到,可以主动释放它占有的资源
使用lock

4.循环等待==>
可以靠按序申请资源来预防**。所谓按序申请,是指资源是有线性顺序的,申请的时候可以先申请资源序号小的,再申请资源序号大的,这样线性化后自然就不存在循环了。

  1. class Account {
  2. private int id;
  3. private int balance;
  4. // 转账
  5. void transfer(Account target, int amt){
  6. Account left = this
  7. Account right = target;
  8. if (this.id > target.id) {
  9. left = target;
  10. right = this;
  11. }
  12. // 锁定序号小的账户
  13. synchronized(left){
  14. // 锁定序号大的账户
  15. synchronized(right){
  16. if (this.balance > amt){
  17. this.balance -= amt;
  18. target.balance += amt;
  19. }
  20. }
  21. }
  22. }
  23. }

用”等待-通知”机制优化循环等待

上例中的代码问题, 如果 apply() 操作耗时长,或者并发冲突量大的时候, 可能要循环上万次才能获取到锁,太消耗 CPU 了。

  1. // 一次性申请转出账户和转入账户,直到成功
  2. while(!actr.apply(this, target))

优化方案:
如果线程要求的条件(转出账本和转入账本同在文件架上)不满足,则线程阻塞自己,进入等待状态;当线程要求的条件(转出账本和转入账本同在文件架上)满足后,通知等待的线程重新执行。其中,使用线程阻塞的方式就能避免循环等待消耗 CPU 的问题。

“等待-通知”机制: 线程首先获取互斥锁,当线程要求的条件不满足时,释放互斥锁,进入等待状态;当要求的条件满足时,通知等待的线程,重新获取互斥锁。

注意:**
wait()、notify()、notifyAll() 方法操作的等待队列是互斥锁的等待队列,所以如果 synchronized 锁定的是 this,那么对应的一定是 this.wait()、this.notify()、this.notifyAll();如果 synchronized 锁定的是 target,那么对应的一定是 target.wait()、target.notify()、target.notifyAll() 。而且 wait()、notify()、notifyAll() 这三个方法能够被调用的前提是已经获取了相应的互斥锁,所以我们会发现 wait()、notify()、notifyAll() 都是在 synchronized{}内部被调用的。如果在 synchronized{}外部调用,或者锁定的 this,而用 target.wait() 调用的话,JVM 会抛出一个运行时异常:java.lang.IllegalMonitorStateException。

  1. class Allocator {
  2. private List<Object> als;
  3. // 一次性申请所有资源
  4. synchronized void apply(
  5. Object from, Object to){
  6. // 经典写法
  7. while(als.contains(from) ||
  8. als.contains(to)){
  9. try{
  10. wait();
  11. }catch(Exception e){
  12. }
  13. }
  14. als.add(from);
  15. als.add(to);
  16. }
  17. // 归还资源
  18. synchronized void free(
  19. Object from, Object to){
  20. als.remove(from);
  21. als.remove(to);
  22. notifyAll();
  23. }
  24. }

尽量使用 notifyAll()
notify() 是会随机地通知等待队列中的一个线程,而 notifyAll() 会通知等待队列中的所有线程

使用 notify() 的风险在于可能导致某些线程永远不会被通知到。举例:
假设我们有资源 A、B、C、D,线程 1 申请到了 AB,线程 2 申请到了 CD,此时线程 3 申请 AB,会进入等待队列(AB 分配给线程 1,线程 3 要求的条件不满足),线程 4 申请 CD 也会进入等待队列。我们再假设之后线程 1 归还了资源 AB,如果使用 notify() 来通知等待队列中的线程,有可能被通知的是线程 4,但线程 4 申请的是 CD,所以此时线程 4 还是会继续等待,而真正该唤醒的线程 3 就再也没有机会被唤醒了。

安全性, 活跃性以及性能问题

安全性问题

理论上线程安全的程序,就要避免出现原子性问题、可见性问题和有序性问题。

多个线程同时访问同一数据,并且至少有一个线程会写这个数据的时候,如果我们不采取防护措施,那么就会导致并发 Bug,对此还有一个专业的术语,叫做数据竞争(Data Race).

竞态条件,指的是程序的执行结果依赖线程执行的顺序; **在并发场景中,程序的执行依赖于某个状态变量,也就是类似于下面这样:

  1. if (状态变量 满足 执行条件) {
  2. 执行操作
  3. }

那面对数据竞争和竞态条件问题,又该如何保证线程的安全性呢?
其实这两类问题,都可以用互斥这个技术方案,而实现互斥的方案有很多,CPU 提供了相关的互斥指令,操作系统、编程语言也会提供相关的 API。从逻辑上来看,我们可以统一归为:锁。

活跃性问题

活跃性问题,指的是某个操作无法执行下去。我们常见的“死锁”就是一种典型的活跃性问题,当然除了死锁外,还有两种情况,分别是“活锁”和“饥饿”。

活锁:有时线程虽然没有发生阻塞,但仍然会存在执行不下去的情况,这就是所谓的“活锁”
类比现实世界里的例子,路人甲从左手边出门,路人乙从右手边进门,两人为了不相撞,互相谦让,路人甲让路走右手边,路人乙也让路走左手边,结果是两人又相撞了。这种情况,基本上谦让几次就解决了,因为人会交流啊。可是如果这种情况发生在编程世界了,就有可能会一直没完没了地“谦让”下去,成为没有发生阻塞但依然执行不下去的“活锁”。

解决“活锁”的方案很简单,谦让时,尝试等待一个随机的时间就可以了。

饥饿: 指的是线程因无法访问所需资源而无法执行下去的情况。**
“不患寡,而患不均”,如果线程优先级“不均”,在 CPU 繁忙的情况下,优先级低的线程得到执行的机会很小,就可能发生线程“饥饿”;持有锁的线程,如果执行的时间过长,也可能导致“饥饿”问题。

解决“饥饿”问题的方案很简单,有三种方案:一是保证资源充足,二是公平地分配资源(使用公平锁),三就是避免持有锁的线程长时间执行。
**

性能问题

“锁”的过度使用可能导致串行化的范围过大

第一,既然使用锁会带来性能问题,那最好的方案自然就是使用无锁的算法和数据结构了。**
在这方面有很多相关的技术,例如线程本地存储 (Thread Local Storage, TLS)、写入时复制 (Copy-on-write)、乐观锁等;Java 并发包里面的原子类也是一种无锁的数据结构;Disruptor 则是一个无锁的内存队列,性能都非常好……

第二,减少锁持有的时间。
互斥锁本质上是将并行的程序串行化,所以要增加并行度,一定要减少持有锁的时间。这个方案具体的实现技术也有很多,例如使用细粒度的锁,一个典型的例子就是 Java 并发包里的 ConcurrentHashMap,它使用了所谓分段锁的技术(这个技术后面我们会详细介绍);还可以使用读写锁,也就是读是无锁的,只有写的时候才会互斥。

性能方面的度量指标有很多,我觉得有三个指标非常重要,就是:吞吐量、延迟和并发量。

  • 吞吐量:指的是单位时间内能处理的请求数量。吞吐量越高,说明性能越好。
  • 延迟:指的是从发出请求到收到响应的时间。延迟越小,说明性能越好。
  • 并发量:指的是能同时处理的请求数量,一般来说随着并发量的增加、延迟也会增加。所以延迟这个指标,一般都会是基于并发量来说的。例如并发量是 1000 的时候,延迟是 50 毫秒。

并发编程是一个复杂的技术领域,微观上涉及到原子性问题、可见性问题和有序性问题,宏观则表现为安全性、活跃性以及性能问题。