java线程-Java内存模型

多线程编程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.这就是缓存的可见性问题。

缓存-可见性.svg

线程切换-原子性问题

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

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

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

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

线程切换-原子性问题.svg

编译执行-有序性问题

有序性指的是程序按照代码的先后顺序执行。 但是编译器为了优化性能,有时候会改变程序中语句的先后顺序。例如程序:“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对象的时候,就会报空指针异常。

编译优化-有序性问题.svg

实际上,在代码的编译执行过程中,有三种情况可能会导致指令重排

  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修饰的变量的读写指令不可以跟其前面的读写指令重排,也不可以跟后面的读写指令重排。

完全禁止指令重排.svg

指令重排是为了优化代码的执行效率,过于严格的限制指令重排,显然会降低代码的执行效率。因此,Java内存模型将volatile的语义定义为:部分禁止指令重排序。

对volatile修饰的变量执行写操作,Java内存模型只禁止位于其前面的读写操作与其进行重排序,位于其后面的读写操作可以与其进行指令重排序。

对volatile修饰的变量执行读操作,Java内存模型只禁止位于其后面的读写操作与其进行重排序,位于其前面的读写操作可以与其进行指令重排序。

部分禁止指令重排.svg

为了能实现上述细化之后的指令重排禁止规则,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
复制代码

volatile有序性问题.svg

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。

cpu缓存一致性协议-MESI.svg

  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缓存之间仍然会存在短暂的数据不一致的情况,也就是会存在短暂的可见性问题。

cpu缓存一致性协议-StoreBuffer-InvalidateQueue.svg

可见性案例:

  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之间的缓存数据会存在短暂不一致的情况

猜你喜欢

转载自juejin.im/post/7193988110311489593