并发编程有3个源头性问题:缓存导致的可见性问题,编译优化导致的有序性问题,以及线程切换导致的原子性问题。解决可见性问题和有序性问题的方法是按需禁用缓存和编译优化,Java的内存模型就是一种按需禁用缓存和编译优化的规则,它规定了 JVM 如何提供相关的方法,这些已经在Java内存模型与Hppens-Before规则进行了描述。

互斥锁,解决原子性问题

我们把一个或者多个操作在 CPU 执行过程中不被中断的特性称为原子性。由于操作系统的时间片轮转机制,以及高级语言可能包含多个指令,导致一句高级语言在执行过程中可能出现线程切换。在并发编程中就会因为线程切换导致原子性问题

锁模型是解决原子性问题的通用方案。线程在进入临界区之前必须持有锁,退出临界区时释放锁,此时其他线程就能再次获取锁。

锁与资源之间是 1:N 的关系,即一把锁可以保护多个资源。同时要注意不能用自己的锁保护别人的资源;要让代码实现互斥,必须使用同一把锁。

synchronized 关键字是 Java 语言对锁模型的实现,它可以修饰方法或者代码块,被修饰的方法和代码块会隐式地添加lock()unlock()方法。

什么是原子性问题

现代操作系统都是基于线程的分时调度系统,CPU会为线程分配时间片,线程分配都时间片就获取到CPU的使用权。比如说线程 A 读取文件,它可以将自己标记为「休眠状态」,让出 CPU 的使用权。文件读取完成之后,操作系统再将其唤醒,线程 A 就有机会重新获得 CPU 的使用权。

线程切换为什么导致并发问题呢?Java 是一门高级语言,高级语言的一条语句往往包含多个 CPU 指定,比如说 count += 1 这条语句,至少包含 3 条 CPU 指令:

  1. 把变量 count 从内存加载到 CPU 的寄存器;
  2. 在寄存器中执行 +1 操作;
  3. 将结果写入内存(缓存机制导致可能写入的是 CPU 缓存而不是内存)。

操作系统以指令为单位执行,期间伴随着线程切换。这就导致 count += 1 执行到一半,就有可能碰到线程切换,导致并发问题的产生,如下图所示:

线程切换导致的原子性问题

我们把一个或者多个操作在 CPU 执行过程中不被中断的特性称为原子性,即我们期望 count += 1 在执行过程中是原子一样的,不可分割的整体,线程切换不会在执行这条语句相关的CPU指令时发生,但允许线程切换在count += 1执行之前或者之后发生。

锁模型

锁模型是一种解决原子性问题的通用技术方案。在锁模型中,临界区是一段要互斥执行的代码,在进入临界区之前我们要执行 lock() 操作持有锁,只有获取到锁的线程才能执行临界区的代码;执行完临界区代码执行 unlock() 操作释放锁,此时其他线程就可以尝试获取锁。

锁模型

在现实生活中,我们用锁来保护我们的东西,但不能用自己的锁来锁别人的东西。在锁模型中,锁与临界区中被保护的资源也有着关联关系,图中用箭头来表示它们之间的关联。

我们不能用一把锁来保护范围之外的资源,代码要实现互斥则要使用同一把锁。

Java 语言提供的锁技术:synchronized

锁是一种通用的技术方案,Java 语言提供的 synchronized 关键字,就是锁的一种实现。synchronized 关键字可以用来修饰方法,也可以用来修饰代码块,它的使用示例基本上都是下面这个样子:

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

前面说过,锁模型中有锁以及它保护的资源,synchronized 修饰代码块的时候锁显然是 obj 对象,那么 synchronized 修饰非静态方法和静态方法的时候,它创建的锁是什么呢?

Java 中有一条隐式规则:

当修饰静态方法的时候,锁定的是当前类的 Class 对象;
当修饰非静态方法的时候,锁定的是当前实例对象 this。

相当于

class X {
  // 修饰静态方法
  synchronized(X.class) static void bar() {
    // 临界区
  }
}

class X {
  // 修饰非静态方法
  synchronized(this) void foo() {
    // 临界区
  }
}

锁和受保护资源的关系

锁可以保护一个或者多个资源。我们可以用一个范围较大的锁,比如说 X.class 保护多个相关的资源;也可以用不同的锁对被保护资源进行精细化管理,这就叫细粒度锁

一把锁保护一个资源

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

这是一段想解决 count += 1 问题的代码,我们对 addOne() 使用 synchronized 加上互斥锁,可以保证其原子性。根据 Happens-before 管程中锁的规则:对一个锁的解锁 Happens-Before 于后续对这个锁的加锁,也可以保证其可见性。即使是 1000 个线程同时执行 addOne() 也可以保证 value 增加 1000。

但我们无法保证 get() 的可见性,管程中锁的规则,是只保证后续对这个锁的加锁的可见性,而 get() 方法并没有加锁操作,所以可见性没法保证。所以我们给 get() 也加上锁:

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

此时 get()addOne() 都持有 this 这把锁,此时 get()addOne() 是互斥的,并且保证了可见性,缩模型如下图所示:

一把锁保护一个资源

两把锁保护不同资源的问题

如果将 value 改为 static 的,addOne() 变为静态方法:

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

代码不互斥

此时 get()addOne() 分别持有不同的锁,get()addOne() 不互斥,也就不能保证可见性,就会导致并发问题。

一把锁保护多个资源

现在要写一个银行转账的方法,用户 A 给用户 B 转账,将其转换成代码:

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

用户 A 给用户 B 转账 100,要保证 A 的余额减少 100,B 的余额增加 100。由于转账操作可以是并发的,所以要保证转账操作没有并发问题。比如说 A 的余额只有 100,两个线程分别执行 A 给 B 转账 100,A 给 C 转账 100,这两个线程有可能同时从内存中读取到 A 的余额是 100,这就产生了并发问题。

解决这个问题的第一反应,就是给 transfer(Account target, int amt) 加上 synchronized。这样做真的对么?transfer() 此时有两个需要被保护的资源 target.balancethis.balance 即别人钱和自己的钱,但我们使用的锁是 this 锁,如下图所示:

锁无法锁住别人的资源

自己的锁 this 能保护自己的 this.balance 但是无法保护别人的 target.balance,就像我的锁不能即保护我家的东西,又保护你家的东西一样。

所以我们需要一把锁的范围更大一点,让它能够覆盖到所有的被保护资源,比如说传入同一个对象作为锁:

class Account {
  private Object lock;
  private int balance;
  private Account();
  // 创建Account时传入同一个lock对象
  public Account(Object lock) {
    this.lock = lock;
  } 
  // 转账
  void transfer(Account target, int amt){
    // 此处检查所有对象共享的锁
    synchronized(lock) {
      if (this.balance > amt) {
        this.balance -= amt;
        target.balance += amt;
      }
    }
  }
}

或者使用类锁 Accout.class,由于 Accoutn.class 是在 Java 虚拟机加载 Account 类时创建的,所以 Account.class 是所有 Account 对象共享且唯一的一把锁。

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

Accout.class 就可以同时保护两个不同对象的临界区资源:

更粗粒度的锁

相关文章

03 | 互斥锁(上):解决原子性问题

04 | 互斥锁(下):如何用一把锁保护多个资源?


深页
105 声望3 粉丝