Java 并发/多线程教程(十一)-JAVA内存模型

本系列译自jakob jenkov的Java并发多线程教程,个人觉得很有收获。由于个人水平有限,不对之处还望矫正!

        Java内存模型指定Java虚拟机如何与计算机的内存(RAM)一起工作。Java虚拟机是整个计算机的一个模型,所以这个模型自然包含了一个内存模型——也就是Java内存模型。

        如果您想要设计正确的并发程序,那么理解Java内存模型是非常重要的。Java内存模型指定了不同线程如何以及何时可以看到由其他线程写入共享变量的值,以及在必要时如何同步访问共享变量。

        原来的Java内存模型不够用,所以Java内存模型在Java 1.5中被修改了。Java内存模型的这个版本仍然在Java 8中使用。

内部JAVA内存模型

      JVM内部使用的Java内存模型划分了线程栈和堆之间的内存。这个图表从逻辑的角度演示了Java内存模型

Java 并发/多线程教程(十一)-JAVA内存模型

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

        线程栈还包含每个正在执行的方法的所有本地变量(调用栈上的所有方法)。线程只能访问它自己的线程栈。线程创建的局部变量对于所有其他线程都是不可见的,而不是创建线程的线程。即使两个线程在执行完全相同的代码,两个线程仍然会在各自的线程栈中创建该代码的本地变量。因此,每个线程都有自己的每个局部变量的版本。所有基本数据类型本地变量如(boolean、byte、short、char、int、long、float、double)都被完全存储在线程栈中,因此对其他线程来说是不可见的。一个线程可能将pritimive变量的副本传递给另一个线程,但是它不能共享原始的局部变量本身。

        堆包含在Java应用程序中创建的所有对象,而不管创建对象的线程是什么。这包括原始类型的对象版本(如字节、整数、Long等等)。如果一个对象被创建并分配给一个局部变量,或者作为另一个对象的成员变量创建,对象仍然存储在堆中,这无关紧要。

          下面是一个图表,说明了在线程堆栈上存储的调用堆栈和本地变量,以及存储在堆上的对象:

Java 并发/多线程教程(十一)-JAVA内存模型

        局部变量可能是一种基本数据类型,在这种情况下,它完全被放在线程栈中。

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

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

        对象的成员变量和对象本身一起存储在堆中.

        静态类变量也与类定义一起存储在堆中。

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

        以下的图解说明上面的几点

Java 并发/多线程教程(十一)-JAVA内存模型

两个线程有一组本地变量,局部变量(局部变量2)指向堆上的一个共享对象(对象3),这两个线程对同一对象有不同的引用。
它们的引用是本地变量,因此存储在每个线程的线程堆栈中(在每个线程堆栈中),不过,这两个不同的引用指向堆上的同一个对象。注意,共享对象(对象3)是如何引用对象2和对象4作为成员变量的。

上图也给我们展示了本地变量同时指向堆中的两个不同对象(如variable1 指向堆上的两个不同对象Object1,Object2),理论上如果线程都引用这两个对象,他们都可以访问这两个对象的。但是在上图中,每个线程只对这两个对象的其中一个有引用。

因此,什么样的代码会出现上面的内存图呢?下面的代码非常简单的展示了这个问题:

public class MyRunnable implements Runnable(){

      public void run(){

            methodOne();

      }

      public void methodOne(){

          int localVariablel = 45;

          MySharedObject localVariable2 = MySharedObject.sharedInstance;

          //... do more with local variables.

          methodTwo();

      }

      public void methodTwo(){

          Integer localVariablel = 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();

        // meber variables pointing to two objects on the heap

        public Integer object2 = new Integer(22);

        public Integer 4 = 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的两个副本都指向了静态变量指向的MySharedObject的同一个实例。mysharedobtintinstance也被存储在堆中。它对应于上面的图中的对象3。

注意,MySharedObject类也包括两个成员变量,成员变量和类一样存储在堆上。这两个成员变量指向两个Integer对象,这些整数对象对应上图的Object2和Object2.注意method2()如何创建本地变量localVariable1,localVariable1是对一个Integer对象的引用。

methodTwo()方法创建了一个名为localVariable1的本地变量,这个变量引用一个Integer对象,这个方法把localVariable1的引用指向一个Integer实例,每个线程执行methodTwo()时都会存储一个localVariable1的引用副本,实例化的两个整数对象将被存储在堆中,但是由于该方法每次执行该方法时都会创建一个新的整数对象,因此执行该方法的两个线程将创建单独的整数实例。

在method2()中创建的整数对象对应于上面的图中的对象1和对象5。

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

硬件内存架构

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

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

Java 并发/多线程教程(十一)-JAVA内存模型

      现代计算机通常有2个或更多的cpu。其中的一些cpu也可能有多个内核。需要指出的是,在一台拥有2个或更多cpu的现代计算机上,可以同时运行多个线程。每个CPU都可以在任何给定的时间运行一个线程。这意味着,如果您的Java应用程序是多线程的,在你的Java应用程序中每个CPU都可以同时运行一个线程(并发)。

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

        每个CPU也可能有一个CPU缓存。事实上,大多数现代的cpu都有一个一定大小的CPU缓存。CPU可以比主内存更快地访问它的缓存内存,但是通常不像它能够访问它的内部寄存器那样快。因此,CPU缓存在内部寄存器和主内存之间的速度之间。一些cpu可能有多个缓存层(1级缓存、2级缓存 ),但是这与了解Java内存模型如何与内存交互是不重要的。重要的是要知道,cpu可以有某种类型的缓存内存层。

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

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

连接Java内存模型和硬件内存架构之间的差距

正如前面提到的,Java内存模型和硬件内存架构是不同的。硬件内存体系结构不区分线程堆栈和堆。在硬件上,线程堆栈和堆都位于主内存中。线程栈和堆的某些部分有时可能出现在CPU缓存和内部CPU寄存器中。这张图中有这样的例子:


Java 并发/多线程教程(十一)-JAVA内存模型

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

      1、线程更新(写)到共享变量的可见性

      2、阅读、检查和写入共享变量时的竞态条件。

这两个问题都将在下面的部分中解释

共享对象的可见性

如果两个或多个线程共享一个对象,如果不正确使用volatile声明或同步,那么对一个线程所做的共享对象的更新可能对其他线程来说是不可见的。

假设共享对象最初存储在主内存中。在CPU上运行的线程会将共享对象读取到它的CPU缓存中。在那里,它对共享对象进行了更改。只要CPU缓存没有被刷新到主存,那么共享对象的更改版本就不会被运行在其他CPU上的线程所看到。这样,每个线程都可以使用自己的共享对象副本,每个副本都位于不同的CPU缓存中

下图演示了所描绘的场景。在左侧CPU上运行的一个线程将共享对象复制到它的CPU缓存中,并将它的count变量更改为2。对于在正确的CPU上运行的其他线程来说,这个更改是不可见的,因为更新计数还没有被刷新到主内存中。


Java 并发/多线程教程(十一)-JAVA内存模型

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

竞态条件

如果两个或多个线程共享一个对象,并且多个线程在该共享对象中更新变量,那么可能会出现竞态条件。想象一下,如果线程A读取一个共享对象的变量计数到它的CPU缓存中。想象一下,线程B也一样,但是进入不同的CPU缓存。现在线程A添加了一个计数,而线程B也做了相同的工作。现在,var1已经在每个CPU缓存中增加了两次。

如果这些增量是按顺序执行的,那么变量计数将会增加两次,并将原来的值+2写回主存,然而,这两个增量在没有适当同步的情况下同时进行。不管线程A和B将其更新后的计数写回主存,更新后的值只会比原来的值高1,尽管有两个增量。

这张图说明了上面描述的竞态条件的问题:


Java 并发/多线程教程(十一)-JAVA内存模型

要解决这个问题,您可以使用Java同步块。同步块保证在任何给定的时间内只有一个线程可以进入给定的关键部分。同步块也保证在同步块中访问的所有变量都将从主内存中读取,当线程退出同步块时,所有更新的变量将再次被刷新回主存,不管变量是否被声明为volatile。

上一篇:Java 并发/多线程教程(九)-线程安全和共享资源


下一篇:HanLP分词工具中的ViterbiSegment分词流程