深入理解Java内存模型

Java 内存模型是什么,为什么要有 Java 内存模型,Java 内存模型解决了什么问题

 

计算机内存模型:

现代计算机,CPU在计算时,并不总是从内存读取数据,数据读取顺序优先级是:寄存器一高速缓存(多级缓存)一内存

 

使用CPU Cache原因:

计算机在执行程序时,每条指令都是在CPU中执行的,执行指令过程中,势必涉及到数据的读取和写入。

由于程序运行过程中的临时数据是存放在主存(物理内存)中的,由于CPU执行速度很快,而内存读写数据的过程远远慢于CPU执行指令的速度,【CPU的频率太快,主存(内存)跟不上】,如果任何时候对数据的操作都要通过和内存的交互来进行,CPU常常要等待主存,会大大降低指令执行的速度。浪费资源。

高速缓存cache是为了缓解CPU和内存之间速度的不匹配问题(各结构速度比较:cpu->cache->memory)

 

CPU cache的意义

1)时间局部性:如果某个数据被访问,那么在不久的将来它很可能再次被访问

2)空间局部性:如果某个数据被访问,那么与它相邻的数据很快也能被访问。

 

缓存一致性问题(CPU多级缓存的缓存一致性问题) (Cache Coherence):

程序在运行过程中,会将运算需要的数据从主存复制一份到CPU的高速缓存中,CPU进行计算时就可直接从它的高速缓存读取数据和向其中写入数据,当运算结束后,再将高速缓存中的数据刷新到主存当中

如:i = i + 1;

当线程执行这个语句时,会先从主存中读取i值,接着复制一份到高速缓存中,然后CPU执行指令对i进行加1操作,再将数据写入高速缓存,最后将高速缓存中i最新的值刷新到主存当中。

 

这个代码在单线程中没有问题,但在多线程中运行就会有问题了。

在多核CPU中,每条线程可能运行于不同的CPU中,因此每个线程运行时有自己的高速缓存。

如果一个变量在多个CPU中都存在缓存(一般在多线程编程时才会出现),那就可能存在缓存不一致的问题。

 

【由于缓存为CPU私有,多线程下,将可能出现缓存一致性问题(内存可见性问题)】

 

缓存一致性的解决方案:

http://images.cnitblog.com/blog/288799/201408/212219343783699.jpg

为了解决缓存不一致性问题,通常来说有以下2种解决方法(硬件方案):

1)在总线(数据总线)加LOCK#锁的方式:效率低下

由于CPU和内存间通信是通过总线进行的。

在早期的CPU中,是通过在总线上加LOCK#锁的形式来解决缓存不一致的问题。

因为CPU和其他部件进行通信都是通过总线来进行的,如果对总线加LOCK#锁的话,也就是说阻塞了其他CPU对其他部件访问(如内存),从而使得只能有一个CPU能使用这个变量的内存。

如上面例子中 如果一个线程在执行 i = i +1,如果在执行这段代码的过程中,在总线上发出了LCOK#锁的信号,那么只有等待这段代码完全执行完毕之后,其他CPU才能从变量i所在的内存读取变量,然后进行相应的操作。这样就解决了缓存不一致的问题。

问题:由于在锁住总线期间,其他CPU无法访问内存,导致效率低下

 

2)缓存一致性协议

缓存一致性协议中,有MSI、MESI、MOSI及Dragon Protocol等,最出名的是Intel 的MESI协议,MESI协议保证了每个缓存中使用的共享变量的副本是一致的。

关于MESI(Modified Exclusive Shared Or Invalid)协议:

MESI (伊利诺斯协议)是一种广泛使用的支持写回策略的缓存一致性协议,该协议被应用在Intel奔腾系列的CPU中

 

核心的思想:

当CPU写数据时,如果发现操作的变量是共享变量,即在其他CPU中也存在该变量的副本,会发出信号通知其他CPU将该变量的缓存行置为无效状态,因此当其他CPU需要读取这个变量时,发现自己缓存中缓存该变量的缓存行是无效的,那么它就会从内存(主存)重新读取。

 

实现方式:

MESI为了保证多个CPU缓存中共享数据的一致性,定义了cache line的四种状态,CPU对cache的四种操作可能会产生不一致的状态,因此缓存控制器监听到本地操作和远程操作时,需要对地址一致的cache 状态进行一致性修改,保证数据在多个缓存之间保持一致性。

http://hi.csdn.net/attachment/201203/4/0_13308376919qw9.gif

MESI协议中的状态:CPU中每个缓存行(caceh line)用4种状态进行标记(使用额外的两位(bit)表示): 状态间相互转换关系用上表表示

1.M(Modified): 被修改

该缓存行只被缓存在该CPU的缓存中,且是被修改过的(dirty),即与主存中的数据不一致,该缓存行中的内存需要在未来的某个时间点(允许其它CPU读取请主存中相应内存之前)写回(write back)主存。

当被写回主存之后,该缓存行的状态会变成独享(exclusive)状态。

2.E(Exclusive): 独享的

该缓存行只被缓存在该CPU的缓存中,它是未被修改过的(clean),与主存中数据一致。

该状态可以在任何时刻当有其它CPU读取该内存时变成共享状态(shared)。

同样地,当CPU修改该缓存行中内容时,该状态可以变成Modified状态。

3.S(Shared): 共享的

该状态意味着该缓存行可能被多个CPU缓存,并且各个缓存中的数据与主存数据一致(clean),当有一个CPU修改该缓存行中,其它CPU中该缓存行可以被作废(变成无效状态(Invalid))。

4.I(Invalid): 无效的

该缓存是无效的(可能有其它CPU修改了该缓存行)。

 

 

操作:

在典型系统中,可能会有几个缓存(在多核系统中,每个核心都会有自己的缓存)共享主存总线,每个相应的CPU会发出读写请求,而缓存的目的是为了减少CPU读写共享

主存的次数。

一个缓存除在Invalid状态外都可以满足cpu的读请求,一个invalid的缓存行必须从主存中读取(变成S或者 E状态)来满足该CPU的读请求

一个写请求只有在该缓存行是M或者E状态时才能被执行如果缓存行处于S状态,必须先将其它缓存中该缓存行变成Invalid状态(也既是不允许不同CPU同时修改同一缓存行,即使修改该缓存行中不同位置的数据也不允许)。该操作经常作用广播的方式来完成,如:RequestFor Ownership (RFO)

缓存可以随时将一个非M状态的缓存行作废,或者变成Invalid状态,而一个M状态的缓存行必须先被写回主存

一个处于M状态的缓存行必须时刻监听所有试图读该缓存行相对就主存的操作,这种操作必须在缓存将该缓存行写回主存并将状态变成S状态之前被延迟执行。

一个处于S状态的缓存行也必须监听其它缓存使该缓存行无效或者独享该缓存行的请求,并将该缓存行变成无效(Invalid)。

一个处于E状态的缓存行也必须监听其它缓存读主存中该缓存行的操作,一旦有这种操作,该缓存行需要变成S状态。

对于M和E状态而言总是精确的,他们在和该缓存行的真正状态是一致的。而S状态可能是非一致的,如果一个缓存将处于S状态的缓存行作废了,而另一个缓存实际上可能已经独享了该缓存行,但是该缓存却不会将该缓存行升迁为E状态,这是因为其它缓存不会广播他们作废掉该缓存行的通知,同样由于缓存并没有保存该缓存行的copy的数量,因此(即使有这种通知)也没有办法确定自己是否已经独享了该缓存行。

从上面的意义看来E状态是一种投机性的优化:如果一个CPU想修改一个处于S状态的缓存行,总线事务需要将所有该缓存行的copy变成invalid状态,而修改E状态的缓存不需要使用总线事务。

 

 

处理器指令优化:

处理器为提高运算速度而做出违背代码原有顺序的优化。

为了使得处理器内部的运算单元能尽可能被充分利用,处理器可能会对输入代码进行乱序执行(Out-Of-Order Execution)优化,处理器会在计算后将对乱序执行的代码进行结果重组,保证结果准确性(只是保证最终一致性)。

【保证了最终一致性,牺牲了顺序指令】

与处理器的乱序执行优化类似,Java虚拟机的即时编译器(JIT)中也有类似的指令重排序(Instruction Recorder)优化

 

每个CPU设计都是不同的,每个CPU对指令乱序的程度也是不一样的。比较保守的如x86仅会对Store Load乱序,但是一些优化激进的CPU(PS的Power)会允许更多情况的乱序产生。

 

 

指令重排完全是因为性能考虑。一条指令的执行是可以分为很多步骤的。

汇编指令也不是一步就可以执行完毕的,在CPU中实际工作时,它还是需要分为多个步骤依次执行的。当然,每个步骤所涉及的硬件也可能不同。如,取指时会用到PC寄存器和存储器,译码时会用到指令寄存器组,执行时会使用ALU,写回时需要寄存器组。

注意:ALU指算术逻辑单元。它是CPU的执行单元,是CPU的核心组成部分,主要 功能是进行二进制算术运算。

 

由于每一个步骤都可能使用不同的硬件完成,就发明了 流水线技术 来执行指令

当第2条指令执行时,第1条执行其实并未执行完,确切地说第一条指令还没开始执行,只是刚刚完成了取值操作而己。这样的好处大大性能提升。

有了流水线这个神器,CPU才能真正高效的执行,但是,别忘了一点,流水线总是害怕被中断的。流水线满载时,性能确实相当不错,但是一旦中断,所有的硬件设备都会进入一个停顿期,再次满载又需要几个周期,因此,性能损失会比较大。所以,必须要想办法尽 量不让流水线中断!

之所以需要做指令重排,就是为了尽量少的中断流水线。当然了,指令重排只是减少中断的一种技术,实际上,在CPU的设计中,还会使用更多的软硬件技术来防止中断。

 

 

指令乱序执行问题:

乱序分两种,分别是编译器的指令重排CPU的乱序执行。乱序是为了优化指令执行的速度而产生的。且为了维护程序原来的语义,编译器和CPU不会对两个有数据依赖的指令重排(reorder)。

这种优化在单线程下是安全的,但是在多线程下,就导致了乱序问题。

如:

CPU-0将要执行两条指令,分别是:

STORE x

LOAD y

当CPU-0执行指令1时,发现这个变量x的当前状态为Shared,意味着其它CPU也持有了x,根据缓存一致性协议,CPU-0在修改x前必须通知其它CPU,直到收到来自其它CPU的ack才会执行真正的修改x。

事情没有这么简单。现代CPU缓存通常都有一个Store Buffer,作用是先将要Store的变量记下来,注意此时并不真的执行Store操作,然后待时机合适的时候再执行实际的Store

有了Store Buffer,CPU-0在向其它CPU发出disable消息后并不是干等着,而是转而执行指令2(由于指令1和指令2在CPU-0看来并不存在数据依赖)【CPU乱序执行】。

这样做效率是有了,也带来了乱序问题。

虽然写程序时,是先STORE x再LOAD y,但实际上CPU却是先LOAD y再STORE x,这个便是CPU乱序执行(reorder)的一种情况!

CPU这种优化没有问题,但是CPU不知道指令间蕴含着什么样的逻辑顺序。

 

单核下没问题,但是多核下,每个核都可以操作数据,也有各自的缓存。就会出现后写的将前面写的覆盖等。

单核处理器时代处理器能够保证处理器做出的优化不会影响结果,但是多核时代就会造成乱序,使最终结果错误

所以,在多核环境下,需要额外执行一些事情,才能保证数据的正确性

 

 

指令乱序问题解决方案:

内存屏障(memory barrier)是一种让CPU知道某段指令执行的顺序是不可被重排的机制。

如果不想指令1、2被CPU重排,程序应该这么写:

STORE x

WMB (Write memory barrier)

LOAD y

通过在STORE x之后加上这个写内存屏障,就能保证在之后LOAD y指令不会被重排到STORE x之前了。

 

 

关于指令重排:

计算机在执行程序时,为了提高性能,编译器和处理器的常常会对指令做重排,一般分以下3种

编译器优化的重排

编译器在不改变单线程程序语义的前提下,可以重新安排语句的执行顺序。

 

编译器重排的例子:

线程 1             线程 2

1: x2 = a ;      3: x1 = b ;

2: b = 1;         4: a = 2 ;

两个线程同时执行,分别有1、2、3、4四段执行代码,其中1、2属于线程1 , 3、4属于线程2 ,从程序的执行顺序上看,似乎不太可能出现x1 = 1 和x2 = 2 的情况,但实际上这种情况是有可能发现的,因为如果编译器对这段程序代码执行重排优化后,可能出现下列情况:

线程 1              线程 2

2: b = 1;          4: a = 2 ;

1:x2 = a ;        3: x1 = b ;

这种执行顺序下就有可能出现x1 = 1 和x2 = 2 的情况,这也就说明在多线程环境下,由于编译器优化重排的存在,两个线程中使用的变量能否保证一致性是无法确定的。

 

指令并行的重排

现代处理器采用了指令级并行技术来将多条指令重叠执行。如果不存在数据依赖性(即后一个执行的语句无需依赖前面执行的语句的结果),处理器可以改变语句对应的机器指令的执行顺序

 

先了解一下指令重排的概念,处理器指令重排是对CPU的性能优化,从指令的执行角度来说一条指令可以分为多个步骤完成,如下

CPU在工作时,需要将上述指令分为多个步骤依次执行(注意硬件不同有可能不一样),由于每一个步会使用到不同的硬件操作,比如取指时会只有PC寄存器和存储器,译码时会执行到指令寄存器组,执行时会执行ALU(算术逻辑单元)、写回时使用到寄存器组。

为了提高硬件利用率,CPU指令是按流水线技术来执行的.

 

虽然流水线技术可以大大提升CPU的性能,但不幸的是一旦出现流水中断,所有硬件设备将会进入一轮停顿期,当再次弥补中断点可能需要几个周期,这样性能损失也会很大,就好比工厂组装手机的流水线,一旦某个零件组装中断,那么该零件往后的工人都有可能进入一轮或者几轮等待组装零件的过程。因此我们需要尽量阻止指令中断的情况,指令重排就是其中一种优化中断的手段.

 

 

内存系统的重排

由于处理器使用缓存和读写缓存冲区,使得加载(load)和存储(store)操作看上去可能是在乱序执行,因为三级缓存的存在,导致内存与缓存的数据同步存在时间差。

 

其中编译器优化的重排属于编译期重排,指令并行的重排和内存系统的重排属于处理器重排,在多线程环境中,这些重排优化可能会导致程序出现内存可见性问题,下面分别阐明这两种重排优化可能带来的问题

 

 

总结:

线程计算时,原始的数据来自内存,在计算过程中,有些数据可能被频繁读取,这些数据被存储在寄存器和高速缓存中,当线程计算完后,这些缓存的数据在适当的时候应该写回内存,当多个线程同时读写某个内存数据时,由于涉及数据的可见性、操作的有序性,所以就会产生多线程并发问题。

 

 

并发编程三要素:

JMM的关键技术点都是围绕着多线程的原子性、可见性和有序性来建立的。

缓存一致性问题其实就是可见性问题。处理器优化是可以导致原子性问题的。指令重排即会导致有序性问题。

1.原子性(Atomicity):

一个操作不能被打断,要么全部执行完毕,要么不执行。

基本类型数据的访问大都是原子操作,但在32位JVM中,32位的JVM会将64位数据(long、double)的读写操作分为2次32位的读写操作来进行,这就导致了long、double类型的变量在32位虚拟机中是非原子操作,数据有可能会被破坏,也就意味着多个线程在并发访问时是非线程安全的。

原子性变量操作包括read、load、assign、use、和write

 

例:32位JVM下,对64位long类型的数据的访问的问题:

public class NotAtomicity {

    @Getter @Setter   public  static long t = 0;

    //改变变量t的线程

    public static class ChangeT implements Runnable{

        private long to;

        public ChangeT(long to) {

            this.to = to;

        }

        public void run() {

            //不断的将long变量设值到 t中

            while (true) {

                NotAtomicity.setT(to);

                //将当前线程的执行时间片段让出去,以便由线程调度机制重新决定哪个线程可以执行

                Thread.yield();

            }

        }

    }

    //读取变量t的线程,若读取的值和设置的值不一致,说明变量t的数据被破坏了,即线程不安全

    public static class ReadT implements Runnable{

        public void run() {

            //不断的读取NotAtomicity的t的值

            while (true) {

                long tmp = NotAtomicity.getT();

                //比较是否是自己设值的其中一个

                if (tmp != 100L && tmp != 200L && tmp != -300L && tmp != -400L) {

                    //程序若执行到这里,说明long类型变量t,其数据已经被破坏了

                    System.out.println(tmp);

                }

                将当前线程的执行时间片段让出去,以便由线程调度机制重新决定哪个线程可以执行

                Thread.yield();

            }

        }

    }

    public static void main(String[] args) {

        new Thread(new ChangeT(100L)).start();

        new Thread(new ChangeT(200L)).start();

        new Thread(new ChangeT(-300L)).start();

        new Thread(new ChangeT(-400L)).start();

        new Thread(new ReadT()).start();

    }

}

创建了4个线程来对long类型的变量t进行赋值,赋值分别为100,200,-300,-400,有一个线程负责读取变量t,如果正常的话,读取到的t的值应该是我们赋值中的一个,但是在32的JVM中,事情会出乎预料。如果程序正常的话,我们控制台不会有任何的输出,可实际上,程序一运行,控制台就输出了下面的信息:

-4294967096

4294966896

-4294967096

-4294967096

4294966896

之所以会出现上面的情况,是因为在32位JVM中,64位的long数据的读和写都不是原子操作,即不具有原子性,并发的时候相互干扰了。

32位的JVM中,要想保证对long、double类型数据的操作的原子性,可对访问该数据的方法进行synchronized同步.保证对64位数据操作的原子性。

 

 

2.可见性(Visibility):

一个线程对共享变量做了修改之后,其他的线程立即能够看到(感知到)该变量这种修改(变化)。

可见性问题是一个综合性问题。除了缓存优化或者硬件优化(有些内存读写可能不会立即触发,而会先进入一个硬件队列等待)会导致可见性问题外,指令重排以及编辑器的优化,都有可能导致一个线程的修改不会立即被其他线程察觉。

 

3.有序性(Ordering):

有序性问题是因为程序在执行时,可能会进行指令重排,重排后的指令与原指令的顺序未必一致。

如果在本线程内观察,所有的操作都是有序的;如果在一个线程中观察另一个线程,所有的操作都是无序的。前半句是指“线程内表现为串行的语义”,后半句是指“指令重排序”现象和“工作内存和主内存同步延迟”现象。

 

 

Java内存区域:

见JVM相关文章。包括PC、虚拟机栈、本地方法栈、方法区、Java堆、

https://blog.csdn.net/qq_34190023/article/details/84928012

 

Java线程与硬件处理器

在Window系统和Linux系统上,Java线程的实现是基于一对一的线程模型,一对一模型就是通过语言级别层面程序去间接调用系统内核的线程模型,即在用Java线程时,Java虚拟机内部是转而调用当前操作系统的内核线程来完成当前任务。

内核线程(Kernel-Level Thread,KLT):由操作系统内核(Kernel)支持的线程,这种线程是由操作系统内核来完成线程切换,内核通过操作调度器进而对线程执行调度,并将线程的任务映射到各个处理器上。每个内核线程可以视为内核的一个分身,这也就是操作系统可以同时处理多任务的原因。

由于编写的多线程程序属于语言层面的,程序一般不会直接去调用内核线程,取而代之的是一种轻量级的进程(Light Weight Process),也是通常意义上的线程,由于每个轻量级进程都会映射到一个内核线程,因此可通过轻量级进程调用内核线程,进而由操作系统内核将任务映射到各个处理器,这种轻量级进程与内核线程间1对1的关系就称为一对一的线程模型。如下图:

https://i-blog.csdnimg.cn/blog_migrate/be3461a56e629d48884fd4ecf3533aa4.png

如图所示,每个线程最终都会映射到CPU中进行处理,如果CPU存在多核,那么一个CPU将可以并行执行多个线程任务。

 

JMM与硬件内存架构的关系

多线程的执行最终都会映射到硬件处理器上进行执行,但Java内存模型和硬件内存架构并不完全一致。

对于硬件内存来说只有寄存器、缓存内存、主内存的概念,没有工作内存(线程私有数据区域)和主内存(堆内存)之分,即Java内存模型对内存的划分对硬件内存并没有任何影响, JMM只是一种抽象的概念,并不实际存在,不管是工作内存的数据还是主内存的数据,对于计算机硬件来说都会存储在计算机主内存中,当然也有可能存储到CPU缓存或寄存器中,

总体上来说,Java内存模型和计算机硬件内存架构是一个相互交叉的关系,是一种抽象概念划分与真实物理硬件的交叉。(注意对于Java内存区域划分也是同样的道理)

https://i-blog.csdnimg.cn/blog_migrate/fd1f1005000dd73ceb5ac6999c0a6fdc.png

 

JMM内存模型(Java Memory Model):

对于处理器乱序执行问题及不同CPU优化的差异性问题等,如果需要写跨平台多线程程序,势必要了解每一个CPU的细节,来插入确切的、足够的内存屏障来保证程序的正确性。

为了屏蔽各种硬件和操作系统的差异,以实现java在各种平台下都能达到一致的并发效果,虚拟机规范中定义了JVM规范

JMM是一种规范,规范了JVM与计算机内存如何协同工作,规定了变量的访问规则(JVM中将变量存储到内存和从内存中取出变量这样的底层细节.一个线程如何和何时可以看到其他线程修改过的共享变量的值),来屏蔽底层平台内存管理细节。

JMM是围绕原子性,有序性、可见性展开的

为了获得较好的执行性能,JVM并没有限制执行引擎使用处理器的寄存器或高速缓存来提升指令执行速度,也没有限制编译器对指令进行重排序。即在java内存模型中,也会存在缓存一致性问题和指令重排序的问题,所以多线程环境中必须解决可见性和有序性的问题。

 

JMM规定了所有变量都存储在主内存(Main Memory)中。每个线程有自己的工作内存(Working Memory,线程的工作内存中保存了该线程用到的变量的主内存的副本拷贝,线程对变量的所有操作(读取、赋值等)都必须在工作内存中进行,而不能直接读写主内存中的变量(volatile变量仍然有工作内存的拷贝,但由于它特殊的操作顺序性规定,所以看起来如同直接在主内存中读写访问一般)。不同线程间也无法直接访问对方工作内存中的变量,线程间值的传递都需要通过主内存来完成。

 

主内存(Main Memory)和工作内存(Working Memory):

主内存(线程共享):

即平时说的Java堆内存,存放程序中所有的类实例、静态数据等变量,

主要存储Java实例对象,所有线程创建的实例对象都存放在主内存中,不管该实例对象是成员变量还是方法中的本地变量(也称局部变量),也包括共享的类信息、常量、静态变量。由于是共享数据区域,多条线程对同一个变量进行访问可能会发现线程安全问题

 

工作内存(线程私有):

存放的是该线程从主内存中拷贝过来的变最以及访问方法所取得的局部变量,

主要存储当前方法的所有本地变量信息(工作内存中存储着主内存中的变量副本拷贝),每个线程只能访问自己的工作内存,即线程中的本地变量对其它线程是不可见的,就算是两个线程执行的是同一段代码,它们也会各自在自己的工作内存中创建属于当前线程的本地变量,当然也包括了字节码行号指示器、相关Native方法的信息。注意由于工作内存是每个线程的私有数据,线程间无法相互访问工作内存,因此存储在工作内存的数据不存在线程安全问题。

局部变量,方法定义参数 和 异常处理器参数存储在线程的本地内存

 

关系:

每个线程对变量的操作都是以先从主内存将其拷贝到工作内存再对其进行操作的方式进行,多个线程间不能直接互相传递数据通信,只能通过共享变量来进行

主内存和工作内存与 JVM 内存结构中的 Java 堆、栈、方法区等并不是同一个层次的内存划分,无法直接类比。

 

数据存储类型以及操作方式

对于一个实例对象中的成员方法而言,

如果方法中包含本地变量是基本数据类型(boolean,byte,short,char,int,long,float,double),将直接存储在工作内存的帧栈结构中,

但倘若本地变量是引用类型,那么该变量的引用会存储在功能内存的帧栈中,而对象实例将存储在主内存(共享数据区域,堆)中。

但对于实例对象的成员变量,不管它是基本数据类型或包装类型、引用类型,都会被存储到堆区。

static变量及类本身相关信息将会存储在主内存中。

注:在主内存中的实例对象可被多线程共享,倘若两个线程同时调用了同一个对象的同一个方法,那么两条线程会将要操作的数据拷贝一份到自己的工作内存中,执行完成操作后才刷新到主内存

 

 

内存间交互操作(每个操作都是原子性的)

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

https://i-blog.csdnimg.cn/blog_migrate/2f5d7b5a871509a4447c4d7eeb66db0d.png

•lock(锁定):作用于主内存的变量,把一个变量标识为一条线程独占状态。

•unlock(解锁):作用于主内存变量,把一个处于锁定状态的变量释放出来,释放后的变量才可以被其他线程锁定。

•read(读取):作用于主内存变量,把一个变量值从主内存传输到线程的工作内存中,以便随后的load动作使用

•load(载入):作用于工作内存的变量,它把read操作从主内存中得到的变量值放入工作内存的变量副本中。

•use(使用):作用于工作内存的变量,把工作内存中的一个变量值传递给执行引擎,每当虚拟机遇到一个需要使用变量的值的字节码指令时将会执行这个操作。

•assign(赋值):作用于工作内存的变量,它把一个从执行引擎接收到的值赋值给工作内存的变量,每当虚拟机遇到一个给变量赋值的字节码指令时执行这个操作。

•store(存储):作用于工作内存的变量,把工作内存中的一个变量的值传送到主内存中,以便随后的write的操作。

•write(写入):作用于主内存的变量,它把store操作从工作内存中一个变量的值传送到主内存的变量中。

 

如果要把一个变量从主内存中复制到工作内存,就需要按顺寻地执行read和load操作,

如果把变量从工作内存中同步回主内存中,就要按顺序地执行store和write操作。

Java内存模型只要求上述操作必须按顺序执行,而没有保证必须是连续执行。也就是read和load间,store和write间是可以插入其他指令的,如对主内存中的变量a、b进行访问时,可能的顺序是read a,read b,load b, load a。

 

 

原子性问题:

在Java中,对基本数据类型的变量的读取和赋值操作是原子性操作,即这些操作是不可被中断的,要么执行,要么不执行。

x = 10;       //原子性操作

y = x;        //非原子性操作,包含2个操作,它先要去读取x的值,再将x的值写入工作内存,虽然读取x的值以及 将x的值写入工作内存 这2个操作都是原子性操作,但是合起来就不是原子性操作了.1.read a、2.assign b

x++;         //非原子性操作 1.read c 、2.add 3.assign to c

x = x + 1;     //非原子性操作 1.read c、2.add、3.assign to c;

 

只有简单的读取、赋值(而且必须是将数字赋值给某个变量,变量之间的相互赋值不是原子操作)才是原子操作。

PS:引用类型的赋值也是原子性,如Object obj = obj2;这里赋值的只是内存地址,4个字节

 

不过这里有一点需要注意:在32位平台下,对64位数据的读取和赋值是需要通过两个操作来完成的,不能保证其原子性。但是好像在最新的JDK中,JVM已经保证对64位数据的读取和赋值也是原子性操作了。【未验证】

 

Java内存模型只保证了基本读取和赋值是原子性操作,如果要实现更大范围操作的原子性,可通过synchronized和Lock来实现。由于synchronized和Lock能够保证任一时刻只有一个线程执行该代码块,那么自然就不存在原子性问题了,从而保证了原子性。

Java保证原子性的方案:

1)Atomic包

JDK5提供的并发包

2)CAS算法

 

3)Synchronized

 

4)Lock

 

 

可见性问题:

可见性是指当一个线程修改了共享变量的值,其它线程能够立即得知这个修改。

JMM是通过在变量修改后将新值同步回主内存,在变量读取前从主内存刷新变量值这种依赖主内存作为传递媒介的方式来实现可见性的。

 

Java保证可见性的方案:

1)volatile关键字来保证可见性。

当一个共享变量被volatile修饰时,它会保证修改的值会立即被更新到主存,当有其他线程需要读取时,会去内存(主存)中读取新值。

普通的共享变量不能保证可见性,普通共享变量被修改后,写入主存时间是不确定的,当其他线程去读取时,此时内存中可能还是原来的旧值,因此无法保证可见性。

 

2)synchronized保证可见性:

使用synchronized关键字,在同步方法/同步块开始时(Monitor Enter),使用共享变量时会从主内存中刷新变量值到工作内存中(即从主内存中读取最新值到线程私有的工作内存中),在同步方法/同步块结束时(Monitor Exit),会将工作内存中的变量值同步到主内存中去(即将线程私有的工作内存中的值写入到主内存进行同步)。

 

3)Lock保证可见性:

使用Lock接口的最常用的实现ReentrantLock(重入锁)来实现可见性:执行lock.lock()方法和synchronized开始位置(Monitor Enter)有相同的语义,即使用共享变量时会从主内存中刷新变量值到工作内存中(即从主内存中读取最新值到线程私有的工作内存中),在方法的最后finally块里执行lock.unlock()方法和synchronized结束位置(Monitor Exit)有相同的语义,即会将工作内存中的变量值同步到主内存中去(即将线程私有的工作内存中的值写入到主内存进行同步)。

 

synchronized和Lock能保证同一时刻只有一个线程获取锁然后执行同步代码,并且在释放锁前会将对变量的修改刷新到主存当中。因此可以保证可见性。

 

4)final保证可见性:

被final修饰的变量,在构造方法数一旦初始化完成,并且在构造方法中并没有把“this”的引用传递出去(“this”引用逃逸是很危险的,其他的线程很可能通过该引用访问到只“初始化一半”的对象),那么其他线程就可以看到final变量的值。

被 final 修饰的字段在声明时或者构造器中,一旦初始化完成,那么在其他线程无须同步就能正确看见 final 字段的值。这是因为一旦初始化完成,final 变量的值立刻回写到主内存。

 

 

有序性问题:

线程在引用变量时不能直接从主内存中引用,如果线程工作内存中没有该变量,则会从主内存中拷贝一个副本到工作内存中,这个过程为read-load,完成后线程会引用该副本。当同一线程再度引用该字段时,有可能重新从主存中获取变量副本(read-load-use),也有可能直接引用原来的副本(use),也就是说readloaduse顺序可以由JVM实现系统决定

这时线程与线程之间的操作的先后顺序,会决定了程序对主内存区最后的修改是不是正确的。

 

另外,Java内存模型具备一些先天的“有序性”,即不需要通过任何手段就能够得到保证的有序性,这个通常也称为 happens-before 原则。如果两个操作的执行次序无法从happens-before原则推导出来,那么它们就不能保证它们的有序性,虚拟机可以随意地对它们进行重排序。

 

Java内存模型还规定了在执行上述八种基本操作时,必须满足如下规则:

•不允许read和load、store和write操作之一单独出现

•不允许一个线程丢弃它的最近assign的操作,即变量在工作内存中改变了之后必须同步到主内存中。

•不允许一个线程无原因地(没有发生过任何assign操作)把数据从工作内存同步回主内存中。

•一个新的变量只能在主内存中诞生,不允许在工作内存中直接使用一个未被初始化(load或assign)的变量。即就是对一个变量实施use和store操作之前,必须先执行过了assign和load操作。

•一个变量在同一时刻只允许一条线程对其进行lock操作,lock和unlock必须成对出现

•如果对一个变量执行lock操作,将会清空工作内存中此变量的值,在执行引擎使用这个变量前需要重新执行load或assign操作初始化变量的值

•如果一个变量事先没有被lock操作锁定,则不允许对它执行unlock操作;也不允许去unlock一个被其他线程锁定的变量。

•对一个变量执行unlock操作之前,必须先把此变量同步到主内存中(执行store和write操作)。

 

 

Happen-Before原则:哪些指令不能重排

虽然Java虚拟机和执行系统会对指令进行一定的重排,但指令重排是有原则的,并非所有的指令都可以随便改变执行位置,以下罗列了一些基本原则, 这些原则是指令重排不可违背的

 

Java内存模型中定义的两项操作之间的次序关系,如果说操作A先行发生于操作B,操作A产生的影响能被操作B观察到,“影响”包含了修改了内存中共享变量的值、发送了消息、调用了方法等。

下面是Java内存模型下一些”天然的“happens-before关系,这些happens-before关系无须任何同步器协助就已经存在,可以在编码中直接使用。如果两个操作之间的关系不在此列,并且无法从下列规则推导出来的话,它们就没有顺序性保障,虚拟机可以对它们进行随意地重排序。

 

a.程序次序规则(Pragram Order Rule):

在一个线程内,按照程序代码顺序,书写在前面的操作先行发生于书写在后面的操作。准确地说应该是控制流顺序而不是程序代码顺序,因为要考虑分支、循环结构。

一个线程内,按照代码顺序,书写在前面的操作先行发生于书写在后面的操作

这个规则是用来保证程序在单线程中执行结果的正确性,但无法保证程序在多线程中执行的正确性。

 

b.管程锁定规则(Monitor Lock Rule):

一个unlock操作先行发生于后面对同一个锁的lock操作。”后面“是指时间上的先后顺序。

 

c.volatile变量规则(Volatile Variable Rule):

对一个volatile变量的写操作先行发生于后面对这个变量的读取操作,”后面“是指时间上的先后顺序。

 

d.传递性(Transitivity):

如果操作A先行发生于操作B,操作B先行发生于操作C,那么操作A先行发生于操作C。

 

e.线程启动规则(Thread Start Rule):

Thread对象的start()方法先行发生于此线程的每一个动作(run方法)。

 

f.线程中断规则(Thread Interruption Rule):

对线程interrupt()方法的调用先行发生于被中断线程的代码检测到中断事件的发生,可通过Thread.interrupted()方法检测是否有中断发生。【interrupt方法必须发生在捕获该动作前】

 

g.线程终结规则(Thread Termination Rule):

线程中的所有操作都先行发生于对此线程的终止检测,我们可以通过Thread.join()方法结束,Thread.isAlive()的返回值等作段检测到线程已经终止执行。【所有操作都发生在线程死亡前】

 

h.对象终结规则(Finalizer Rule):

一个对象初始化完成(构造方法执行完成)先行发生于它的finalize()方法的开始。

 

 

Java保证有序性的方案:

在Java内存模型中,允许编译器和处理器对指令进行重排序,但是重排序过程不会影响到单线程程序的执行,却会影响到多线程并发执行的正确性。

1)volatile:

volatile关键字本身通过加入内存屏障来禁止指令的重排序。

2)synchronized:

synchronized关键字通过一个变量在同一时间只允许有一个线程对其进行加锁的规则来实现

 

3)Lock:

synchronized和Lock保证每个时刻是有一个线程执行同步代码,相当于是让线程顺序执行同步代码,自然就保证了有序性。

 

 

 

在执行程序时为了提高性能,编译器和处理器经常会对指令进行重排序。重排序分成三种类型:

1.编译器优化的重排序。编译器在不改变单线程程序语义放入前提下,可以重新安排语句的执行顺序。

2.指令级并行的重排序。现代处理器采用了指令级并行技术来将多条指令重叠执行。如果不存在数据依赖性,处理器可以改变语句对应机器指令的执行顺序。

3.内存系统的重排序。由于处理器使用缓存和读写缓冲区,这使得加载和存储操作看上去可能是在乱序执行。

 

从Java源代码到最终实际执行的指令序列,会经过下面三种重排序:

https://i-blog.csdnimg.cn/blog_migrate/892dc2b9125eb417738145f0bd32b5be.png

为保证内存的可见性,Java编译器在生成指令序列的适当位置会插入内存屏障指令来禁止特定类型的处理器重排序。Java内存模型把内存屏障分为LoadLoad、LoadStore、StoreLoad和StoreStore四种:

屏障类型

指令示例

说明

LoadLoad Barriers

Load1; LoadLoad; Load2

确保Load1数据的装载之前于Load2及所有后续装载指令的装载。

StoreStore Barriers

Store1; StoreStore; Store2

确保Store1数据对其他处理器可见(刷新到内存),之前于Store2及所有后续存储指令的存储。

LoadStore Barriers

Load1; LoadStore; Store2

确保Load1数据装载之前于Store2及所有后续的存储指令刷新到内存。

StoreLoad Barriers

Store1; StoreLoad; Load2

确保Store1数据对其他处理器变得可见(指刷新到内存),之前于Load2及所有后续装载指令的装载。StoreLoad Barriers会使该屏障之前的所有内存访问指令(存储和装载指令)完成之后,才执行该屏障之后的内存访问指令。

作用:通过内存屏障可以禁止特定类型处理器的重排序,从而让程序按我们预想的流程去执行

 

基于保守策略的JMM内存屏障插入策略:

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

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

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

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

 

相关博文:

Java内存模型是什么,为什么要有Java内存模型,Java内存模型解决了什么问题?

Java内存模型

指令重排序

全面理解Java内存模型(JMM)及volatile关键字

Java内存模型原理,你真的理解吗?

从JVM角度理解线程

 

  • 1
    点赞
  • 3
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值