3.并发编程-可见性与有序性

可见性与有序性

线程可见性的问题

简单来讲,就是线程A修改了某个共享变量,但线程B无法知道该变量已经被修改,也可就是:

读线程不能及时的读取到其他线程写入的最新的值,这就是所谓的可见性

public class VolatileDemo {
 public static boolean stop=false;
 public static void main(String[] args) throws InterruptedException {
     Thread t1=new Thread(()->{
         int i=0;
         while(!stop){
         	i++;
         }
     });
     t1.start();
     System.out.println("begin start thread");
     Thread.sleep(1000);
     stop=true;
 }
}

这个案例比较简单,就是t1线程中用到了stop这个属性,接在在main线程中修改了 stop 这个属性的值 来使得t1线程结束,但是t1线程并没有按照期望的结果执行

volatile解决可见性问题

在上面的程序中,可以增加volatile来解决,代码如下

public class VolatileDemo {
 public static volatile boolean stop=false;
 public static void main(String[] args) throws InterruptedException {
     Thread t1=new Thread(()->{
         int i=0;
         while(!stop){
         	i++;
         }
     });
     t1.start();
     System.out.println("begin start thread");
     Thread.sleep(1000);
     stop=true;
 }
}

为了提升处理性能做的优化

在整个计算机的发展历程中,除了CPU、内存以及I/O设备不断迭代升级来提升计算机处理性能之外, 还有一个非常核心的矛盾点,就是这三者在处理速度的差异。CPU的计算速度是非常快的,其次是内 存、最后是IO设备(比如磁盘),也就是CPU的计算速度远远高于内存以及磁盘设备的I/O速度。

如下图所示,计算机是利用CPU进行数据运算的,但是CPU只能对内存中的数据进行运算,对于磁盘中 的数据,必须要先读取到内存,CPU才能进行运算,也就是CPU和内存之间无法避免的出现了IO操作

而cpu的运算速度远远高于内存的IO速度,比如在一台2.4GHz的cpu上,每秒能处理2.4x109次,每次 处理的数据量,如果是64位操作系统,那么意味着每次能处理64位数据量。

在这里插入图片描述

虽然CPU从单核升级到多核甚至到超线程技术在最大化的提高CPU的处理性能,但是仅仅提升CPU性能 是不够的,如果内存和磁盘的处理性能没有跟上,就意味着整体的计算效率取决于最慢的设备,为了平 衡这三者之间的速度差异,最大化的利用CPU。所以在硬件层面、操作系统层面、编译器层面做出了很 多的优化

  1. CPU增加高速缓存
  2. 操作系统层面增加了进程、线程,通过CPU的时间片切换最大化的提升CPU的使用率
  3. 编译器的指令优化,更合理的去利用好CPU的高速缓存

但是每一种优化,都会带来相应的问题,而这些问题就是导致线程安全问题的根源。

CPU层面的缓存

CPU在做计算时,和内存的IO操作时无法避免的,而这个IO过程相对于CPU的计算速度来说是非常耗时,基于这样一个问题,所以在CPU层面设计了高速缓存,这个缓存行可以缓存存储在内存中的数据,CPU每次会先从缓存行中读取需要运算的数据,如果缓存行中不存在该数据,才会从内存中加载,通过这样一个机制可以减少CPU和内存的交互开销从而提升CPU的利用率。

对于主流的x86平台,cpu的缓存行(cache)分为L1、L2、L3总共3级。

在这里插入图片描述

缓存一致性问题

CPU高速缓存的出现,虽然提升了CPU的利用率,但是同时也带来了另外一个问题 – 缓存一致性问题 。

这个一致性问题体现在,在多线程环境中,当多个线程并行执行加载同一块缓存数据时,由于每个CPU都有自己独立的L1,L2缓存,所以每个CPU的这部分缓存空间都会缓存到相同的数据,并且每个CPU执行相关指令时,彼此之间不可见,就会导致缓存一致性问题

在这里插入图片描述

缓存一致性协议

为了达到数据访问的一致,需要各个处理器在访问缓存时遵循一些协议,在读写时根据协议来操作,常见的协议有MSI \ MESI \ MOSI等,最常见的就是MESI协议。

说白了,这些协议就是来解决缓存行的缓存一致性问题,君子协议

MESI表示缓存行的四种状态,分别是:

  • M (Modify):表示共享数据只缓存在当前CPU缓存中,并且是被修改状态,也就是缓存的数据和主内存中的数据不一致
  • E (Exclusive):表示缓存独占状态,数据只缓存在当前CPU缓存中,并没有被修改
  • S (Shared):表示数据可能被多个CPU缓存,并且各个缓存中的数据和主内存中的一致
  • I (Invalid):表示缓存已经失效

在CPU缓存行中,每一个Cache一定会处于以下三种状态之一

  • Shared
  • Exclusive
  • Invalid

在这里插入图片描述

说白了就是:当缓存更新时,需要让其他CPU的缓存失效

指令重排序

指令重排序的示例代码

public class SeqExample {

 private volatile static int x=0,y=0;
 private volatile static int a=0,b=0;
 public static void main(String[] args) throws InterruptedException {
     int i=0;
     for(;;){
         i++;

         x=0;y=0;
         a=0;b=0;
         Thread t1=new Thread(()->{
             a=1;
             x=b;
         });
         Thread t2=new Thread(()->{
             b=1;
             y=a;
         });
         /**
             * 可能的结果:
             * 1和1
             * 0和1
             * 1和0
             * ----
             * 0和0
             */
            t1.start();
            t2.start();
            t1.join();
            t2.join();
            String result="第"+i+"次("+x+","+y+")";
            if(x==0&&y==0){
                System.out.println(result);
                break;
            }else{
            }
        }
    }
}

CPU层面如何导致指令重排序

在讲解CPU导致的指令重排序之前,先引入一个storeBuffer,为了实现异步缓存一致性而存在

在这里插入图片描述

假设有如下代码

int a = 0;
function(){
 a = 1;
 b = a+1;
 assert(b==2); // fasle
}

// 什么情况下,b==2 会是fasle
b = a+1;
a = 1;
// 这样, b==2 会是false 即所谓的指令从排序

图解如上代码

在这里插入图片描述

其实说白了就是,CPU0将a = 1;然后需要发送缓存一致性消息给别的CPU,然后需要等别的CPU的同步消息回来以后,此时的a 才能真的是1,在此之前都是0

当CPU0发送完缓存一致性消息以后,它是可以继续往下执行的,因为是异步的,所以它拿着a=0继续往下搞,所以最终造成了指令重排序的假象,代码执行直接换行了,说白了就还是异步导致的

除了引入的Store Buffer之外,还有引入的Store Forwarding、Invaild Queue

内存屏障解决CPU顺序一致性

CPU在性能优化的道路上导致的顺序一致性问题,在CPU层面是无法被解决的,原因是CPU只是一个运算工具,它只接收指令并解决指令,并不清楚当前执行的逻辑中是否存在不能被优化的问题,也就是说硬件层面无法优化这种顺序一致性带来的可见性问题。

因此,在CPU层面提供了写屏障、读屏障、全屏障这样的指令,在X86架构中,这三种指令分别是 SFENCE、LFENCE、MFENCE指令

  • SFENCE:也就是save fence,写屏障指令。在sfence指令前的写操作必须在sfence指令后的写操作 前完成
  • LFENCE:也就是load fence,读屏障指令。在lfence指令前的读操作必须在lfence指令后的读操作 前完成
  • MFENCE:也就是modify/mix,混合屏障指令,在mfence前得读写操作必须在mfence指令后的读 写操作前完成。

在Linux系统中,将这三种指令分别封装成了, smp_wmb-写屏障 、 smp_rmb-读屏障 、 smp_mb-读写屏障 三 个方法

在这里插入图片描述

JMM

什么是JMM

首先,我们知道java程序是运行在java虚拟机上的,同时我们也知道,jvm是一种跨平台的实现,也就是Write Once,Run Anywhere

那么JVM如何实现在不同平台上都能达到线程安全的呢,所以这个时候JMM出现了,Java内存模型(Java Memory Model,JMM)就是一种符合内存模型规范的,屏蔽了各种硬件和操作系统访问差异的,保证了Java程序在各种平台下对内存的访问都能保证效果一致的机制以及规范

java内存模型规定了所有的变量都存储在主内存中,每个线程都有自己的工作内存,线程的工作内存中保存了这个线程中用到的变量的主内存副本拷贝,线程对变量的所有操作都必须在工作内存中进行,而不能直接读写主内存

不同的线程之间也无法直接访问对方工作内存中的变量,线程之间变量的传递均需要自己的工作内存和主内存之间进行数据同步,流程图如下:

再总结一下:JMM定义了共享内存中多线程程序读写操作的行为规范,在虚拟机中把共享变量存储到内存以及从内存中取出共享变量的底层实现细节

目的是解决由于多线程通过共享内存进行通信时,存在的本地内存数据不一致,编译器会对代码指令重排序、处理器会对代码乱序执行带来的问题

本地内存时JMM的一个抽象概念,并不真实存在.它涵盖了缓存、写缓冲区、寄存器以及其他的硬件和编译器优化

其实实际上JMM的整个内存模型和CPU的高速缓冲和内存的交互模型是一致的,因为不管软件怎么设计,最终都是交由硬件来执行的。而这个抽象模型存在的意义在于,它可以针对不同的平台来保证并发场景下的可见性问题

Happens-Before模型

也就是告诉你,那些场景下肯定不会存在可见性问题

前面讲了那么多,都是为了讲清楚,到底是什么原因导致了在多线程环境下的可见性和有序性问题,并且也了解到了volatile解决可见性问题的本质

那么有没有那些情况下,不需要增加volatile关键字,也能保证在多线程环境下的可见性和有序性

从JDK 1.5开始,引入了Happens-Before的概念来阐述多个线程来操作共享变量的可见性问题。所以我们可以认为在JMM中,如果一个操作执行的结果需要对另一个线程可见,那么这两个操作必须要存在happens-before关系,这两个操作可以是同一个线程,也可以不是

程序顺序规则

一个线程中每个操作,happens-before这个线程中的任意后续操作,可以简单认为是as-if-serial

as-if-serial的意思是,不管怎么重排序,单线程的程序执行结果不能改变

  • 处理器不能对存在依赖关系的操作进行重排序,因为重排序会改变程序执行的结果
  • 对于没有依赖关系的质量,即使重排序,也不会改变在单线程环境下的执行结果

具体看一段代码,A和B是允许重排序的,但是C不允许,因为存在依赖关系。根据as-if-serial语义,在单线程环境下,不管怎么重排序,最终的执行结果都不会发生变化

int a = 2;   //A
int b = 2;   //B
int c = a*b; //C
传递性规则

依然看下面这段代码,根据规则知道,这三者之间存在happens-before关系

int a = 2;   //A
int b = 2;   //B
int c = a*b; //C
  • A happens-before B
  • B happens-before C
  • A happens-before C

这三个happens-before关系,就是根据happens-before的传递性推导出来的。此时可能就疑惑了,不是说A和B允许重排序的嘛?那么A happens-before B不一定存在啊,也可能是B可以重排序在A之前执行呢?

没毛病,确实如此,JMM不要求A一定要在B之前执行,但是它要求的是前一个操作执行的结果对后一个操作可见。这里操作A的执行结果不需要对操作B可见,并且重排序操作A和操作B后的执行结果与A happens-before B顺序执行的结果一致,这种情况下是允许重排序的

volatile变量规则

对于volatile修饰的变量的写操作,一定happens-before后续对于volatile变量的读操作,这个是因为volatile底层通过内存屏障机制防止指令重排

假如有两个线程A和B,分别访问write方法和reader方法,那么将出现以下可见性规则

public class VolatileExample {
	int a=0;
	volatile boolean flag=false;
	public void writer(){
		a=1;        //1
		flag=true;  //2
	}
	public void reader(){
		if(flag){    //3
			int i=a; //4
		}
	}
}
  • 1 happens-before 2 、 3 happens-before 4 这个是程序顺序规则
  • 2 happens-before 3 是由volatile规则产生的,对于一个volatile变量的都,总能看到任意线程对这个volatile变量的写入
  • 1 happens-before 4 基于传递性规则以及volatile的内存屏障策略共同保证

那么最终的结论是,如果线程B执行reader方法是,如果flag为true,那么意味着i = 1成立

监视器锁规则

一个线程对于一个锁的释放操作,一定happens-before与后续线程对这个锁的加锁操作

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

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

start规则

如果线程A执行操作ThreadB.start(),那么线程B的ThreadB.start()之前的操作happens-before线程B中的任意操作

public StartDemo{
	int x=0;
	Thread t1 = new Thread(()->{
		// 主线程调用 t1.start() 之前
		// 所有对共享变量的修改,此处皆可见
		// 此例中,x==10
	});
	// 此处对共享变量 x修改
	x = 10;
	// 主线程启动子线程
	t1.start();
}
join规则

如果线程A执行操作ThreadB.join()并成功返回,那么线程B中任意操作happens-before于线程A从ThreadB.join()操作成功的返回

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

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值