Java内存模型

时间:2020-01-09 10:35:51  来源:igfitidea点击:

Java内存模型指定Java虚拟机如何与计算机的内存(RAM)一起使用。 Java虚拟机是整个计算机的模型,因此该模型自然包括Java内存模型(又称为Java内存模型)。

如果要正确设计行为并发的程序,理解Java内存模型非常重要。 Java内存模型指定不同线程如何以及何时看到其他线程写入共享变量的值,以及在必要时如何同步对共享变量的访问。

原始的Java内存模型不足,因此Java内存模型在Java 1.5中进行了修订。该版本的Java内存模型今天仍在Java(Java 14+)中使用。

内部Java内存模型

JVM内部使用的Java内存模型在线程堆栈和堆之间分配内存。此图从逻辑角度说明了Java内存模型:

Java虚拟机中运行的每个线程都有其自己的线程堆栈。线程堆栈包含有关线程调用了哪些方法以达到当前执行点的信息。我将其称为"调用堆栈"。当线程执行其代码时,调用堆栈会更改。

线程堆栈还包含正在执行的每个方法(调用堆栈上的所有方法)的所有局部变量。线程只能访问自己的线程堆栈。由线程创建的局部变量对创建线程之外的所有其他线程不可见。即使两个线程执行的代码完全相同,这两个线程仍将在各自的线程堆栈中创建该代码的局部变量。因此,每个线程对每个局部变量都有其自己的版本。

基本类型的所有局部变量("布尔","字节","短","字符","整数","长","浮点","双")都完全存储在线程堆栈中,因此不会对其他线程可见。一个线程可以将一个主要变量的副本传递给另一个线程,但是它不能共享原始局部变量本身。

堆包含在Java应用程序中创建的所有对象,而不管创建该对象的线程是什么。这包括原始类型的对象版本(例如,"字节","整数","长整数"等)。创建对象并将其分配给局部变量,或者将其创建为另一个对象的成员变量都没有关系,该对象仍存储在堆中。

这是说明调用堆栈和存储在线程堆栈上的局部变量以及存储在堆上的对象的图:

局部变量可以是原始类型,在这种情况下,它完全保留在线程堆栈中。

局部变量也可以是对对象的引用。在这种情况下,引用(局部变量)存储在线程堆栈中,但是对象本身(如果存储在堆中)。

一个对象可能包含方法,而这些方法可能包含局部变量。即使该方法所属的对象存储在堆中,这些局部变量也存储在线程堆栈中。

对象的成员变量与对象本身一起存储在堆中。当成员变量是原始类型时,以及它是对对象的引用时,都是如此。

静态类变量也与类定义一起存储在堆中。

引用对象的所有线程都可以访问堆上的对象。当线程可以访问对象时,它也可以访问该对象的成员变量。如果两个线程同时在同一个对象上调用一个方法,则它们都将有权访问该对象的成员变量,但是每个线程将拥有自己的局部变量副本。

这是说明以上几点的图:

两个线程具有一组局部变量。局部变量之一("局部变量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()方法调用methodTwo()。

methodOne()声明一个原始的局部变量(int类型的localVariable1)和一个作为对象引用的局部变量localVariable2.

每个执行methodOne()的线程将在各自的线程堆栈上创建其自己的localVariable1localVariable2的副本。 localVariable1变量将彼此完全分开,仅存在于每个线程的线程堆栈中。一个线程看不到另一个线程对其" localVariable1"副本所做的更改。

每个执行methodOne()的线程也将创建自己的localVariable2副本。但是,localVariable2的两个不同副本最终都指向堆上的同一对象。该代码将" localVariable2"设置为指向静态变量引用的对象。静态变量只有一个副本,并且此副本存储在堆中。因此," localVariable2"的两个副本最终都指向静态变量所指向的" MySharedObject"的同一实例。 MySharedObject实例也存储在堆中。它对应于上图中的对象3.

注意MySharedObject类也包含两个成员变量。成员变量本身与对象一起存储在堆中。这两个成员变量指向另外两个Integer对象。这些"整数"对象对应于上图中的对象2和对象4.

还要注意methodTwo()如何创建一个名为localVariable1的局部变量。该局部变量是对"整数"对象的对象引用。该方法将" localVariable1"引用设置为指向新的" Integer"实例。执行" methodTwo()"的每个线程的" localVariable1"引用将存储在一个副本中。实例化的两个Integer对象将存储在堆中,但是由于该方法每次执行时都会创建一个新的Integer对象,因此执行此方法的两个线程将创建单独的Integer实例。在" methodTwo()"内部创建的" Integer"对象对应于上图中的对象1和对象5.

还要注意类型为long的MySharedObject类中的两个成员变量,这是一个原始类型。由于这些变量是成员变量,因此它们仍与对象一起存储在堆中。仅局部变量存储在线程堆栈上。

硬件内存架构

现代硬件内存体系结构与内部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. 由于在未运行对" count"的更新之前,在右CPU上运行的其他线程看不到此更改。刷新回主内存了。

要解决此问题,我们可以使用Java的volatile关键字。关键字" volatile"可以确保给定的变量直接从主存储器中读取,并在更新时始终写回到主存储器中。

竞争条件

如果两个或者多个线程共享一个对象,并且一个以上的线程更新该共享对象中的变量,则可能会发生竞争条件。

想象一下,线程A是否将共享库的变量" count"读入其CPU缓存中。还要想象一下,线程B的功能相同,但是它位于不同的CPU缓存中。现在,线程A向count加一个,而线程B执行相同的操作。现在var1已经增加了两次,在每个CPU缓存中增加了一次。

如果这些增加是顺序执行的,则变量" count"将增加两次,并将原始值+ 2写回到主存储器。

但是,这两个增量是在没有适当同步的情况下并发执行的。不管线程A和B中哪个线程将其更新的count版本写回到主存,尽管有两个增量,更新后的值仅比原始值高1.

该图说明了如上所述的竞争条件问题的发生:

要解决此问题,我们可以使用Java同步块。同步块可确保在任何给定时间只有一个线程可以输入代码的给定关键部分。同步块还保证将从同步块中读取的所有变量都从主存储器中读取,并且当线程退出同步块时,所有更新的变量将再次刷新回主存储器,而不管该变量是否声明为volatile或者volatile。不是。