Java高并发之内存模型

概述

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

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

原始Java内存模型不足,因此Java内存模型在Java 1.5中进行了修订。此版本的Java内存模型仍在Java 8中使用。

内部Java内存模型

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

java-memory-model-1.png

逻辑视角下的Java内存模型
在Java虚拟机中运行的每个线程都有自己的线程堆栈。线程堆栈包含有关线程调用哪些方法以达到当前执行点的信息。我将其称为“调用堆栈”。当线程执行其代码时,调用堆栈会发生变化。

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

基本类型(所有局部变量 boolean,byte,short,char,int,long, float,double)完全存储在线程栈上,因此不是其他线程可见。一个线程可以将一个局部变量的副本传递给另一个线程,但它不能共享原始局部变量本身。

堆包含在Java应用程序中创建的所有对象,无论创建该对象的线程是什么。这包括基本类型(例如对象的版本Byte,Integer,Long等等)。无论是创建对象并将其分配给局部变量,还是创建为另一个对象的成员变量,该对象仍然存储在堆上。

下面的图表说明了存储在线程堆栈上的调用堆栈和局部变量,以及存储在堆上的对象:

java-memory-model-2.png

Java Memory Model显示局部变量和对象在内存中的存储位置。
局部变量可以是基本类型,在这种情况下,它完全保留在线程堆栈上。

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

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

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

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

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

这是一个说明上述要点的图表:

java-memory-model-3.png

Java内存模型显示从局部变量到对象以及从对象到其他对象的引用。
两个线程有一组局部变量。其中一个局部变量(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 member1 = 67890;
}

如果两个线程正在执行该run()方法,则前面显示的图表将是结果。该run()方法调用methodOne()和methodOne()调用methodTwo()

methodOne()声明一个基本类型的局部变量(localVariable1 类型int)和一个引用类型的局部变量,它是一个对象引用(localVariable2)。

每个线程执行methodOne()会创建自己的副本,localVariable1并 localVariable2在各自的线程堆栈。该localVariable1变量将彼此完全分开,只是存储在每个线程的线程堆栈。一个线程无法看到另一个线程对其副本的更改localVariable1。

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

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

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

另请注意MySharedObject类型类中的两个成员变量,long它们是基本类型。由于这些变量是成员变量,因此它们仍与对象一起存储在堆上。只有局部变量存储在线程堆栈中。

硬件内存架构

现代硬件内存架构与内部Java内存模型略有不同。了解硬件内存架构也很重要,以了解Java内存模型如何与其一起工作。本节介绍了常见的硬件内存架构,后面的部分将介绍Java内存模型如何与其配合使用。

这是现代计算机硬件架构的简化图:

java-memory-model-4.png

现代硬件内存架构。
现代计算机通常有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寄存器中。这在图中说明:

java-memory-model-5.png

CPU内部寄存器,CPU缓存和主存储器之间的线程堆栈和堆的划分。
当对象和变量可以存储在计算机的各种不同存储区域中时,可能会出现某些问题。两个主要问题是:

线程更新(写入)对共享变量的可见性。
阅读,检查和编写共享变量时的竞争条件。
以下各节将解释这两个问题。

共享对象的可见性

如果两个或多个线程共享一个对象,而没有正确使用volatile声明或同步,则一个线程对共享对象的更新可能对其他线程不可见。

想象一下,共享对象最初存储在主存储器中。然后,在CPU上运行的线程将共享对象读入其CPU缓存中。它在那里对共享对象进行了更改。只要CPU缓存尚未刷新回主内存,共享对象的更改版本对于在其他CPU上运行的线程是不可见的。这样,每个线程最终都可能拥有自己的共享对象副本,每个副本都位于不同的CPU缓存中。

下图说明了草绘的情况。在左CPU上运行的一个线程将共享对象复制到其CPU缓存中,并将其count变量更改为2.此更改对于在正确CPU上运行的其他线程不可见,因为count尚未将更新刷新回主内存。

java-memory-model-6.png

Java内存模型中的可见性问题。
要解决此问题,您可以使用Java的volatile关键字。该volatile 关键字可以确保一个给定的变量从主内存中直接读取和更新的时候总是写回主内存。

竞争条件

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

想象一下,如果线程A将count共享对象的变量读入其CPU缓存中。想象一下,线程B也做同样的事情,但是进入不同的CPU缓存。现在线程A添加一个count,线程B执行相同的操作。现在var1已经增加了两次。

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

但是,两个增量同时执行而没有适当的同步。无论将其更新版本写count回主存储器的线程A和B中的哪一个,更新的值将仅比原始值高1,尽管有两个增量。

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

java-memory-model-7.png

Java内存模型中的竞争条件问题。
要解决此问题,您可以使用Java synchronized块。同步块保证在任何给定时间只有一个线程可以进入代码的给定关键部分。同步块还保证在同步块内访问的所有变量都将从主内存中读入,当线程退出synchronized块时,所有更新的变量将再次刷新回主内存,无论变量是否声明为volatile

你可能感兴趣的:(Java高并发之内存模型)