谈谈并发编程、JMM、volatile

本文深入探讨Java并发编程的关键概念,包括多CPU、多核架构下的缓存一致性协议(MESI),线程上下文切换,以及Java内存模型(JMM)。详细解释了JMM如何解决原子性、可见性和有序性问题,特别是在多线程环境中的作用。并通过具体代码示例展示了volatile关键字如何确保变量的可见性和禁止指令重排。
摘要由CSDN通过智能技术生成

最近看了一些关于java并发编程的知识,小厂工作用到的不多,但是知识储备还是得有呀,毕竟面试会用到。下面是总结的知识点、一下全部针对(单机、多CPU、多核)的硬件架构

知识点储备篇(餐前甜点)

1、关于当前计算机硬件(多CPU、多核)

现代一台计算机的机构通常又多个CPU组成、每个CPU又有一定的核数。我们的java运行的线程都是在CPU中的寄存器执行、当一个线程执行的时候首先会从ARM中将变量copy一份放置到CPU cache(CPU缓存区)中、再由集群器执行,执行完毕之后再写回到CPU cache再由cache写回到内存

问1:为什么会有这么多CPU cache呢?

因为CPU的运行速度和执行速度远高于内存,为了避免每次CPU运行作从内存中取值,从而设置L1、L2、L3三个缓存区,每次CPU执行从缓存中取值。三个内存区的运行速度都不同、L1>L2>L3也就是说,寄存器每次运行的时候会从L1中取值、L2则为L1的缓存区、以此类推

2、缓存一致性协议(MESI)

在上述环境中,当多个处理器的运算任务都涉及同一 块主内存区域时,将可能导致各自的缓存数据不一致的情况,如果真的发生这种情况,那同步 回到主内存时以谁的缓存数据为准呢?为了解决一致性的问题,需要各个处理器访问缓存时都 遵循一些协议,在读写时要根据协议来进行操作,这类协议有MSIMESI(IllinoisProtocol)、MOSI、Synapse、Firefly及DragonProtocol,等等

MESI : M 修改 (Modified)、E 独享、互斥 (Exclusive)、S 共享 (Shared)、I 无效 (Invalid)

问2:这里的MESI协议是如何工作的呢?

举一个例子、当一个线程T1和另一个线程T2同时要修改主内存的变量i时、T1竞争到CPU1的使用权,copy一份变量i到自己的缓存中当前i的状态是E(独享、互斥)。另一个线程T2也竞争到CPU2开始操作变量i,copy一份到自己的缓存中、此时CPU1会嗅探到CPU2也在使用这个变量于是乎将状态改为S(共享 )、CPU2将copy的i状态也变为S(共享 )。两个CPU执行各自的操作,假如CPU1先执行结束,会将状态置为M(修改)而CPU2通过嗅探到CPU1状态的更变将自己的缓存区的状态改为I(无效),并且重新加载主内存中变量i的值进行运算。

除了MESI协议之外还有一种方式可以处理缓存一致性的问题,那就是加锁。Bus Locking(总线锁)。当一个CPU对其缓存中的数据进行操作的时候,往总线中发送一个Lock信号。这个时候,所有CPU收到这个信号之后就不操作自己缓存中的对应数据了,当操作结束,释放锁以后,所有的CPU就去内存中获取最新数据更新。

3、什么是线程上下文切换

CPU通过时间片分配算法来循环执行任务,当前任务执行一个时间片后会切换到下一个 任务。但是,在切换前会保存上一个任务的状态,以便下次切换回这个任务时,可以再加载这个任务的状态。所以任务从保存到再加载的过程就是一次上下文切换。
上面这些知识明白之后对理解JMM和volatile很有帮助、餐前甜点吃完了,开始回归我们的大餐吧、进入java环境
 

JMM和volatile(正餐)

1、什么是JMM

官方语言 : JMM (Java Memory Model)是Java内存模型,JMM定义了程序中各个共享变量的访问规则,即在虚拟机中将变量存储到内存和从内存读取变量这样的底层细节。 为什么要设计JMM 屏蔽各种硬件和操作系统的内存访问差异,以实现让Java程序在各种平台下都能达到一致的内存访问效果。

大白话 : JMM是一个抽象概念、计算机的操作最终都是在硬件上的、JMM的存在是为了屏蔽掉我们各种硬件或者操作系统之间的差异,让我们的代码达到在任何系统上都一致访问内存的效果(和上面的差不多,哈哈哈哈哈哈)

是不是感觉和硬件系统架构的样子差不多,工作内存可以看做为我们硬件的内存条、而线程中的工作内存则像是CPU中的cpu cache。我们线程工作时会将主内存中的变量copy一份放置到自己的工作内存中运行,执行完毕后再将变量写回到主内存。JMM就在中间负责通讯。
 
问3:JMM和JVM的有什么区别呢?  内容引自: https://blog.csdn.net/zhaomengszu/article/details/80270696

JMM中的主内存、工作内存与jvm中的Java堆、栈、方法区等并不是同一个层次的内存划分,这两者基本上是没有关系的,如果两者一定要勉强对应起来,那从变量、主内存、工作内存的定义来看,主内存主要对应于Java堆中的对象实例数据部分,而工作内存则对应于虚拟机栈中的部分区域。从更低层次上说,主内存就直接对应于物理硬件的内存,而为了获取更好的运行速度,虚拟机(甚至是硬件系统本身的优化措施)可能会让工作内存优先存储于寄存器和高速缓存中,因为程序运行时主要访问读写的是工作内存。

此时就应该了解JMM是什么了,并且知道JMM和JVM的区别

2、为什么需要JMM

由于JVM运行程序的实体是线程,而每个线程创建时JVM都会为其创建一个工作内存(有些地方称为栈空间),用于存储线程私有的数据,线程与主内存中的变量操作必须通过工作内存间接完成,主要过程是将变量从主内存拷贝的每个线程各自的工作内存空间,然后对变量进行操作,操作完成后再将变量写回主内存,如果存在两个线程同时对一个主内存中的实例对象的变量进行操作就有可能诱发线程安全问题。
以上关于主内存与工作内存之间的具体交互协议,即一个变量如何从主内存拷贝到工作内存、如何从工作内存同步到主内存之间的实现细节,Java内存模型定义了以下八种操作来完成

3、JMM八种操作

(1) lock(锁定) :作用于主内存的变量,把一个变量标记为一条线程独占状态
(2) unlock(解锁):作用于主内存的变量,把一个处于锁定状态的变量释放出来,释放后的变量才可以被其他线程锁定
(3) read(读取):作用于主内存的变量,把一个变量值从主内存传输到线程的工作内存中,以便随后的load动作使用
(4) load(载入):作用于工作内存的变量,它把read操作从主内存中得到的变量值放入工作内存的变量副本中
(5) use(使用):作用于工作内存的变量,把工作内存中的一个变量值传递给执行引擎
(6) assign(赋值):作用于工作内存的变量,它把一个从执行引擎接收到的值赋给工作内存的变量
(7) store(存储):作用于工作内存的变量,把工作内存中的一个变量的值传送到主内存中,以便随后的write的操作
(8) write(写入):作用于工作内存的变量,它把store操作从工作内存中的一个变量的值传送到主内存的变量中
 
一般变量会从主内存->工作内存->运算->写回工作内存->同步主内存的顺序执行
同步规则分析
1)不允许一个线程无原因地(没有发生过任何assign操作)把数据从工作内存同步回主内存中
2)一个新的变量只能在主内存中诞生,不允许在工作内存中直接使用一个未被初始化(load或者assign)的变量。即就是对一个变量实施use和store操作之前,必须先自行assign和load操作。
3)一个变量在同一时刻只允许一条线程对其进行lock操作,但lock操作可以被同一线程重复执行多次,多次执行lock后,只有执行相同次数的unlock操作,变量才会被解锁。lock和unlock必须成对出现。
4)如果对一个变量执行lock操作,将会清空工作内存中此变量的值,在执行引擎使用这个变量之前需要重新执行load或assign操作初始化变量的值。
5)如果一个变量事先没有被lock操作锁定,则不允许对它执行unlock操作;也不允许去unlock一个被其他线程锁定的变量。
6)对一个变量执行unlock操作之前,必须先把此变量同步到主内存中(执行store和write操作)
 

4、并发编程的可见性,原子性与有序性问题(全是理论知识,了解即可)

原子性: 原子性指的是一个操作是不可中断的,即使是在多线程环境下,一个操作一旦开始就不会被其他线程影响。

在java中,对基本数据类型的变量的读取和赋值操作是原子性操作有点要注意的是,对于32位系统的来说,long类型数据和double类型数据(对于基本数据类型, byte,short,int,float,boolean,char读写是原子操作),它们的读写并非原子性的,也就是说如
果存在两条线程同时对long类型或者double类型的数据进行读写是存在相互干扰的,因为对于32位虚拟机来说,每次原子读写是32位的,而long和double则是64位的存储单元,这样会导致一个线程在写时,操作完前32位的原子操作后,轮到B线程读取时,恰好只读取到了后32位的数据,这样可能会读取到一个既非原值又不是线程修改值的变量,它可能是“半个变量”的数值,即64位数据被两个线程分成了两次读取。但也不必太担心,因为读取到“半个变量”的情况比较少见,至少在目前的商用的虚拟机中,几乎都把64位的数据的读写操作作为原子操作来执行,因此对于这个问题不必太在意,知道这么回事即可。
X=10; //原子性(简单的读取、将数字赋值给变量)
Y = x; //变量之间的相互赋值,不是原子操作
X++; //对变量进行计算操作
X = x+1;
可见性 : 可见性指的是当一个线程修改了某个共享变量的值,其他线程是否能够马上得知这个修改的值。
 
对于串行程序来说,可见性是不存在的,因为我们在任何一个操作中修改了某个变量的值,后续的操作中都能读取这个变量值,并且是修改过的新值。
但在多线程环境中可就不一定了,前面我们分析过,由于线程对共享变量的操作都是线程拷贝到各自的工作内存进行操作后才写回到主内存中的,这就可能存在一个线程1修改了共享变量x的值,还未写回主内存时,另外一个线程2又对主内存中同一个共享变量x进行操作,但此时1线程工作内存中共享变量x对线程2来说并不可见,这种工作内存与主内存同步延迟现象就造成了可见性问题,另外 指令重排 以及 编译器优化 也可能导致可见性问题(一会儿说)
 
有序性 : 有序性指的是程序执行的顺序按照代码的先后顺序执行。
 
我们总认为代码的执行是按照先后顺序执行的,这样理解对于单线程而言没毛病。但是对于多线程而言可能会出现乱序的情况、因为在文件编译的时候(这里的编译值指的是class文件编译为CPU运行的指令)会发生 指令重排 的现象,A=1、B=0这样的顺序可能发生重新排序,(这里看不出来一会儿看demo)
 

5、JMM如何解决并发编程的三大特性

原子性问题:
除了JVM自身提供的对基本数据类型读写操作的原子性外,可以通过 synchronized Lock 实现原子性。因为synchronized和Lock能够保证任一时刻只有一个线程访问该代码块,(Lock就不试了)
没有 synchronized的demo
使用synchronized之后
 
可见性问题
volatile关键字保证可见性。当一个共享变量被volatile修饰时,它会保证修改的值立即被其他的线程看到,即修改的值立即更新到主存中,当其他线程需要读取时,它会去内存中读取新值。synchronized和Lock也可以保证可见性,因为它们可以保证任一时刻只有一个线程能访问共享资源,并在其释放锁之前将修改的变量刷新到内存中。
 
这里我们上代码做一个测试,首先不使用volatile关键字
我们先启动嗅探线程(Thread2),主线程休眠1秒后再启动修改线程(Thread1),我们在Thread中改变flag的值,休眠5分钟。直观上应该觉得Thread2会打印出 “嗅探到状态改变",但是迟迟没有打印,这是因为在Thread2中的flag取的工作内存中的值,并没有重新取主内存中的值,而Thread1修改玩flag状态之后就同步到主内存中,因此Thread2一直处于循环。这就丧失了可见性
 
接着我们使用volatile关键字来看一下它是如何实现可见性的
当我们使用volatile关键字之后当Thread1修改flag之后会直接放到主内存中并且,其他线程的工作内存的变量副本会被清除然后重新获取主内存的变量值。
 
问4:既然volatile能让线程实时同步主内存的变量,那么不能保证原子性吗?
这里我们还是直接上代码、使用原子性的demo,我们去掉 synchronized关键字,改为volatile做一下测试!多运行几次试一下
多运行几次结果发现都不一样,为什么呢!
因为num++本身不是一个原子操作、分为三步:
num = 0;
temp = num+1;
num = temp;
如果Thread1和Thread2全部执行num++时、这时Thread1运行结束将num刷新到主内存中、这是主内存中的变量num应该为1、但是因为num++不是原子操作、所以Thread2可能已经运行过temp = num+1;只剩下num = temp、即便是主内存中的num变量发生修改、Thread2同步主内存中的num=1、但是只剩下num = temp的操作、所以temp = 1、赋值给num、导致原本经过两个线程计算的num为2、现在为1的情况。
 
问5:为什么 synchronized可以保证可见性呢?
因为JMM给 synchronized设定一个强制的规定、
1>加锁前要清空自己的工作内存、从主内存中重新获取变量
2>解锁后将自己的变量同步回主内存、
 
有序性问题
在Java里面,可以通过volatile关键字来保证一定的“有序性。另外可以通过synchronized和Lock来保证有序性,很显然,synchronized和Lock保证每个时刻是有一个线程执行同步代码,相当于是让线程顺序执行同步代码,自然就保证了有序性。
 
在运行Demo之前我们想了解一个概念什么叫做  指令重排序
指令重排序(指令重排): 即只要程序的最终结果与 它顺序化情况的结果相等,那么指令的执行顺序可以与代码顺序不一致,此过程叫指令的重排 序。
JVM能根据处理器特性(CPU多级缓存系统、多核处理器等)适当的对机器指令进行重排序,使机器指令能更符合CPU的执行特性,最大限度的发挥机器性能。
 
问6:指令重排对我们有什么影响? 一共会发生多少次指令重排?
首先指令重排可能会影响到我们的运行结果(展示Demo)、并且指令重排不仅仅是发生在源文件编译时、从源文件开始到最终执行在硬件上的命令都可能发生指令重排
在jvm中将源码文件变异成class也有指令重排,通过 happens-before 原则来保证有序性
 
happens-before 原则(理论知识、了解即可)
只靠sychronized和volatile关键字来保证原子性、可见性以及有序性,那么编写并发程序可能会显得十分麻烦,幸运的是,从JDK 5开始,Java使用新的JSR-133内存模型,提供了 happens-before 原则 来辅助保证程序执行的原子性、可见性以及有序性的问题,它是判断数据是否存在竞争、线程是否安全的依据,happens-before 原则内容如下
1. 程序顺序原则,即在一个线程内必须保证语义串行性,也就是说按照代码顺序执行。
2. 锁规则 解锁(unlock)操作必然发生在后续的同一个锁的加锁(lock)之前,也就是说,如果对于一个锁解锁后,再加锁,那么加锁的动作必须在解锁动作之后(同一个锁)。
3. volatile规则 volatile变量的写,先发生于读,这保证了volatile变量的可见性,简单的理解就是,volatile变量在每次被线程访问时,都强迫从主内存中读该变量的值,而当该变量发生变化时,又会强迫将最新的值刷新到主内存,任何时刻,不同的线程总是能 够看到该变量的最新值。
4. 线程启动规则 线程的start()方法先于它的每一个动作,即如果线程A在执行线程B的start方法之前修改了共享变量的值,那么当线程B执行start方法时,线程A对共享变量的修改对线程B可见
5. 传递性 A先于B ,B先于C 那么A必然先于C
6. 线程终止规则 线程的所有操作先于线程的终结,Thread.join()方法的作用是等待当前执行的线程终止。假设在线程B终止之前,修改了共享变量,线程A从线程B的join方法成功返回后,线程B对共享变量的修改将对线程A可见。
7. 线程中断规则 对线程 interrupt()方法的调用先行发生于被中断线程的代码检测到中断事件的发生,可以通过Thread.interrupted()方法检测线程是否中断。
8. 对象终结规则 对象的构造函数执行,结束先于finalize()方法

所有的指令重排都必须要as-if-serial语义 : 即不管怎么重排序(编译器和处理器为了提高并行度),(单线程)程序的执行结果不能被改变。编译器、runtime和处理器都必须遵守as-if-serial语义。编译器和处理器不会对存在数据依赖关系的操作做重排序,因为这种重排序会改变执行结果。但是,如果操作之间不存在数据依赖关系,这些操作就可能被编译器和处理器重排序

我们可以看一个Demo,看一下指令重排
 Demo中可以看到如果运行的话,输出的结果理论按照代码运行的顺序上应该有三种可能(0,1)、(0,0)、(1,1)但是现在缺出现了第四种情况就是(0,0)、是不是只有当线程中  a=1和x=b、b=1和y=a调换位置之后才会触发这种情况,这就会在执行的指令上发生重排,这就可以用一个java的小Demo模拟出来指令重排。
 
问6:为什么会发生指令重排?有什么好处呀
之前有说道指令重排在很多环节可能都会发生比如
在线程运行时、线程1发出A指令,线程2发出B指令、因为指令A和B都对变量X有操作,但是指令A执行之后还有其他指令,那么指令B就无法操作变量X,但是线程不会处于一个等待的状态,会调整q=3和X=8的位置。既然q=3和X=8没有关联关系,并且调整之后对整个程序(单线程情况下)的运行结果没有影响,那么这两个指令可能就会发生指令重排。 因此指令重排在不干扰运行结果的情况下,可以相对的提升系统的性能

那么回来volatile如何能保证有序性(避免指令重排)这里还得说一个知识点叫内存屏障(内存栅栏)

内存屏障,又称内存栅栏 ,是一个CPU指令,它的作用有两个,一是保证特定操作的执行顺序,二是保证某些变量的内存可见性(利用该特性实现volatile的内存可见性)。 由于编译器和处理器都能执行指令重排优化 。如果在指令间插入一条Memory Barrier则会告诉编译器和CPU,不管什么指令都不能和这条Memory Barrier指令重排序,也就是说通过插入内存屏障禁止在内存屏障前后的指令执行重排序优化。Memory Barrier的另外一个作用是强制刷出各种CPU的缓存数据,因此任何CPU上的线程都能读取到这些数据的最新版本。总之,volatile变量正是通过内存屏障实现其在内存中的语义,即可见性和禁止重排优化。
 
可以看下下面这个很经典的有序性例子;

这是工作中一般会用到的单例模式,但是如Demo上展示的一样,这种没有使用volatile关键字的写法是否会有问题,如果在单线程下工作不会出现问题,但是在多线程下会出现线程安全的问题。原因在于某一个线程执行到第一次检测,读取到的instance不为null时,instance的引用对象可能没有完成初始化。

因为instance = new DoubleCheckLock();可以分为以下3步完成(伪代码)
memory = allocate(); //1.分配对象内存空间
instance(memory); //2.初始化对象
instance = memory; //3.设置instance指向刚分配的内存地址,此时instance!=null
 
由于步骤1和步骤2间可能会重排序,如下:
memory=allocate(); //1.分配对象内存空间
instance=memory; //3.设置instance指向刚分配的内存地址,此时instance!=null,但是对象还没有初始化完成!
instance(memory); //2.初始化对象

指令看上步骤2和步骤3没有依赖关系、这种数据的重排是可以被允许的、当第二个线程进来时可能已经指向了内存地址,但是还没有初始化对象,所以也会有线程安全问题、一般会使用volatile 禁止重排。

//禁止指令重排优化
private volatile static DoubleCheckLock instance;
 
volatile 的禁止重排是如何实现的,就是通过内存屏障( Memory Barrier
为了实现volatile的内存语义,编译器在生成字节码时,会在指令序列中插入内存屏障来禁止特定类型的处理器重排序。对于编译器来说,发现一个最优布置来最小化插入屏障的总数几乎不可能。为此,JMM采取保守策略。下面是基于保守策略的JMM内存屏障插入策略。
∙在每个volatile写操作的前面插入一个StoreStore屏障。
∙在每个volatile写操作的后面插入一个StoreLoad屏障。
∙在每个volatile读操作的后面插入一个LoadLoad屏障。
∙在每个volatile读操作的后面插入一个LoadStore屏障
volatile读(Load)/写(store)分别是针对volatile修饰的变量取值和赋值的操作
 
举例来说,第三行最后一个单元格的意思是:在程序中,当第一个操作为普通变量的读或
写时,如果第二个操作为volatile写,则编译器不能重排序这两个操作。
从上图可以看出:
当第二个操作是volatile写时,不管第一个操作是什么,都不能重排序。这个规则确保volatile写之前的操作不会被编译器重排序到volatile写之后。
∙当第一个操作是volatile读时,不管第二个操作是什么,都不能重排序。这个规则确保volatile读之后的操作不会被编译器重排序到volatile读之前。
∙当第一个操作是volatile写,第二个操作是volatile读时,不能重排序。
下面是保守策略下,volatile写插入内存屏障后生成的指令序列示意图
上图中StoreStore屏障可以保证在volatile写之前,其前面的所有普通写操作已经对任意处理器可见了。这是因为StoreStore屏障将保障上面所有的普通写在volatile写之前刷新到主内存。
 
下面用一个小demo来分析一下
class VolatileBarrierExample {
              int a; volatile int v1 = 1 ;
              volatile int v2 = 2 ;
              void readAndWrite() {
                            int i = v1; // 第一个volatile读
                            int j = v2; // 第二个volatile读
                            a = i + j; // 普通写
                            v1 = i + 1 ; // 第一个volatile写
                           v2 = j * 2 ; // 第二个 volatile 写
              }
}
指令分析
注意,最后的StoreLoad屏障不能省略。因为第二个volatile写之后,方法立即return。此时编 译器可能无法准确断定后面是否会有volatile读或写,为了安全起见,编译器通常会在这里插 入一个StoreLoad屏障。
到这里并发和volatile就说完事了。

拓展内容(小朋友你是否有很多问号)

1、除了volatile之外还有什么办法添加内存屏障吗?

我们知道了volatile可以添加内存屏障保证有序性,除此之外还有什么办法呢?

在Java中的sun.misc包下提供了一个类叫Unsafe的类,这个类无法new出来、一般使用通过反射来执行(有兴趣的小伙伴可以试试)这个类如果玩不明白的话不建议玩、Unsafe不属于jvm操控,但是会直接操控内存,一但玩坏了会引发很多内存问题(谨慎使用)。关于内存屏障的方法如下

loadFence() 在该方法之前的所有读操作,一定在load屏障之前执行完成。

storeFence() 在该方法之前的所有写操作,一定在store屏障之前执行完成

fullFence() 在该方法之前的所有读写操作,一定在full屏障之前执行完成,这个内存屏障相当于上面两个(load屏障和store屏障)的合体功能。

2、你晓不晓得总线风暴?

什么是总线风暴?

我们看过MESI协议之后就了解到,协议的工作就是当线程获取主内存的变量时,需要不停的去嗅探其他线程是否也是用此变量,而访问主内存都需要通过BUS总线、因此BUS总线的带宽达到峰值。这就是总线风暴。JMM的CAS操作也会引起总线风暴、可以自己了解下

如何解决总线风暴呢?

我们知道了总线风暴的问题所在也就自然找到了问题的所在,解决办法就是减少volatile关键字的使用和CAS行为,可以使用synchronize,lock等来代替。


这篇知识点就完结了,接下来写啥呢~~~

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值