Java 中的 syncronized 你真的用对了吗

 

生活中随处可见并行的例子,并行 顾名思义就是一起进行的意思,同样的程序在某些时候也需要并行来提高效率,在上一篇文章中我们了解了 Java 语言对缓存导致的可见性问题、编译优化导致的顺序性问题的解决方法,下面我们就来看看 Java 中解决因线程切换导致的原子性问题的解决方案 -- 锁 。

 

说到锁我们并不陌生,日常工作中也可能经常会用到,但是我们不能只停留在用的层面上,为什么要加锁,不加锁行不行,不行的话会导致哪些问题,这些都是在使用加锁语句时我们需要考虑的。

 

来看一个使用 32 位的 CPU 写 long 型变量需不需要加锁的问题:

 

我们知道 long 型变量长度为 64 位,在 32 位 CPU 上写 long 型变量至少需要拆分成 2 个步骤:一次写 高 32 位,一次写低 32 位。

 

对于单核 CPU 来说,同一时刻只有一个线程在执行,禁止 CPU 中断就意味着禁止线程切换,获得 CPU 使用权的这个线程就会一直运行,所以 2 次写操作要么同时都被执行,要么都不被执行,单核 CPU 是保证原子性的。

 

对于多核 CPU,同一时刻,一个线程在 CPU-1 上运行,另一个线程在 CPU-2 上运行,此时禁止 CPU 切换,只能保证 CPU 上有线程运行,并不能保证同一时刻只有一个线程运行,如果两个线程同时都在写高位,那么得出的结果可就不正确了。

 

 所以,互斥修改共享变量这个条件非常重要,也就是说同一时刻只有一个线程在修改共享变量,只要保证这个条件,不论单核还是多核,操作就都是原子性的了。

 

一说到互斥、原子性,我们马上就想到了代码加锁,没错加锁是正确的选择,但是怎么加呢? 要想知道怎么加锁,首先我们要知道加锁锁的是什么以及我们想要保护的资源是什么,看下图说说锁的是什么,要保护的是什么呢?

 

Java 中的 syncronized 你真的用对了吗_第1张图片      图中锁的 M 资源,保护的也是 M 资源。

 

程序中的锁与现实中的锁也是类似的,每一把锁都有自己要保护的资源,这是至关重要的,如图保护资源 M 的锁为 LM,就像我家大门的锁保护我家,你家大门的锁保护你家一样,如果程序出现类似我家大门锁保护你家的情况,那么就会导致诡异的并发问题了。

 

了解了锁的是什么与保护的是什么之后,我们看看怎么加锁的问题,还是用 count += 1 的例子,看代码:

 

class Test{
  long value = 0L;
  long get() {
    return value;
  }
  synchronized void addOne() {
    value += 1;
  }
}
 
 

分析一下,这段代码中锁的是当前对象,要保护的资源是对象中的成员属性 value,这样的加锁方式开启10 个线程分别调用 10000次 addOne()方法,我们预期的结果是 value 最终会达到 100000,结果如何呢 ?

 

经过测试,addOne() 不加 synchronized 结果会出现小于 100000 的情况,加上 synchronized 结果符合我们的预期,针对测试结果,简要分析如下:

 

加锁之后,线程之间是互斥的,也就是说同一时刻只有一个线程执行,这样就原子性可以保证了。

 

那么可见性呢?一个线程操作结束后另一个线程能获取到上一个线程的操作结果吗?答案是肯定的,这就跟我们上一章说的 happen before 原则联系到一起了,“一个锁的解锁操作对另一个锁的加锁操作是可见的”,再结合传递性规则,一个锁在解锁前,对共享变量的修改,即解锁前对共享变量修改 happen before 于 这个锁的解锁,这个锁的解锁操作 happen before于另一个锁的加锁。

 

所以,解锁前对共享变量修改happen before于另一个锁的加锁,也就是说解锁前对共享变量修改对于另一个锁的加锁是可见的。

 

到这一切看似还挺完美,其实我们忽略了 get() 方法,多线程操作 get()  方法会是安全的吗?在没有任何前提操作的情况下,直接调用 get() 方法当然没问题,就是取值又不涉及修改。但是如果在执行 addOne() 方法后调用呢?显然,这时候 value 值的修改对 get()  方法是不可见的,happen before 中只说了锁的规则,这里要想保证可见性,对 get()方法也需要加上一把锁。代码如下:

 

class Test{
  long value = 0L;
  synchronized long get() {
    return value;
  }
  synchronized void addOne() {
    value += 1;
  }
}

这里我们用同一把锁,保护了共享资源 value。说到这,我们根据资源关系来将使用锁的情况分为两种:

 

  1. 保护没有关系的多个资源

     

  2. 保护有关系的多个资源

 

对于 1 的情况,由于属性之间没有关系,每个资源都用一把锁来控制,例如修改账户的密码、修改余额操作,密码与余额是没有关系的资源,分别用两把锁来控制即可,这种锁叫做细粒度锁,使用不同的锁对受保护的资源进行精细化管理,可以提升性能。

 

对于 2 的情况 ,则需要粒度更大的锁去保护多个资源,看下面这段代码:

 

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

乍一看,没问题,转账操作加了锁,妥妥的。其实则不然,看图就明白了:

 

Java 中的 syncronized 你真的用对了吗_第2张图片

 

现在这就是"用我家锁锁了你家"的典型例子,这时候临界区有多个资源,我们应该使用更大粒度的锁,看看这样改怎么样:

 

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 作为更大粒度的锁是可行的, class 就是我们常说的 “类模板”,在 JVM 中只会加载一次,所以所有 Account 对象的类模板都是相同的,这样就能够保证用一把大锁锁住了有关系的共享资源。

 

问题是解决了,仔细一想,如果用 Account.class 作为锁,那岂不是所有的转账操作都是串行了,这样肯定是不行的,生活中转账肯定也不是串行的,如果串行那效率真的是很太差了。

 

正确的方式应该是这样的:

 

class Account {
 //静态属性 替代 Account.class 作为一把大锁
  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;
      }
    }
  }
 

这样一改,效率就上来了,问题也解决了,实际在开发中我们这也是我们最常用的加锁的方式,使用静态成员属性作为锁去保护有关系的多个资源。

 

 

总结:

我们从导致并发 bug 的原子性问题解决办法---加锁入手,了解了常规加锁方式背后的逻辑---锁的是什么与保护的是什么,与加锁后变量的传递性规则,到最后不同资源关系对应着不同的加锁方式---细粒度锁,粗粒度锁。

 

如果想了解更多关于锁知识,请看我的这篇文章: 聊聊锁机制

 

你可能感兴趣的:(Java 中的 syncronized 你真的用对了吗)