| |
|
开发:
C++知识库
Java知识库
JavaScript
Python
PHP知识库
人工智能
区块链
大数据
移动开发
嵌入式
开发工具
数据结构与算法
开发测试
游戏开发
网络协议
系统运维
教程: HTML教程 CSS教程 JavaScript教程 Go语言教程 JQuery教程 VUE教程 VUE3教程 Bootstrap教程 SQL数据库教程 C语言教程 C++教程 Java教程 Python教程 Python3教程 C#教程 数码: 电脑 笔记本 显卡 显示器 固态硬盘 硬盘 耳机 手机 iphone vivo oppo 小米 华为 单反 装机 图拉丁 |
-> Java知识库 -> 011Java并发包008内存模型 -> 正文阅读 |
|
[Java知识库]011Java并发包008内存模型 |
1 JMM内存模型1.1 是什么JMM即Java内存模型(Java Memory Model),JMM本身是一种抽象的概念,并不真实存在,它描述的是一组规则或规范,通过规范定制了程序中各个变量的访问方式。 Java内存模型规定: 所有的变量都存储在主内存(内存条),每条线程都有着自己独立的工作内存(寄存器,L1、L2、L3缓存)。 线程的工作内存中保存了被该线程使用的变量的主内存副本(简单来说就是把变量从主内存拷到自己的工作内存中,这就是副本)。 线程对变量的所有操作都必须在工作内存中进行,而不能直接操作主内存中的变量。 不同线程之间无法访问对方的工作内存中的变量,线程之间变量值的传递均需要通过主内存来完成。 内存模型如下: 这里假定一个CPU有多核,一个线程使用一个内核。 1.2 意义在一个计算机系统中,数据存储的位置主要有硬盘和内存,以及多级缓存。因为访问速度的问题,CPU的运行并不是直接操作内存,而是先将内存中的数据读取到缓存,内存的读操作和写操作产生的时间差异就会造成不一致的问题。 Java内存模型的主要目的,就是定义程序中各种变量的访问规则,来屏蔽各种硬件和操作系统的内存访问差异,以实现让Java程序在各种平台下都能达到一致的内存访问效果。 1.3 八种原子操作一个变量如何从主内存拷贝到工作内存、如何从工作内存同步到主内存之间的实现细节,Java内存模型定义了以下八种操作来完成: 1)lock(锁定):作用于主内存的变量,它把一个变量标识为一条线程独占的状态。 2)unlock(解锁):作用于主内存的变量,它把一个处于锁定状态的变量释放出来,释放后的变量才可以被其他线程锁定。 3)read(读取):作用于主内存的变量,它把一个变量的值从主内存传输到线程的工作内存中,以便随后的load操作使用。 4)load(载入):作用于工作内存的变量,它把read操作从主内存中得到的变量值放入工作内存的变量副本中。 5)use(使用):作用于工作内存的变量,它把工作内存中一个变量的值传递给执行引擎,每当虚拟机遇到一个需要使用到变量的值的字节码指令时就会执行这个操作。 6)assign(赋值):作用于工作内存的变量,它把一个从执行引擎接收到的值赋给工作内存的变量,每当虚拟机遇到一个给变量赋值的字节码指令时执行这个操作。 7)store(存储):作用于工作内存的变量,它把工作内存中一个变量的值传送到主内存中,以便随后write操作使用。 8)write(写入):作用于主内存的变量,它把store操作从工作内存中得到的变量的值放入主内存的变量中。 除此之外,Java内存模型还规定了在执行上诉八种操作时必须满足的规则: 如果要把一个变量从主内存中复制到工作内存中,就需要按顺序地执行read和load操作,如果把变量从工作内存中同步到主内存中,就需要按顺序地执行store和write操作。但Java内存模型只要求上述八种操作必须按顺序执行,而没有保证必须是连续执行。 不允许read和load、store和write操作之一单独出现。 不允许一个线程丢弃它的最近assign的操作,即变量在工作内存中改变了之后必须同步到主内存中。 不允许一个线程无原因的(没有发生过任何assign操作)把数据从工作内存同步回主内存中。 一个新的变量只能在主内存中诞生,不允许在工作内存中直接使用一个未被初始化(load或assign)的变量。对一个变量实施use和store操作之前,必须先执行过了load或assign操作。 一个变量在同一个时刻只允许一条线程对其进行lock操作,但lock操作可以被同一条线程重复执行多次,多次执行lock后,只有执行相同次数的unlock操作,变量才会被解锁。所以lock和unlock必须成对出现。 如果对一个变量执行lock操作,将会清空工作内存中此变量的值,在执行引擎使用这个变量前需要重新执行load或assign操作初始化变量的值。 如果一个变量事先没有被lock操作锁定,则不允许对它执行unlock操作,也不允许去unlock一个被其他线程锁定的变量。 对一个变量执行unlock操作之前,必须先把此变量同步到主内存中,即在unlock操作前必须先执行store和write操作。 1.4 三大特性1.4.1 可见性可见性是指在多线程坏境下,线程在工作内存中修改了某一个共享变量的值,其他线程能够立即获取该共享变量变更后的值。 一般情况下,共享变量不能保证可见性,因为数据修改后被写入内存的时机是不确定的,而线程间变量值的传递均需要通过主内存来完成。 保证可见性的办法是使用volatile关键字,但是可以使用同步锁保证同一时刻只能有一个线程获取和更新共享变量。 1.4.2 原子性原子性是指在多线程坏境下,线程对数据的操作要保证全部成功或者全部失败,并且不能被其他线程干扰。 线程在读取主内存变量、操作变量、写回主内存变量的一系列过程中,其他线程不能对该内存变量进行修改,或者在发现变量被修改后应重新读取该变量。 一般情况下,共享变量不能保证原子性,因为存在多个线程同时写入共享变量到主内存的情况,这就会导致前一个线程写入的值会被后一个线程写入的值覆盖。 保证可见性的办法是使用自旋锁,但是可以使用同步锁保证同一时刻只能有一个线程获取和更新共享变量。 1.4.3 有序性了解有序性需要先了解指令重排序:计算机在执行程序时,为了提高性能,编译器和处理器常常会做指令重排序。 指令重排序一般分为以下三种: 重排序需要遵守一定规则: 1)在进行重排序时,必须要考虑指令之间的数据依赖性,即有依赖关系的程序不会发生重排序。 2)在进行重排序后,在单线程环境中能保证重排序执行结果和顺序执行结果是一致的,在多线程环境中无法保证一致。 有序性是指在多线程环境下,禁止指令重排序,保证结果的一致性。 一般情况下,不能保证多线程环境中的有序性。 保证有序性的办法是使用volatile关键字,但是可以使用同步锁保证同一时刻只能有一个线程执行同步代码。 2 缓存一致性2.1 背景计算机核心组件:CPU、内存、I/O设备(硬盘)。三者在处理速度上存在巨大差异,CPU速度最快,其次是内存,硬盘速度最慢。 为了提升计算性能,CPU从单核提升到了多核,甚至用到了超线程技术最大化提高CPU处理性能,然而内存和硬盘的发展速度远远不及CPU。CPU的高度运算需要高速的数据,如果后两者处理性能没有跟上,意味着整体的计算效率取决于最慢的设备。 为了平衡三者之间的速度差异,最大化的利用CPU提升性能,从硬件、操作系统、编译器等方面,做出了很多优化: 硬件层面优化:CPU增加高速缓存。 操作系统层面优化:增加了进程和线程,通过CPU时间片切换最大化提升CPU的使用率。 编译器层面优化:优化指令,更合理的利用CPU高速缓存。 2.2 CPU高速缓存使用高速缓存作为内存和处理器之间的缓冲,可以很好的解决处理器与内存的速度矛盾。 处理器和内存,以及同高速缓存进行交互的工作原理如下: 1)加载程序及数据到主内存。 2)加载程序及数据到高速缓存。 3)处理器执行程序,将结果存储在高速缓存。 4)高速缓存将数据写回主内存。 带有高速缓存的CPU执行流程如下: 由于CPU运算速度超过了普通高速缓存的处理能力,CPU厂商又引入了多级缓存: 2.3 缓存一致性问题高速缓存很好的解决了处理器与内存的速度矛盾,但是也为计算机系统带来了更高的复杂度,如果CPU里有多个内核,而每个内核都维护了自己的缓存,那么这时候多线程并发就会产生缓存一致性问题。 缓存一致性问题的根源不在于多个核,而是多个缓存,以及缓存的写操作。 2.4 总线锁和缓存锁2.4.1 总线锁(总线控制协议)为了解决缓存一致性的问题,操作系统提供了总线锁定的机制。 总线(Bus)是一组信号线,用来在计算机各种功能部件之间传送信息。按照所传输的信息种类,计算机的总线可以划分为数据总线、地址总线和控制总线。 数据总线(Data Bus)用来在处理器和内存之间传输数据,地址总线(Address Bus)用于在内存中存储数据的地址,控制总线(Control Bus)用二进制信号对所有连接在系统总线上设备的行为进行同步。 在多线程环境下,当线程要对共享内存进行操作的时候,在总线上发出一个LOCK#信号,这个信号会使其他线程无法通过总线来访问共享内存中的数据。 总线锁定把处理器和内存之间的通信锁住了,这使得锁定期间其他处理器不能操作其他内存地址的数据,所以总线锁定的开销比较大,这种机制显然是不合适的,后来的处理器都提供了缓存一致性协议。 2.4.2 缓存锁(缓存一致性协议)相比总线锁,缓存锁即降低了锁的力度,其核心机制是基于缓存一致性协议来实现的。 常用的缓存一致性协议都是属于窥探协议,各个核能够时刻监控自己和其他核的状态,从而统一管理协调。常见的协议有MSI、MESI、MOSI等,最常见的就是MESI协议,MESI表示缓存行(缓存存储数据的单元)的四种状态,分别是: M(Modify)表示缓存行是被修改状态,只在当前CPU中有缓存,并且被修改了,还没有更新到主内存中。 E(Exclusive)表示缓存行是独占状态,只在当前CPU中有缓存,并且没有被修改。 S(Shared)表示缓存行是共享状态,在多个CPU中有缓存,并且没有被修改。 I(Invalid)表示缓存行是无效状态,当前CPU中缓存的数据是无效的。 在MESI协议中,每个缓存行都需要监听其它缓存行对共享数据的读写操作。 在多线程环境下,MESI协议的流程如下: 当线程1读取共享数据到缓存行中存储,会将状态设为E。 当线程2读取该共享数据到缓存行中存储,会将状态设为S。线程1监听到线程2读取该共享数据后,会将状态由E改为S。 当线程1修改该共享数据后,会将状态由S改为M,在其他线程读取该共享数据前写回到主内存。线程2监听到线程1修改该共享数据后,会将状态由S改为I。 当线程2修改该共享数据时,发现状态为I,会重新读取共享数据到缓存行,并将状态由I改为E,修改该共享数据后,会将状态由E改为M,在其他线程读取该共享数据前写回到主内存。 2.4.3 不能使用缓存锁的情况如果被操作的数据不能被缓存在处理器内部,或者操作的数据跨越多个缓存行(状态无法标识),处理器会使用总线锁。 另外,当处理器不支持缓存锁时,自然也只能用总线锁了,比如说奔腾486以及更老的处理器。 2.5 内存操作的原子性原子操作是指不可被中断的一个或者一组操作。 处理器会自动保证基本的内存操作的原子性,也就是一个处理器从内存中读取或者写入一个字节时,其他内存是不能访问这个字节的内存地址。但处理器不能自动保证复杂的内存操作的原子性,比如跨总线宽度、跨多个缓存行或者跨页表的操作。 总线锁和缓存锁是处理器保证复杂内存操作原子性的两个机制。 2.6 存储缓存和无效队列2.6.1 MESI协议的缺陷虽然MESI协议保证了缓存的强一致性,但是实现强一致性还需要对CPU提出两点要求: 1)CPU缓存要及时响应总线事件。 2)CPU严格按照程序顺序执行内存操作指令。 只要保证了以上两点,缓存一致性就能得到绝对的保证。但是由于效率的原因,CPU不可能保证以上两点: 1)总线事件到来之际,缓存可能正在执行其他的指令,例如向CPU传输数据,那么缓存就无法马上响应总线事件了。 2)CPU如果严格按照程序顺序执行内存操作指令,意味着回写数据之前,必须要等到所有其他缓存的失效确认,这个等待的过程严重影响CPU的计算效率。 2.6.2 存储缓存为了在写回数据时,避免等待其他缓存的失效确认,对每个线程都维护了一个存储缓冲(Store Buffer)来暂时缓存要回写的数据。 CPU在将数据写入存储缓冲之后就认为写操作已完成,不等待其他缓存返回失效确认继续执行其他指令,等所有的失效确认完成之后,再向存储缓冲的数据写回到内存中。 正是因为使用了存储缓冲,导致一些数据的内存写入操作可能会晚于程序中的顺序,也就是重排序。 2.6.3 无效队列因为存储缓冲大小是有限制的,并且失效操作比较耗时,于是对每个线程维护了一个失效队列(Invalidation Queue)来存储失效操作。 对于到来的失效请求,失效确认消息马上发出,同时将失效操作放入失效队列,但并不马上执行。 由于使用了失效队列,失效操作不会立即执行,读操作就会读取到过时的数据,导致可见性的问题。 3 内存屏障3.1 乱序访问程序在运行时,内存实际的访问顺序和程序代码编写的访问顺序不一定一致,这就是内存乱序访问。 内存乱序访问行为出现的理由是为了提升程序运行时的性能。内存乱序访问主要发生在两个阶段: 1)运行时,多处理器间交互引起内存乱序访问(MESI协议) 2)编译时,编译器优化导致内存乱序访问(指令重排) 内存屏障能够让处理器或编译器在内存访问上有序,一个内存屏障之前的内存访问操作必定先于其之后的完成。内存屏障包括两类: 1)处理器的内存屏障。 2)编译器的内存屏障。 3.2 内存屏障的种类3.2.1 处理器内存屏障Store Memory Barrier(a.k.a. ST, SMB, smp_wmb):写屏障,CPU在执行屏障之后的指令之前,先执行所有已经在存储缓冲中保存的指令。 Load Memory Barrier (a.k.a. LD, RMB, smp_rmb):读屏障,CPU在执行任何的加载指令之前,先执行所有已经在失效队列中的指令。 有了内存屏障,就可以保证缓存的一致性了。 3.2.2 编译器内存屏障为了提高性能,编译器会对指令重排序,通过插入内存屏障,可以避免编译器对指令进行重排序。内存屏障的另一个作用是强制更新缓存。 内存屏障通常所谓的四种,即: LoadLoad(LL)屏障:对于语句Load1; LoadLoad; Load2,保证Load1的读操作在Load2的读操作之前执行。 StoreStore(SS)屏障:对于语句Store1; StoreStore; Store2,保证Load1的写操作在Store2的写操作之前执行。 LoadStore(LS)屏障:对于语句Load1; LoadStore; Store2,保证Load1的读操作在Load2的写操作之前执行。 StoreLoad(SL)屏障:对于语句Store1; StoreLoad; Load2,保证Load1的写操作在Store2的读操作之前执行。 需要注意的是,StoreLoad(SL)屏障同时具备其他三个屏障的效果,因此也称之为全能屏障,是目前大多数处理器所支持的,但是相对其他屏障,该屏障的开销相对昂贵。 3.3 使用场景3.3.1 volatile语义中的内存屏障volatile的内存屏障策略非常严格保守: 1)在每个volatile读操作前插入LoadLoad屏障,在读操作后插入LoadStore屏障。 2)在每个volatile写操作前插入StoreStore屏障,在写操作后插入StoreLoad屏障。 由于内存屏障的作用,避免了volatile变量和其它指令重排序,并且在多线程之间实现了通信,使得volatile表现出了轻量锁的特性。 3.3.2 final语义中的内存屏障对于final域,必需保证一个对象的所有final域被写入完毕后才能引用和读取。 4 先行发生原则(Happen-Before)HappenBefor解决的是可见性问题,即前一个操作的结果对于后续操作是可见的。 在内存模型中,如果一个操作执行的结果需要对另一个操作可见,那么这两个操作必须要存在HappenBefor关系。这两个操作可以是同一个线程,也可以是不同的线程。 八条规则: 1)次序规则 按照代码顺序,一个线程内,写在前面的操作先行发生于写在后面的操作(前一个操作的结果可以被后续的操作获取)。 2)锁定规则 按照时间顺序,一个unlock操作先行发生于后面对同一个锁的lock操作(上一个线程unlock后,下一个线程才能获取到锁进行lock)。 3)volatile变量规则 按照时间顺序,对一个volatile变量的写操作,先行发生于后面对这个变量的读操作(前面的写对后面的读是可见的)。 4)传递规则 5)线程启动规则(Thread Start Rule) Thread对象的start()方法,先行发生于线程的其他方法。 6)线程中断规则(Thread Interruption Rule) 对线程interrupt()方法的调用,先行发生于被中断的线程检测到中断事件。可以通过Thread.interrupted()检测是否发生中断。 7)线程终止规则(Thread Termination Rule) 线程中的所有操作,都先行发生于对此线程的终止检测。 8)对象终结规则(Finalizer Rule) 对象没有完成初始化之前,不能调用finalized()方法。 5 使用volatile关键字5.1 可见性可见性是指在多线程坏境下,线程在工作内存中修改了某一个共享变量的值,其他线程能够立即获取该共享变量变更后的值。 代码如下:
运行结果如下:
结果说明: 线程一直在运行,并没有因为调用了setRunning()方法就停止了运行。 现在有两个线程,一个是main线程,另一个是RunThread。它们都在访问isRunning变量。按照内存模型,main线程将isRunning读取到本地线程内存空间,修改后再刷新回主内存。 main线程在修改后,还没来得及写入主内存就去做其他事情了,RunThread线程无法读到main线程改变的isRunning变量,从而出现了死循环,导致RunThread无法终止。 解决办法就是在isRunning变量上加上volatile关键字修饰,强制main线程将修改后的值写回主内存,强制RunThread线程从主内存中取值。 代码如下:
运行结果如下:
5.2 原子性原子性是指在多线程坏境下,线程对数据的操作要保证全部成功或者全部失败,并且不能被其他线程干扰。 volatile只能保证对单次读/写的原子性,不能保证复合类操作的原子性。 代码如下:
运行结果如下:
结果说明: 在多线程环境下,有两个线程分别将count读取到本地内存,其中线程1抢到CPU执行权,执行自增操作后,线程2抢到CPU执行权,执行自增操作后将结果写回到主存中,并通知线程1读取的count失效,线程1抢到CPU执行权,将自增操作后的结果写回到主内存,最终导致了count的结果不合预期,而是小于1000。 因为自增操作是由三个指令构成的操作,所以在这三个指令执行期间,线程只会读取一次主内存的数据。 如果想要在复合类的操作中保证原子性,可用使用synchronized关键字来实现,还可以通过Java并发包中的循环CAS的方式来保证。 5.3 有序性有序性是指在多线程环境下,禁止指令重排序,保证结果的一致性。 重排序在单线程模式下是一定会保证最终结果的正确性,但是在多线程环境下,问题就出来了。 但是运行代码并不能找到支持指令重排序的结果,所以这个地方以后还需要补充。 代码如下:
预测结果说明: 控制台打印的数据中应该有1出现,但实际情况却只以后2,这个并不能看出程序作了重排序。 预测的结果是有1出现,出现1的原因是,为了提供程序并行度,编译器和处理器可能会对指令进行重排序,而在write()方法中由于第一步“count = 2;”和第二步“flag = true;”不存在数据依赖关系,有可能会被重排序。 使用volatile关键字修饰共享变量便可以禁止这种重排序。若用volatile修饰共享变量,在编译时,会在指令序列中插入内存屏障来禁止特定类型的处理器重排序。 |
|
|
上一篇文章 下一篇文章 查看所有文章 |
|
开发:
C++知识库
Java知识库
JavaScript
Python
PHP知识库
人工智能
区块链
大数据
移动开发
嵌入式
开发工具
数据结构与算法
开发测试
游戏开发
网络协议
系统运维
教程: HTML教程 CSS教程 JavaScript教程 Go语言教程 JQuery教程 VUE教程 VUE3教程 Bootstrap教程 SQL数据库教程 C语言教程 C++教程 Java教程 Python教程 Python3教程 C#教程 数码: 电脑 笔记本 显卡 显示器 固态硬盘 硬盘 耳机 手机 iphone vivo oppo 小米 华为 单反 装机 图拉丁 |
360图书馆 购物 三丰科技 阅读网 日历 万年历 2024年11日历 | -2024/11/23 21:26:30- |
|
网站联系: qq:121756557 email:121756557@qq.com IT数码 |