多线程进阶学习05------Volatile详解

简介: 多线程进阶学习05------Volatile详解

JMM

内存模型可以理解为在特定的操作协议下,对特定的内存或者高速缓存进行读写访问的过程抽象描述,不同架构下的物理机拥有不一样的内存模型,Java虚拟机是一个实现了跨平台的虚拟系统,因此它也有自己的内存模型,即Java内存模型(Java Memory Model, JMM)。

究竟什么是内存模型?

内存模型描述了程序中各个变量(实例域、静态域和数组元素)之间的关系,以及在实际计算机系统中将变量存储到内存和从内存中取出变量这样的底层细节

Java Memory Model(Java内存模型), 围绕着在并发过程中如何处理可见性、原子性、有序性这三个特性而建立的模型。

java内存模型

  • JCP定义了一种Java内存模型,以前是在JVM规范中,后来独立出来成为JSR-133 (Java内存模型和线程规范修订)
  • 内存模型:在特定的操作协议下,对特定的内存或高速缓存进行读写访问的过程抽象
  • Java内存模型主要关注JVM中把变量值存储到内存和从内存中取出变量值这样的底层细节
  • 所有变量(共享的)都存储在主内存中,每个线程都有自己的工作内存;工作内存中保存该线程使用到的变量的主内存副本拷贝
  • 线程对变量的所有操作(读、写)都应该在工作内存中完成
  • 所有变量(共享的)都存储在主内存中,每个线程都有自己的工作内存;工作内存中保存该线程使用到的变量的主内存副本拷贝
    线程对变量的所有操作(读、写)都应该在工作内存中完成
    不同线程不能相互访问工作内存,交互数据要通过主内存

JMM规定了一系列内存间操作

具体流程如下图所示:

af4f1b9128f844188f653e1426fe8c05.png

  1. lock:锁定,把变量标识为线程独占,作用于主内存变量
  2. read:读取,把变量值从主内存读取到工作内存
  3. load:载入,把read读取到的值放入工作内存的变量副本中
  4. use:使用,把工作内存中一个变量的值传递给执行引擎
  5. assign:赋值,把从执行引擎接收到的值赋给工作内存里面的变量
  6. store:存储,把工作内存中一个变量的值传递到主内存中
  1. write:写入,把store进来的数据存放入主内存的变量中
  2. unlock:解锁,把锁定的变量释放,别的线程才能使用,作用于主内存变量

注意点:

  1. 不允许read和load、store和write操作之一单独出现,以上两个操作必须按顺序执行,但不保证连续执行,也就是说,read与load之间、store与write之间是可插入其他指令的
  2. 不允许一个线程丢弃它的最近的assign操作,即变量在工作内存中改变了之后必须把该变化同步回主内存
  3. 不允许一个线程无原因地(没有发生过任何assign操作)把数据从线程的工作内存同步回主内存中
  4. 一个新的变量只能从主内存中“诞生”,不允许在工作内存中直接使用一个未被初始化的变量,也就是对一个变量实施use和store操作之前,必须先执行过了load和assign操作
  5. 一个变量在同一个时刻只允许一条线程对其执行lock操作但lock操作可以被同一个条线程重复执行多次,多次执行lock后,只有执行相同次数的unlock操作,变量才会被解锁
  6. 如果对一个变量执行lock操作,将会清空工作内存中此变量的值,在执行引擎使用这个变量前,需要重新执行load
  7. 如果一个变量没有被lock操作锁定,则不允许对它执行unlock操作,也不能unlock一个被其他线程锁定的变量
  1. 对一个变量执行unlock操作之前,必须先把此变量同步回主内存(执行store和write操作)

三大特性

原子性

就是把一个或多个操作看成是一个原子操作,在CPU执行过程中,不会被中断,这样的特性就称为原子性。

1∶是对多线程而言的,对于单线程,原子性没有意义

2∶主要针对的、或者说要保护的是临界资源(共享变量),是对临界资源的操作,尤其是写操作

3∶原子操作是不可分割的

可见性

一个线程对临界资源(共享变量)的修改,另一个线程能够立即看到

要实现临界资源(共享变量)的可见性,至少要保证两点∶

1:线程修改了共享变量的值过后,要能够及时的从工作内存刷新回到主内存中

2:其它线程要能够及时的从主内存中,把最新的数据更新到自己的工作内存中

有序性

就是要保证程序执行的顺序,是按照代码的逻辑先后顺序来执行的

在Java程序中

1:一个线程内部,所有操作都可以视为是有序的(保证单线程内,串行语义执行的一致性)

2∶如果是多线程环境下,从一个线程去观察另外一个线程,所有操作都是无序的(指令重排现象,也有可能是工作内存和主内存同步延迟的现象)

重排序

重排序︰编译器或处理器为了优化程序的执行性能,对指令执行的顺序进行重新排列的一种手段

目的∶为了优化程序的执行性能·

分类︰

1:编译器优化的重排序·

2:指令级并行的重排序

3∶内存系统的重排序斜

e3a7ee31e6f34bfc8c2876e0d8016d54.png

编译器优化的重排序∶编译器在不改变程序在单线程环境下运行的语义前提下,可以重新安排语句的执行顺序·

目的︰尽可能减少寄存器的读取、存储次数,复用寄存器存储的数据

第一步A=某个计算的结果值

第二步B=某个计算结果的值

第三步C=使用A来计算一个新的数据

优化重排一下∶

第一步A=某个计算的结果值

第二步C=使用A来计算一个新的数据

第三步B=某个计算结果的值

指令级并行的重排序︰处理器将多条指令并行执行,如果不存在数据依赖,处理器可以改变语句对应的指令的执行顺序。“

lnt a = 5;

lnt b = 6;

内存系统的重排序︰处理器使用缓存和读写缓冲区,使得数据的加载、存储操作,看上去是乱序执行的。

并发编程里面的一个重要原则︰不要假设指令执行的顺序

不会重排

数据依赖︰如果两个操作访问同一个共享变量,而且,这两个操作里面有一个为写操作,那么这两个操作之间就存在数据依赖性。

数据依赖分类:

读后写:读一个变量过后,再写一个变量 a=b; b=1;

写后写∶写一个变量过后,再写一个变量 a=5 ; a=6;

写后读:写一个变量过后,再读这个变量 a=5; b=a;

具有数据依赖性的指令是不会被重排的

as-if-serial语义︰不管有没有重排序,也不关心如何进行的重排序,单线程环境不,程序的执行结果不会被改变。

编译器、JVM、处理器都必须要遵守这个语义。

happens-before

为什么需要 happens-before 原则?

happens-before 原则的诞生是为了程序员和编译器、处理器之间的平衡。程序员追求的是易于理解和编程的强内存模型,遵守既定规则编码即可。编译器和处理器追求的是较少约束的弱内存模型,让它们尽己所能地去优化性能,让性能最大化。happens-before 原则的设计思想其实非常简单:

  • 为了对编译器和处理器的约束尽可能少,只要不改变程序的执行结果(单线程程序和正确执行的多线程程序),编译器和处理器怎么进行重排序优化都行。
  • 对于会改变程序执行结果的重排序,JMM 要求编译器和处理器必须禁止这种重排序。

下面这张是 《Java 并发编程的艺术》这本书中的一张 JMM 设计思想的示意图,非常清晰。

d0a549006dd3498f9f254dee319ee953.png

了解了 happens-before 原则的设计思想,我们再来看看 JSR-133 对 happens-before 原则的定义:

  • 如果一个操作 happens-before 另一个操作,那么第一个操作的执行结果将对第二个操作可见,并且第一个操作的执行顺序排在第二个操作之前。
  • 两个操作之间存在 happens-before 关系,并不意味着 Java 平台的具体实现必须要按照 happens-before 关系指定的顺序来执行。如果重排序之后的执行结果,与按 happens-before 关系来执行的结果一致,那么 JMM 也允许这样的重排序。
int userNum = getUserNum();   // 1
int teacherNum = getTeacherNum();  // 2
int totalNum = userNum + teacherNum;  // 3
  • 1 happens-before 2
  • 2 happens-before 3
  • 1 happens-before 3

虽然 1 happens-before 2,但对 1 和 2 进行重排序不会影响代码的执行结果,所以 JMM 是允许编译器和处理器执行这种重排序的。但 1 和 2 必须是在 3 执行之前,也就是说 1,2 happens-before 3 。

happens-before 原则表达的意义其实并不是一个操作发生在另外一个操作的前面,虽然这从程序员的角度上来说也并无大碍。更准确地来说,它更想表达的意义是前一个操作的结果对于后一个操作是可见的,无论这两个操作是否在同一个线程里。

举个例子:操作 1 happens-before 操作 2,即使操作 1 和操作 2 不在同一个线程内,JMM 也会保证操作 1 的结果对操作 2 是可见的。

happens-before规则

程序顺序规则:一个线程中的每个操作happens-before该线程中任意后续操作

int a = 5;
int b = 6;
int c = a + b;
}

监视器锁规则︰对一个锁的解锁操作happens-before随后对这个锁的加锁·

volatile 变量规则︰对一个volatile修饰的字段进行的写操作happens-before任意后续对这个volatile修饰的字段进行的读操作

private volatile int num = 0;
public void run(){
    num = 8;
    //
    int a = num + 5;
}

start 规则︰如果在线程A里面去执行了线程B的start,那么在线程A里面的B.start()操作happens-before 线程B中的任意操作

join规则︰如果线程A执行了线程B.join(),那么线程B中的任意操作happens-before 线程A执行了线程Bjoin()之后的操作·

程序中断规则:对线程interrupted()方法的调用先行于被中断线程的代码检测到中断时间的发生。对象finalize规则:一个对象的初始化完成(构造函数执行结束)先行于发生它的finalize()方法的开始。

happens-before是JMM对程序员的承诺,有了它,我们在加锁,再线程同步的时候,就不用陷入有序性的底层细节中,他能天然保证有序。

happens-before 和 JMM 什么关系?

94c4a81796534b0bb7eb217637a2cfb3.png

要学习 happens-before 这里首先介绍下JMM的设计意图。这个问题首先从实际出发:

  1. 我们程序员写代码时,是要求内存模型易于理解,易于编程,所以我们需要依赖一个强内存模型来编码。 也就是说向公理一样,定义好的规则,我们遵守规则写代码就完事了。
  2. 对于编译器和处理器的实现来说,它们希望约束尽量少一些,毕竟你限制它们肯定影响它们的执行效率,不能让他们尽己所能的优化来提供性能。所以他们需要一个弱内存模型。

好了,上面谈到的这两点明显就是冲突的,作为程序员我们希望JMM提供给我们一个强内存模型,而底层的编译器和处理器又需要一个弱内存模型来提高自己的性能。

因此JMM在设计时,定义了如下策略:

  1. 对于会改变程序执行结果的重排序,JMM要求编译器和处理器必须禁止这种重排序。
  2. 对于不会改变程序执行结果的重排序,JMM对编译器和处理器不做要求(JMM允许这种重排序)。

只要不改变程序的执行结果,编译器和处理器想怎么优化就怎么优化。

内存屏障

内存屏障︰是一种屏障指令,它使得CPU或编译器对屏障指令的前和后所发出的内存操作执行一个排序的约束。也叫内存栅栏或栅栏指令

内存屏障的能力∶

1∶阻止屏障两边的指令重排序·

2︰写数据的时候加了屏障的话,强制把写缓冲区的数据刷回到主内存中

3∶读数据的时候加了屏障的话,让工作内存/CPU高速缓存当中缓存的数据失效,重新到主内存中获取新的数据

基本分类

1:读屏障:Load Barrier :在读指令之前插入读屏障,让工作内存/CPU高速缓存当中缓存的数据失效,重新到主内存中获取新的数据

2:写屏障: Store Barrier ︰在写指令之后插入写屏障,强制把写缓冲区的数据刷回到主内存中

重排序和内存屏障

1︰重排序可能会给程序带来问题,因此,有些时候,我们希望告诉JVM,这里不需要排序

JVM本身为了保证可见性∶

2∶对于编译器的重排序,JMM会根据重排序的规则,禁止特定类型的编译器重排序

3∶对于处理器的重排序,Java编译器在生成指令序列的适当位置,插入内存屏障指令,来禁止特定类型的处理器排序

JVM的内存屏障

LoadL.oad Barriers :Load1; LoadLoad; Load2

禁止重排序∶访问Load2的读取操作一定不会重排到Load1之前保证Load2在读取的时候,自己缓存内到相应数据失效,Load2会去主内存中获取最新的数据

LoadStore Barriers :Load1;LoadStore; Store2

禁止重排序∶一定是Load1读取数据完成后,才能让Store2及其之后的写出操作的数据,被其它线程看到。

StoreStore, Barriers :Store1;StoreStore; Store2

禁止重排序︰一定是Store1的数据写出到主内存完成后,才能让Store2及其之后的写出操作的数据,被其它线程看到。保证 Store1指令写出去的数据,会强制被刷新回到主内存中

StoreLoad Barriers :Store1;StoreLoad; Load2e

禁止重排序∶一定是 Store1 的数据写出到主内存完成后,才能让Load2来读取数据

同时保证︰强制把写缓冲区的数据刷回到主内存中

让工作内存/CPU高速缓存当中缓存的数据失效,重新到主内存中获取新的数据

为什么说:StoreLoadBarriers.是最重的?

重∶就是跟内存交互次数多,交互延迟较大、消耗资源较多

扩展

这些屏障指令并不是处理器真实的执行指令,他们只是JMM定义出来的、跨平台的指令。

因为不同硬件实现内存屏障的方式并不相同,JMM为了屏蔽这种底层硬件平台的不同,抽象出了这些内存屏障指令,在运行的时候,由JVM来为不同的平台生成相应的机器码。

这些内存屏障指令,在不同的硬件平台上,可能会做一些优化,从而只支持部分的JMM 的内存屏障指令。

在x86机器上,就只有StoreLoadBarriers是有效的,其它的都不支持,被替换成nop,也就是空操作。

Volatile

被volatile修饰的变量,具有如下特性∶

1:保证可见性︰

(1):对一个volatile修饰的变量进行读操作的话,总是能够读到这个变量的最新的值,也就是这个变量最后被修改的值

(2):一个线程修改了volatile修饰的变量的值的时候,那么这个变量的新的值,会立即刷新回到主内存中

(3):一个线程去读取volatile 修饰的变量的值的时候,该变量在工作内存中的数据无效,需要重新到主内存去读取最新的数据

2:禁止指令重排,也就是说要求维护happens-before的关系

(1):对volatile变量的写入,不能重排到写入之前的操作之前

3a4ced8e3a7a4351b30b0edd6825bdbc.png

(2)对volatile变量的读取,不能重排到读取操作的后续操作之后

7d155fd638fa4b40a861e0c7c64d1451.png

Volatile内存语义

volatile写的内存语义︰写一个volatile变量时,JMM会把该线程对应的工作内存中的共享变量的值刷新到主内存中

volatile读的内存语义︰读一个volatile变量时,JMM会把线程对应的工作内存中的共享变量数据设置为无效的,然后会从主内存中去读取共享变量最新的数据

Volatile内存语义实现

1:字节码层面

它影响的是Class内的Field 的flags : 添加了一个ACC_VOLATILE

JVM在把字节码生成为机器码的时候,发现操作是volatile 的变量的话,就会根据JMM要求,在相应的位置去插入内存屏障指令

2:JMM层面:插入内存屏障

volatile 写之前的操作,都禁止重排序到volatile之后

volatile读之后的操作,都禁止重排序到volatile之前

volatile 写之后volatile读,禁止重排序的

为了实现volatile内存语义,按如下方式来插入内存屏障∶

(1)在每个volatile 写操作的前面插入一个 StoreStore屏障

(2)在每个volatile 写操作的后面插入一个StoreLoad屏障

(3)在每个volatile读操作的后面插入一个LoadLoad屏障

(4)在每个volatile读操作的后面插入一个LoadStore屏障

需要注意的是: volatile写是在前面和后面分别插入内存屏障,而volatile读操作是在后面插入两个内存屏障

c7329298051e4ba49c06770b968703f8.png

JMM采用保守的内存屏障插入策略,始终在每个volatile写后面,插入一个StoreLoad.屏障。

这样可以保证在任意的处理器平台上, volatile语义的正确性。

3:处理器层面

cpu执行机器码指令的时候,是使用lock前缀指令来实现volatile 的功能的。

Lock指令,相当于内存屏障,功能也类似内存屏障的功能∶

(1)首先对总线/缓存加锁,然后去执行后面的指令,最后,释放锁,同时把高速缓存的数据刷新回到主内存

(2)在lock锁住总线/缓存的时候,其它cou的读写请求就会被阻塞,直到锁释放。Lock过后的写操作,会让其它cpu.的高速缓存中相应的数据失效,这样后续这些cpu.在读取数据的时候,就会从主内存去加载最新的数据

加了Lock指令过后的具体表现,就跟JMM添加内存屏障后一样。

双重检查锁机制的单例模式

**

/**
 * 3.懒汉式(双重检查,线程安全)
 */
public class Singleton3 {
    //1.构造器私有化
    private Singleton3() {
    }
    //2.本类内部创建对象实例
    private static volatile Singleton3 singleton3;
    //3.提供一个获取实例的方法,但是在第一次使用的时候再去实例化
    public static Singleton3 getInstance() {
        if (singleton3 == null) {
            synchronized (Singleton3.class) {
                if (singleton3 == null) {
                    singleton3 = new Singleton3();
                }
            }
        }
        return singleton3;
    }
}

为什么要使用volatile关键字?

这是因为 singleton3 = new Singleton3(); 这一步并不是原子操作,别看这是一行代码,在底层它被分成了三步操作:

1.分配一部分空间用于创建对象

2.在分配好的空间创建对象

3.将创建好的对象指向变量,在这里变量是singleton3

cpu在操作时为了提高效率会出现指令重排的操作,也就是把这三步进行乱序。假如说步骤变成了 1、3、2。那么就会导致 当a线程执行到第2步(执行顺序是132)的时候失去了操作权,b线程开始执行(由于a线程没有执行第2步就把空间指向了变量),发现singleton3指向的空间不是null,但是实际上此空间并没有实例,那么b线程使用该对象时候就会报错。

使用了volatile关键字就会禁止指令重排。

优点:进行了懒加载

缺点:编写复杂,要考虑线程安全


相关文章
|
6天前
|
Java 调度 C#
C#学习系列相关之多线程(一)----常用多线程方法总结
C#学习系列相关之多线程(一)----常用多线程方法总结
|
6天前
|
安全 编译器 C#
C#学习相关系列之多线程---lock线程锁的用法
C#学习相关系列之多线程---lock线程锁的用法
|
6天前
|
C#
C#学习相关系列之多线程---ConfigureAwait的用法
C#学习相关系列之多线程---ConfigureAwait的用法
|
6天前
|
C#
C#学习相关系列之多线程---TaskCompletionSource用法(八)
C#学习相关系列之多线程---TaskCompletionSource用法(八)
|
6天前
|
Java C#
C#学习系列相关之多线程(五)----线程池ThreadPool用法
C#学习系列相关之多线程(五)----线程池ThreadPool用法
|
6天前
|
存储 Java 调度
从零开始学习 Java:简单易懂的入门指南之线程池(三十六)
从零开始学习 Java:简单易懂的入门指南之线程池(三十六)
|
6天前
|
Java 调度
从零开始学习 Java:简单易懂的入门指南之多线程(三十四)
从零开始学习 Java:简单易懂的入门指南之多线程(三十四)
|
6天前
|
消息中间件 缓存 Java
【多线程学习】深入探究定时器的重点和应用场景
【多线程学习】深入探究定时器的重点和应用场景
|
6天前
|
监控 安全 Java
【多线程学习】深入探究阻塞队列与生产者消费者模型和线程池常见面试题
【多线程学习】深入探究阻塞队列与生产者消费者模型和线程池常见面试题
|
6天前
|
消息中间件 监控 安全
【JAVAEE学习】探究Java中多线程的使用和重点及考点
【JAVAEE学习】探究Java中多线程的使用和重点及考点