深入理解Java内存模型

Java内存模型(Java Memory Model)

Java内存模型是根据英文Java Memory Model(JMM)翻译过来的。其实JMM并不像JVM内存结构一样是真实存在的。他只是一个抽象的概念。JSR-133: Java Memory Model and Thread Specification中描述了,JMM是和多线程相关的,他描述了一组规则或规范,这个规范定义了一个线程对共享变量的写入时对另一个线程是可见的。

Java内存模型规定了Java虚拟机如何与计算机内存(RAM)一起工作。Java虚拟机是整个计算机的模型,因此这个模型自然包括内存模型—即Java内存模型。如果你想要设计行为正确的并发程序,理解Java内存模型是非常重要的。Java内存模型指定不同的线程如何以及何时可以看到其他线程写入共享变量的值,以及在必要时如何同步对共享变量的访问。

简单总结下,Java的多线程之间是通过共享内存进行通信的,而由于采用共享内存进行通信,在通信过程中会存在一系列如可见性、原子性、顺序性等问题,而JMM就是围绕着多线程通信以及与其相关的一系列特性而建立的模型。JMM定义了一些语法集,这些语法集映射到Java语言中就是volatile、synchronized等关键字。

What is JMM?

JVM内部使用的Java内存模型在线程堆栈和堆之间分配内存。下图从逻辑角度说明了Java内存模型:
深入理解Java内存模型_第1张图片

在Java虚拟机中运行的每个线程都有自己的线程堆栈。线程堆栈包含关于线程调用了哪些方法以达到当前执行点的信息。我将把它称为“调用堆栈(call stack)”。当线程执行其代码时,调用堆栈发生变化。

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

所有基本类型的本地变量(boolean、byte、short、char、int、long、float、double)都完全存储在线程堆栈中,因此其他线程无法看到它们。一个线程可以将基本数据类型的变量的副本传递给另一个线程,但是它不能共享局部变量本身。

堆包含在Java应用程序中创建的所有对象,而不管创建对象的线程是什么。这包括基本数据类型的对象类型(如Byte、Integer、Long等)。无论对象是被创建并分配给局部变量,还是作为另一个对象的成员变量创建,这些对象都都存储在堆中。

下面这个图,说明调用堆栈和本地变量存储在线程堆栈,和对象存储在堆:
深入理解Java内存模型_第2张图片

局部变量如果是原始类型,在这种情况下,它会保存在线程堆栈上。局部变量也可以是对象的引用。在这种情况下,引用(局部变量)存储在线程堆栈中,而对象本身则存储在堆中。

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

对象的成员变量与对象本身一起存储在堆中。当成员变量是基本数据类型时,以及当它是对对象的引用时,都是这样。静态类变量也与类定义一起存储在堆中。

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

下面这个图来说明以上几点:

深入理解Java内存模型_第3张图片

两个线程有一组局部变量。一个局部变量(Local variable 2)指向堆上的一个共享对象(Object 3),两个线程对同一个对象有不同的引用。它们的引用是本地变量,因此存储在每个线程的线程堆栈中(在每个线程上)。不过,这两个不同的引用指向堆上的同一个对象。

注意共享对象(Object 3)如何引用对象2和对象4作为成员变量(由对象3到对象2和对象4的箭头表示)。通过对象3中的成员变量引用,两个线程可以访问对象2和对象4。

该图还显示了一个指向堆上两个不同对象的局部变量。在这种情况下,引用指向两个不同的对象(对象1和对象5),而不是同一个对象。理论上,如果两个线程都引用了对象1和对象5,那么两个线程都可以访问对象1和对象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()的线程将在各自的线程堆栈上创建自己的localVariable1和localVariable2副本。localVariable1变量将彼此完全分离,只存在于每个线程的线程堆栈中。一个线程无法看到另一个线程对其localVariable1副本所做的更改。

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

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

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

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

硬件内存架构(Hardware Memory Architecture)

现代硬件内存体系结构与内部Java内存模型有些不同。理解硬件内存体系结构也很重要,要理解Java内存模型是如何使用它的。本节描述常见的硬件内存体系结构,后面的部分将描述Java内存模型如何使用它。

下面是一个现代计算机硬件架构的简化图:
深入理解Java内存模型_第4张图片

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

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

现在大多数cpu都有某种大小的高速缓存(cache memory)。CPU可以比主存更快地访问它的高速缓存内存,但通常没有访问内部寄存器的速度快。因此,CPU高速缓冲存储器的速度介于内部寄存器和主存之间。一些cpu可能有多个缓存层(L1和L2),但是了解Java内存模型如何与内存交互并不重要。重要的是要知道cpu可以有某种类型的缓存内存层。

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

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

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

怎么样将Java内存模型和硬件内存体系结构联系起来?

就像我们前面说的一样,Java内存模型和硬件内存体系结构是不同的。硬件内存体系结构不区分线程堆栈和堆。在硬件上,线程堆栈和堆都位于主内存中。线程堆栈和堆的一部分有时可能出现在CPU缓存和内部CPU寄存器中。如下图所示:

深入理解Java内存模型_第5张图片

当对象和变量可以存储在计算机中不同的内存区域时,可能会出现某些问题。两个主要问题是:

  • 线程更新(写)到共享变量的可见性
  • 读取、检查和写入共享变量时的竞争条件

共享对象的可见性

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

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

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

深入理解Java内存模型_第6张图片

要解决这个问题,可以使用Java的volatile关键字。volatile关键字可以确保直接从主存读取给定的变量,并在更新时始终将其写回主存。

竞争条件

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

想象一下,如果线程A将一个共享对象的变量计数读入到它的CPU缓存中。再想象一下,线程B执行相同的操作,但是进入不同的CPU缓存。现在线程A将1添加到count中,线程B也这样做。现在count增加了两次,在每个CPU缓存中一次。如果这些增量是按顺序执行的,则变量计数将增加两次,并将原始值+2写回主内存。

但是,这两个增量是同时执行的,没有适当的同步。不管哪个线程A和B将更新后的count写回主内存,更新后的值只比原始值高1,尽管有两个增量。

该图演示了上述竞态条件下出现的问题:
深入理解Java内存模型_第7张图片

要解决这个问题,可以使用synchronized块。同步块保证在任何给定时间只有一个线程可以进入给定的代码临界段。Synchronized块还保证在Synchronized块中访问的所有变量都将从主内存中读入,当线程退出Synchronized块时,所有更新的变量都将被再次刷新回主内存,而不管变量是否声明为volatile。可以看出synchronized功能很强大,但是synchronized是比较影响性能的,虽然编译器提供了很多锁优化技术,但是也不建议过度使用。

你可能感兴趣的:(深入理解Java内存模型)