jvm-java内存模型(JMM)(七)

 2019-12-10 16:14  阅读(787)
文章分类:Java Core

jvm-java内存模型(JMM)(七)

java虚拟机规范中试图定义一种java自己的内存模型,用来屏蔽各种硬件和操作系统的内存访问差异,所以就有了JMM
Java内存模型的主要目标是定义程序中各个变量的访问规则,即在虚拟机中将变量存储到内存和从内存中取出变量这样底层细节(此处的变量与Java编程时所说的变量不一样,指包括了实例字段、静态字段和构成数组对象的元素,但是不包括局部变量与方法参数,后者是线程私有的,不会被共享。)。

主内存与工作内存

Java内存模型中规定了所有的变量都存储在主内存中,每条线程还有自己的工作内存(可以与前面讲的处理器的高速缓存类比),线程的工作内存中保存了该线程使用到的变量到主内存副本拷贝,线程对变量的所有操作(读取、赋值)都必须在工作内存中进行,而不能直接读写主内存中的变量。不同线程之间无法直接访问对方工作内存中的变量,线程间变量值的传递均需要在主内存来完成,线程、主内存和工作内存的交互关系如下图所示,和上图很类似。

20191210001678\_1.png

注意:这里的主内存、工作内存与Java内存区域的Java堆、栈、方法区不是同一层次内存划分,这两者基本上没有关系。如果非要扯上关系,那可以从变量,主内存,工作内存的定义来看,主内存主要对应java堆中的对象实例数据部分,而工作内存则对应于虚拟机栈中的部分区域
这里的“高速缓存(变量副本)“说的是工作内存。
程序运行时主要访问读写的是工作内存。

内存交互操作

由上面的交互关系可知,关于主内存与工作内存之间的具体交互协议,即一个变量如何从主内存拷贝到工作内存、如何从工作内存同步到主内存之间的实现细节,Java内存模型定义了以下八种操作来完成:

  • 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内存模型还规定了在执行上述八种基本操作时,必须满足如下规则:

  • 不允许read和load、store和write操作之一单独出现
  • 不允许一个线程丢弃它的最近assign的操作,即变量在工作内存中改变了之后必须同步到主内存中。
  • 不允许一个线程无原因地(没有发生过任何assign操作)把数据从工作内存同步回主内存中。
  • 一个新的变量只能在主内存中诞生,不允许在工作内存中直接使用一个未被初始化(load或assign)的变量。即就是对一个变量实施use和store操作之前,必须先执行过了assign和load操作。
  • 一个变量在同一时刻只允许一条线程对其进行lock操作,lock和unlock必须成对出现。lock可以被同一条线程重复执行多次,但是必须相同次数的unlocak操作。
  • 如果对一个变量执行lock操作,将会清空工作内存中此变量的值,在执行引擎使用这个变量前需要重新执行load或assign操作初始化变量的值
  • 如果一个变量事先没有被lock操作锁定,则不允许对它执行unlock操作;也不允许去unlock一个被其他线程锁定的变量。
  • 对一个变量执行unlock操作之前,必须先把此变量同步到主内存中(执行store和write操作)。

这8种内存访问操作很繁琐,后文会使用一个等效判断原则,即先行发生(happens-before)原则来确定一个内存访问在并发环境下是否安全。

volatile变量规则

volatile赋予了变量可见——禁止编译器对成员变量进行优化,它修饰的成员变量在每次被线程访问时,都强迫从内存中重读该成员变量的值;而且,当成员变量发生变化时,强迫线程将变化值回写到共享内存,这样在任何时刻两个不同线程总是看到某一成员变量的同一个值,这就是保证了可见性.

不加volatile并不代表其他线程的修改就真的不可见了,只是可能有延迟;volatile的可见性只是把回写方式保存缓存一致性的架构变成直写的效果——缓存修改了立即能回映到内存;而不加volatile的则是CUP在执行该线程任务一段时间后(也许是几个时钟周期),或者切换任务后,将就修改过的缓存映射回内存。所以其他CPU对这块内存修改后的读取会有较大的延迟~~而不是完全读不到。

关键字volatile是JVM中最轻量的同步机制。volatile变量具有2种特性:

  • 保证变量的可见性。对一个volatile变量的读,总是能看到(任意线程)对这个volatile变量最后的写入,这个新值对于其他线程来说是立即可见的。
  • 屏蔽指令重排序:指令重排序是编译器和处理器为了高效对程序进行优化的手段。

volatile语义并不能保证变量的原子性。对任意单个volatile变量的读/写具有原子性,但类似于i++、i–这种复合操作不具有原子性,因为自增运算包括读取i的值、i值增加1、重新赋值3步操作,并不具备原子性。

非原子性操作
类似”a += b”这样的操作不具有原子性,在某些JVM中”a += b”可能要经过这样三个步骤:
(1)取出a和b
(2)计算a+b
(3)将计算结果写入内存
如果有两个线程t1,t2在进行这样的操作。t1在第二步做完之后还没来得及把数据写回内存就被线程调度器中断了,于是t2开始执行,t2执行完毕后t1又把没有完成的第三步做完。这个时候就出现了错误,相当于t2的计算结果被无视掉了。所以上面的例子在同步add方法之前,实际结果总是小于预期结果的,因为很多操作都被无视掉了。类似的,像”a++”这样的操作也都不具有原子性。所以在多线程的环境下一定要记得进行同步操作

由于volatile只能保证变量的可见性和屏蔽指令重排序,只有满足下面2条规则时,才能使用volatile来保证并发安全,否则就需要加锁(使用synchronized、lock或者java.util.concurrent中的Atomic原子类)来保证并发中的原子性。

  • 运算结果不存在数据依赖(重排序的数据依赖性),或者只有单一的线程修改变量的值(重排序的as-if-serial语义)
    变量不需要与其他的状态变量共同参与不变约束
  • 因为需要在本地代码中插入许多内存屏蔽指令在屏蔽特定条件下的重排序,volatile变量的写操作与读操作相比慢一些,但是其性能开销比锁低很多。

long/double非原子协定

JMM要求lock、unlock、read、load、assign、use、store、write这8个操作都必须具有原子性,但对于64为的数据类型(long和double,具有非原子协定:允许虚拟机将没有被volatile修饰的64位数据的读写操作划分为2次32位操作进行。(与此类似的是,在栈帧结构的局部变量表中,long和double类型的局部变量可以使用2个能存储32位变量的变量槽(Variable Slot)来存储的。

如果多个线程共享一个没有声明为volatile的long或double变量,并且同时读取和修改,某些线程可能会读取到一个既非原值,也不是其他线程修改值的代表了“半个变量”的数值。不过这种情况十分罕见。 java内存模型虽然允许虚拟机不把long和double的读写操作实现为原子操作,但允许虚拟机选择把这些操作实现为具有原子性的操作,而且还强力建议虚拟机这样实现。

就是说long ,double操作本身没有原子性,但是vm实现时对long ,double做了原子性操作。所以在使用时不需要对long,double做volatile操作。

原子性、可见性、有序性

java 内存模型是围绕着并发过程中如何处理原子性,可见性,有序性这3个特征来建立的。

原子性

原子操作(atomic operation)。所谓原子操作是指不会被线程调度机制打断的操作;这种操作一旦开始,就一直运行到结束,中间不会有任何 context switch (换到另一个线程) 如: 赋值或者return。比如”a = 1;”和 “return a;”这样的操作都具有原子性。

如果代码不能保证操作为原子操作,可以使用synchronized来保证原子操作

可见性

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

java内存模型是通过在变量修改后将新值同步回主内存,在变量读取前从主内存刷新变量。
普通变量与volatile变量的区别是:volatile的特殊规则保证了新值能立即同步到主内存,以及每次使用前立即从主内存刷新。
能保证可见性还有synchronized和final

有序性

JMM的有序性表现为:如果在本线程内观察,所有的操作都是有序的;如果在一个线程中观察另一个线程,所有的操作都是无序的。前半句指“线程内表现为串行的语义”(as-if-serial),后半句值“指令重排序”和普通变量的”工作内存与主内存同步延迟“的现象。

volatile,与synchronized 可以保证有序性。

重排序

在执行程序时为了提高性能,编译器和处理器经常会对指令进行重排序。从硬件架构上来说,指令重排序是指CPU采用了允许将多条指令不按照程序规定的顺序,分开发送给各个相应电路单元处理,而不是指令任意重排。重排序分成三种类型:

  • 编译器优化的重排序:编译器在不改变单线程程序语义放入前提下,可以重新安排语句的执行顺序。
  • 指令级并行的重排序:现代处理器采用了指令级并行技术来将多条指令重叠执行。如果不存在数据依赖性,处理器可以改变语句对应机器指令的执行顺序。
  • 内存系统的重排序:由于处理器使用缓存和读写缓冲区,这使得加载和存储操作看上去可能是在乱序执行。

20191210001678\_2.png

先行发生原则(happens-before)

java语言中有一个“先行发生原则”,它是判断数据是否存在竞争,线程是否安全的主要依据,通过这个规则我们可以一揽子解决并发环境下两个操作是否可能存在冲突的所有问题。
happens-before如:如果操作A先行发生于操作B,则A产生的影响能被操作B观察到,“影响”包括修改了内存中共享变量的值、发送了消息、调用了方法等。如果两个操作满足happens-before原则,那么不需要进行同步操作,JVM能够保证操作具有顺序性,此时不能够随意的重排序。否则,无法保证顺序性,就能进行指令的重排序。

java内存模型定义了一些“天然的”先行发生关系,只要在下面规则里面无须任何同步协助,他们的顺序就能很好的保证,如果没有,则虚拟机可以对它们随意进行重排序。

  • 程序次序规则(Program Order Rule):在同一个线程中,按照程序代码顺序,书写在前面的操作先行发生于书写在后面的操纵。准确的说是程序的控制流顺序,考虑分支和循环等。
  • 管理锁定规则(Monitor Lock Rule):一个unlock操作先行发生于后面(时间上的顺序)对同一个锁的lock操作。
  • volatile变量规则(Volatile Variable Rule):对一个volatile变量的写操作先行发生于后面(时间上的顺序)对该变量的读操作。
  • 线程启动规则(Thread Start Rule):Thread对象的start()方法先行发生于此线程的每一个动作。
  • 线程终止规则(Thread Termination Rule):线程的所有操作都先行发生于对此线程的终止检测,可以通过Thread.join()方法结束、Thread.isAlive()的返回值等手段检测到线程已经终止执行。
  • 线程中断规则(Thread Interruption Rule):对线程interrupt()方法的调用先行发生于被中断线程的代码检测到中断时事件的发生。Thread.interrupted()可以检测是否有中断发生。
  • 对象终结规则(Finilizer Rule):一个对象的初始化完成(构造函数执行结束)先行发生于它的finalize()的开始。
  • 传递性(Transitivity):如果操作A 先行发生于操作B,操作B 先行发生于操作C,那么可以得出A 先行发生于操作C。
点赞(0)
版权归原创作者所有,任何形式转载请联系作者; Java 技术驿站 >> jvm-java内存模型(JMM)(七)

相关推荐