Java 内存模式
Java内存模型规范了Java虚拟机与计算机内存是如何协同工作的。Java虚拟机是一个完整的计算机的一个模型,因此这个模型自然也包含一个内存模型——又称为Java内存模型。
如果你想设计表现良好的并发程序,理解Java内存模型是非常重要的。Java内存模型规定了如何和何时可以看到由其他线程修改过后的共享变量的值,以及在必须时如何同步的访问共享变量
原始的Java内存模型存在一些不足,因此Java内存模型在Java1.5时被重新修订。这个版本的Java内存模型在Java8中人在使用。
Java内存模型内部原理
Java内存模型把Java虚拟机内部划分为线程栈和堆。这张图演示了Java内存模型的逻辑视图
image每一个运行在Java虚拟机里的线程都拥有自己的线程栈。这个线程栈包含了这个线程调用的方法当前执行点相关的信息。一个线程仅能访问自己的线程栈。一个线程创建的本地变量对其它线程不可见,仅自己可见。即使两个线程执行同样的代码,这两个线程任然在在自己的线程栈中的代码来创建本地变量。因此,每个线程拥有每个本地变量的独有版本。
线程堆栈还包含执行每个方法的所有局部变量(调用堆栈上的所有方法)。 一个线程只能访问它自己的线程堆栈。 线程所创建的局部变量对于所有其他线程都是不可见的,而不是创建线程的线程。 即使两个线程执行完全相同的代码,两个线程仍然会在每个自己的线程堆栈中创建该代码的局部变量。 因此,每个线程都有自己的每个局部变量的版本。
原始类型(boolean,byte,short,char,int,long,float,double)的所有局部变量都完全存储在线程堆栈上,因此对其他线程不可见。 一个线程可以将一个原始类型变量的副本传递给另一个线程,但是它不能共享原始局部变量本身。
该堆包含您的Java应用程序中创建的所有对象,无论创建对象的线程如何。 这包括基本类型的对象版本(例如,字节,整数,长等)。 如果对象被创建并分配给局部变量,或者创建为另一个对象的成员变量,对象仍然存储在堆上,这并不重要。
下面的图示出了存储在线程堆栈上的调用堆栈和局部变量以及存储在堆上的对象:
image局部变量可能是一个原始类型,在这种情况下,它完全保留在线程栈上。
局部变量也可以是对象的引用。在这种情况下,引用(局部变量)存储在线程堆栈上,但对象本身存储在堆上。
对象可能包含方法,这些方法可能包含局部变量。这些局部变量也存储在线程堆栈中,即使该方法所属的对象存储在堆上。
对象的成员变量与对象本身一起存储在堆上。当成员变量是原始类型,并且它是对对象的引用时,这是真的。
静态类变量也与类定义一起存储在堆上。
所有对该对象引用的线程都可以访问堆上的对象。当线程访问对象时,它也可以访问对象的成员变量。如果两个线程同时在同一个对象上调用一个方法,那么它们都可以访问对象的成员变量,但每个线程都有自己的局部变量副本。
[图片上传失败...(image-f8c969-1529157793436)]
两个线程有一组局部变量。局部变量(Local Variable 2)之一指向堆上的共享对象(对象3)。两个线程各自对同一个对象有不同的引用。它们的引用是局部变量,因此存储在每个线程的线程堆栈(每个)上。两个不同的引用指向堆上的同一个对象。
注意共享对象(Object 3)如何引用Object 2和Object 4作为成员变量(由Object 3到Object 2和Object 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引用设置为指向一个新的整数实例。 localVariable1引用将被存储在执行methodTwo()的每个线程的一个副本中。实例化的两个Integer对象将被存储在堆上,但是由于该方法在每次执行该方法时都会创建一个新的Integer对象,所以执行此方法的两个线程将创建单独的Integer实例。 methodTwo()中创建的Integer对象对应于上图中的Object 1和Object 5。
还要注意类型为long的类MySharedObject中的两个成员变量,这是一个原始类型。由于这些变量是成员变量,它们仍然与对象一起存储在堆上。只有局部变量存储在线程堆栈中
硬件内存架构
现代硬件内存架构与内部Java内存模型有所不同。 了解硬件内存架构也很重要,以了解Java内存模型的工作原理。 本节介绍常见的硬件内存架构,后面的部分将介绍Java内存模型的工作原理。
以下是现代计算机硬件架构的简化图:
image现代计算机通常有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寄存器中。 这在图中说明:
image当对象和变量可以存储在计算机的各种不同的存储区域中时,可能会出现某些问题。 两个主要问题是:
- 线程更新(写入)到共享变量的可见性。
- 读取,检查和写入共享变量时的竞争条件
这两个问题将在以下部分中解释。
共享对象的可见性
如果两个或多个线程共享一个对象,没有正确使用volatile声明或同步,一个线程所做的共享对象的更新可能对其他线程是不可见的。
假设共享对象最初存储在主内存中。 在CPU 1上运行的线程然后将共享对象读入其CPU缓存。 在那里它对共享对象进行了更改。 只要CPU缓存尚未刷新到主内存,共享对象的更改版本对于在其他CPU上运行的线程不可见。 这样一来,每个线程可能会以自己的共享对象副本结束,每个副本都坐在不同的CPU缓存中。
下图说明了草图的情况。 在左CPU上运行的一个线程将共享对象复制到其CPU缓存中,并将其计数变量更改为2.对于正确CPU上运行的其他线程,此更改不可见,因为更新计数尚未刷新到主 记忆还没
image要解决这个问题,你可以使用Java的volatile关键字。 volatile关键字可以确保给定的变量直接从主内存中读取,并在更新时总是写回主内存。
注意
volatile并不能保证线程的安全,只是在某种场景下使用才能保证线程安全。如果有且只有一个线程在写,其他的所有线程都在读,那么可以保证线程的安全,如果多个线程写多个线程读,那么不能保证线程安全
条件竞争
如果两个或多个线程共享对象,并且多个线程更新该共享对象中的变量,则可能会发生竞争条件。
假设线程A将共享对象的变量计数读入其CPU缓存中。 想像一下,那个线程B也是一样的,但进入不同的CPU缓存。 现在线程A增加一个计数,线程B也是一样的。 现在,var1已经增加了两次,每次CPU缓存一次。
如果这些增量依次执行,则变量计数将被增加两次,并将原始值+ 2写回到主存储器。
但是,两个增量在没有正确同步的情况下同时进行。 不管线程A和B哪个将其更新版本的计数写回主内存,尽管有两个增量,更新后的值将仅比原始值高1。
该图说明了如上所述的竞争条件的问题的发生:
image要解决此问题,您可以使用Java同步块。 同步块保证在任何给定时间只有一个线程可以进入代码的给定关键部分。 同步块还保证在同步块内访问的所有变量将从主存储器读入,并且当线程退出同步块时,所有更新的变量将被刷新回主存储器,而不管该变量是否被声明为volatile。
网友评论