跳到主要内容

11、Java并发编程:Java内存模型(内存模型,硬件内存架构,共享对象的可见性,竞态条件)

Java内存模型规定了Java虚拟机使用计算机内存(RAM)的方式。Java虚拟机是整个计算机的一种模型,所以这个模型自然包含一个内存模型,也就是Java内存模型。

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

原始的Java内存模型有很多不足,所以在Java 1.5中Java内存模型得到了完善。而Java8仍沿用了此版本的Java内存模型。

Java内部内存模型

JVM内部使用的java内存模型将内存划分为线程栈和堆。此图从逻辑角度说明了Java内存模型:
 
Java虚拟机中运行的每个线程都有自己的线程栈。线程栈的信息包含了线程调用了哪些方法以到达当前执行点。我将此称为“调用栈”。调用栈随着线程执行其代码而改变。

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

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

堆包含了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类也包含两个成员变量。成员变量本身与对象一起存储在堆中。这两个成员变量指向另外两个整数对象。这些整数对象对应于上图中的对象2和对象4。

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

还要注意类MySharedObject中的两个long类型的成员变量,long是一个基本类型。因为这些变量是成员变量,所以它们仍然与对象一起存储在堆中。只有局部变量存储在线程栈中。

硬件内存架构

现代的硬件内存架构与Java内部内存模型有些不同。要想理解Java内存模型是如何与硬件一起工作的,那么理解硬件内存架构也很重要。本节描述了常见的硬件内存架构,下一节将描述Java内存模型如何与之一起工作。

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

现代计算机通常有两个或更多的CPU。其中一些CPU可能也有多个内核。关键是,在一台拥有两个或更多CPU的现代计算机上,有可能同时运行多个线程。每个CPU都能在任何特定的时间运行一个线程。这意味着,如果Java应用程序是多线程的,那么每个CPU可能同时地(并发地)在Java应用程序中运行一个线程。

每个CPU包含一组寄存器,这些寄存器本质上位于CPU内存中。CPU在这些寄存器上执行操作比在主存中的变量上执行操作要快得多。这是因为CPU访问这些寄存器的速度比访问主存的速度快得多。

每个CPU还可以具有CPU高速缓冲(cache)存储器层。事实上,大多数现代CPU都有一定大小的高速缓存层。CPU访问高速缓存比主存快得多,但通常不如内部寄存器速度快。因此,CPU高速缓存的速度介于内部寄存器和主内存之间。有些CPU可能有多个缓存层(级别1和级别2),但了解Java内存模型如何与内存交互并不重要。重要的是要知道CPU可以有某种类型的缓存层。

计算机还包含一个主存储器区(RAM)。所有CPU都可以访问主存。主内存区域通常比CPU的高速缓存大得多。

通常,当CPU需要访问内存时,它会将一部分内存读入CPU缓存。它甚至可以将一部分缓存读入其内部寄存器,然后对其执行操作。当CPU需要将结果写回主存时,它会将值从其内部寄存器刷新到高速缓冲存储器,并在某个时刻将值刷新回主存。

当CPU需要在缓存中存储其他内容时,存储在缓存中的值通常会被刷新回主内存。CPU缓存可以一次将数据写入其部分内存,并一次刷新其部分内存。它不必每次更新时都读/写完整的缓存。通常,缓存在称为“缓存线”的较小内存块中更新。一条或多条高速缓存线可能被读入高速缓存,一条或多条高速缓存线可能被再次刷新回主内存。

跨越Java内存模型和硬件内存架构之间的鸿沟

如前所述,Java内存模型和硬件内存架构是不同的。硬件内存架构不会区分线程栈和堆。在硬件上,线程栈和堆都位于主内存中。部分线程堆栈和堆有时可能存在于CPU缓存和CPU内部寄存器中。如图所示:
 

当对象和变量可以存储在计算机的多个不同存储区域时,可能会出现某些问题。两个主要问题是:
线程更新(写入)到共享变量的可见性。
读取、检查和写入共享变量时的竞态条件。
在下面的章节中将解释这两个问题。

共享对象的可见性

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

假设共享对象最初存储在主内存中。然后,在CPU 1上运行的线程将共享对象读入其CPU缓存。它在缓存上对共享对象进行了更改。只要CPU缓存没有被刷新回主内存,其他CPU上运行的线程就看不到共享对象的更改版本。这样,每个线程最终都可能拥有自己的共享对象副本,每个副本都位于不同的CPU缓存中。

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

要解决这个问题,可以使用Java的volatile关键字。volatile关键字可以确保直接从内存读取修饰的变量,并且在更新时总是写回内存。

竞态条件

如果两个或多个线程共享一个对象,并且多个线程更新该共享对象中的变量,则可能出现竞态。

假设线程A将共享对象的变量count读入其CPU缓存。再假设,线程B也做了同样的事情,但是count读到了不同的CPU缓存。现在线程A给count加1,线程B也这样做。现在var1增加了两次,每个CPU缓存一次。(译者注:原文即为var1,但实际应指count

如果这些增量操作是按顺序执行的,那么变量count将增加两次,并将原始值加2写回内存。

然而,这两个增量操作是在没有适当同步的情况下并发执行的。不管是线程A还是线程B将更新后的count版本写回内存,更新后的值仅比原始值高1,尽管增加了2次。

下图说明了上述出现的竞态条件问题:
 

要解决这个问题,可以使用Java同步块。同步块确保了在任何时间只有一个线程可以进入代码的临界区。同步块还确保了同步块中访问的所有变量都将从主内存中读取,当线程退出同步块时,所有更新的变量都将再次刷新回主内存,无论变量是否声明为volatile。