JMM内存模型

概述

JMM,全名为Java Memory Model,即Java内存模型。它是一组规范,需要各个JVM的实现来遵守JMM规范,它屏蔽了各种硬件和操作系统的内存访问差异,以实现Java程序在各个平台下都能达到一致的内存访问效果。不像C/C++那样直接访问物理硬件和操作系统的内存模型,它的主要目的是解决由于多线程通过共享内存进行通信时,存在的本地内存数据不一致、编译器会对代码重排序、处理器会对代码乱序执行等带来的问题。可以保证并发编程场景中的原子性、可见性和有序性。

它有利于开发者可以利用这些规范,更方便地开发多线程程序。如果没有这样的JMM内存模型来规范,那么很可能经过了不同JVM的不同规则的重排序之后,导致不同的虚拟机上运行的结果不一样。

JVM对Java内存模型的实现

物理PC内存模型

在了解Java内存模型之前,我们先来了解一下cpu和计算机内存的交互情况。

最下面是主内存,上面是cpu 。在现代计算机中,cpu和内存之间的速度差距巨大,所以在这两者之间引入了高速缓存,来作为内存与处理器之间的缓冲。cpu处理数据都要放在寄存器中处理,寄存器一般很小,但是读取速度很快。高速缓存是内存的部分拷贝,因为高速缓存速度快,把常用的数据放这里可以提高速度。高速缓存分为一级,二级,三级缓存,自上而下速度逐渐变慢,但是容量逐渐变大。但是在解决速度差异的同时,也引入了其他问题,缓存一致性。

当多个处理器的运算任务都涉及同一块主内存区域时,将可能导致各自的缓存数据不一致,举例说明变量在多个CPU之间的共享。如果真的发生这种情况,那同步回到主内存时以谁的缓存数据为准呢?为了解决一致性的问题,需要各个处理器访问缓存时都遵循一些协议,在读写时要根据协议来进行操作,这类协议有MSI、MESI(Illinois Protocol)、MOSI、Synapse、Firefly及Dragon Protocol等。

Java内存模型

JMM抽象了主内存和本地内存的概念。从抽象的角度来看,JMM定义了线程和主内存之间的抽象关系:线程之间的共享变量存储在主内存中,每个线程都有一个私有的本地内存,本地内存中存储了该线程以读/写共享变量的副本。如下图所示:

 

线程不能直接读写主内存的变量,而是只能操作自己工作内存中的变量,然后再同步到主内存中。主内存是多个线程共享的,单线程间不共享工作内存,如果线程间需要通信,必须借助主内存中转来完成。

Java内存模型所带来地问题

  • 可见性问题

CPU中运行的线程从主存中拷贝共享数据到它的本地内存,并在之后对这个变量在本地内存中做出了更改,但这个变更对运行在其他CPU中的线程是不可见地,因为这个更改还没有flush到主存中:要解决共享对象可见性这个问题,我们可以使用volatile关键字或者是加锁。

  • 竞争现象

线程A和线程B共享一个对象obj。假设线程A从主存读取Obj.count变量到自己的本地内存中,同时,线程B也读取了Obj.count变量到它的CPU缓存,并且这两个线程都对Obj.count做了加1操作。此时,Obj.count加1操作被执行了两次,不过都在不同的本地内存中。如果这两个加1操作是串行执行的,那么Obj.count变量便会在原始值上加2,最终主存中的Obj.count的值会是3。然而如果线程A读取count到自己的本地内存,并且在未更改count的情况下被剥夺了时间片,这时线程B读取count到本地内存,然后两个线程执行加1操作,不管是线程A还是线程B先flush计算结果到主存,最终主存中的Obj.count只会增加1次变成2,尽管一共有两次加1操作。 要解决上面的问题我们可以使用java synchronized代码块。

Java内存模型的重排序

在执行程序时,为了提高性能,编译器和处理器常常会对指令作优化。如下面这种情况:

重排序是指代码指令不严格按照代码语言顺序执行的。

下面这段程序将可能因为重排序出现不同的结果

 
  1. /**

  2. * 演示重排序的现象

  3. * @author samy

  4. * @date 2019/9/25 19:32

  5. */

  6. public class OutOfOrderExecution {

  7. private static int x = 0,y = 0;

  8. private static int a = 0,b = 0;

  9. private static int c;

  10.  
  11. public static void main(String[] args) throws InterruptedException {

  12. CountDownLatch latch = new CountDownLatch(1);

  13. for (;;){

  14. c ++;

  15. a = 0;

  16. b = 0;

  17. x = 0;

  18. y = 0;

  19.  
  20. Thread one = new Thread(new Runnable() {

  21. @Override

  22. public void run() {

  23. try {

  24. latch.await();

  25. } catch (InterruptedException e) {

  26. e.printStackTrace();

  27. }

  28. a = 1;

  29. x = b;

  30. }

  31. });

  32.  
  33. Thread two = new Thread(new Runnable() {

  34. @Override

  35. public void run() {

  36. try {

  37. latch.await();

  38. } catch (InterruptedException e) {

  39. e.printStackTrace();

  40. }

  41. b = 1;

  42. y = a;

  43. }

  44. });

  45.  
  46. one.start();

  47. two.start();

  48. latch.countDown();

  49. one.join();

  50. two.join();

  51.  
  52.  
  53. System.out.println("x = " + x + ",y = " + y);

  54. }

  55. }

  56. }

以上程序可能会以下情况:

  •  (正常情况)线程1先执行完,线程2再开始执行,那么输出结果为x = 1,y = 0
  • (正常情况) 线程2先执行完,线程1再开始执行,那么输出结果为x = 0,y = 1
  • (正常情况)线程1执行a = 1后被调度掉线程2执行,对b = 1赋值,然后输出结果是x = 1,y = 1
  • (重排序情况)x= b,y = a重排序到a = 1,b = 1前执行,执行顺序为(y = a,a = 1,x = b,b=1)那么输出结果是x = 0,y = 0

重排序共分为三种:

  • 编译器优化

编译器(包括JVM,JIT编译器等)出于优化的目的(例如当前有了数据a,那么如果把对a的操作放到一起效率会更高,避免了读取b后又返回来重新读取a的时间开销),在编译的过程中会进行一定程度的重排,导致生成的机器指令和之前的字节码的顺序不一致。刚才出现x=0,y=0的情况就是编译器优化下的结果。

  • 指令重排序

CPU 的优化行为,和编译器优化很类似,是通过乱序执行的技术,来提高执行效率,可能会将部分汇编代码会提前执行。所以就算编译器不发生重排,CPU 也可能对指令进行重排。

  • 内存的"重排序"

内存系统内不存在重排序,但是内存会带来看上去和重排序一样的效果,所以这里的“重排序”打了双引号。由于内存有缓存的存在,在JMM里表现为主存和本地内存,由于主存和本地内存的不一致,会使得程序表现出乱序的行为。

在刚才的例子中,假设没编译器重排和指令重排,但是如果发生了内存缓存不一致,也可能导致同样的情况:线程1 修改了 a 的值,但是修改后并没有写回主存,所以线程2是看不到刚才线程1对a的修改的,所以线程2看到a还是等于0。同理,线程2对b的赋值操作也可能由于没及时写回主存,导致线程1看不到刚才线程2的修改。

 

可见性

Happens-Before

要想保证执行操作B的线程看到A的结果,那么A和B之间必须满足Happens-Before关系。如果两个操作之间缺乏Happens-Before关系,那么JVM对它们任意地重排序。

两个操作之间具有happens-before关系,并不意味着前一个操作必须要在后一个操作之前执行!happens-before仅仅要求前一个操作(执行的结果)对后一个操作可见,且前一个操作按顺序排在第二个操作之前。



Happens-Before的规则包括:

  • 程序顺序原则:在一个线程内一段代码的执行结果是有序的。虽然还会指令重排,但是随便它怎么排,结果是按照我们代码的顺序生成的不会变。
  • 锁规则:无论是在单线程环境还是多线程环境,对于同一个锁来说,一个线程对这个锁解锁之后,另一个线程获取了这个锁都能看到前一个线程的操作结果。
  • volatile变量规则:如果一个线程先去写一个volatile变量,然后一个线程去读这个变量,那么这个写操作的结果一定对读的这个线程可见。
  • 线程启动规则:在主线程A执行过程中,启动子线程B,那么线程A在启动子线程B之前对共享变量的修改结果对线程B可见。
  • 线程结束规则:在主线程A执行过程中,子线程B终止,那么线程B在终止之前对共享变量的修改结果在线程A中可见。
  • 中断规则:线程interrupt()方法的调用比检测线程中断的事件发生的早,可以通过Thread.interrupted()检测到是否发生中断。
  • 终结器规则:一个对象的初始化的完成,也就是构造函数执行的结束一定早于happens-before它的finalize()方法。
  • 传递性:A happens-before B , B happens-before C,那么A happens-before C。

原子性

简单来说就是一组操作,要么全部执行成功,要么全部执行不成功,不会出现执行一半的情况,是不可风割的。

Java中的synchronized和Lock就实现了原子性,在一个线程在做某个操作时其他线程无法对其进行干扰,只能等待它执行完成或者出现异常后才能执行。

Java中的原子操作

  • 除long和double之外的基本类型的赋值操作
  • 所有引用的赋值操作,不管是32位机器还是64位机器
  • java.concurrent.Atomic.*包中所有类的原子操作
  • ..

long和double的原子性

在官方文档中,对于64位值的写入可以分为两个32位的操作进行写入。所以在32位上的JVM,对于long和double变量的操作就不是原子性的,在64位的JVM就是原子性的。在商用的虚拟机中,已经将long和double变量的写入都为原子性的

原子操作+原子操作 !=原子操作

  • 0
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值