Java并发编程(Java Concurrency)(11)- Java 内存模型

原文链接:http://tutorials.jenkov.com/java-concurrency/java-memory-model.html

摘要:这是翻译自一个大概30个小节的关于Java并发编程的入门级教程,原作者Jakob Jenkov,译者Zhenning Lang,转载请注明出处,thanks and have a good time here~~~(希望自己不要留坑)

Java 内存模型指明了 Java 虚拟机在运行时,与计算机内存(RAM)的联系。由于 Java 虚拟机是基于一整套虚拟计算机模型的,所以这套虚拟的计算机模型自然而然的也包含了内存模型 —— 即这里所说的Java 内存模型。

如果想要设计一个运行正常的并发程序,那么理解 Java 内存模型是直观重要的。Java 内存模型规定了线程“是如何”以及“在何时”可以看到其他线程对共享变量的操作,并且在需要时,该如何同步对于共享变量的使用。

最初始的 Java 内存模型并不是很完美,所以从 Java 1.5 开始修改了 Java 内存模型,并且这一修改一直沿用到 Java 8。

1 Java 内部的内存模型

Java 虚拟机内部的内存模型将内存划分为“堆”(heap)和“属于线程的栈”(thread stacks)。下图从逻辑的角度展示了 Java 内存模型对于内存的划分:

《Java并发编程(Java Concurrency)(11)- Java 内存模型》

1.1 线程栈(Thread stack)

每个在 Java 虚拟机内部运行的线程具有其独特的线程栈,其中一方面包含了该线程运行到现在位置之前所调用的方法的相关信息(译者的理解:类似于 C 语言中的栈,保存了调用当前方法之前所调用的函数信息,用于当前方法运行结束后恢复原来的状态,从而继续执行)。我将这些信息称为“调用栈”(call stack),这是因为当线程执行其代码的过程中,栈的内容将发生变化。

另一方面,线程栈中还保存了该线程所执行的全部方法的所有局部变量的值(这里的全部方法指的是 call stack 中所保存的全部方法)。一个线程只能获取到其本身的线程栈中存储的变量值,所以一个线程所创建的局部变量对于其他线程来说是不可见的。即便是两个线程在执行同一段代码,这两个线程也会在其各自的线程栈中创建这段代码所定义的不同的局部变量。

所有基础类型的局部变量(boolean, byte, short, char, int, long, float, double)被完全限定在了线程栈中,并且对于其他线程是不可见的。一个线程可能会通过传递一个基础类型变量的副本到另一个线程,但是这绝不意味着两个线程共享一个局部变量。

1.2 堆(Heap)

堆中存储了 Java 程序所创建的所有对象,无论这个对象是被哪个线程创建的。“所有对象”当然也包含了基础类型的对象版本(例如 Byte, Integer, Long 等等)。无论一个对象是被当做函数的局部变量创建和使用,还是被当做成员变量被创建和使用,对象本身都被存储在堆中。

下图展示了线程栈中所存储的 call stack 和局部变量,以及堆中所存储的对象:

《Java并发编程(Java Concurrency)(11)- Java 内存模型》

一个局部变量(local variable)可能是一个基础类型的变量,这种情况下变量被完完全全限制在线程栈的内部;一个局部变量也可能是一个对象的引用(a reference to an object,可以理解为指针),这种情况下引用虽然保存在线程栈内,但对象本身却存储在堆中

虽然对象存储在堆中,但其所包含方法(函数)内的局部变量还是将保存在线程栈内(译者理解:也就是说,堆中所保存的实际是一个对象的成员变量和对象本身的结构,例如函数入口信息等)。

一个对象的成员变量和对象本身一起保存在堆内,无论成员变量是初始类型还是对其他对象的引用。

类的静态成员与类的定义一起也保存在堆内。

堆中的对象可以被所有具有该对象引用的线程访问。如果一个线程具有一个对象的读写权限,那么它也有该对象成员变量的读写权限(译者理解:这里的读写权限并非说一定可以读写,因为私有变量就不行,这里的意思是说有这个可能性)。如果两个线程同时调用了一个处理同一个对象的方法,那么这两个线程都将具有这个对象成员变量的读写权限,但是每个线程都将创建其自己的本地变量(引用)的副本。

上面的观点可以由下图展示:

《Java并发编程(Java Concurrency)(11)- Java 内存模型》

上图中的两个线程都具有一系列的本地变量。其中本地变量 2 (Local Variable 2)指向了一个堆内的共享对象(Object 3)。两个线程格子拥有对于 Object 3 的引用。由于两个线程本身的引用类型都是其局部变量,因此各自存储在自己的线程栈内,尽管他们指向了同一个对象。

请注意共享对象(Object 3)的成员变量包含了 Object 2 和 Object 4 的引用。通过引用型的成员变量,两个线程都具有 Object 2 和 Object 4 的读写权限。

图中还展示了一个局部变量是如何指向堆内的两个不同的对象的。例子中两个线程的 Local variable 1 分别指向了 Object 1 和 Object 5。理论上来说如果两个线程都有 Object 1 和 Object 5 的引用,那么两个线程都可以读写 Object 1 和 Object 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()方法的线程都将在各自的线程栈内创建其自己的 localVariable1 和 localVariable2。其中,localVariable1 将完全依照线程被分割开来;然而属于不同线程的 localVariable2 却指向了相同的对象(存储在堆内),这是因为代码中 localVariable2 指向了一个由静态变量所引用的对象,而静态变量只存在于堆内的唯一的位置。因此,不同线呈的 localVariable2 都会指向静态变量 sharedInstance 所指向的 MySharedObject 的实例(该实例也存储在堆内部),该对象对应了图中的 Object 3。

此外,MySharedObject 类也包含了两个“引用类型的”成员变量和两个基础类型的成员变量,这些变量都存储在堆内(注意:只有方法内的局部变量才存储在线程栈内,对象的成员变量都和对象本身一起存储在堆内)。其中两个引用类型变量(object2 和 object4)指向了两个 Integer 类型的对象,这两个 Integer 类型的对象对应了图里的 Object 2 和 Object 4。

另一方面,methodTwo() 内定义了局部变量 localVariable1,localVariable1 是引用类型并指向了一个 Integer 对象。methodTwo() 中 localVariable1 被定义为指向一个新创建的 Integer 实例。对于不同的线程来说,每个线程都会创建其单独的 localVariable1 变量,并且都会在堆内创建独立的、新的 Integer 对象。例子中不同线程在 methodTwo() 内创建的 Integer 对象对应了图中的 Object 1 和 Object 5。

2 硬件存储架构

现代的硬件存储架构与 Java 内存模型是有一定差异的。理解硬件存储架构,以及其与 Java 内存模型的关系都非常重要。这一节将介绍计算机硬件存储架构的一些常识,下一节将介绍二者的关系部分。

下图是现代计算机硬件架构的简略示意图:

《Java并发编程(Java Concurrency)(11)- Java 内存模型》

一个现代的计算机通常具有两个或多个 CPU,每个 CPU 也可能包含多核。重点是只要多于一个 CPU,就有可能发生多线程“同时”运行的情况,因为每个 CPU 都有能力在指定的时间运行一个线程。

每个 CPU 又包含了一系列的必不可少的(几个)寄存器(Register)。与内存相比CPU 可以以超高的速度操作这些寄存器。

每个 CPU 也可能包含 CPU 缓存(Cache)层,实际上现代的计算机都包含一定容量的 CPU 缓存。CPU 从缓存中取数据要比从内存中取数据快很多,但一般情况下要比从寄存器中取数据慢。有些计算机也会有分层缓存,但这对理解 Java 内存模型来说并很重要,重要的是知道存在缓存。

计算机还拥有主内存区(RAM,Main Memory)。所有的 CPU 都可以读写主内存。内存的存储空间要远远大于缓存区。

一般情况下,当 CPU 想要读写内存数据时,它会先将内存中的一部分数据读入缓存中,再将缓存中的一部分数据读入寄存器中,然后再进行接下来的运算。当 CPU 需要将数据写回内存时,它首先将数据从内部寄存器冲入缓存,再在特定的时刻将数据从缓存写入内存。

事实上,所谓“特定的时刻”指的是 CPU 所要的数据在缓存中没有,需要重新从内存中读取一些其他数据的时候。此时数据就会从缓存写入内存中。此外,CPU 缓存的读写也不是将缓存内全部的数据一下子都写入内存,或者一下子从内存中取出填满缓存的数据,而是利用了称作“缓存流水线”(cache line)的小块内存分割技术,简而言之就是缓存和内存的交互每次都是以小块的内存为单位进行的。

3 Java 内存模型与硬件内存架构的联系

正如前文所述,Java 内存模型与硬件的存储架构是不同的。硬件的存储架构才不区分什么堆和线程栈,对于硬件来说,这两者都存在内存里。当然,有时堆和线程栈的数据可能出现在缓存或寄存器中,如下图所示:

《Java并发编程(Java Concurrency)(11)- Java 内存模型》

对象和变量可能会被存储在计算机的不同存储区域(内存、缓存和寄存器)内的实时导致了一些问题的发生。其中两个主要的问题是:

  • 线程对于共享变量的写操作的可见性
  • 读写共享变量时发生的竞争

下面的章节将详细介绍这两部分内容。

3.1 共享对象的可见性

如果两个线程在操作共享对象,并且没有利用 volatile 关键字或者同步机制进行合适的处理,那么一个线程对共享变量的写操作可能无法被其他线程观测到。

假设共享对象一开始存在内存中。CPU 1 中的线程将对象从内存中读入缓存,并且修改了对象的值。由于 CPU 缓存不是实时的将变化写回内存,所以这次修改无法被其他 CPU 内的线程观察到。这种情况的结果是,每个线程都有自己的共享对象的一份副本,而这份副本存在线程所在的 CPU 的缓存中。

下图展示了上面描述的情况。左侧 CPU 内的线程将共享对象 obj 读入了其 CPU 缓存中并将其成员变量 obj.count 的值更新成 2。但这一操作右侧的 CPU 是无法获知的,因为更新后的数值还没有被写回主内存。

《Java并发编程(Java Concurrency)(11)- Java 内存模型》

为了解决这一问题,可以使用 Java 提供的 volatile(易变的) 关键字。该关键字保证了对于其修饰的变量的读/写都是直接从/到内存中的。

3.2 竞争

如果两个或多个线程共享一个对象,并且多于一个线程修改共享对象内的成员变量值,竞争就可能会发生。

假设一个 CPU 内的线程 A 将一个共享对象的成员变量 count 读入缓存,同时另一个 CPU 内的线程 B 也做了同样的操作。如果线程 A 和 B 都将 count 加 1,即 count 在两个线程内被加了两次。如果这两个操作是按照顺序一先一后被执行的,那么当然存 count 被最终写入内存时是被加 2 的可能。但是对于没有经过合理同步(synchronization)的并发情况,最终的结果很可能是 count 被写入内存时只被加了 1,这一情况如下图所示:

《Java并发编程(Java Concurrency)(11)- Java 内存模型》

为了解决上述问题可以使用 Java 提供的同步(synchronized)代码块机制。一个同步代码块保证了在同一时间只可能有一个线程去执行临界区内的代码。同步机制还保证了所有在同步代码段内使用的变量都是直接从内存中读取的,并且执行完毕后所有被更新的变量都会被写回到内存中,无论变量是否被 volatile 关键字所修饰。

    原文作者:java内存模型
    原文地址: https://blog.csdn.net/jacklang/article/details/53227439
    本文转自网络文章,转载此文章仅为分享知识,如有侵权,请联系博主进行删除。
点赞