多线程编程bug源头

cpu,内存,I/O设备都在不断的迭代,不断朝着更快的方向努力,可是,在这个快速发展的过程中,有一个中心对立一直存在,便是这三者的速度差异。cpu > 内存 > i/0。依据木桶理论,程序全体的功能取决于最慢的操作-i/o设备的读写,也便是说单方面进步cpu功能是无效的。 为了平衡这三者的速度差异,计算机系统组织,操作系统,编译程序都做出了奉献,首要体现在:

  1. cpu增加了缓存,以均衡与内存的速度差异;
  2. 操作系统增加了进程,线程,以分时复用cpu,进而均衡cpu与i/o设备的速度差异;
  3. 编译程序优化指令履行次序,使得缓存能够得到愈加合理地运用。

CPU缓存-可见性问题

在单核年代,一切的线程都在一颗cpu上运转,cpu缓存与内存的数据共同性简单处理,因为一切线程都操作同一颗cpu的缓存,一个线程对缓存的写,对其他一个线程来说一定是可见的。 一个线程对同享变量的修正,其他一个线程能够立刻看到,咱们称之为可见性

在多核年代,每颗cpu都有自己的缓存,这时cpu缓存与内存的数据共同性就没那么简单处理了。当多个线程在不同的cpu上运转时,这些线程操作的是不同的cpu缓存。假定线程a在cpu-1上运转,线程b在CPU-2上运转,此刻线程a对变量v的操作对线程b来说是不行见的。

public class Test{
  private long count = 0;
  private void add(){
    int i = 0;
    while(i++ <= 10000){
      count += 1;
     }
   }
  
  public static long calc(){
    final Test test = new Test();
    Thread t1 = new Thread(()->{
      test.add();
     });
    
    Thread t2 = new Thread(()->{
      test.add();
     });
    
    t1.start();
    t2.start();
    
    t1.join();
    t2.join();
    retun count;
   }
}

上面的程序,假如在单核年代,那么成果毋庸置疑是20000,但在多核年代,终究成果是10000-20000之间的随机数。 咱们假定t1和t2线程一起开始履行,那么第一次都会将count=0读到各自的cpu缓存里,履行完count += 1之后,各自的cpu缓存里的值都是1,而不是咱们期望的2.之后因为各自的cpu缓存里都有count值,所以导致终究count的计算成果小于20000.这便是缓存的可见性问题。

java线程-Java内存模型

线程切换-原子性问题

java并发程序都是基于多线程的,这样也会涉及到线程切换。履行count += 1的操作,至少需求三条cpu指令:

  1. 首先,需求把变量count 从内存中加载到cpu的寄存器。
  2. 在寄存器中履行 +1 操作。
  3. 将成果写入内存,缓存机制导致或许写入的是cpu的缓存还不是内存。

对于上面的三个指令来说,假如线程a刚刚履行完指令1,就和线程b发生了线程切换,导致count的值仍是为0,而不是+1之后的值,就会导致其成果不是咱们期望的2.

咱们把一个或者多个操作在cpu履行的过程中不被中止的特性称为原子性。

java线程-Java内存模型

编译履行-有序性问题

有序性指的是程序依照代码的先后次序履行。 可是编译器为了优化功能,有时分会改变程序中句子的先后次序。例如程序:“a = 6; b = 7”,编译优化后的次序或许为:”b=7;a=6;“。 java中最经典的案例便是单例模式,运用两层检查创立单例目标,确保线程安全。

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

假定有两个线程a b一起调用getInstance()办法,于是一起对Singleton.class加锁,此刻jvm确保只要一个线程能够加锁成功,假定是a,那么b就会处于等候状况,当a履行完,开释锁之后,B被唤醒,继续履行,发现已经有bean目标,所以直接return了。咱们认为jvm创立目标的次序是这样的:

  1. 分配一块内存m;
  2. 在内存m上初始化singleton目标;
  3. 然后m的地址赋值给bean变量;

可是实际上优化后的履行路径是这样的:

  1. 分配一块内存m;
  2. 将m的地址赋值给bean变量;
  3. 在内存M上初始化singleton目标;

当线程a先履行完getinstance()办法,当履行完指令2的时分,恰好发生了线程切换,切换到了线程b,假如此刻线程B,也履行了getinstance办法,那么线程B在履行第一个判其他时分,就会发现bean!=null,直接return,假如这个时分咱们拜访bean目标的时分,就会报空指针异常。

java线程-Java内存模型

实际上,在代码的编译履行过程中,有三种状况或许会导致指令重排

  1. 编译优化导致的重排序
  2. CPU指令并行履行导致的重排序
  3. 硬件内存模型导致的重排序

Java内存模型

从上面的分析,CPU缓存导致了可见性问题,线程切换导致了原子性问题,编译履行导致了有序性问题。那怎么处理这三个问题呢?很直接的做法便是制止运用CPU缓存,制止线程切换,制止指令重排。可是CPU缓存,线程切换,指令重排都是为了进步代码运转的功率,但为了确保多线程编程不会出现问题,过度制止运用这些技术,也会影响代码履行功率,所以java内存模型就应运而生,对应的标准便是JSR-133。之所以叫java内存模型,是因为要处理的问题,都是跟内存有关。

Java内存模型处理多线程的3个问题,首要依托3个关键词和1个规矩,3个关键词分别是:volatile、synchronized、final,1个规矩是:happens-before规矩。

volatile

volatile关键字能够处理可见性、有序性和部分原子性问题。

volatile怎么处理可见性问题

对于用volatile润饰的变量,在编译成机器指令时,会在写操作后面,加上一条特殊的指令:“lock addl #0x0, (%rsp)”,这条指令会将CPU对此变量的修正,立即写入内存,并告诉其他CPU更新缓存数据。

volatile怎么处理有序性问题

制止指令重排序又分为彻底制止指令重排序和部分制止指令重排序。彻底制止指令重排是指volatile润饰的变量的读写指令不行以跟其前面的读写指令重排,也不行以跟后面的读写指令重排。

java线程-Java内存模型

指令重排是为了优化代码的履行功率,过于严格的约束指令重排,显然会降低代码的履行功率。因此,Java内存模型将volatile的语义界说为:部分制止指令重排序。

对volatile润饰的变量履行写操作,Java内存模型只制止坐落其前面的读写操作与其进行重排序,坐落其后面的读写操作能够与其进行指令重排序。

对volatile润饰的变量履行读操作,Java内存模型只制止坐落其后面的读写操作与其进行重排序,坐落其前面的读写操作能够与其进行指令重排序。

java线程-Java内存模型

为了能完结上述细化之后的指令重排制止规矩,Java内存模型界说了4个细粒度的内存屏障(Memory Barrier),也叫做内存栅门(Memory Fence),它们分别是:StoreStore、StoreLoad、LoadLoad、LoadStore。

  • 在每个 volatile 写操作的前面刺进一个 StoreStore 屏障。
  • 在每个 volatile 写操作的后面刺进一个 StoreLoad 屏障。
  • 在每个 volatile 读操作的后面刺进一个 LoadLoad 屏障。
  • 在每个 volatile 读操作的后面刺进一个 LoadStore 屏障。
# other ops
[StoreStore]
x = 1 # volatile润饰x变量,volatile写操作
[StoreLoad]
y = x # volatile读操作
[LoadLoad]
[LoadStore]
# other ops

java线程-Java内存模型

volatile怎么处理部分原子性问题

两类原子性问题,一类是64位long和double类型数据的读写的原子性问题,另一类是自增句子(例如count++)的原子性问题。volatile能够处理第一类原子性问题,可是无法处理第二类原子性问题。

synchronized

synchronized也能够处理可见性、有序性、原子性问题。只不过,它的处理方法比较简单粗犷,让本来并发履行的代码串行履行,并且,每次加锁和开释锁,都会同步CPU缓存和内存中的数据。

final

final润饰变量时,初衷是告诉编译器:这个变量生而不变,能够可劲儿优化,这就导致在1.5版别之前优化的很努力,以至于都优化错了,两层检索办法创立单例,构造函数的错误重排导致线程或许看到final变量的值会变化。可是1.5以后已经对final润饰的变量的重排进行了约束。

happens-before

概念:前面一个操作的成果对后续操作是可见的。也便是说,happens-before约束了编译器的优化行为,虽允许编译器优化,可是要求编译器优化后一定要恪守happens-before准则。

happens-before一共有六项规矩:

程序的次序性规矩

前面的操作happens-before于后续的恣意操作。

例如上面的代码 x=42happens-before于v=true;,比较契合单线程的思想,程序前面临某个变量的修正一定是对后续操作可见的。

volatile变量规矩

对一个volatile变量的写操作,happens-befores与后续对这个变量的读操作。 这怎么看都有禁用缓存的意思,形似和1.5版别之前的语义没有变化,这个时分咱们需求相关下一个规矩来看这条规矩。

传递性

a happens-before于 b,b happens-before于c,那么a happens-before与c。

  1. x = 42 happens-before于 写变量v = true; —-规矩1
  2. 写变量v = true happens-before于 读变量v==true。—-规矩2
  3. 所以x = 42 happens-before于 读变量v == true;—-规矩3

假如线程b读到了v= true,那么线程a设置的x=42对线程b是可见的。也便是说线程b能看到x=42。

管程中锁的准则

对一个锁的解锁happens-before于对后续这个锁的加锁操作。 synchronized是java里对管程的完结。

管程中的锁在java里是隐式完结的,例如下面的代码,在进入同步块之前,会主动加锁,而在代码块履行完会主动开释锁。加锁和解锁的操作都是编译器帮咱们完结的。

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

依据管程中锁的准则,线程a履行完代码块后x的值变成12,线程B进入代码块时,能够看到线程a对x的写的操作,也便是线程b能看到x=12。

start()

主线程a启动子线程b后,子线程B能够看到主线程a在启动子线程b前的操作。也便是在线程a中启动了线程b,那么该start()操作happens-before于线程b中恣意操作。

int x = 0;
Thread B = new Thread(()->{
  // 这儿能看到变量x的值,x = 12;
});
x = 12;
B.start();

join

主线程a等候子线程b完结(主线程a调用子线程b的join办法完结),当b完结后(主线程a中join办法回来),主线程a能够看到子线程b的恣意操作。这儿都是对同享变量的操作。

假如在线程a中调用了线程b的join()并成功回来,那么线程b中恣意操作happens-before于该join操作的回来。

int x = 0;
Thread b = new Thread(()->{
  x = 11;
});x = 12;
b.start();
b.join();
// x = 11;

线程中止规矩

线程a调用了线程b的interrupt()办法,happens-before于线程b的代码检测到中止事件的发生。

目标终结规矩

一个目标初始化完结,happens-before于它的finalize()办法的调用

CPU缓存共同性协议与可见性

CPU缓存共同性协议

目的是为了确保不同CPU之间的缓存数据共同,比较经典的共同性协议便是MESI协议。

缓存行有4种不同的状况:

  • 已修正Modified (M)

    缓存行是脏的(dirty),与主存的值不同。假如其他CPU内核要读主存这块数据,该缓存行必须回写到主存,状况变为同享(S).

  • 独占Exclusive (E)

    缓存行只在当前缓存中,可是洁净的(clean)–缓存数据同于主存数据。当其他缓存读取它时,状况变为同享;当前写数据时,变为已修正状况。

  • 同享Shared (S)

    缓存行也存在于其它缓存中且是洁净的。缓存行能够在恣意时刻扔掉。

  • 无效Invalid (I)

    缓存行是无效的

咱们经过一个例子来深化了解一下MESI缓存行4种不同状况的搬运方法,例如咱们有3个CPU,分别为CPU0,CPU1,CPU2,初始变量V=1。

java线程-Java内存模型

  1. 第一步,CPU0读取V,CPU0 Cache里的V=1,状况为E。其他CPU Cache没有数据
  2. 第二步,CPU0更新V=2,CPU0 Cache里的V=2,状况为M,Memory里V = 1。其他CPU Cache没有数据
  3. 第三步,CPU1读取V,先经过总线播送一条读恳求到其他CPU,CPU0接纳到告诉后,状况为M,需求将更新后的数据更新到住内存,再将状况变为S,才干响应CPU1的读取恳求,并将CPU1 Cache里的缓存行的变为S。
  4. 第四步,CPU2读取V,同第三步
  5. 第五步,CPU2更新V,因为CPU2缓存行里的状况为S,假如需求修正V,则需求先播送其他缓存行状况为S的CPU Cache,将其他CPU上对应的缓存行的状况改为I,并回复invalidate ack音讯给CPU2,CPU2收到invalidate ack音讯后,更新数据V=3,同步更新到主内存上,CPU2上的缓存行状况则变为E。
  6. 第六步,CPU0读取,发现其CPU缓存行的状况为I,所以CPU0先播送读恳求,CPU1不做处理,CPU2上将缓存行的状况改为S,CPU0从内存中读取,并更新缓存行状况为S。

Store Buffer

从上述第五步中咱们能够了解到,当多个CPU同享同一个数据,其中一个CPU更新数据,需求先播送invalidate音讯,其他CPU收到invalidate音讯后将缓存行状况改为I,然后回来invalidate ack音讯给这个CPU,然后这个CPU收到invalidate ack音讯后,才干更新数据并同步更新到内存上。这个是十分耗时的一个操作,需求等CPU写操作完结后才干履行其他指令,会影响CPU的履行功率。所以计算机科学家,在CPU和CPU缓存之间增加了Store Buffer,用于完结异步写操作。

CPU会将写操作的信息存储到Store Buffer后,CPU就能够履行其他操作指令,Store Buffer担任完结播送invalidate音讯,接纳invalidate ack音讯,写入缓存和内存等。

读取音讯的时分也是先从Store Buffer里获取,假如Store Buffer里没有再从缓存和主存里获取。

Invalidate Queue

Store Buffer发送给其他CPU invalidate音讯之后,需求等候其他CPU设置缓存失效并回来invalidate ack音讯,才干履行写入缓存和内存的操作。可是其他CPU或许忙于履行其他指令,所以导致store buffer写入缓存和内存操作不及时,有很多的写操作信息存储在Store Buffer里。

你也许会想到能够扩大Store Buffer的存储空间,来让Store Buffer存储更多的写操作信息。

计算机科学家则是在Cpu Cache和总线之间规划了一个Invalidate Queue,用于存储invalidate音讯和回来invalidate ack音讯,并异步履行缓存行状况设置为I的操作。

CPU缓存共同性协议与可见性

假如没有Store Buffer和Invalidate Queue,那么,缓存共同性协议是能够确保各个CPU缓存之间的数据共同性,也就不会存在可见性问题。可是,当引进Store Buffer和Invalidate Queue来异步履行写操作之后,即便运用缓存共同性协议,但各个CPU缓存之间仍然会存在时间短的数据不共同的状况,也便是会存在时间短的可见性问题。

java线程-Java内存模型

可见性案例:

  1. CPU0和CPU1均读取了内存中的数据a=1到各自的缓存中,对应的缓存行状况均标记为S(同享)。CPU0履行写入操作a=2,为了进步写入的速度,CPU0将写入操作a=2存储到Store Buffer中后就立刻回来。假定此刻Store Buffer还没有完结写入缓存和内存操作。
  2. CPU0读取数据,是直接从Store Buffer里获取到a=2。CPU1读取数据,发现Store Buffer里没有数据,就从缓存里读取到a = 1。此刻出现缓存数据不共同的状况。
  3. 假定Cpu0的Store Buffer会发送音讯给Cpu1的Invalidate Queue。在Invalidate Queue还没有将失效信息更新到Cpu1的缓存前,Cpu1仍是读取不到最新值a=2。

将写操作写入Store Buffer到Invalidate Queue依据失效信息将Cpu缓存行的状况设置为I的这段时间内,多个Cpu之间的缓存数据会存在时间短不共同的状况