Java内存模型

        先从计算机的硬件效率说起,CPU的计算速度比内存快几个数量级,为了平衡CPU和内存之间的矛盾,引入的高速缓存,每个CPU都有高速缓存,甚至是多级缓存L1、L2和L3,那么缓存与内存的交互需要缓存一致性协议(MESI,与volitle保持可见性有关)。那么最终处理器、高速缓存、主内存的交互关系如下:

 

    Java的内存模型(Java Memory Model,简称JMM)也定义了线程、工作内存、主内存之间的关系,非常类似于硬件方面的定义:

  

  Java虚拟机运行时内存的区域划分:

堆:new出的实例对象都存储在这个区域,是GC的主战场,线程共享。
方法区:存储类信息、常量、静态变量等,线程共享
虚拟机栈:每个方法的执行都会创建栈帧,用于存储局部变量、操作数栈、动态链接等,虚拟机栈主要存储这些信息,线程私有
本地方法栈:虚拟机使用到的Native方法服务,例如c程序等,线程私有
程序计数器:记录程序运行到哪一行了,相当于当前线程字节码的行号计数器,线程私有

    所以对于JMM定义的主内存,大部分时候可以对应堆内存、方法区等线程共享的区域,这里只是概念上对应,其实程序计数器、虚拟机栈等也有部分是放在主内存的,具体看虚拟机的设计。

    线程A、B同时去读取主内存的count初始值存放在各自的工作内存里,同时执行了自增操作,写回主内存,最终得到了错误的结果。

  造成这个错误的本质原因:

可见性:工作内存的最新值不知道什么时候会写回主内存
有序性:线程之间必须是有序的访问共享变量

  接下来,我们来具体分析一下,为什么要从可见性和有序性两个方面来限定。

  给count加上volatile关键字,就保证了可见性。

private volatile int count = 0;

  1.有序性:防止指令重排序

  指令重排序是JVM为了优化指令、提高程序运行效率,在不影响单线程程序执行结果的前提下,尽可能地提高并行度。指令重排序包括编译器重排序和运行时重排序。

double r = 2.1; //(1) 
double pi = 3.14;//(2) 
double area = pi*r*r;//(3)

  虽然代码语句的定义顺序为1->2->3,但是计算顺序1->2->3与2->1->3对结果并无影响,所以编译时和运行时可以根据需要对1、2语句进行重排序。

  内存屏障是CPU指令。如果字段是volatile,Java内存模型将在写操作后插入一个写屏障指令,在读操作前插入一个读屏障指令。

  2.可见性:保证内存可见性  

  可见性是指线程之间的可见性,一个线程修改的状态对另一个线程是可见的。也就是一个线程修改的结果,另一个线程马上就能看到。对变量count加上volatile关键字修饰的话,它以保证当A线程对变量count值做了变动之后,会立即刷回到主内存中,而其它线程读取到该变量的值也作废,强迫重新从主内存中读取该变量的值.
  volatile可见性是通过汇编加上Lock前缀指令,触发底层的MESI缓存一致性协议来实现的。
  假如说当前有一个cpu去主内存拿到一个变量x的值初始为1,放到自己的工作内存中。此时它的状态就是独享状态E,然后此时另外一个cpu也拿到了这个x的值,放到自己的工作内存中。此时之前那个cpu会不断地监听内存总线,发现这个x有多个cpu在获取,那么这个时候这两个cpu所获得的x的值的状态就都是共享状态S。然后第一个cpu将自己工作内存中x的值带入到自己的ALU计算单元去进行计算,返回来x的值变为2,接着会告诉给内存总线,将此时自己的x的状态置为修改状态M。而另一个cpu此时也会去不断的监听内存总线,发现这个x已经有别的cpu将其置为了修改状态,所以自己内部的x的状态会被置为无效状态I,等待第一个cpu将修改后的值刷回到主内存后,重新去获取新的值。这个谁先改变x的值可能是同一时刻进行修改的,此时cpu就会通过底层硬件在同一个指令周期内进行裁决,裁决是谁进行修改的,就置为修改状态,而另一个就置为无效状态,被丢弃或者是被覆盖.
  当然,MESI也会有失效的时候,缓存的最小单元是缓存行,如果当前的共享数据的长度超过一个缓存行的长度(64KB)的时候,就会使MESI协议失败,此时的话就会触发总线加锁的机制,第一个线程cpu拿到这个x的时候,其他的线程都不允许去获取这个x的值。

 

    既然保证了可见性,加上了volatile关键词,为什么还是无法得到正确的结果,原因是count++,并非原子操作,count++等效于如下步骤:

1.从主内存中读取count赋值给线程副本变量: temp=count
2.线程副本变量加1: temp=temp+1
3.线程副本变量写回主内存:count=temp

  就算是真的严苛的给总线加锁,导致同一时刻,只能有一个处理器访问到count变量,但是在执行第2步操作时,其他cpu已经可以访问count变量,此时最新运算结果还没刷回主内存,造成了错误的结果,所以必须保证顺序性。那么保证顺序性的本质,就是保证同一时刻只有一个CPU可以执行临界区代码。这时候做法通常是加锁,锁本质是分两种:悲观锁和乐观锁。如典型的悲观锁synchronized、JUC包下面典型的乐观锁ReentrantLock。

  总结一下:要保证线程安全,必须保证三点:原子性、可见性、有序性

posted @ 2018-08-09 13:21  鄙人取个名字好难  阅读(145)  评论(0编辑  收藏  举报