Java内存模型

51次阅读

共计 4897 个字符,预计需要花费 13 分钟才能阅读完成。

Java 内存模型指定了 JVM 如何与计算机内存协同工作。JVM 是整个计算机的模型因此这个模型包含了内存模型,也就是 Java 内存模型。

如果你像要设计正确行为的并发程序,那么了解 Java 内存模型是非常重要的。Java 内存模型指定了如何以及何时不同的线程能够看到其他线程写入共享变量的值,以及如何在需要的时候如何同步访问共享变量。

最初的 Java 内存模型是不足的,因此 Java 内存模型在 Java1.5 做了改进,这个版本的 Java 内存模型在 Java8 中仍然被使用。

内部的 Java 内存模型

Java 内存模型在 JVM 内部使用,将内存分为了线程栈和堆。下面的图从逻辑角度给出了 Java 内存模型:

每个运行在 JVM 内部的线程都有自己的线程栈。线程栈包含关于线程调用的哪个方法到达了当前执行点的信息。我对此引用为“调用栈”。随着线程执行代码,调用栈会发生变化。

调用栈还包含每个被执行的方法的所有本地变量(所有调用栈上的方法)。一个线程只能够访问它自己的线程栈。由一个线程创建的本地变量对其他线程不可见。即使两个线程执行同一段代码,这两个线程也会在他们各自的线程栈中创建这段代码涉及的本地变量。因此,每个线程都有自己版本的本地变量。

所有内建类型的本地变量(boolean,byte,short,char,int,long,float,double)被存储在线程栈并且对其他线程不可见。一个线程可能会传递一个内建类型变量的副本给其他线程,但是它不会贡献它自己的内建本地变量。

堆包含了你的 Java 程序中创建的所有对象,不管是哪个线程创建的。这包含了对象版本的内建类型(如 Byte,Integer,Long 等等)。如果一个对象呗创建并被复制给一个本地变量,或者被创建为一个成员变量都是没关系的,对象仍然存储在堆上。

下图给出了调用栈和存储在线程栈中的本地变量,以及存储在堆上的对象:

一个本地变量可能是一个内建类型,这种情况它完全存储在线程栈。

一个本地变量可能是一个对象的引用。这种情况这个引用(本地变量)存储在线程栈中,但是对象本身存储在堆上。

一个对象可能包含方法,并且这些方法可能包含本地变量。这些本地变量存储在线程栈,即使方法所属对象存储在堆上。

一个对象的成员变量和对象一起存储在堆上。对于成员变量是内建类型,或者它是对象的引用都是如此。

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

堆上的对象能够被所有拥有这个对象引用的线程访问。当一个线程访问一个对象,它也可以访问这个对象的成员变量。如果两个线程在同一个对象上同时调用它的同一个方法,这两个线程会同时又权限访问这个对象的成员变量,但是每个线程会有它自己的本地变量副本。

下面的图给出了上面所说的:

两个线程有同一组本地变量。一个本地变量(Local Variable 2)指向了堆上的一个共享对象(Object3)。每个线程都有对同一个对象的不同引用。它们的引用是本地变量并且存储在各自的线程栈上,尽管这两个不同的引用指向堆上的同一个对象。

注意共享对象(Object 3)有一个对 Object2 和 Object4 的引用作为它的成员变量,通过 Object3 中的这些成员变量引用,这两个线程可以访问 Object2 和 Object4。

图中还给出了一个本地变量指向堆上的两个不同的对象。这个例子中引用指向了两个不同对象(Object1 和 Object5),而不是同一个对象。理论上所有线程如果有指向所有有对象的引用,那么这些线程可以访问到 Object1 和 Object5。但是在图中每个线程只有一个引用指向这两个对象之一。

那么,什么样的 Java 代码能够满足上面的内存图示?请看下面的简单代码:

public class MyRunnable implements Runnable {public void run() {methodOne();
  }

  public void methodOne() {
    int localVariable1 = 45;
    
    MyShareObject localVariable2 = MyShareObject.shareInstance;
    
    // ... do more with local variables.
    
    methodTwo();}

  public void methodTwo() {Integer localVariable1 = new Integer(99);
    
    // ... do more with local variable.
  }
}
public class MyShareObject {
  
  // static variable pointing to instance of MyShareObject

  public static final MySharedObject sharedInstance = new MySharedObject();

  // member variable 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() 的线程会在各自的线程栈上创建它自己的 localVariable1 和 localVariable2 的副本。两个 localVariable1 变量完全和对方没有关系,只是活在各自的线程栈上。一个线程不能看到另一个线程它自己的 localVariable1 副本变化。

每个执行 methodOne() 的线程也会在各自的线程栈上创建它们自己的 localVariable2 副本。然而这两个不同的 localVariable2 副本是指向堆上的同一个对象。代码设置 localVariable2 指向被一个静态变量引用的对象。这里只有一个静态变量的副本并且这个副本存储在堆上。因此所有 localVariable2 的这两个副本都指向同一个被静态变量指向的 MySharedObject 实例。MySharedObject 实例存储在堆上,它对应图上的 Object3。

注意 MySharedObject 类还包含了两个成员变量。成员变量和这个对象一起存储在堆上。这两个成员变量指向了两个 Integer 对象。这些 Integer 对象对应图上 Object2 和 Object4。

注意 methodTwo() 创建了一个名为 localVariable1 的本地变量,这个本地变量是一个 Integer 对象的引用。这个方法设置 localVariable1 引用指向了一个新的 Integer 实例。localVariable1 引用会存储在执行 methodTwo() 方法的每个线程的副本中。两个被实例化的 Integer 对象会存储在堆中,但是由于每次方法执行时都创建了一个新的 Integer 对象,两个线程会执行并创建两个不同的 Integer 实例。methodTwo() 中创建的 Integer 对象对应图中的 Object1 和 Object5。

注意 MySharedObject 中的两个 long 型的成员变量是内建类型。由于这些变量的成员变量,因此它们仍然和对象一起存储在堆上。只有本地变量会存储在线程栈上。

硬件内存架构

现代硬件内存架构和内部 Java 内存模型有些区别。对于了解 Java 内存模型如何工作,了解硬件内存架构也很重要。这部分描述通用硬件内存架构,下一个部分会描述 Java 内存模型是如何工作在硬件内存之上。

这里有一个简单的计算机硬件架构模型:

现代计算机通常有 2 个或更多的 CPU。有些 CPU 还有多个核。重点是,在一个有 2 个或更多 CPU 的计算机上,有多个线程同时运行是可能的。每个 CPU 能够在任何时候运行一个线程。这意味着如果你的 Java 程序是多线程的,每个 CPU 一个线程同时并发运行在你的 Java 程序中。

每个 CPU 包含一组寄存器,本质行是 CPU 内的存储。CPU 在这些寄存器中执行操作会比在主存中快的多。这是因为 CPU 能够更快的访问这些寄存器。

每个 CPU 可能还有一个 CPU 缓存层。实际上,大部分现代 CPU 都有一个特定大小的缓存层。CPU 能比访问主存更快的访问缓存,但是一般不会比访问它的内部寄存器更快。因此,CPU 缓存是一个介于内部寄存器和主存之间的地方。有些 CPU 可能有多级缓存(Level1 和 Level2),但是这对理解 Java 内存模型如何与内存交互来说并不是很需要知道。

一个计算机也包含一个主存区域(RAM)。所有 CPU 都能访问主存。主存区域比 CPU 缓存大的多。

一般来说,当一个 CPU 需要访问主存,它会将主存的一本读取到它的 CPU 缓存。甚至它可能会读取部分缓存到它的内部寄存器并在其上操作。当 CPU 需要将结果写回到主存它会将值从内部寄存器刷到缓存,在摸个时间点将缓存中的值刷回到主存。

当 CPU 需要在缓存中存储一些其他东西时,缓存中存储的值会被刷回到主存。每次缓存更新时,CPU 不必读写整块缓存。对于缓存在较小内存块上的更新的标准说法是“cache lines”。一个或多个 cache lines 会被读到缓存,一个或多个 cache lines 会被刷回主存。

连接 Java 内存模型和硬件内存架构

上面说道,Java 内存模型和硬件内存架构不同。硬件内存架构不会分辨线程栈和堆。在硬件上,线程栈和堆都定位到主存。部分线程栈和堆可能在某些时候会占用 CPU 缓存和内部 CPU 寄存器。如下图所示:

当对象和变量能被存储在计算机的不同内存区域时,特定的问题就会发生。两个主要问题是:

  • 线程更新(写)到共享变量的可见性
  • 读写检查共享变量时发生的竞态条件

这些问题会在下面的部分解释。

共享变量的可见性

如果两个或多个线程共享一个对象,如果没有恰当使用 volatile 声明或者同步,一个线程对共享变量的更新对其他线程可能会不可见。

想象一个共享对象初始存储在主存。一个运行在 CPU1 上的线程将这个共享变量读取到它的 CPU 缓存,然后对这个共享变量做一些改变,只要 CPU 缓存没有被刷回主存,这个共享变量的变更版本对运行在其他 CPU 上的线程就是不可见的。这种方式每个线程会有这个共享变量的本地副本,每个副本位于不同的 CPU 缓存中。

下图展示了这种情况。运行在左边 CPU 的线程将共享变量拷贝到它的 CPU 缓存,并将这个对象的 count 变量变为 2. 这个变化对运行在右边 CPU 上的线程不可见,因为对 count 的更新还没有刷回主存。

为了解决这个问题,你可以使用 Kava 的 volatile 关键字。volatile 关键字能够保证一个给定的变量从主存中读取,并且当变量更新时会写回主存。

竞态条件

如果两个或多个线程共享一个对象,多余一个线程更新这个共享对象的变量,静态条件就可能发生。

想象如果线程 A 读取了一个共享对象的 count 变量到它的 CPU 缓存,线程 B 做同样的事情,但是是在一个不同的 CPU 缓存。现在线程 A 对 count 加 1,线程 B 也对 count 加 1. 现在 count 被加了两次,每次都是在不同的 CPU 缓存。

如果这些增加的操作被顺序执行,那么变量 count 会增加两次并有初始值 + 2 的值被写回主存。

但是这两次增加是在没有同步的情况下并发操作的。不管线程 A 还是线程 B 将它们对 count 的更新版本写回主存,count 只会得到初始值 +1,尽管有两次更新。

下面的图描述了静态条件:

为了解决这个问题你可以用一个 synchronized 块。一个 synchronized 块保证了同时只有一个线程能进入一个给定的关键代码区域。synchronized 块也保证了所有在 synchronized 块中访问的变量会从主存中读取,当一个线程退出 synchronized 块,所有对变量的更新会再次刷回主存,不管这个变量是否被声明为 volatile。

正文完
 0