并发编程之五—你还不了解的互斥锁

在之前的《并发编程学习笔记之二中并发问题的源头》了解到引发原子性问题主要是线程切换。在Java中一行代码最后可能会被翻译成多条计算机指令,更何况是代码块或是方法。一个或者多个操作在CPU中执行不被中断,称之其具有原子性。

线程切换依赖的是CPU中断。在单核CPU,阻止CPU中断是一间相对可行的事,只要保证同一时刻只有一个线程执行就可以。不过到了多核CPU下,禁止中断CPU只能保证CPU上的线程同时执行,但是并不能保证同一时刻只有一条线程执行。那么还有如何保证同一时刻只有一条线程去修改共享变量(也就是互斥)的有效方法吗?

简易锁模型

 

如图就是一个简易锁模型,蓝色的部分就是临界区也就是每个线程间互斥的部分(也是受保护的资源)。线程在进入临界区之前进行加锁lock()操作,持有锁的线程执行临界区代码后会进行解锁unlock()操作。这就是最简单的模型。就好像停车,当有空车位的时候,你可以把车停上去,也就是获得这个车位的锁。车位就是临界区,在这段时间别的车是无法停靠到你的车位的,当你车子开走的时候的,也就是释放锁的过程,其他车可以停到这个车位了。

指定目标的锁

加锁可以解决并发问题,但是前提是加对锁,锁对资源。就比如在小区,把车停到别人家车库,那是肯定不行的。那把上面的简易锁模型改进下就是:

 

这样是不是就明确了是自家锁,去锁自家的资源了。首先,我们要把临界区要保护的资源标注出来,如图中临界区里增加了一个元素:受保护的资源 R;其次,我们要保护资源 R 就得为它创建一把锁 R;最后,针对这把锁 R,我们还需在进出临界区时添上加锁操作和解锁操作。另外,在锁 R 和受保护资源之间,我特地用一条线做了关联,这个关联关系非常重要。

锁在代码中的体现:

锁是一种通用的技术,在Java中synchronized就是一种锁的体现,synchronized可以修饰静态方法,非静态方法,代码块等。

代码事例:

class X {

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

synchronized的加锁和解锁unlock的动作是被java默认加上的,是为了操作的一把锁。而对于锁定的对象,synchronized也有默认的规则:

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

还记得count+=1的原子性问题吗,现在有了锁就迎刃而解了。代码如下:

class SafeCalc {
    long value = 0L;
    long get() {
    return value;
}

    synchronized void addOne() {
        value += 1;
    }
}

这里加锁的本意是任意时刻只有一条线程进去临界区。所以此时无论是一个CPU还是多个CPU访问addOne方法执行10000次,得到的最count的值都是10000。但是还有一个问题就是get获取最终的值会是10000吗?这个还真不一定。回想下锁的Happens-Before原则,“对一个锁解锁 Happens-Before 后续对这个锁的加锁”,指的是前一个线程的解锁操作对后一个线程的加锁操作可见,综合 Happens-Before 的传递性原则,我们就能得出前一个线程在临界区修改的共享变量(该操作在解锁之前),对后续进入临界区(该操作在加锁之后)的线程是可见的。而get方法没有加锁,所以不满意Happens-Before原则,所以get方法获取最终值不一定是10000。当然解决的方式也是给get方法加锁。此时应该你应该注意到锁和资源应该是有一定关系的。

锁与资源的关系

锁与资源的关系可以是一对多,也就是一把锁可以锁多个资源。但是不能多把锁去一个资源。比如我们去看球赛,一张门票可以免费停车,那么就是一张门票可以锁定一个座位和一个车位。不可以出现两张重复的票去锁定一个座位或者车位。同时还有一个问题,锁是不可变得。你不能用昨天票再来看今天的球赛,也不能用其他羽毛球比赛的票来看NBA的比赛。

如何用一把锁锁定多个资源

一把锁保护多个没有关联关系的资源可以解决并发问题,也很符合我们的惯性思维。但是所有操作都是串行化的话,就会产生性能方面的问题,估计你自己也接受不了。最佳的解决方法就是将锁细化也就是细粒度锁。用不同的锁对受保护资源进行精细化管理。来看下银行账户类的例子:

在一个账户类下有余额和密码两个成员变量。取款和查询余额操作余额这个资源;修改密码和查看密码是操作的密码这个资源。两个资源没有绝对的关联关系。我们用一个final 对象 balLock 作为锁锁定余额,用一个 final 对象 pwLock 作为锁锁定密码。代码如下:

class Account {
    // 锁:保护账户余额
    private final Object balLock = new Object();
    // 账户余额
    private Integer balance;
    // 锁:保护账户密码
    private final Object pwLock = new Object();
    // 账户密码
    private String password;
    // 取款
    void withdraw(Integer amt) {
        synchronized(balLock) {
            if (this.balance > amt){
                this.balance -= amt;
                }
            }
        }

    // 查看余额
    Integer getBalance() {
        synchronized(balLock) {
            return balance;
        }
    }
    // 更改密码
    void updatePassword(String pw){
        synchronized(pwLock) {
            this.password = pw;
        }
    }
    // 查看密码
    String getPassword() {
        synchronized(pwLock) {
            return password;
        }
    }
}

取款,查询余额和查看密码,修改密码用同一把锁来,四个操作只能串行化,同一时刻只能执行一个操作,而现在不同的锁锁不同的资源,操作密码和操作余额操作是可以并行的,效率大大提高,资源管理更细化,性能得到提高。总结一下就是:不相关的资源,用不同的锁去保护。

用锁来保护有关联关系的资源

什么是有关联关系的资源,拿银行的转账业务举个例子,账户A给账户B转100元,账户A减少100元,账户B增加100元,两个账户是有关联关系的。代码示例如下:

class Account {
    private int balance;
    // 转账
    synchronized void transfer(Account target, int amt){
        if (this.balance > amt) {
            this.balance -= amt;
            target.balance += amt;
        }
    }
}

声明一个账户类,再声明一个账户类的成员方法余额,一个被锁修饰的转账方法。此时synchronized锁住的是两个资源一个是当前账户,一个目标账户。看起来貌似没有什么问题。临界区的两个资源被同一把锁住。但是问题就是出在这把锁上。在非静态方法中,synchronized锁住的是当前对象this,也就是当前账户,那么目标同时给其他转账时,那目标账户读到的值一定就是最新的吗?

 

再来具体分析下:两条线程同时进行转账操作,涉及 A、B、C三个账户都是200元。线程1由A账户给B账户转账100元,线程2由账户B给账户C转账100元。理想情况是A账户最终是100元,B账户是200元,C账户是300元。实际可能并不是这样的。

        假设线程1和2在两个CPU上执行,那么线程1和线程2都可以进去临界区,因为线程1锁的账户A,线程2锁的的账户B。那么就有可能线程1和2读到账户B的余额都是200元。假如线程1先于线程2写balance,线程1写的balance会被线程2写的balance覆盖,那账户B的余额就是100元;如果线程2先于线程1写balance,线程2写的balance会被线程1的balance覆盖,那么账户B的余额会是300元,就是不可能是200元。流程图如下:

上面发生的问题是每个资源都是自己的锁锁自己的资源。这对于有关联的资源显然是行不通的。那么该如何用一把锁去锁住两个资源呢?

       没错,可以新建一个对象,让所有对象都总有一个唯一的对象,在创建账户的时候传入。这样就可以保证多个资源共享一把锁。不过这里有一个明显的弊端就是每创建一个账户对象就需要把这个锁传入,这实施起来就很有难度了,毕竟在实施可能是多个工程下,这样把保证传入的是一个lock锁对象,想想都觉得头疼。同时,如果传入的不是一个锁对象的话,那问题就更严重了。那还有没有更优的做法?这个确实有。代码如下:

 

class Account {
    private int balance;
    // 转账
    void transfer(Account target, int amt){
        synchronized(Account.class) {
            if (this.balance > amt) {
                this.balance -= amt;
                target.balance += amt;
            }
        }
    }
}

用 Account.class 作为共享的锁的优势是不毕再担心是否会是一把锁了。Account.class 是所有 Account 对象共享的,而且这个对象是 Java 虚拟机在加载 Account 类的时候创建的,所以不用担心它的唯一性。问题就这样解决了。流程图如下:

 

引申:转账的操作其实就是为了保证转账过程“原子性”的特征,只不过这里的原子性是面向Java的,而JMM中的原子性是面向CPU指令的。而原子性的本质就是其实不是不可分割,不可分割只是外在表现,其本质是多个资源间有一致性的要求,操作的中间状态对外不可见。解决原子性的本质就是保证中间状态对外不可见。

死锁问题是如何产生的?

在上面的银行转账的例子中,共享锁的问题虽然解决了一把锁保护多个资源的并发问题,但是也产生了转账串行化的问题。在现实生活中,账户A向账户B转账,账户B向账户C转账的操作本来是可以并行的,但是共享锁却把他们串行化了,这么差的性能怎么能够接受?所以上面的例子是脱离实际的。不过在这之前貌似有一种将锁细化的得方法能解决串行化的问题,话不多说,

上代码:

class Account {
    private int balance;
    // 转账
    void transfer(Account target, int amt){
    // 锁定转出账户
        synchronized(this) {
        // 锁定转入账户
            synchronized(target) {
                if (this.balance > amt) {
                    this.balance -= amt;
                    target.balance += amt;

                }
            }
        }
     }
}

细粒度锁解决了串行化的问题,但是这样我们写付出了一定的代码,上述代码执行过程中会遇到这样的问题:

       假设两个线程同时进行转账操作,线程1执行账户A向账户B转账,线程2执行账户B向账户A转账。此时两个线程可进去transfer获取到转出账户的锁,线程1获得账户A的锁,线程2获得账户B的锁。两条线程继续执行获取转入账户的锁,问题就来了,线程1需要获取账户B的锁,可是线程 2还没有释放,所以就进入等待状态(synchronized不会主动释放锁);而线程2需要获取账户A的锁,而账户A的锁还在被线程1持有,所以也进去等待尝试阶段。最终结果就是两条线程获取不到想要的锁,就会死等下去,这也就是死锁。

         产生死锁,一直等待下下去,CPU资源占用会飙升,直到拖垮整个应用。最简单的方式就是重启应用,可这不能避免下次死锁的产生。死锁有没有发生的必要条件,还真有,大牛,Coffman 早就总结过了,只有以下这四个条件都发生时才会出现死锁:

  • 互斥,共享资源 X 和 Y 只能被一个线程占用;
  • 占有且等待,线程 1 已经取得共享资源 X,在等待共享资源 Y 的时候,不释放共享资源 X;
  • 不可抢占,其他线程不能强行抢占线程 1 占有的资源;
  • 循环等待,线程 1 等待线程 2占有的资源,线程 2等待线程 1占有的资源,就是循环等待。

在已经产生死锁的四个必须条件,那么只需要破坏掉其中一个条件死锁也不会发生了:

用锁就是为了互斥,所以互斥的条件是破坏不了的,那么其他三个条件呢:

  • 对于“占用且等待”这个条件,我们可以一次性申请所有的资源,这样就不存在等待了。在代码中也就是一次获得账户A和账户B
  • 对于“不可抢占”这个条件,占用部分资源的线程进一步申请其他资源时,如果申请不到,可以主动释放它占有的资源,这样不可抢占这个条件就破坏掉了。对应代码中,如果线程1执行,获取不到账户B,那就释放掉账户A;
  • 对于“循环等待”这个条件,可以靠按序申请资源来预防。所谓按序申请,是指资源是有线性顺序的,申请的时候可以先申请资源序号小的,再申请资源序号大的,这样线性化后自然就不存在循环了。

那么如何优化银行账户的例子呢,先来看如何破坏“占用且等待”的条件。要保证一次获取全部资源,重新定义一个类Alocator,声明一个全局的list用来存储账户资源。方法apply用来存储资源,free方法用来归还资源。在transfer方法之前先尝试获取资源,如果获取全部资源再进行转账操作,转账结束释放资源。要注意的是Alocator必须是单例,也只能是单例。代码如下:

class Allocator {
    private List<Object> als =new ArrayList<Object>();
    // 一次性申请所有资源
    synchronized boolean apply(Object from, Object to){
        if(als.contains(from) ||
            als.contains(to)){
            return false;
        } else {
            als.add(from);
            als.add(to);
        }

        return true;
}

    // 归还资源
    synchronized void free(Object from, Object to){
        als.remove(from);
       als.remove(to);
      }
}

class Account {
  // actr 应该为单例
  private Allocator actr;
  private int balance;
  // 转账
  void transfer(Account target, int amt){
    // 一次性申请转出账户和转入账户,直到成功
    while(!actr.apply(this, target))
      ;
    try{
      // 锁定转出账户
      synchronized(this){              
        // 锁定转入账户
        synchronized(target){           
          if (this.balance > amt){
            this.balance -= amt;
            target.balance += amt;
          }
        }
      }
    } finally {
      actr.free(this, target)
    }
  } 
}

如何破坏不可抢占资源?synchronized获取不到资源的时候会进入阻塞状态,不会释放已锁定的资源。只有sdk中的lock锁来替换下。

        破坏这个条件,需要对资源进行排序,然后按序申请资源。这个实现非常简单,我们假设每个账户都有不同的属性 id,这个 id 可以作为排序字段,申请的时候,我们可以按照从小到大的顺序来申请。比如下面代码中,①~⑥处的代码对转出账户(this)和转入账户(target)排序,然后按照序号从小到大的顺序锁定账户。这样就不存在“循环”等待了。

class Account {

    private int id;
    private int balance;
    // 转账
    void transfer(Account target, int amt){
        Account left = this ①
        Account right = target; ②
          if (this.id > target.id) { ③
            left = target; ④
            right = this; ⑤
        } ⑥
        // 锁定序号小的账户
        synchronized(left){
            // 锁定序号大的账户
            synchronized(right){
                if (this.balance > amt){
                    this.balance -= amt;
                    target.balance += amt;
                }
            }
        }
    }
}

以上的形式都是用自己

引申:到底互斥锁的是什么?

在上提到每一个对象都可以作为锁:

  • 对于普通同步方法,锁是当前实例对象。
  • 对于静态同步方法,锁是当前类的Class对象。
  • 对于同步方法块,锁是Synchonized括号里配置的对象

从JVM规范中Synchonized在JVM里的实现原理,JVM基于进入和退出Monitor对象来实现方法同步和代码块同步,但两者的实现细节不一样。代码块同步是使用monitorenter和monitorexit指令实现的,而方法同步是使用另外一种方式实现的,细节在JVM规范里并没有详细说明。但是,方法的同步同样可以使用这两个指令来实现。

        monitorenter指令是在编译后插入到同步代码块的开始位置,而monitorexit是插入到方法结束处和异常处,JVM要保证每个monitorenter必须有对应的monitorexit与之配对。任何对象都有一个monitor与之关联,当且一个monitor被持有后,它将处于锁定状态。线程执行到monitorenter指令时,将会尝试获取对象所对应的monitor的所有权,即尝试获得对象的锁。

        synchronized用的锁是存在Java对象头里的。如果对象是数组类型,则虚拟机用3个字宽(Word)存储对象头,如果对象是非数组类型,则用2字宽存储对象头。在32位虚拟机中,1字宽等于4字节,即32bit。Java对象头里的Mark Word里默认存储对象的HashCode、分代年龄和锁标记位。

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章