内部Java内存模型
JVM内部使用的Java内存模型在线程堆栈和堆之间分配内存。此图从逻辑角度说明了Java内存模型:
Java虚拟机中运行的每个线程都有其自己的线程堆栈。线程堆栈包含有关线程调用了哪些方法以达到当前执行点的信息。我将其称为“调用堆栈”。当线程执行其代码时,调用堆栈会更改。
线程堆栈还包含正在执行的每个方法(调用堆栈上的所有方法)的所有局部变量。线程只能访问自己的线程堆栈。由线程创建的局部变量对创建线程之外的所有其他线程不可见。即使两个线程执行的代码完全相同,两个线程仍将在各自的线程堆栈中创建该代码的局部变量。因此,每个线程对每个局部变量都有其自己的版本。
原始类型的所有局部变量( boolean
,byte
,short
,char
,int
,long
, float
,double
)完全存储在线程栈上,因此不是其他线程可见。一个线程可以将一个主要变量的副本传递给另一个线程,但是它不能共享原始局部变量本身。
堆包含在Java应用程序中创建的所有对象,而不管创建该对象的线程是什么。这包括原语类型(例如对象的版本Byte
,Integer
,Long
等等)。创建对象并将其分配给局部变量,或者将其创建为另一个对象的成员变量都没有关系,该对象仍存储在堆中。
这是说明调用堆栈和存储在线程堆栈上的局部变量以及存储在堆上的对象的图:
局部变量可以是原始类型,在这种情况下,它完全保留在线程堆栈中。
局部变量也可以是对对象的引用。在这种情况下,引用(局部变量)存储在线程堆栈中,但是对象本身(如果存储在堆中)。
一个对象可能包含方法,而这些方法可能包含局部变量。这些局部变量也存储在线程堆栈中,即使该方法所属的对象存储在堆中也是如此。
对象的成员变量与对象本身一起存储在堆中。当成员变量是原始类型时,以及它是对对象的引用时,都是如此。
静态类变量也与类定义一起存储在堆中。
引用对象的所有线程都可以访问堆上的对象。当线程可以访问对象时,它也可以访问该对象的成员变量。如果两个线程同时在同一个对象上调用一个方法,则它们都将有权访问该对象的成员变量,但是每个线程将拥有自己的局部变量副本。
这是说明以上几点的图:
两个线程具有一组局部变量。局部变量(Local Variable 2
)之一指向堆上的共享对象(对象3)。这两个线程分别具有对同一对象的不同引用。它们的引用是局部变量,因此存储在每个线程的线程堆栈中(在每个线程上)。但是,两个不同的引用指向堆上的同一对象。
注意共享对象(对象3)如何将对象2和对象4作为成员变量进行引用(从对象3到对象2和对象4的箭头所示)。通过对象3中的这些成员变量引用,两个线程可以访问对象2和对象4。
该图还显示了一个局部变量,该局部变量指向堆上的两个不同对象。在这种情况下,引用指向两个不同的对象(对象1和对象5),而不是同一对象。从理论上讲,如果两个线程都引用了两个对象,则两个线程都可以访问对象1和对象5。但是在上图中,每个线程仅引用了两个对象之一。
那么,哪种Java代码可能导致上面的内存图?好吧,代码和下面的代码一样简单:
public class MyRunnable implements Runnable() {
public void run() {
methodOne();
}
public void methodOne() {
int localVariable1 = 45;
MySharedObject localVariable2 =
MySharedObject.sharedInstance;
//... do more with local variables.
methodTwo();
}
public void methodTwo() {
Integer localVariable1 = new Integer(99);
//... do more with local variable.
}
}
public class MySharedObject {
//static variable pointing to instance of MySharedObject
public static final MySharedObject sharedInstance =
new MySharedObject();
//member variables 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;
}
如果有两个线程正在执行该run()
方法,则结果将如前所示。该run()
方法调用methodOne()
和methodOne()
calls methodTwo()
。
methodOne()
声明一个基本的局部变量(localVariable1
类型int
)和一个局部变量,该局部变量是对象引用(localVariable2
)。
每个线程执行methodOne()
会创建自己的副本,localVariable1
并 localVariable2
在各自的线程堆栈。该localVariable1
变量将彼此完全分开,只是生活的每个线程的线程堆栈。一个线程看不到另一个线程对其的副本所做的更改localVariable1
。
每个执行的线程methodOne()
还将创建自己的副本localVariable2
。但是,两者的两个不同副本localVariable2
最终指向堆上的同一对象。该代码设置localVariable2
为指向静态变量引用的对象。静态变量只有一个副本,并且此副本存储在堆中。因此,两个localVariable2
end副本最终都指向MySharedObject
静态变量指向的同一实例。该MySharedObject
实例也存储在堆中。它对应于上图中的对象3。
注意MySharedObject
该类也包含两个成员变量。成员变量本身与对象一起存储在堆中。这两个成员变量指向另外两个Integer
对象。这些Integer
对象对应于上图中的对象2和对象4。
还要注意如何methodTwo()
创建一个名为的局部变量localVariable1
。此局部变量是对对象的对象引用Integer
。该方法将localVariable1
引用设置为指向新Integer
实例。该localVariable1
引用将在每个执行线程中存储在一个副本中methodTwo()
。Integer
实例化的两个对象将存储在堆上,但是由于该方法Integer
每次执行该方法时都会创建一个新对象,因此,执行此方法的两个线程将创建单独的Integer
实例。Integer
内部创建的对象methodTwo()
对应于上图中的对象1和对象5。
还要注意MySharedObject
类型类中的两个成员变量,long
它们是原始类型。由于这些变量是成员变量,因此它们仍与对象一起存储在堆中。仅局部变量存储在线程堆栈上。
硬件内存架构
现代硬件内存体系结构与内部Java内存模型有所不同。同样重要的是,还要了解硬件内存架构,并了解Java内存模型如何与之协同工作。本节描述了常见的硬件内存体系结构,而下一节将描述Java内存模型如何与之协同工作。
这是现代计算机硬件体系结构的简化图:
现代计算机通常其中装有2个或更多CPU。其中一些CPU也可能具有多个内核。关键是,在具有2个或更多CPU的现代计算机上,可能同时运行多个线程。每个CPU都可以在任何给定时间运行一个线程。这意味着,如果Java应用程序是多线程的,则每个CPU可能在Java应用程序中同时(并发)运行一个线程。
每个CPU包含一组寄存器,这些寄存器本质上是CPU内存。CPU在这些寄存器上执行操作的速度比对主存储器中的变量执行操作的速度快得多。这是因为CPU可以比访问主存储器更快地访问这些寄存器。
每个CPU可能还具有一个CPU高速缓存存储层。实际上,大多数现代CPU都具有一定大小的高速缓存存储层。CPU可以比主存储器更快地访问其高速缓存,但通常不如其访问内部寄存器的速度快。因此,CPU高速缓存内存介于内部寄存器和主内存的速度之间。某些CPU可能具有多个高速缓存层(第1级和第2级),但是了解Java内存模型如何与内存交互并不重要。重要的是要知道CPU可以具有某种高速缓存层。
计算机还包含一个主存储区(RAM)。所有CPU都可以访问主存储器。主存储区通常比CPU的高速缓存大得多。
通常,当CPU需要访问主内存时,它将部分主内存读入其CPU缓存。它甚至可以将缓存的一部分读入其内部寄存器,然后对其执行操作。当CPU需要将结果写回主存储器时,它将把值从其内部寄存器刷新到高速缓存,然后在某个时候将值刷新回主存储器。
当CPU需要在高速缓存中存储其他内容时,通常会将高速缓存中存储的值刷新回主存储器。CPU高速缓存可以一次将数据写入其部分内存,并一次刷新其部分内存。它不必每次更新都读取/写入完整的缓存。通常,缓存在称为“缓存行”的较小存储块中更新。可以将一个或多个高速缓存行读入高速缓存存储器,并且可以将一个或多个高速缓存行再次刷新回主存储器。
弥合Java内存模型和硬件内存体系结构之间的差距
如前所述,Java内存模型和硬件内存体系结构是不同的。硬件内存体系结构不区分线程堆栈和堆。在硬件上,线程堆栈和堆都位于主内存中。有时,部分线程堆栈和堆可能会出现在CPU缓存和内部CPU寄存器中。下图对此进行了说明:
当对象和变量可以存储在计算机的各种不同存储区域中时,可能会出现某些问题。两个主要问题是:
- 线程更新(写入)到共享变量的可见性。
- 读取,检查和写入共享变量时的竞争条件。
这两个问题将在以下各节中进行说明。
共享对象的可见性
如果两个或多个线程共享一个对象,而没有正确使用volatile
声明或同步,则一个线程对共享对象所做的更新可能对其他线程不可见。
想象一下,共享对象最初存储在主存储器中。然后,在CPU一个上运行的线程将共享对象读入其CPU缓存中。在那里,它更改了共享库。只要尚未将CPU缓存刷新回主内存,就可以在其他CPU上运行的线程看不到共享对象的更改版本。这样,每个线程都可以拥有自己的共享库副本,每个副本位于不同的CPU缓存中。
下图说明了这种情况。在左侧CPU上运行的一个线程将共享库复制到其CPU缓存中,并将其count
变量更改为2。对于在右侧CPU上运行的其他线程,此更改不可见,因为count
尚未将更新刷新回主存储器。。
要解决此问题,可以使用Java的volatile关键字。该volatile
关键字可以确保一个给定的变量从主内存中直接读取和更新的时候总是写回主内存。
比赛条件
如果两个或多个线程共享一个对象,并且一个以上的线程更新该共享对象中的变量,则 可能会发生竞争条件。
想象一下,线程A是否将count
共享库的变量读入其CPU缓存中。还要想象一下,线程B的功能相同,但是它位于不同的CPU缓存中。现在,线程A将加上一个count
,而线程B执行相同的操作。现在var1
已增加了两次,每个CPU高速缓存一次。
如果这些增量是顺序执行的,则该变量count
将被增量两次,并且原始值+ 2将写回到主存储器中。
但是,这两个增量是在没有适当同步的情况下同时执行的。不管线程A和B中哪个线程将其更新后的版本写count
回主内存,尽管有两个增量,但更新后的值仅比原始值高1。
该图说明了如上所述的竞争条件问题的发生:
要解决这个问题,可以使用Java同步块。同步块保证在任何给定的时间只有一个线程可以进入代码的给定关键部分。同步块还保证同步块内访问的所有变量都将从主内存中读取,当线程退出同步块时,所有更新的变量将再次刷新回主存,而不管变量是否声明为volatile。
作者:Jakob Jenkov