内部 Java 内存模型与硬件层面内存模型的关系是什么?
Java 内存模型很好的说明了 JVM 是如何在内存里工作的,JVM 可以理解为 java 执行的一个操作系统,作为一个操作系统就有内存模型,这就是我们常说的 JAVA 内存模型。
如果我们想正确的写多线程的并行程序。理解好 java 内存模型在多线程下的工作方式是及其重要的,这可以帮我们更好的理解底层的工作方式。
java 内存模型说明了不同的线程怎样以及何时可以看到其他线程写入共享变量的值,以及同步程序怎么共享变量。最初的 java 内存模型不够好,存在很多的不足,所以在 java1.5z 中,java 内存模型的版本的进行了一次重大的更新与改进,并且在 java8 中仍然被使用。
内部 java 内存模型
JVM 的内部的内存模型分为了两部分,thread stack 和 heap,也就是线程栈和堆,我们将复杂的内存模型抽象成下图:
每一个在 JVM 中运行的线程在内存里都会有属于自己的线程栈。线程栈一般包含这个线程的方法执行到哪一个点了这些信息,也被称作“call stack”,当线程执行代码,调用栈就会随着执行的状态改变。
线程栈也包括了每个方法执行时的 local 变量,所有的方法也都存储在线程栈上,一个线程可以只能访问自己的线程栈。每个线程自己创建的本地本地变量对其他线程是不可见的,也就是私有的,即使两个线程调用的是同一个方法,每个线程会分别保存一份本地变量,各自属于各自的线程栈。
所有基本类型的 local 变量( boolean, byte, short, char, int, long, float, double)全都被存储在线程栈里,而且对其他线程是不可见的,一个线程可能会传递一份基本类型的变量值的一份拷贝给另一个线程,但是自己本身的变量是不能共享的,只能传递拷贝。
堆中存储着 java 程序中 new 出来的对象,不管是哪个线程 new 出来的对象,都存在一起,而且不区分是哪个线程的对象。这些对象里面也包括那些原始类型的对象版本(e.g. Byte, Integer, Long etc.). 不管这个对象是分配给本地变量还是成员变量,最终都是存在堆里。
下面这个图就说明了线程栈中存储了 local 变量,堆中存储着对象 object。
一个原始数据类型的本地变量将完全被存储在线程栈中。
本地变量也可以是指向对象的引用,在这种情况下,本地变量存在线程栈上,但是对象本身是存在堆上。
一个对象可能包含方法这些方法同时也会包含本地变量,这些本地变量也是存储在线程栈上面,即使他们所属于的对象和方法是存在堆上的。
一个对象的成员变量是跟随着对象本身存储在堆上的,不管成员变量是原始数据类型还是指向对象的引用。
静态的类变量一般也存储在堆上,根据类的定义。
存储在堆上的对象可以被所有的线程通过引用来访问。当一个线程持有一个对象的引用时,他同时也就可以访问这个对象的成员变量了。如果两个线程同时调用同一个对象的一个方法,他们就会都拥有这个对象的成员变量,但是每一个线程会享有自己私有的本地变量。
下面这张图就说明以上的内容
两个线程有一系列的本地变量。其中一个本地变量(Local Variable 2)指向堆中的 object3.这两个线程每个都有指向同一个对象 object3 的不同引用。他们的引用是本地变量,都存在各自的线程栈中,虽然这两个不同的引用是指向同一个对象的。
我们还可以发现,共有的对象 object3 有指向 object2 和 object4 的引用,这些引用是作为 object3 中的成员变量存在的。通过 object3 中的成员变量的引用,两个线程都可以访问到 object2 和 object4.
这个图也说明了指向堆中不同对象的本地变量。例如图中的 object1 和 object5,不是同一个对象。理论上,所有的线程都可以访问堆中的对象,只要这个线程持有堆中对象的引用。但是这个图中,每个线程只有这两个对象中的一个引用。
下面,我们将写一段实际的代码,这段代码的内存模型就跟上图一样:
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 方法那么,上图的内存模型就会是这段程序的执行结果。
methodOne()声明了一个原始数据类型的本地变量,int 类型的 localVariable1 ,和一个指向对象的引用的本地变量(localVariable2).
每个线程执行 methodOne()的时候都会创建属于自己的一份本地变量的拷贝,也就是 localVariable1 and localVariable2 在他们各自的线程栈的空间中。localVariable1 将会被完全对其他线程是不可见的,只存在与每个线程自己的线程栈空间中。一个线程不能看到其他线程对 localVariable1 所做的改变与操作,是不可见的。
每个线程执行 methodOne()方法的时候也会创建 localVariable2 的拷贝,但是不同的 localVariable2 的拷贝最终却指向同一个堆上的对象。这段代码让 localVariable2 指向之前通过一个静态变量引用的对象。静态变量只会存在一份,不会有多余的拷贝,而且静态变量是存在堆中的。所以,localVariable2 的两份拷贝同时指向同一个 MySharedObject 对象的实例,与此同时,还有一个堆中的静态变量也指向这个对象实例。这个对象就是对应上图中的 object3.
我们发现 MySharedObject 包含这两个成员变量。这些成员变量跟对象一样存储在堆上。这两个成员变量指向两个 integer 对象。这两个对象分别对应上图中的 object2 和 object4.
我们发现,methodTwo() 创建了一个本地变量叫做 localVariable1。这个本地变量是一个对象的引用,他指向一个 integer 对象。这个方法将本地变量 localVariable1 指向一个新的值。在执行 methodTwo()的时候,每个线程都会持有一份 localVariable1 的拷贝。这两个 Integer 对象将会被初始化在堆上,但是因为每次执行这个方法的时候,这个方法都会创建一个新的对象,所以两个线程会拥有独立的对象实例。这两个对象就对应上图中的 object1 和 object5.
我们发现 MySharedObject 中的成员变量是原始数据类型,但由于他们是成员变量,所以依旧存储在堆上。只有本地变量存储在线程栈中。
硬件层面的内存模型
硬件层面的内存内存结构与 JVM 中的内存结构是有不同的,对我们来说,正确理解掌握硬件层面的内存模型是很必要的,这可以帮助我们理解 java 多线程的底层机制,更要了解 java 内存模型如何在硬件内存结构上工作。这一章将讲述硬件层面内存模型,下一部分将讲述 java 如何结合硬件工作。
下图是一个简化的现代计算机硬件结构图:
现代计算机通常会有两个甚至更多的 cpu,这些 cpu 可能还会有多个核心,这个意义是,拥有多个 cpu 的计算机可能会有多个线程在同时执行,每个 cpu 都可以在任何给定的时间运行一个线程。这就意味着如果我们的 java 程序是多线程的,在内部就每个线程就会有一个 cpu 在同时执行。
每个 cpu 都会有一系列的寄存器 registers 在 cpu 的内存中,而且这些寄存器是很重要的。cpu 在寄存器上进行计算操作比在主内存中进行计算要快的多。这是因为 cpu 访问寄存器的速度比访问内存要快得多。
每个 cpu 也会有一个 cpu 的 cache 内存。这是因为 cpu 访问 cache 比访问内存的速度要快得多,但是却比访问的寄存器要慢一些,所以 cache 的速度是介于寄存器和内存的。一些 cpu 还有多级 cache,比如(Level 1 and Level 2),但是这对于我们理解 java 内存模型关系不大,我们只需要 cpu 有三层内存结构,寄存器-cache-内存(RAM).
一台计算机一般都会有主内存也就是 RAM,所有 cpu 都可以访问主内存,主内存的容量一般远比 cache 大得多。
一般的,当 cpu 需要访问内存的时候,他会先读取一部分主内存到 cache 中,甚至,会读取一部分 cache 到内部的寄存器中,然后再在寄存器进行计算操作。当 cpu 将计算结果写回内存中时,他会 flush 寄存器和 cache 中的数据,然后将值写回至内存中。
当 cpu 要求 cache 去存储其他内容时,也会将 cache 中的内容 flush 到内存中。cpu 的 cache 可以边写入一部分数据到内存,边写入一部分到自己 cache 中,所以在更新数据,不必要全部清空 cache,可以边读边写。一般的,cache 真正更新数据是在更小的内存块上,叫做“cache lines”。多个“cache lines”可能正在读取数据到 cache 中,而另一部分可能正在将数据写回到内存中。
Java 内存模型和硬件内存模型的联系
上文已经提到,java 内存模型和硬件内存模型是不同的。硬件内存模型不区分堆和栈。在硬件层面,所有的线程栈和堆都被存储在主内存中,一部分线程栈和堆可能有时候会出现在 cpu cache 中和 cpu 寄存器中。下图可以说明这个问题:
当对象和变量被存储在不同的内存区域的时候,很多问题就可能发生,主要有以下两类问题:
当线程对一些共享数据进行更新或者写操作时,可见性的问题
当读写共享数据产生资源竞速的问题 接下来的部分就会讨论这两个问题
共享对象的可见性
如果多个线程在共享一个对象,没有正确使用 volatile 或者 synchronize 声明,更新共享对象的时候就可能出现其他线程不可见的问题。
我们假设共享对象初始化主内存中。一个在 cpu 中运行的线程读取共享对象到 cache 中。这时候,随着程序的执行,可能导致共享对象发生一些变化。只要 cpu 的 cache 还没有被写回到主内存中,这个共享对象的变化就对其他在 cpu 上运行的线程不可见。这种情况下,每个线程都会有持有一份自己对于共享对象的拷贝,这份拷贝存储在各自的 cpu 的 cache 中,而且对于其他线程是不可见的。
下图说明了大致的情况,在左边 cpu 执行的线程将共享对象读取到 cache 中,并且将他的值改变为 2.这个变化对右边的 cpu 的其他线程是不可见的,因为对于变量 count 的更新还没有被写回到主内存中。
想要解决这个共享对象可见性的问题,可以使用 java 的 volatile 关键字(参见笔者的另一篇 volatile 的博文),这个关键字可以保证所给定的变量都是直接从主内存中读取,而且每当更新时就立即写回到内存中,所以可以保证变化是及时可见的。
资源竞速
如果多个线程共享一个对象,而且多个线程需要更新共享对象中的变量,那么就可能造成资源竞速的发生。
假设线程 A 读取读取一个共享对象的变量 count 到 cpu 的 cache 中,同时,线程 B 也执行同样的步骤,但是是读取到一个不同的 CPU 的 cache 中,现在线程 A 给 count 加一,线程 B 也做同样的事情,现在这个变量被加了两次,分别在不同的 cpu 的 cache 中。
如果这两次递增操作是被按顺序先后执行的,这个变量 count 就会被加两次而且比最初的值加了 2,写回到主内存中。
然而,如果这两个递增操作是并发执行的,且没有正确的进行同步操作,写回内存的时候,更新后的值只会被加一,虽然实际上是进行了两次递增操作。 下图就说明了程序并发执行的时候,产生的资源竞速的问题:
想要解决这个问题,我们可以使用 java 中的 synchronize 关键字。synchronize 可以保证只有一个线程能进入那些被声明为 synchronize 的代码段中。同步的线程可以保证所有同步代码段中的变量都会从内存中读取,而且当线程离开代码块的时候,所有更新后的值都会被写回主内存中,不管这个变量有没有被声明 volatile。
评论