11

并发编程学习--并发理论基础篇

并发理论基础

可见性、原子性、有序性问题。并发编程BUG源头

可见性

一个线程对共享变量的修改,另外一个线程能够立刻看到,我们称为可见性。

在单核的时代,不会出现问题。

clipboard.png

多核时代,就会出现问题了。

线程 A 操作的是 CPU-1 上的缓存,而线程 B 操作的是CPU-2上的缓存。线程A对变量V的操作对线程B不具备可见性了。这个就属于硬件程序猿给软件程序猿挖的坑。

clipboard.png

以下代码calc得到的结果不会是20000。验证了多核场景下的可见性问题。

public class Test {
  private long count = 0;
  private void add10K() {
    int idx = 0;
    while(idx++ < 10000) {
      count += 1;
    }
  }
  public static long calc() {
    final Test test = new Test();
    // 创建两个线程,执行 add() 操作
    Thread th1 = new Thread(()->{
      test.add10K();
    });
    Thread th2 = new Thread(()->{
      test.add10K();
    });
    // 启动两个线程
    th1.start();
    th2.start();
    // 等待两个线程执行结束
    th1.join();
    th2.join();
    return count;
  }
}
原子性

我们把一个或者多个操作在 CPU 执行的过程中不被中断的特性称为原子性

count+1,这个简单的指令至少需要三条CPU指令。

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

操作系统在任务切换,是可以发生在任何一条CPU指令中间的,而不是在高级语言中的一句语句。

如下图,线程A和线程B从CPU寄存器中读出来的值都是0,并都是将1写入到内存中。所以不会是我们期待的2。

clipboard.png

有序性

编译器为了优化性能,有时候会改变程序中语句的先后顺序。

一个经典的案例,案例的双重检查锁。

public class Singleton {
  static Singleton instance;
  static Singleton getInstance(){
    if (instance == null) {
      synchronized(Singleton.class) {
        if (instance == null)
          instance = new Singleton();
        }
    }
    return instance;
  }
}

当两个线程同时执行这段代码时,其中一个线程是有可能获取到一个null的对象,访问instance成员变量就会触发空指针异常了。

问题出现在new这个动作上

我们认为的new动作应该是

  1. 分配一块内存M
  2. 在内存M上初始化Singleton对象
  3. 然后M的地址赋值给instance变量

实际上经过编译后的优化,顺序变成

  1. 分配一块M
  2. 将M的地址赋值给instance变量
  3. 最后在内存M上初始化Singleton对象。

clipboard.png

思考

在 32 位的机器上对 long 型变量进行加减操作存在并发。

确实会的,以为long类型变量占8位字节,也就是64位的,32位机器需要把变量拆分成两个32位操作。官方推荐把long/double变量声明为volatile或者使用同步锁。

Java是如何解决可见性和原子性问题的

Java 内存模型规范了 JVM 如何提供按需禁用缓存和编译优化的方法。

具体来说,这些方法包括volatile、synchronized、final三个关键字。以及六项Happens-before规则。

Happens-before规则
程序的顺序性规则

程序前面对某个变量的修改一定是对后续操作可见的。

volatile变量规则

对一个volatile变量的写操作,Happens-before于后续对这个volatile变量的读操作。

传递性

如果A Happens-before B,且B happens-before C,那么A Happens-before C。

管程中的锁规则

指对一个锁的解锁Happens before 于后续对这个锁的加锁。

也就是说一定要有解锁动作,才能对这个锁进行再次加锁。

synchronized是java对管程的实现。

线程start()规则

它指的是主线程A启动子线程B后,子线程B能够看到主线程在启动子线程B前的操作。

Thread B = new Thread(()->{
  // 主线程调用 B.start() 之前
  // 所有对共享变量的修改,此处皆可见
  // 此例中,var==77
});
// 此处对共享变量 var 修改
var = 77;
// 主线程启动子线程
B.start();
线程join()原则

它指的是主线程A等待子线程B完成,当子线程B完成后,主线程能够看到子线程的操作。

Thread B = new Thread(()->{
  // 此处对共享变量 var 修改
  var = 66;
});
// 例如此处对共享变量修改,
// 则这个修改结果对线程 B 可见
// 主线程启动子线程
B.start();
B.join()
// 子线程所有对共享变量的修改
// 在主线程调用 B.join() 之后皆可见
// 此例中,var==66
1,2,3规则举例说明

使用以下例子说明一下顺序性、volatile变量规则,传递性。

class VolatileExample {
  int x = 0;
  volatile boolean v = false;
  public void writer() {
    x = 42;
    v = true;
  }
  public void reader() {
    if (v == true) {
      // 这里 x 会是多少呢?
    }
  }
}

clipboard.png

  1. x=42 Happens-before写变量 v=true,这属于顺序规则
  2. 写变量v=true Happens-before 读变量v=true,这个是volatile变量规则
  3. 那么根据传递性规则,x=42 Happens-before 读变量v=true。
不可忽视的final

final修饰变量时,初衷是告诉编译器:这个变量生而不变,可以使劲儿优化。

使用final的时候,只要提供正确的构造函数没有“逸出”,就不会出问题。

一个关于逸出的例子,例子中通过global.obj读取变量x是有可能读取到0的。

final int x;
// 错误的构造函数
public FinalFieldExample() { 
  x = 3;
  y = 4;
  // 此处就是讲 this 逸出,
  global.obj = this;
}

解决原子性问题

简易锁模型

clipboard.png

改进后的锁机制

clipboard.png

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

如何用一把锁保护多个资源

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

不同的资源用不同的锁保护,各自管各自的。

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

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;
    }
  }
}
保护有关联关系的多个资源

如果多个资源是有关联关系的,那这个问题就比较复杂了。

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

上面的代码是错误的示例。

因为this这把锁根本锁不住target,也就是别人的账户。

clipboard.png

会造成什么问题呢?假设这样的一个场景,ABC三个账户均为200,一个线程执行A转账100给B,一个线程执行B转账100给C。最终导致的结果可能是B为300,或者B为100。而我们期望的结果B的余额应该是200才是正确的。

clipboard.png

那么其实解决方案也非常简单,就是把锁的对象this改成Account.class

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

clipboard.png

死锁

在上面一章中,我们使用了Account.class锁住了转账的动作,也就是说每笔转账动作,都是串行的动作了,性能下降严重,如何提升性能呢?

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

clipboard.png

相比较于Account.class这个大锁,我们使用了两个细粒度的锁。

看起来很完美,但是很可能造成死锁

死锁的一个比较专业定义是:一组互相竞争资源的线程因互相等待,导致永久阻塞的现象。

clipboard.png

怎么解决死锁的问题

要解决死锁,就要了解死锁发生的条件。

Coffman牛人总结了为四个条件:

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

也就是说,我们只要破坏掉其中一个条件,死锁就迎刃而解了。

其中互斥性无法破坏,因为我们使用的就是互斥锁。

破坏其余三个条件:

1. 破坏占有等待,可以一次性申请所有的资源
2. 破快不可抢占,占用部分资源的线程进一步申请其他资源时,如果申请不到,主动释放它占有的资源
3. 破坏循环等待,可以按序申请资源来预防。
破坏占用且等待
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)
    }
  } 
}

clipboard.png

破坏不可抢占条件

使用Lock,synchronzied无法解决,后续讨论。

破坏循环等待条件

增加id属性,作为排序属性,锁定顺序按照从小到大的顺序锁定。

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;
        }
      }
    }
  } 
}
选择合适的方案

既然解决死锁有三个方法,那么从三个方法中选择出一个好的解决方法也显得至关重要。

比如上面的例子,破坏占用且等待条件成本就比破坏循环等待的成本来得高。

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

用synchronized实现等待--通知

clipboard.png

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();
  }
}

安全性、活跃性、性能问题

安全性

存在共享数据并且该数据会发生变化,通俗地说,就是多个线程会同时读写同一个数据。

如果多个线程同时写同一个数据,这种情况就称之为数据竞争。

活跃性

除了死锁,还有两种情况,分别是 活锁和饥饿。

活锁,线程虽然没有发生阻塞,但仍然执行不下去的情况。好比现实中的礼让问题。

活锁解决,加入尝试等待一个随机时间。

饥饿,线程因无法访问所需资源而无法执行下去。

饥饿解决,一般使用公平锁。

性能问题

从方案层面,解决性能问题:

  1. 使用无锁算法和数据结构,线程本地存储(Thread Local)、写入时复制(Copy on write)、乐观锁等;java的原子类;无锁的内存队列Disruptor
  2. 减少锁的持有时间,使用细粒度锁、读写锁

性能的度量指标有很多,一般三个非常重要,吞吐量、延迟、并发量。

管程:并发编程的万能钥匙

clipboard.png

public class BlockedQueue<T>{
  final Lock lock =
    new ReentrantLock();
  // 条件变量:队列不满  
  final Condition notFull =
    lock.newCondition();
  // 条件变量:队列不空  
  final Condition notEmpty =
    lock.newCondition();

  // 入队
  void enq(T x) {
    lock.lock();
    try {
      while (队列已满){
        // 等待队列不满 
        notFull.await();
      }  
      // 省略入队操作...
      // 入队后, 通知可出队
      notEmpty.signal();
    }finally {
      lock.unlock();
    }
  }
  // 出队
  void deq(){
    lock.lock();
    try {
      while (队列已空){
        // 等待队列不空
        notEmpty.await();
      }
      // 省略出队操作...
      // 出队后,通知可入队
      notFull.signal();
    }finally {
      lock.unlock();
    }  
  }
}

java线程

线程的生命周期

clipboard.png

Java 语言中线程共有六种状态,分别是:

  1. NEW
  2. RUNNABLE
  3. BLOCKED
  4. WAITING
  5. TIMED_WAITING
  6. TERMINATED

看着比图中多了几个状态,其实java中的BLOCED WAITING TIMED_WAITING都属于休眠状态,这个状态下的线程没有CPU的使用权。

clipboard.png

创建多少个线程才是合适的?

最佳线程数 =CPU 核数 * [ 1 +(I/O 耗时 / CPU耗时)

为什么局部变量是安全的?

每个线程都有自己的调用栈,局部变量保存在线程各自的调用栈里面,不会共享,所以局部变量不会有并发问题。

clipboard.png

clipboard.png

如何用面向对象思想写好并发程序

  1. 封装共享变量
  2. 识别共享变量间的约束条件
  3. 制定并发访问策略

小鸡
214 声望24 粉丝

1.01的365次方=37.8