[译]Java Memory Model(Java内存模型)JMM与硬件内存模型

[译]Java Memory Model(Java内存模型)JMM与硬件内存模型

翻译自:Java Memory Model

  • Java内存模型
  • 硬件内存结构
  • JMM以及硬件内存结构的对应关系
    • 共享对象的可见性
    • 线程竞争情况

Java内存模型(后面简称JMM)定义了Java虚拟机如何通过计算机内存(RAM)进行工作的。JVM是整个计算机的模型,所以JVM中存在着内存模型(即JMM) – AKA(不知道怎么翻译)

如果你想要设计正确的并发程序,那理解JMM就很重要了。JMM定义了不同的线程什么时候以及怎样才能看到其他线程写入的共享变量值,并且在必要的情况下,如何去同步(synchronize)的访问这些共享的变量。

原来的JMM是不足的,所以在java 1.5的时候JMM被重新设计,并且这个模型现在依然在Java8中运行着。

JMM内存模型

JVM内部运行的JMM将内存分割为线程栈(thread stacks)堆(heap),下面这张图简单的表现出了JMM的基本模型:

在JMM中,每个线程拥有自己的线程栈。栈中存储着调用方法的时候,运行到方法的哪一句代码的信息。我把这个称为调用栈(可以理解为指针)。线程在执行方法的时候总是跳转到指定的代码,那指针也在不断的改变。

在线程栈中,也包含了每个方法执行的时候所需要的所有局部变量。一个线程只能访问他自己的线程栈。局部变量除了创建他的线程可以看到以外,其他线程均不可见。甚至,当两个线程调用同一个方法的时候,两个线程同样会在他们本地的线程栈中创建他们各自的局部变量。因此,每个线程都有属于他们自己的局部变量表。

所有的基本数据类型(boolean, byte, short, char, int, long, float, double)是存储于线程栈中的,并且各自的线程只能负责自己的一份,并不能看到其他线程的变量。一个线程需要传递值给另外一个线程的时候,基本变量只能通过拷贝值的形式进行传递,并不能进行不同线程之间的数据共享。

堆中则存储着所有由Java程序创建的对象,包括不同线程创建的。对象包含了基本属性包装类(Byte, Integer, Long等等),不论是在线程栈中创建的局部变量或者对象应用于另外一个对象的属性,对象都是存储在堆内存中的

下面这张图是对以上说的问题的展示:

局部变量是基本数据类型的时候,整个变量存储于线程栈中。

而当局部变量是一个对象的时候,对象的引用存储于线程栈,而引用只想存储于堆中对象的地址。

一个对象中的方法运行的时候可能需要一些局部变量,即使该对象位于堆内存中,方法执行的局部变量依然会位于线程栈中。

对象的成员变量同样存储于堆中,无论这个成员变量是一个基本数据类型或者另外一个对象。

静态成员变量则在堆中与Class存储在一起。

存储在堆内存中的对象,可以被拥有该对象的所有线程进行访问。当一个线程能够访问这个对象的时候,该线程同样可以访问对象的成员属性。如果两个线程同时调用该对象的同一个方法的时候,两个线程可以同事访问对象中的成员属性,但是局部变量则在每个线程中各有一份。

下面这张图是对以上的说明:

两个线程各自拥有自己的局部变量。其中一个变量(Local Variable 2)指向堆中一个共享的对象(Object 3)。两个线程中个字节拥有不同的引用但指向同一个对象。这两个引用是他们各自的局部变量并且存储于他们各自的线程栈中。即使这样这两个不同的引用还是指向了同一个对象。

这里注意到Object 3内部有指向Object 2以及Object 4的引用来作为成员属性。通过这两个引用,能够访问到Object 3的线程也能访问到Object 2Object 4

这张图中也显示了同一个方法(method Two()),在不同的线程栈中指向不同的对象(Object 1 and Object 5)的情况。虽然在所有线程栈中都可以访问Object 1和Object 5,但是他们没有这两个对象的引用。

实现上面内存模型的代码:

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()方法都在他们各自的线程栈中创建localVariable1localVariable2localVariable1只会在他们各自的线程栈中存活,并且其他线程不能看到该线程的localVariable1

每个线程执行methodOne()方法也会创建各自的localVariable2变量的引用拷贝。并且,两个拷贝都会指向堆中的同一个对象。代码中设置localVariable2执行对象中的一个静态变量。这个静态变量只有一份并且存储在堆中。因此,两个localVariable2的引用同时指向同一个存储在静态变量中的实例对象MySharedObjectMySharedObject存储在堆中,她就是图中堆内存中的Object 3

MySharedObject包含着两个成员变量的引用。成员变量本身和对象一起存储在堆内存中,两个成员变量指向两个不同的Integer实例对象中。这两个Integer对象就是对应图中的Object 2Object 4

methodTwo()方法中创建了一个名为localVariable1的局部变量。这个局部变量是一个指向Integer对象的引用。这个方法让localVariable1指向了一个新的Integer对象。在methodTwo()方法中,每个线程都会在其线程栈中保存localVariable1变量(即一个引用)。两个线程运行的时候,创建的两个不同的Integer对象都将会被存储在堆内存中。这methodTwo()方法中创建的Integer对象对应图中的Object 1Object 5

MySharedObject对象中还有两个基本数据类型的成员变量(long类型),他们将和对象实例一起被存放与堆内存中,只有局部变量才被存储于线程栈中。

硬件内存结构

现代的硬件内存结构和JMM存在着一些差异。了解硬件内存结构有助于了解JMM,这一章节描述了通用的硬件内存运作,下一章节将描述JMM怎么配合硬件去运行。

这里有一张简单的计算机硬件结构:

现在的计算机,通常提供2个(或以上)的CPU,甚至一个CPU还是多个核心的。这些多核的电脑,就能够处理多个线程同时运行。每个CPU在特定的时间内都能够运行一个线程。这将意味着如果编写的Java程序是多线程的话,那么每个CPU将会运行一个线程。

每个CPU存在一系列的寄存器。CPU从这些寄存器上读写数据要比在主内存上读写数据快得多。

每个CPU也拥有自己的一定容量的缓存内存。而CPU从这些内存上读写数据的熟读又要比从主内存中读写要快得多,但还是比寄存器慢。所以CPU缓存是介于寄存器以及内存之间的。一些CPU还拥有多级缓存(一级缓存、二级缓存),但这跟JMM的抽象没太大的关系。我们只需要指定CPU拥有这一些内存就可以了。

一个计算机还包含了主内存(RAM,后面称主存)。所有的CPU都能够访问主存中的数据。主存的大小也要比CPU中的缓存大得多。

所以实际上,为了提升访问速度,当CPU需要从主存中读取数据的时候,会先将一部分数据读取到CPU缓存中去,然后再从缓存中读取到CPU的寄存器中。然后再进行一系列的操作。当CPU需要将数据刷新到主存时,会先将数据从寄存器刷新到缓存,再讲缓存中的数据刷新到主存里面。

在CPU缓存中,CPU在缓存中写完数据,然后再将数据写到主存。每次读写都不会整个缓存进行刷新。缓存中每次刷新都只会刷新最小的单位称为“缓存行”。每次读写数据,都是以“缓存行”作为单位进行读写。

JMM以及硬件内存结构的对应关系

正如刚刚提到的,JMM模型与现实中计算机的模型有一些不同。硬件中的内存并不会将分开来。在硬件中,堆和栈中的数据都存在于主存中。栈和堆中的数据,都将可能被读取到CPU缓存以及CPU寄存器当中。下图是两个模型的对应关系:

很多时候,对象和变量存储于这几个不同的区域中时,问题就会变得很复杂。两个主要的问题就是:
– 线程更新访问共享的变量
– 当线程读、写、确认数据的时候的竞争情况

共享对象的可见性

如果两(多)个线程同时访问堆中的一个共享对象,若没有使用volatile关键字进行修饰变量或者对修改变量进行同步,一个线程去更新对象中的值,另外的线程将不能同时感知。

试想一下,一个共享对象在主存中。一个CPU的上线程将对象读取到他们各自的CPU缓存中,并且做出一些修改。但是这个CPU并不会立即把缓存中的修改刷新到主存中,这时候其他CPU读取到的主存中的数据依然是旧的版本,那么这时候多线程将会造成数据的错误。

接下来的图片将简单描述这个情况。左边的CPU将共享对象读取到缓存中并且修改对象中的数据count = 2,这时候数据还没刷新到主存当中,那右边的CPU依然只能读到旧的值(也就是1)。

解决这个问题的方法就是使用Java中的volatile关键字。这个关键字能够让每个线程直接从主存中读取数据,并且实时的将修改后的数据写回主存。

线程竞争情况

如果多个线程对同一个共享对象进行写操作,那么将会出现线程安全问题。

想象一下如果线程A从共享对象中读取count变量到CPU缓存中。另外线程B也做同样的操作。当线程A和B同时修改count变量时。那这时候count变量在两个不同的CPU中都进行了操作。

如果这两个操作是顺序执行的,那么这个值在主存中的情况应该是加上2的情况。但是因为这个操作并不是同步的,所以其实在主存中的情况应该只是加上1,并不是我们预想中的加上2的情况。

操作如图所示:

解决这个问题的方法是使用Java的synchronized代码块。代码块中的代码保证数据只能被一个线程读写(无论有没有被volatile关键字修饰),并且在需要的时候读取到CPU缓存,而线程运行结束时将数据写回主存。

Weidan

评论已关闭。