Java内存模型指定Java虚拟机如何使用计算机内存(RAM)。Java虚拟机是整个计算机的模型,所以这个模型自然包含一个内存模型,也就是Java内存模型。
要想设计出正确的并发程序,理解Java内存模型是非常重要的。Java内存模型指定了不同线程如何以及何时可以看到其他线程写入共享变量的值,以及在必要时如何同步对共享变量的访问。
原始的Java内存模型不足,因此Java内存模型在Java 1.5中进行了修订。此版本的Java内存模型仍在Java 8中使用。
内部Java内存模型
JVM内部使用的Java内存模型在线程堆栈和堆之间划分内存。下图从逻辑角度说明了Java内存模型:
Java虚拟机中运行的每个线程都有其自己的线程堆栈。线程堆栈包含有关线程调用了哪些方法以达到当前执行点的信息。我们将其称为“调用堆栈”。当线程执行其代码时,调用堆栈会更改。
线程堆栈还包含正在执行的每个方法(调用堆栈上的所有方法)的所有局部变量。一个线程只能访问它自己的线程堆栈。由线程创建的局部变量对创建它的线程以外的所有其他线程都不可见。即使两个线程正在执行完全相同的代码,两个线程仍将在各自的线程堆栈中创建该代码的局部变量。因此,每个线程对每个局部变量都有其自己的版本。
原始类型(boolean、byte、short、char、int、long、float、double)的所有局部变量都完全存储在线程堆栈中,因此对其他线程不可见。一个线程可以将主要(pritimive)变量的副本传递给另一个线程,但它不能共享原始局部变量本身。
堆包含在Java应用程序中创建的所有对象,而不管是哪个线程创建的对象。这包括原语类型的对象版本(例如Byte、Integer、Long等)。创建对象并将其分配给局部变量,或者将其创建为另一个对象的成员变量都没有关系,该对象仍存储在堆中。
下面是一个图表,说明存储在线程堆栈上的调用堆栈和局部变量,以及存储在堆栈上的对象:
局部变量可以是原始类型,在这种情况下,它完全保留在线程堆栈中。
局部变量也可以是对对象的引用。在这种情况下,引用(局部变量)存储在线程堆栈上,而对象本身(如果存储在堆上)则存储在线程堆栈上。
对象可以包含方法,这些方法可以包含局部变量。这些局部变量也存储在线程堆栈中,即使方法所属的对象存储在堆中。
静态类变量也与类定义一起存储在堆中。
堆上的对象可以被所有引用该对象的线程访问。当线程有权访问某个对象时,它也可以访问该对象的成员变量。如果两个线程同时调用同一个对象上的方法,它们都可以访问该对象的成员变量,但每个线程都有自己的局部变量副本。
上面几点可以用下面这个图表示:
两个线程具有一组局部变量。局部变量之一(局部变量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 member1 = 67890;
}
如果有两个线程正在执行run()方法,那么前面显示的图就是结果。 run()方法调用methodOne(),methodOne()调用methodTwo()。
methodOne()声明一个原始的局部变量(int类型的localVariable1)和一个作为对象引用的局部变量(localVariable2)。
执行methodOne()的每个线程都将在各自的线程堆栈上创建自己的localVariable1和localVariable2副本。localVariable1变量将彼此完全分离,只存在于每个线程的线程堆栈中。一个线程看不到另一个线程对其localVariable1副本所做的更改。
每个执行methodOne()的线程还将创建自己的localVariable2副本。但是,localVariable2的两个不同副本最终都指向堆上的同一对象。该代码将localVariable2设置为指向静态变量引用的对象。静态变量只有一个副本,并且此副本存储在堆中。因此,localVariable2的两个副本最终都指向静态变量指向的MySharedObject的同一实例。 MySharedObject实例也存储在堆中。它对应于上图中的对象3。
注意MySharedObject类也包含两个成员变量。成员变量本身与对象一起存储在堆中。这两个成员变量指向另外两个Integer对象。这些整数对象对应于上图中的对象2和对象4。
还要注意methodTwo()如何创建一个名为localVariable1的局部变量。此局部变量是对Integer对象的对象引用。该方法将localVariable1引用设置为指向新的Integer实例。执行methodTwo()的每个线程的localVariable1引用将存储在一个副本中。实例化的两个Integer对象将存储在堆中,但是由于该方法每次执行该方法时都会创建一个新的Integer对象,因此执行此方法的两个线程将创建单独的Integer实例。在methodTwo()内部创建的Integer对象对应于上图中的对象1和对象5。
还请注意,类型为long的MySharedObject类中的两个成员变量是基本类型。由于这些变量是成员变量,因此它们仍与对象一起存储在堆中。仅局部变量存储在线程堆栈上。
硬件内存体系结构
现代硬件内存体系结构与内部Java内存模型有所不同。同样重要的是,还要了解硬件内存架构,并了解Java内存模型如何与之协同工作。本节描述了常见的硬件内存体系结构,下一节将描述Java内存模型如何与之协同工作。
这是现代计算机硬件体系结构的简化图:
现代计算机通常有两个或更多的CPU。其中一些CPU可能也有多个内核。关键是,在一台拥有两个或更多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声明或synchronized,则一个线程对共享对象进行的更新可能对其他线程不可见。
假设共享对象最初存储在主内存中。然后,在一个CPU上运行的线程将共享对象读入其CPU缓存。在那里,它对共享对象进行了更改。只要CPU缓存没有被刷新回主内存,其他CPU上运行的线程就看不到更改后的共享对象版本。这样,每个线程最终都可能拥有自己的共享对象副本,每个副本都位于不同的CPU缓存中。
下图说明了这种情况。在左侧CPU上运行的一个线程将共享库复制到其CPU缓存中,并将其count变量更改为2。在右侧CPU上运行的其他线程看不到此更改,因为尚未将计数更新刷新回main记忆呢。
下图说明了大致情况。在左侧CPU上运行的一个线程将共享对象复制到其CPU缓存中,并将其count变量更改为2。在右侧CPU上运行的其他线程看不到此更改,因为count变量的更新尚未刷新回主内存。
要解决此问题,可以使用Java的volatile关键字。 volatile关键字可以确保给定的变量直接从主内存中读取,并在更新时始终写回到主内存中。
竞态条件
如果两个或多个线程共享一个对象,并且多个线程更新该共享对象中的变量,就可能出现竞态条件。
假设线程A将共享对象的count变量读入其CPU缓存。想象一下,线程B也做了同样的事情,但是位于不同的CPU缓存中。现在线程A使count加1,线程B也这样做。现在var1增加了两次,但在每个CPU中各缓存一次。
如果这些增加是顺序执行的,则count变量将增加两次,并将原始值+ 2写回到主内存中。
但是,这两个增量是在没有适当同步的情况下同时执行的。无论线程A和B中哪个线程将其更新后的count版本写回主内存,更新后的值仅比原始值高1,尽管有两个增量。
下图说明了上述竞态条件问题的发生:
要解决此问题,可以使用Java同步块(synchronized)。同步块可确保在任何给定时间只有一个线程可以进入代码的给定关键部分。同步块还保证从同步块中访问的所有变量都将从主存储器中读取,并且当线程退出同步块时,所有更新的变量都将被再次刷新回主内存,而不管变量是否被声明为易失性变量(即不管变量是否用volatile关键字修饰)。
更多内容可以关注我的公众号【码之初】,专注Java基础|架构|源码|读书。