摘要

我们这一讲主要讲解基于volatile实现并发:可见性跟有序性问题,讲解volatile的时候,需要讲解:cpu缓存模型 -> java内存模型 -> 并发编程3大特性:原子性、可见性、有序性 -> volatile的作用 -> volatile的底层原理 -> volatile实战。

思维导图

image.png

内容

cpu多级缓存模型

绝大多数的运算任务都不可能只靠处理器“计算”就能完成。处理器至少要与内存交互,如读取运算数据、存储运算结果等,这个I/O操作就是很难消除的(无法仅靠寄存器来完成所有运算任务)

1.volatile引入

我们先看下以下例子:

public class VolatileTest {
     static int flag = 0;
    public static void main(String[] args) {
        /**
         * 1、开启一个读线程,读取flag的值
         */
        new Thread(){
            @Override
            public void run() {
              int localFlag = flag;
              while (true){
                 if(localFlag != flag){
                      System.out.println("读取到的标识位值:"+flag);
                      localFlag = flag;
                  }
                  try {
                      TimeUnit.SECONDS.sleep(2);
                  } catch (Exception e) {
                      e.printStackTrace();
                  }
              }
            }
        }.start();
        /**
         * 2、开启一个读写线程,修改flag值
         */
        new Thread(){
            @Override
            public void run() {
                int localFlag = flag;
                while (true){
                    System.out.println("标识位被修改了:"+ ++localFlag);
                    flag = localFlag;
                    try {
                        TimeUnit.SECONDS.sleep(2);
                    } catch (Exception e) {
                        e.printStackTrace();
                    }
                }
            }
        }.start();
    }
}

结果输出为:
image.png

通过以上我们发现:
1、上面代码实例中:flag是静态成员变量,存在与方法区;此方法区里面的资源是线程共享的资源,线程共享资源在多线程数据读取跟修改时候,会出现线程安全问题。而localFlag是方法栈里面的变量是线程私有的数据。每次是把堆里面的数据读取出来赋值给栈里面的数据。
2、共享数据在多线程读写时候,会出现线程不一致性问题;读线程不能够准确读取到写线程修改的数值。
3、在实际的系统运行过程中,可能会产生一个问题,就是说,Thread1修改变量的值,他修改了这个变量的值,结果呢,发现Thread2在他修改变量值之后,没那么快能感知到flag数值的变化。Thread1,已经将flag设置好了;但是Thread2,比如说在一段时间范围内,还是读到了旧的flag,在一小段时间范围内,可能Thread2会感知不到Thread1对flag的值修改,他读到的可能还是flag的这么一个旧的值。

我们将变量加上volatile修饰

 static volatile int flag = 0;

输出结果如下:
image.png
我们发现加上volatile之后,每次读线程都能够准确获取到volatile修饰的数据。

并发编程中:你只要开了多个线程,一定会有一些这种问题,某个线程修改一个变量值,其他线程要立刻感知到这个变量值的变化,但是如果你不用volatile,会有问题:有线程修改了一个变量的值,结果其他的线程感知不到这个变量值的修改

volatile:并发编程中,一个线程修改了某个共享变量值,其他线程会立刻感知到这个变量值的变化。volatile只是保证数据可见性,有些人说他是一个轻量级锁,其实是打错特错的。

2.cpu多节缓存模型

理想中的cpu模型:
image.png
假如我们的cpu内存模型如上:第二个cpu进行数据的读写,第一个cpu进行数据读取,每次第二个cpu会先从主存读取数据,然后进行写操作,将不会存在数据不一致问题,因为主内存里面的数据都是最终的数据。每次cpu1都是读取的主内存里面数据。每次读取时候,只要主内存里面数据更改了,cpu1就能够读取到最新的值。

现代计算机cpu内存模型由于:内存的读取速度跟不上cpu的处理速度。所以引出了;内存的读写速度没什么突破,cpu如果要频繁的读写主内存的话,会导致性能较差,计算性能就会低。所以引出了:cpu多级缓存模型:

image.png
1、现在计算机为了平衡主内存跟cpu处理速度协调问题,在其之间增加了多级cpu缓存。
2、cpu可以直接操作自己对应的cpu缓存,不需要直接频繁的跟主内存通信,这个是现代计算机技术的一个进步,这样可以保证cpu的计算的效率非常的高。
3、这样的cpu多级缓存模型将会导致主内存里面的实时数据在其他cpu线程里面读取不到,会有一个延迟。

cpu多级缓存导致数据不一致问题的分析。
image.png

3.总线加锁机制和MESI缓存一致性原理

早期数据不一致问题使用总线加锁机制保证。
总线加锁机制:
原理: 某个线程修改主内存里面共享数据的时候,会通过一个总线,将共享数据加锁,然后这个线程执行完相应操作之后才会释放锁。
问题: 效率低下:总线加锁机制使得多线程串行化执行,多线程下效率低下。

目前比较常用的是缓存一致性原理:MESI
MESI缓存一致性原理:
原理: 强制刷新主内存+cpu嗅探机制:某一个线程修改某一个数值时候,会将此数值强制刷新到主内存,然后使用cpu的嗅探机制发布一个修改数据的事件,然后其他线程嗅探到此数据被修改,然后使本地cpu缓存数据过期,然后把从主内存里面重新加载。

java内存模型

至JDK 5(实现了JSR-133[3])发布后,Java内存模型才终于成熟、完善起来了。

JVM里面设计一套java内存模型的目的:
屏蔽各种物理硬件跟操作系统,Java程序可以在不同物理硬件跟操作系统上访问内存数据一致。主流语言c语言跟c++使用的是物理硬件跟操作系统的内存模型。

图解java内存模型
java内存模型如下:基于java内存模型我们可以知道:
image.png

主内存跟工作内存:
1、Java内存模型的主要目的是:在虚拟机中把变量值存储到内存和从内存中取出变量值这样的底层细节;此处的变量(Variables)与Java编程中所说的变量有所区别,它包括了实例字段、静态字段和构成数组对象的元素,但是不包括局部变量与方法参数,因为后者是线程私有的(如果局部变量是一个reference类型,它引用的对象在Java堆中可被各个
线程共享,但是reference本身在Java栈的局部变量表中是线程私有的),不会被共享,自然就不会存在竞争问题。
2、Java内存模型规定了所有的变量都存储在主内存(Main Memory)中(此处的主内存与介绍物理 硬件时提到的主内存名字一样,两者也可以类比,但物理上它仅是虚拟机内存的一部分)。每条线程 还有自己的工作内存(Working Memory,可与前面讲的处理器高速缓存类比),线程的工作内存中保 存了被该线程使用的变量的主内存副本(有人会对这段描述中的“副本”提出疑问,如“假设线程中访问一个10MB大小的对象,也会把 这10MB的内存复制一份出来吗?”,事实上并不会如此,这个对象的引用、对象中某个在线程访问到 的字段是有可能被复制的,但不会有虚拟机把整个对象复制一次)。线程对变量的所有操作(读取、赋值等)都必须在工作内 存中进行,而不能直接读写主内存中的数据[3]。不同的线程之间也无法直接访问对方工作内存中的变 量,线程间变量值的传递均需要通过主内存来完成。
3、这里所讲的主内存、工作内存与第2章所讲的Java内存区域中的Java堆、栈、方法区等并不是同一 个层次的对内存的划分,这两者基本上是没有任何关系的。如果两者一定要勉强对应起来,那么从变 量、主内存、工作内存的定义来看,主内存主要对应于Java堆中的对象实例数据部分,而工作内存 则对应于虚拟机栈中的部分区域。

主内存跟工作内存之间的操作:
关于主内存与工作内存之间具体的交互协议,即一个变量如何从主内存拷贝到工作内存、如何从工作内存同步回主内存这一类的实现细节,Java内存模型中定义了以下6种操作来完成。Java虚拟机实现时必须保证下面提及的每一种操作都是原子的、不可再分的。

·read(读取):作用于主内存的变量,它把一个变量的值从主内存传输到线程的工作内存中,以便随后的load动作使用。
·load(载入):作用于工作内存的变量,它把read操作从主内存中得到的变量值放入工作内存的变量副本中。
·use(使用):作用于工作内存的变量,它把工作内存中一个变量的值传递给执行引擎,每当虚 拟机遇到一个需要使用变量的值的字节码指令时将会执行这个操作。
·assign(赋值):作用于工作内存的变量,它把一个从执行引擎接收的值赋给工作内存的变量,每当虚拟机遇到一个给变量赋值的字节码指令时执行这个操作。
·store(存储):作用于工作内存的变量,它把工作内存中一个变量的值传送到主内存中,以便随后的write操作使用。
·write(写入):作用于主内存的变量,它把store操作从工作内存中得到的变量的值放入主内存的变量中。

java多线程并发引发的问题
image.png

并发编程3大特性

介绍完Java内存模型的相关操作和规则后,我们再整体回顾一下这个模型的特征。Java内存模型是 围绕着在并发过程中如何处理原子性、可见性和有序性这三个特征来建立的,我们逐个来看一下哪些 操作实现了这三个特性。
可见性: 当一个线程修改了共享变量的值时候,其他线程就会立马知道这个修改的值。
java中保证可见性的关键字有:
volatile:MESI缓存一致性原理(强制刷新主内存+cpu嗅探机制工作内存缓存过期)
synchronized:对一个变量执行unlock操作之前,必须先把此变量同步回主内存中(执行store、write操作)。
final:被final修饰的字段在构造器中一旦被初始化完 成,并且构造器没有把“this”的引用传递出去(this引用逃逸是一件很危险的事情,其他线程有可能通 过这个引用访问到“初始化了一半”的对象),那么在其他线程中就能看见final字段的值。

原子性: 多并发修改共享变量的时候,同一时刻只能有一个线程修改成功,并且下一个线程修改的时候需要先读取这个主内存数据再修改。
java中保证原子性的关键字:synchronized

有序性:
对于代码,同时还有一个问题是指令重排序,编译器和指令器,有的时候为了提高代码执行效率,会将指令重排序,有序性就是指令不重排。

volatile深入

1、图解volatile如何保证可见性?

image.png

如上图:线程1跟线程2都同时使用read加载主内存里面的数据到各自线程工作内存;当线程1读取flag值后将其修改为1.有一点,只要flag变成了1,然后线程不是要将flag = 1写回工作内存吗?执行assign操作,此时如果这个flag变量是加了volatile关键字的话,那么此时会这样子,就是说一定会强制保证说assign之后,就立马执行store + write,刷回到主内存里去。

从而保证只要工作内存一旦变为flag = 1,主内存立马变成flag = 1
此外,如果这个变量是加了volatile关键字的话,此时他就会让其他线程的工作内存中的这个flag变量的缓存会过期
线程2如果再从工作内存里读取flag变量的值,发现他已经过期了,此时就会重新从主内存里来加载这个flag = 1的值
通过volatile关键字,可以实现的一个效果就是说,有一个线程修改了值,其他线程可以立马感知到这个值。

2、volatile如何保证有序性

java中happens-before原则: 编译器、指令器可能对代码重排序,但是不能乱排,需要遵循:happens-before原则。

常见原则:
一般有如下规则:
程序次序规则:
一个线程内,按照代码顺序,书写在前面的操作先行发生于书写在后面的操作。

锁定规则:
一个unLock操作先行发生于后面对同一个锁的lock操作。

volatile变量规则:
对一个变量的写操作先行发生于后面对这个变量的读操作(如果你先对volatile变量写,再是读,必须保证是先写,再读。你不能把volatile的读胡乱排列到写前面去)。

传递规则:
如果操作A先行发生于操作B,而操作B又先行发生于操作C,则可以得出操作A先行发生于操作C。

线程启动规则:
Thread对象的start()方法先行发生于此线程的每个一个动作

线程中断规则:
对线程interrupt()方法的调用先行发生于被中断线程的代码检测到中断事件的发生。

线程终结规则:
线程中所有的操作都先行发生于线程的终止检测,我们可以通过Thread.join()方法结束、Thread.isAlive()的返回值手段检测到线程已经终止执行

对象终结规则:
一个对象的初始化完成先行发生于他的finalize()方法的开始。

volatile如何保证有序性?

prepare();   // 准备资源
volatile flag = true; 

while(!flag){
  sleep()
}
execute(); // 基于准备好的资源执行操作

volatile为什么能保证有序性呢?
比如上面这个例子,如果用volatile来修饰flag变量(volatile flag = true),他会插入一个内存屏障,内存屏障保证其前面代码不会排到其后面去。后面代码不会排到其前面去。一定可以让prepare()指令在flag = true之前先执行,这就禁止了指令重排。因为volatile要求的是,volatile前面的代码一定不能指令重排到volatile变量操作后面,volatile后面的代码也不能指令重排到volatile前面。

3、图解volatile保证原子性

假设说现在:volatile i = 0。然后两个线程,都执行i++,那么此时为什么不能保证原子性?数据还是可能会出错呢?
image.png

所以在上面场景下:
1、两个线程同时把变量i加载到工作内存里面,都开始进行计算。线程1跟线程2计算出来的i都是为1;此时线程1开始写i=1到主内存;写完之后,将线程2里面工作内存的i=0会过期;这个时候线程2已经use过i了,不会从其工作内存加载数据了;然后线程2也会写i=1到主内存。
2、虽然volatile有这样的机制:写数据的时候,把数据写到主内存,并把其他线程里面的工作内存过期。但是别的线程可能在过期之前已经计算出了与此内存相同的结果,他会把其计算的结果一步一步写到主内存里面去。

4、volatile的底层实现原理:lock指令以及内存屏障

volatile能够保证并发可见性和有序性;但是volatile底层原理上,是如何实现保证可见性的呢?如何实现保证有序性的呢?

volatile可见性底层原理: lock前缀指令 + MESI缓存一致性协议 线程对volatile修饰的变量进行写操作的时候,JVM会发送一条lock前缀指令到cpu,cpu在计算完这个值之后,会立马将这个数值刷新到主内存,然后基于cpu的MESI缓存一致性协议用cpu的嗅探机制将通知其他线程对应数值过期,查询需要从主存查找的数据。然后保证了某个线程修改共享变量时候,其他线程可以看到修改的数据。

volatile有序性底层原理: 内存屏障。

加了volatile的变量,可以保证前后的一些代码不会被指令重排,这个是如何做到的呢?内存屏障。

java内存模型,由于不同的指令,导致有多种不同的内存屏障,一般有以下多个内存屏障:

1、LoadLoad屏障

Load1:

int localVar = this.variable

//代码片段1进行读操作:通过load指令,将主内存的variable变量加载到工作内存里来

Load2:

int localVar = this.variable2

//另外一代码片段2 进行读操作:通过load指令,将variable2从主内存加载到其工作内存里面来,

如果在线程1跟线程2之间加上一个内存屏障(LoadLoad)的话。确保Load1数据的装载先于Load2后所有装载指令。上面就禁止将load2先于load1加载。

LoadLoad屏障:Load1;LoadLoad;Load2,确保Load1数据的装载先于Load2后所有装载指令,他的意思,Load1对应的代码和Load2对应的代码,是不能指令重排的

下面有两个store的代码指令:store刷到主存。

Store1:

this.variable = 1

StoreStore屏障

Store2:

this.variable2 = 2

StoreStore屏障:Store1;StoreStore;Store2,确保Store1的数据一定刷回主存,对其他cpu可见,先于Store2以及后续指令。

LoadStore屏障:Load1;LoadStore;Store2,确保Load1指令的数据装载,先于Store2以及后续指令

StoreLoad屏障:Store1;StoreLoad;Load2,确保Store1指令的数据一定刷回主存,对其他cpu可见,先于Load2以及后续指令的数据装载

volatile的作用是什么呢?

如果:

volatile variable = 1

this.variable = 2 => store操作

int localVariable = this.variable => load操作

对于volatile修改变量的读写操作,都会加入内存屏障。

每个volatile写操作前面,加StoreStore屏障,禁止上面的普通写和他重排;每个volatile写操作后面,加StoreLoad屏障,禁止跟下面的volatile读/写重排

每个volatile读操作后面,加LoadLoad屏障,禁止下面的普通读和voaltile读重排;每个volatile读操作后面,加LoadStore屏障,禁止下面的普通写和volatile读重排。


startshineye
91 声望26 粉丝

我在规定的时间内,做到了我计划的事情;我自己也变得自信了,对于外界的人跟困难也更加从容了,我已经很强大了。可是如果我在规定时间内,我只有3分钟热度,哎,我不行,我就放弃了,那么这个就是我自己的问题,因为你自己...


引用和评论

0 条评论