第一部分:并发理论基础

01|并发问题的根源:CPU/内存/IO设备的速度差异

一、CPU、内存、IO设备的速度差异:cpu > 内存 > IO设备

程序的整体性能取决于最慢的操作——读写IO设备。
为了合理利用CPU的高性能,平衡三者的速度差异,计算机体系结构、操作系统、编译程序做了以下优化:

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

二、并发程序的问题根源

1、缓存导致的可见性问题

单核时代:所有的线程都是在一颗CPU上执行,一个线程对缓存的写,另一个线程一定是可见的。

多核时代:每颗CPU都有自己的缓存,线程A操作CPU-1的缓存,线程B操作CPU-2的缓存,线程A对变量的操作对线程B就不具备可见性了。

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

高级语言里一条语句往往需要多条 CPU 指令完成,例如:count += 1,至少需要三条 CPU 指令。
指令1:首先把变量count从内存加载到CPU的寄存器;
指令2:在寄存器执行+1的操作;
指令3:最后将结果写入内存(缓存机制导致写入的可能是CPU缓存而不是内存)。

操作系统做任务切换,可以发生在任何一条 CPU 指令执行完,而不是高级语言里的一条语句。对于上面的三条指令来说,我们假设 count=0,如果线程 A 在指令 1 执行完后做线程切换,线程 A 和线程 B 按照下图的序列执行,那么我们会发现两个线程都执行了 count+=1 的操作,但是得到的结果不是我们期望的 2,而是 1。

我们把一个或者多个操作在CPU 执行的过程中不被中断的特性称为原子性。CPU能保证的原子操作是CPU指令级的,而不是高级语言的操作符。

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

双重检查创建单例对象

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

实际上这个 getInstance() 方法并不完美。问题出在哪里呢?出在 new 操作上。
我们以为的 new 操作应该是:
1、分配一块内存 M;
2、在内存 M 上初始化 Singleton 对象;
3、最后把 M 的地址赋值给 instance 变量。
但是实际上优化后的执行路径却是这样的:
1、分配一块内存 M;
2、将 M 的地址赋值给 instance 变量;
3、最后在内存 M 上初始化 Singleton 对象。

优化后导致的问题:线程A先执行getInstance()方法,当执行完指令2时恰好发生了线程切换,切换到了线程B上;若此时线程B执行getInstance()方法,当执行到第一个判断
if (instance == null)时,会发现instance != null,所以会直接返回instance,而此时的instance是没有初始化过的,若访问instance的成员变量会触发空指针异常。

问题:为什么32位的机器上对long型变量进行加减操作存在并发隐患?

32位CPU上执行long型变量的写操,long型变量是64位,在32位CPU上执行写操作会被拆分成两次写操作(写高32位和写低32位,如下图所示)。

原因就是:线程切换带来的原子性问题。

如何解决:

在单核CPU场景下,同一时刻只有一个线程执行,禁止CPU中断,意味着操作系统不会重新调度线程,也就是禁止了线程切换,获得CPU使用权的线程就可以不间断地执行,所以两次写操作一定是:要么都被执行,要么都没有被执行,具有原子性。

在多核场景下,同一时刻,有可能有两个线程同时在执行,一个线程执行在CPU-1上,一个线程执行在CPU-2上,此时禁止CPU中断,只能保证CPU上的线程连续执行,并不能保证同一时刻只有一个线程执行,如果这两个线程同时写long型变量高32位的话,那就有可能出现我们开头提及的诡异Bug了。

“同一时刻只有一个线程执行”这个条件非常重要,我们称之为互斥。如果我们能够保证对共享变量的修改是互斥的,那么,无论是单核CPU还是多核CPU,就都能保证原子性了。

02 | Java内存模型:看Java如何解决可见性和有序性问题

导致可见性的原因是缓存,导致有序性的原因是编译优化。Java 内存模型规范了 JVM 如何提供按需禁用缓存和编译优化的方法。具体来说,这些方法包括 volatile、synchronized 和 final 三个关键字,以及六项 Happens-Before 规则。

一、使用 volatile 的困惑

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

例如下面的代码,假如我们创建一个线程A执行write()方法,再创建一个线程B执行reader()方法,我们来想想x到底是多少?

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

Java版本不同答案也是不一样的,低于java1.5版本,可能是42也可能是0,因为x=42可能会被CPU缓存,所以会出现0,但是java1.5及之后修复了这个Bug,所以是42。

修改的方法是增强了volatile的语义,增强的方法是依据Happens-Before规则。

二、Happens-Before 规则:前面一个操作的结果对后续操作是可见的。

1. 程序的顺序性规则:在一个线程中,按照程序执行顺序,前面一个操作的结果对后续操作是可见的。

2. volatile 变量规则:对一个 volatile 变量的写操作,Happens-Before 可见于后续对这个volatile 变量的读操作。乍一看,这个规则有点禁用缓存的意思,如果关联规则 3,就有点不一样了。

3. 传递性:这条规则是指如果 A Happens-Before B 且 B Happens-Before C,那么 A Happens-Before 。

从图中,可以看到:
1.   x=42 Happens-Before写变量 v=true,这是规则1的内容。
2.    写变量v=true Happens-Before 读变量v=true,这是规则2的内容。

根据传递性规则,得到结果:“x=42” Happens-Before 读变量“v=true”。这意味着什么呢?

如果线程B读到了“v=true”,那么线程A设置的“x=42”对线程B是可见的。也就是说,线程B能看到“x==42”,这就是1.5版本对volatile语义的增强。

4. 管程中锁的规则:这条规则是指对一个锁的解锁 Happens-Before 可见于后续对这个锁的加锁。管程是一种通用的同步原语,在 Java 中指的就是synchronized,synchronized 是 Java 里对管程的实现。管程中的锁在 Java 里是隐式实现的,例如下面的代码,在进入同步块之前,会自动加锁,而在代码块执行完会自动释放锁,加锁以及释放锁都是编译器帮我们实现的。

synchronized (this) { //此处自动加锁
  // x是共享变量,初始值=10
  if (this.x < 12) {
    this.x = 12; 
  }  
} //此处自动解锁

假设 x 的初始值是 10,线程 A 执行完代码块后 x 的值会变成 12(执行完自动释放锁),线程 B 进入代码块时,能够看到线程 A 对 x 的写操作,也就是线程 B 能够看到 x==12。

5. 线程 start() 规则:这条是关于线程启动的。它是指主线程 A 启动子线程 B 后,子线程 B 能够看到主线程在启动子线程 B 前的任意操作。

6. 线程 join() 规则:这条是关于线程等待的。它是指主线程 A 等待子线程 B 完成(主线程 A 通过调用子线程 B 的 join() 方法实现),当子线程 B 完成后(主线程 A 中 join() 方法返回),主线程能够看到子线程的操作。当然所谓的“看到”,指的是对共享变量的操作。

7、线程中断规则:对线程interrupt()方法的调用先行发生于被中断线程的代码检测到中断事件的发生,可以通过Thread.interrupted()方法检测到是否有中断发生。

具体来说,当对一个线程,调用 interrupt() 时,
① 如果线程处于被阻塞状态(例如处于sleep, wait, join 等状态),那么线程将立即退出被阻塞状态,并抛出一个InterruptedException异常。仅此而已。
② 如果线程处于正常活动状态,那么会将该线程的中断标志设置为 true,仅此而已。被设置中断标志的线程将继续正常运行,不受影响。

8、对象终结规则:一个对象的初始化完成(构造函数执行结束)先行发生于它的finalize()方法的开始。

  • 0
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值