并发编程学习笔记1——并发编程的主要问题与解决方案

在这里插入图片描述


一、并发编程的主要问题

1.缓存带来的可见性问题

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

在单核时代,所有的线程都是在一颗 CPU 上执行,CPU 缓存与内存的数据一致性容易解决。因为所有线程都是操作同一个 CPU 的缓存,一个线程对缓存的写,对另外一个线程来说一定是可见的。

多核时代,每颗 CPU 都有自己的缓存,当多个线程在不同的 CPU 上执行时,这些线程操作的是不同的 CPU 缓存。这个时候线程 A 对变量 V 的操作对于线程 B 而言就不具备可见性了。
在这里插入图片描述

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; //结果为10000到20000之间随机数
  }
}

2.线程切换带来的原子性问题

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

高级语言里一条语句往往需要多条 CPU 指令完成,例如count += 1,至少需要三条 CPU 指令。

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

操作系统做任务切换,可以发生在任何一条 CPU 指令执行完,当两个线程按照下面顺序执行,结果就为1,而不是2 。
在这里插入图片描述

3.编译优化带来的有序性问题

**有序性指的是程序按照代码的先后顺序执行。**编译器为了优化性能,有时候会改变程序中语句的先后顺序。

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

在jdk1.5之前,这个双重检查创建单例的代码有可能出空指针。

我们以为的 new 操作应该是:

  • 分配一块内存 M;
  • 在内存 M 上初始化 Singleton 对象;
  • 然后 M 的地址赋值给 instance 变量。

但是实际上优化后的执行路径却是这样的:

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

优化后可能会产生这种执行顺序:
在这里插入图片描述


二、解决方案

1.按需禁用缓存和编译优化

导致可见性的原因是缓存,导致有序性的原因是编译优化,那解决可见性、有序性最直接的办法就是按需禁用缓存以及编译优化。

JVM 提供了volatile、synchronized 和 final 三个关键字,以及六项 Happens-Before 规则。

volatile

声明一个 volatile 变量 volatile int x = 0,它表达的是:告诉编译器,对这个变量的读写,不能使用 CPU 缓存,必须从内存中读取或者写入。

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

在 jdk 1.5 以前的版本运行,x 可能是 42,也有可能是 0,变量 x 可能被 CPU 缓存而导致可见性问题。如果在 1.5 以上的版本运行,x 就是等于 42。

Happens-Before 规则

Happens-Before 是指,前面一个操作的结果对后续操作是可见的。

Happens-Before 约束了编译器的优化行为,虽允许编译器优化,但是要求编译器优化后一定遵守Happens-Before 规则。

程序的顺序性规则

在一个线程中,按照程序顺序,前面的操作 Happens-Before 于后续的任意操作。 “x = 42;” Happens-Before 于 “v = true;”。

volatile 变量规则

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

传递性

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

在这里插入图片描述

  • “x=42” Happens-Before 写变量 “v=true” ,这是规则 1 的内容;
  • 写变量“v=true” Happens-Before 读变量 “v=true”,这是规则 2 的内容 。
  • 再根据这个传递性规则,我们得到结果:“x=42” Happens-Before 读变量“v=true”。

这就是 1.5 版本对 volatile 语义的增强,使得 x 一定为 42.

管程中锁的规则

对一个锁的解锁 Happens-Before 于后续对这个锁的加锁。

管程是一种通用的同步原语,在 Java 中指的就是 synchronized,synchronized 是 Java 里对管程的实现。在进入同步块之前,会自动加锁,而在代码块执行完会自动释放锁,加锁以及释放锁都是编译器帮我们实现的。

线程 start() 规则

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

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

线程 join() 规则

如果在线程 A 中,调用线程 B 的 join() 并成功返回,那么线程 B 中的任意操作 Happens-Before 于该 join() 操作的返回。

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

final 关键字

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

jdk 1.5 之前,构造函数的错误重排导致线程可能看到 final 变量的值会变化。

Class Reordering {
  final int x = 0;
  int y = 0;
  public void writer() {
    x = 1;
    y = 2;
  }

  public void reader() {
    int r1 = y;
    int r2 = x;
  }
}

线程A执行writer() 方法时,可能会出现重排,先执行 y=2,然后切换到线程B执行 reader(),此时读出来x=0。

在 1.5 以后 Java 内存模型对 final 类型变量的重排进行了约束。现在只要我们提供正确构造函数没有“逸出”,就不会出问题了。

在下面例子中,在构造函数里面将 this 赋值给了全局变量 global.obj,这就是“逸出”,线程通过 global.obj 读取 x 是有可能读到 0 的。

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

2.锁

原子性问题的源头是线程切换。同一时刻只有一个线程执行这个条件非常重要,我们称之为互斥
在这里插入图片描述

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

class X {
  // 修饰非静态方法
  synchronized void foo() {
    // 临界区
  }
  // 修饰静态方法
  synchronized static void bar() {
    // 临界区
  }
  // 修饰代码块
  Object obj = new Object()void baz() {
    synchronized(obj) {
      // 临界区
    }
  }
}  
  • 当修饰静态方法的时候,锁定的是当前类的 Class 对象,在上面的例子中就是 Class X;
  • 当修饰非静态方法的时候,锁定的是当前实例对象 this。

用 synchronized 解决 count+=1 问题

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

前一个线程的解锁操作对后一个线程的加锁操作可见,综合 Happens-Before 的传递性原则,我们就能得出前一个线程在临界区修改的共享变量(该操作在解锁之前),对后续进入临界区(该操作在加锁之后)的线程是可见的。

所以多个线程同时执行 addOne() 方法,原子性和可见性都可以保证。

但是执行 addOne() 方法后,value 的值对 get() 方法不一定可见,因为没有加锁操作,需要 synchronized 一下即可。

在这里插入图片描述

受保护资源和锁之间的关联关系是 N:1 的关系,反之会出现并发问题。

class SafeCalc {
  static long value = 0L;
  synchronized long get() { //非静态方法
    return value;
  }
  synchronized static void addOne() {
    value += 1;
  }
}

这段代码是用两个锁保护一个资源。这个受保护的资源就是静态变量 value,两个锁分别是 this 和 SafeCalc.class。临界区 addOne() 对 value 的修改对临界区 get() 没有可见性保证,这就导致并发问题了。
在这里插入图片描述

保护没有关联关系的多个资源,例如,银行业务中有针对账户余额(余额是一种资源)的取款操作,也有针对账户密码(密码也是一种资源)的更改操作,我们可以为账户余额和账户密码分配不同的锁来解决并发问题。

也可以用一把互斥锁来保护多个资源,但是会导致取款、查看余额、修改密码、查看密码这四个操作都是串行的。而我们用两把锁,取款和修改密码是可以并行的。

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

保护有关联关系的多个资源,例如银行业务里面的转账操作:

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

在这段代码中,临界区内有两个资源,分别是转出账户的余额 this.balance 和转入账户的余额 target.balance,并且用的是一把锁 this,这把锁可以保护自己的余额 this.balance,却保护不了别人的余额 target.balance,就会出现并发问题。

应该用锁覆盖所有受保护资源。在上面的例子中,this 是对象级别的锁,所以 A 对象和 B 对象都有自己的锁。可以让所有对象都持有一个唯一性的对象,这个对象在创建 Account 时传入。但是传入共享的 lock 很难。还可以用 Account.class 作为共享的锁。Account.class 是所有 Account 对象共享的,而且这个对象是 Java 虚拟机在加载 Account 类的时候创建的,所以我们不用担心它的唯一性。但是会导致所有账户的转账都是串行的。

原子性的本质是多个资源间有一致性的要求,操作的中间状态对外不可见。

3.死锁

转账时,还可以用两把锁实现,先锁转出账户,再锁转入账户。

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

使用细粒度锁可以提升性能,代价就是可能会导致死锁。

死锁是:一组互相竞争资源的线程因互相等待,导致“永久”阻塞的现象。

只有以下这四个条件都发生时才会出现死锁:

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

只要破坏其中一个,就可以成功避免死锁的发生。

  1. 互斥这个条件我们没有办法破坏,因为我们用锁为的就是互斥。

  2. 对于“占用且等待”这个条件,我们可以一次性申请所有的资源,这样就不存在等待了。

可以增加一个唯一的共享对象,它有两个重要功能,分别是:同时申请资源 apply() 和同时释放资源 free()。当账户 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)
    }
  } 
}

如果 apply() 操作耗时非常短,而且并发冲突量也不大时,这个方案还挺不错的。但是如果 apply() 操作耗时长,或者并发冲突量大的时候,死循环就太消耗 CPU 了。

可以用等待 - 通知机制

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

当调用 wait() 方法后,当前线程就会被阻塞,当条件满足时调用 notify(),会通知阻塞中的线程,告诉它
条件曾经满足过。因为被通知线程的执行时间点和通知的时间点基本上不会重合。

除此之外,还有一个需要注意的点,被通知的线程要想重新执行,仍然需要获取到互斥锁,因为曾经获取的锁在调用 wait() 时已经释放了。

如果 synchronized 锁定的是 this,那么对应的一定是 this.wait()、this.notify()、this.notifyAll();如果 synchronized 锁定的是 target,那么对应的一定是 target.wait()、target.notify()、target.notifyAll() 。

条件曾经满足过可以用范式:

  while(条件不满足) {
    wait();
  }

改进代码:

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

尽量使用 notifyAll()。notify() 是会随机地通知等待队列中的一个线程,而 notifyAll() 会通知等待队列中的所有线程。使用 notify() 可能导致某些线程永远不会被通知到。

  1. 破坏不可抢占条件

这一点 synchronized 是做不到的。原因是 synchronized 申请资源的时候,如果申请不到,线程直接进入阻塞状态了,Lock 是可以轻松解决这个问题的。

  1. 对于“循环等待”这个条件,可以靠按序申请资源来预防。比如可以用账户id,在锁定前比较大小,按照顺序锁定。这个成本相对最低,推荐使用。

思考题

1、有一个共享变量 abc,在一个线程里设置了 abc 的值 abc=3,有哪些办法可以让其他线程能够看到?

: 1. 用volatile 修饰 abc。
2. 在给 abc 赋值的地方用 synchronized 括起来。
3. 线程启动后,用 join() 方法等待返回,后续线程再运行。
4. 在abc赋值后对一个volatile变量A进行赋值操作,然后在其他线程读取abc之前读取A的值,通过volatile的可见性和happen-before的传递性实现abc修改后对其他线程立即可见。


2、下面的代码用 synchronized 修饰代码块来尝试解决并发问题,你觉得这个使用方式正确吗?有哪些问题呢?能解决可见性和原子性问题吗?

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

:都不能,因为每次调用时,锁住的对象都不同,互相之间不互斥。


3、破坏占用且等待条件时,我们也是锁了所有的账户,而且还是用了死循环 while(!actr.apply(this, target));,那它比 synchronized(Account.class) 有没有性能优势呢?

: 虽然看起来 while(!actr.apply(this, target));只是锁住了两个对象,但是因为actr是一个单例的对象,这个方法在执行的时候也需要锁住actr,在多线程状态下也相当于是串行化了。
区别在于,如果转账操作很耗时,那么a-b,c-d都获取到锁之后能并行还是有价值的。


4、wait() 方法和 sleep() 方法都能让当前线程挂起一段时间,那它们的区别是什么?

:wait()是Object类的方法,sleep()是Thread类的方法。sleep必须指定时间。wait必须在同步代码块内。调用wait 会释放锁,sleep不会。


参考资料:王宝令----Java并发编程实战

发布了30 篇原创文章 · 获赞 0 · 访问量 5506

猜你喜欢

转载自blog.csdn.net/qq_36089832/article/details/104555157