Java并发编程笔记[1]——并发理论基础(上)

并发编程Bug源头

解决CPU、内存、I/O设备三者速度差异问题

为了合理利用CPU的高性能,平衡三者之间的差异,计算机体系结构、操作系统、编译程序都做出了贡献(简记:硬件、操作系统、应用软件三个层面的优化):

  • CPU增加缓存,均衡与内存的差异;
  • 操作系统增加了进程、线程,以分时复用CPU,进而均衡CPU与I/O设备的速度差异;
  • 编译程序优化指令执行次序,使得缓存能够得到更加合理的应用。

副作用

由此带来的副作用:

  • 缓存导致可见性问题
    一个线程对共享变量的修改,另一个线程能够立刻看到,称为「可见性」
    CPU多核时代

  • 线程切换带来原子性问题
    早期操作系统基于进程切换CPU,不同进程间不共享内存空间 ==> 任务切换需要切换内存映射地址
    现代操作系统基于线程切换CPU,一个进程创建的所有线程共享内存空间 ==> 切换线程很『轻量』
    一个或者多个操作在CPU执行的过程汇总不被中断的特性,称为「原子性」

  • 编译优化带来有序性问题
    「有序性」指程序按照代码的先后顺序执行
    编译器为了优化性能,有时会改变程序中语句的先后顺序

    以上副作用可以简记为:缓存导致的可见性问题、线程切换带来的原子性问题、便以优化带来的有序性问题。

解决可见性&有序性问题:Java内存模型

解决可见性&有序性的基本思路

导致可见性&有序性问题的根本原因是缓存和编译优化,那么直接的办法就是:禁用缓存、禁用编译优化。但如此就没法享受高性能了。这是典型的因噎废食。
合理的方案是:按需禁用缓存及编译优化。『按需』其实就是程序员来控制何时禁用。

Java内存模型

Java内存模型 规范了JVM如何提供按需禁用缓存和编译优化的方法。
具体地说,这些方法包括:

  • volatile
  • synchronized
  • final
  • Happens-Before规则

注意:这些方法并不是互斥的关系。

Happens-Before 7个规则

  • 程序次序规则:在一个线程内一段代码的执行结果是有序的。指令还会重排,但是随便它怎么排,结果是按照我们代码的顺序生成的不会变。
  • 管程锁定规则:无论是在单线程环境还是多线程环境,对于同一个锁来说,一个线程对这个锁解锁之后,另一个线程获取了这个锁都能看到前一个线程的操作结果。(管程是一种通用的同步原语,synchronized就是管程的实现)
  • volatile变量规则:如果一个线程先去写一个volatile变量,然后一个线程去读这个变量,那么这个写操作的结果一定对读的这个线程可见。
  • 线程启动规则:在主线程A执行过程中,启动子线程B,那么线程A在启动子线程B之前对共享变量的修改结果对线程B可见。
  • 线程终止规则:在主线程A执行过程中,子线程B终止,那么线程B在终止之前对共享变量的修改结果在线程A中可见。
  • 线程中断规则:对线程interrupt()方法的调用先行发生于被中断线程代码检测到中断事件的发生,可以通过Thread.interrupted()检测到是否发生中断。
  • 传递规则:happens-before原则具有传递性,即A happens-before B , B happens-before C,那么A happens-before C。
  • 对象终结规则:一个对象的初始化的完成,也就是构造函数执行的结束一定 happens-before它的finalize()方法。

上面的理解来自网络文章面试官:谈谈happens-before?,有删改。

互斥锁:解决原子性问题 & 保护临界资源

互斥锁模型

锁模型
这个模型更像现实世界里面球赛门票的管理,一个座位只允许一个人使用,这个座位就是“受保护资源”,球场的入口就是 Java 类里的方法,而门票就是用来保护资源的“锁”,Java 里的检票工作是由 synchronized 解决的。

synchronized关键字

当修饰静态方法的时候,锁定的是当前类的 Class 对象;当修饰非静态方法的时候,锁定的是当前实例对象 this。
两把锁保护一个资源示意图
如果一个synchronized修饰的静态方法和一个synchronized修饰的普通方法,都对一个变量(资源)进行了保护(多把锁保护一个资源),这两个临界区并没有互斥的关系,即使加了锁(其实是两把),可能就出现并发问题了。

受保护资源和锁之间合理的关联关系应该是 N:1 的关系,也就是说可以用一把锁来保护多个资源,但是不能用多把锁来保护一个资源。

保护多个资源

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

用不同的锁对受保护资源进行精细化管理,能够提升性能。这种锁还有个名字,叫细粒度锁
使用细粒度锁可以提高并行度,是性能优化的一个重要手段。
使用细粒度锁是有代价的,这个代价就是可能会导致死锁

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

用同一把锁来保护多个资源,也就是现实世界的“包场”,那在编程领域应该怎么“包场”呢?很简单,只要我们的锁能覆盖所有受保护资源就可以了。

『原子性』本质

『原子性』的本质是什么?其实不是不可分割,不可分割只是外在表现,其本质是多个资源间有一致性的要求,操作的中间状态对外不可见。例如,在 32 位的机器上写 long 型变量有中间状态(只写了 64 位中的 32 位),在银行转账的操作中也有中间状态(账户 A 减少了 100,账户 B 还没来得及发生变化)。所以解决原子性问题,是要保证中间状态对外不可见

死锁

有个叫 Coffman 的牛人早就总结过了,只有以下这四个条件都发生时才会出现死锁:

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

反过来分析,也就是说只要我们破坏其中一个,就可以成功避免死锁的发生
其中,互斥这个条件我们没有办法破坏,因为我们用锁为的就是互斥。不过其他三个条件都是有办法破坏掉的,到底如何做呢?

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

破坏占用且等待条件

Allocator。它有两个重要功能,分别是:同时申请资源 apply() 和同时释放资源 free()。账户 Account 类里面持有一个 Allocator 的单例(必须是单例,只能由一个人来分配资源)。当账户 Account 在执行转账操作的时候,首先向 Allocator 同时申请转出账户和转入账户这两个资源,成功后再锁定这两个资源;当转账操作执行完,释放锁之后,我们需通知 Allocator 同时释放转出账户和转入账户这两个资源。

class Allocator {
  private List<Object> als =  new ArrayList<>();
  // 一次性申请所有资源
  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 是做不到的。原因是 synchronized 申请资源的时候,如果申请不到,线程直接进入阻塞状态了,而线程进入阻塞状态,啥都干不了,也释放不了线程已经占有的资源。
java.util.concurrent 这个包下面提供的 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;
        }
      }
    }
  } 
}

使用「等待-通知」机制优化循环等待

「等待-通知」机制

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

Java中「等待-通知」实现

在 Java 语言里,等待 - 通知机制可以有多种实现方式,比如 Java 语言内置的 synchronized 配合 wait()、notify()、notifyAll() 这三个方法就能轻松实现。


class Allocator {
  private List<Object> als;
  // 一次性申请所有资源
  synchronized void apply(
    Object from, Object to){
    // 经典写法
    while(als.contains(from) ||
         als.contains(to)){
      try{
        wait();
      }catch(Exception e){
      }   
    } 
    als.add(from);
    als.add(to);  
  }
  // 归还资源
  synchronized void free(
    Object from, Object to){
    als.remove(from);
    als.remove(to);
    notifyAll();
  }
}

致谢

系列文章都是来自极客时间专栏《Java并发编程实战》,感谢作者王宝令老师。