原链接 此为原链接地址
JMM指定了JVM与计算机内存(RAM)怎样工作。JVM是整个计算机的一个模型,自然的这个模型会包含内存模型-也就是JMM。
如果你想设计正确行为的并发程序理解JMM是非常重要的。JMM指定不同的线程怎样何时可以看到被其他线程写入到共享变量中的值,在必要的时候怎样同步访问共享变量。
最初的JMM是不足的,所以JMM在java1.5被改进了。此次的修改一直沿用到Java1.8。
JMM的内部构造
JMM在JVM内部的使用将内存分为了线程栈和堆。下面这个图说明了JMM的逻辑视图
在java虚拟机中运行的每个线程都有自己的线程栈。线程栈包含关于方法的信息,这个信息是线程当前调用方法位置的指针。我喜欢叫“调用栈”。当线程执行代码,调用栈改变。
线程栈也包含每个被执行方法中的所有本地变量(所有在调用栈中的方法)。一个线程只可以访问它自己的线程栈。一个线程创建的本地变量对于其他线程是不可见的。甚至2个线程都在执行绝对相同的代码,这两个线程也会在他们自己的线程栈中创建本地变量。因此,每个线程有他们自己的本地变量的版本。
所有的本地原始类型的变量(boolean,byte,short,char,int,long,float,double)全部存储在线程栈因此对其他线程不可见。一个线程可能传给另外一个线程的原始类型变量的副本,但是不能分享原始本地变量。
堆(Heap)包含了所有的对象在你的java应用中创建的,不管是什么线程创建的对象。这个包含原始类型的对象的版本(Byte,Integer,Long等)。无论一个对象被创建和分配给一个本地变量,或者作为另一个对象的成员变量,这个对象都会存放在堆heap中。
下图说明了调用栈和本地变量存储在线程栈,对象存储在堆中:
一个本地变量可能是原始类型,这中情况此变量绝对存储在线程栈。
一个本地变量可能是一个对象的引用。这种情况这个引用(本地变量)被存储在线程栈,但是对象本身存储在堆heap中。
一个对象可能包含方法并且这些方法可能包含本地变量。这些本地变量也存储在线程栈stack,即使包含在这些方法中的对象依然存储在堆中。
一个对象的成员变量存储中堆中随着这个对象。不管成员变量是原始类型还是一个对象的引用都存储在堆中heap。
静态类变量随着类的定义存储在堆中。
堆中的对象可以被所有的线程访问通过对象的引用。当一个线程访问一个对象时,这个线程也可以访问这个对象点成员变量。如果两个线程调用同一个对象的方法在同一时间,这两个线程都会访问这个对象的成员变量,但是每个线程有他们自己的本地变量的拷贝。
请看下图:
2个线程各自有一套本地变量。其中有一个本地变量(Local Variable 2)指向堆中共享的对象(Object3)。这两个线程各自有一个引用指向相同的对象。他们的引用都是本地变量因此这两个引用存储在各自线程栈中。这两个不同的引用在堆中指向相同的对象。
注意共享对象(Object3)怎样引用Object2和Object4作为成员变量(图示Object3箭头指向Object2和Object4)。通过这些在Object3中成员变量引用这两个线程可以访问Object2和Object4。
上图也展示了一个本地变量指向堆中两个不同的对象。在这种情况引用指向不同的两个对象(Object1和Object5),这两个对象是不同的对象。理论上两个线程都可以访问Object1和Object5,如果两个线程有这两个对象的引用。但是上图每个线程只有一个引用指向其中一个对象。
所以什么样的java代码可以表示上图的内存图?看下面简单的代码。
public class MyRunnable implements Runnable {
public void run() {
methodOne();
}
public void methodOne(){
int localVariable1=45;
MySharedObject localVariable2=MySharedObject.sharedObject;
//.. do more with local variables
methodTwo();
}
public void methodTwo(){
Integer localVariable1=new Integer(99);
//.. do more with local variables
}
}
public class MySharedObject {
//static variable pointing to instance of MySharedObject
public static final MySharedObject sharedObject=new MySharedObject();
//member variable pointing to two objects on the heap
public Integer Object2=new Integer(22);
public Integer Object4=new Integer(44);
public long member1=12345;
public long member2=67890;
}
如果2个线程执行run()方法上面的图就会呈现。run()方法调用methodOne(),methodOne()调用methodTwo()。methodOne()声明了一个原始类型的本地变量(localVariable1类型为int)和一个本地变量类型为对象引用(localVariable2)。
每个线程执行methodOne()方法将创建他们自己的拷贝localVariable1和localVariable2在他们各自的线程栈。引用localVariable1是完全分开的对于每个线程,只存在于每个线程的线程栈中。一个线程不能看到另一个线程对localVariable1副本引用的改变。
每个线程执行methodOne()方法将创建他们自己的拷贝localVariable2。然而,两个不同的localVariable2引用副本指向堆中相同的对象。代码显示设置localVariable2引用通过静态变量指向一个对象引用。这只有一个静态变量的副本且这个副本存储在堆中。因此,localVariable2引用点2个副本都指向相同的MySharedObject实例,这个实例是静态变量指向的。MySharedObject实例也存储在堆中。就是上图的Object3。
注意,MySharedObject类怎样包含2个成员变量。成员变量随着对象存储在堆中。这两个成员变量指向另外两个Integer类型的对象。这些Integer类型的对象就是上图的Object2和Object4。
注意:methodTwo()方法怎样创建名为localVariable1的本地变量。这个本地变量是一个指向Integer类型对象的引用。这个方法设置localVariable1引用指向一个新的Integer实例。当执行methodTwo()方法时会在每个线程中存储localVariable1引用的各自副本。这2个Integer对象实例会存储在堆中,但是由于每次方法执行时都会创建新的Integer对象,两个线程执行这个方法就会创建不同的Integer实例。Integer对象被创建在methodTwo()方法的内部如上图的Object1和Object5。
注意:在MySharedObject类中的两个long原始类型的成员变量。由于这些变量是成员变量,他们也随着对象存储中堆中。只有本地变量存储在线程栈中。
硬件内存结构
当代硬件内存结构与JMM的内部有几分不同。理解硬件内存结构也很重要,这样可以理解JMM是怎样与硬件内存结构工作的。这节描述普通的硬件内存结构,之后的章节描述JMM怎么与它共同工作的。
这是简单的当代计算机硬件结构
一个当代的计算机经常有2个或更多的cpu。一些cpu可能有多核。关键点是,当代计算机有2个或更多CPU使得有多于1个线程同时执行。每个CPU都有能力运行一个线程在任何时候。那意味着如果你的java应用是多线程的,一个线程每个CPU可能会同时(并发)的执行在你的java应用内部。
每个CPU包含一套寄存器,本质上是CPU中的存储。CPU可以在寄存器中执行运算变量更快比在主存中。这是因为CPU访问寄存器比访问主存更快。
每个CPU可能有一层CPU缓存层。实际上,大多数当今CPU有一定大小的缓存层。CPU可以访问缓存层更快比访问主存,但是没有访问CPU内部的寄存器快。所以CPU缓存的访问速度介于寄存器与主存之间。一些CPU有多个缓存层(level1和level2)但是这不重要对于理解JMM怎么与内存进行交互。(没看懂!!!)
一个计算机可以包含主存区域(RAM)。所有的CPU可以访问主存。主存区域典型的比cpu的缓存区域大。
典型的,当CPU需要访问主存,cpu会读取主存的一部分数据进CPU缓存。可能甚至读取部分缓存中的数据进寄存器然后执行计算。当CPU需要将结果写回主存,cpu会从寄存器将数据冲刷到缓存,然后将值冲刷到主存。
在缓存中存储的值会被冲刷到主存当CPU需要在缓存中存储另外一些数据。CPU缓存可以一部分数据进行写操作,一部分数据冲刷到主存。不是一次读/写全部缓存数据进行更新。典型的缓存更新是小的内存块叫做“缓存行”。一个或更多缓存行从缓存进行读取,一个或更多缓存行被冲刷到主存。
缩小JMM与硬件内存结构的差距
就像已经提到的,JMM与硬件内存结构是不同的。硬件内存结构不会区分线程栈和堆。在硬件中,线程栈和堆都位于主存。一部分的线程栈和堆可能有时呈现在CPU缓存中和CPU内部的寄存器中。如下图:
当对象和变量存储在计算机中各种各样不同的内存区域,当然问题会发生。两个主要的问题是:
- 线程更新共享变量的可见性。
- 当读,检测和写共享变量的竞争条件。
这2个问题下面会解释。
共享对象的可见性
如果2个或更多线程共享一个对象,没有使用适当的volatile声明或者同步,一个线程更新共享的对象可能对其他线程不可见。
想象一下共享对象初始化时存储在主存中。一个运行在一个CPU中对线程读取了共享的对象放入CPU缓存中。然后对共享对象进行改变。只要CPU缓存没有把这个改变的对象冲刷回主存,这个共享对象更新的版本对在其他CPU上运行的线程是不可见的。这种方式每个线程都会以他们共享对象的副本进行处理的结束,每个副本都存在不同的CPU缓存中。
下图显示了大概的情况。一个在左侧CPU中运行的线程拷贝了共享的对象到CPU缓存中,改变了count变量为2。这个改变对运行在其他CPU中的线程是不可见的,因为对count对改变还没有冲刷回主存。
为了解决上面的问题你可以使用Java的关键字volatile。volatile关键字可以确保一个变量直接从主存中读取,当改变后直接写回主存。
竞争条件
如果2个或更多的线程共享一个对象,且多于一个线程更新共享对象中的变量,竞争条件就会发生。
想象如果线程A读取共享对象中的变量count进CPU缓存。线程B也一样,但是进入不同的CPU缓存中。现在线程A对count加1,线程B也一样。现在var1被增长了2次,每个CPU一次。
如果这些增长被顺序写回主存,变量count会被增加2次且原始的值+2写回主存。
然而,这两次增长没有进行适当的同步被写入主存。不管线程A和线程B将修改过后的版本count写回主存,修改的值应该只加1比原始的值,不是2次增长。
下图展示竞争条件发生的问题。
解决这个问题你可以使用java的synchronized块。一个synchronized块保证只有一个线程在任何时候可以进入给定的临界代码区域。synchronized块也保证所有只synchronized块中的变量的访问从主存读取,当线程推出此块后,所有更新变量会冲刷回主存。无论变量是否被声明为volatile。