并发编程学习笔记

8次阅读

共计 6976 个字符,预计需要花费 18 分钟才能阅读完成。

并发编程学习

[TOC]

并发理论基础

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

可见性

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

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

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

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

以下代码 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。

有序性

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

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

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 对象。

思考

在 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 会是多少呢?}
  }
}

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

解决原子性问题

简易锁模型

改进后的锁机制

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,也就是别人的账户。

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

那么其实解决方案也非常简单,就是把锁的对象 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;
      }
    }
  } 
}

死锁

在上面一章中,我们使用了 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;
        }
      }
    }
  } 
}

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

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

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

怎么解决死锁的问题

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

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

破坏不可抢占条件

使用 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 实现等待 – 通知

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. 减少锁的持有时间,使用细粒度锁、读写锁

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

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

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 线程

线程的生命周期

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

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

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

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

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

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

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

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

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