- 原文地址:
- 作者: Jakob Jenkov
Java 内存模型描述了JVM如何使用计算机内存(RAM)。 JVM是一个完整计算机的模型,在这个模型中自然就包括内存模型,这个内存模型就称之为JAVA内存模型
只有理解了Java内存模型,才能设计出一个恰当的并发程序。 因为Java内存模型中规范了线程是如何,以及什么时候才能看到其他线程写入的值。 模型中还规范了,在必要时,应该如何同步访问共享变量。
在Java 1.5 之前,Java内存模型存在诸多不足。所以,在Java 1.5中,对Java内存模型进行了重构。 1.5 改进后的Java内存模型也一直沿用到了 Java 8。
内部使用的Java内存模型
JVM内部将Java内存模型划分为线程栈和堆两个模型。他们的逻辑关系,可以参见下图:
在JVM中运行的每一个线程都拥有自己的线程栈。 线程栈包含了被调用的方法中正在执行的相关信息。也可以称之为:调用栈。 随着线程的执行,调用栈也会随之改变。
线程栈还包含了每一个执行方法的局部变量。 线程只能访问自己的线程栈。一个线程创建的局部变量,对于其他线程是不可见的,即便是父线程也不能访问子线程的线程栈。 即使两个线程执行相同的代码,两个线程仍然是在各自的线程栈中创建局部变量。 因此,每一个线程都拥有各自版本的局部变量。
所有基本类型的局部变量(八个基本类型:
boolean
,byte
,short
,char
,int
,long
,float
,double
)都是完全存放在线程栈中的。因此,对于其他线程都是不可见的。 一个线程可以将基本类型的值拷贝传递给另一个线程,但这不是将基本类型局部变量共享出去。(传递出去的只是一份拷贝)
堆中包含了Java应用中所有被创建的对象。不管是哪个线程创建的对象都在堆中。 这其中也包括基本类型的包装类(如:
Byte
,Integer
,Long
等)。 不论是局部变量还是成员变量,只要是创建的对象,都会存放在堆中。
下图就展示了,栈中与堆中的存放的区别:
如果是基本类型的局部变量,那它完全存放于线程栈中。
如果局部变量是一个对象的引用,那引用本身是存放在线程栈中的,而对象是存放在堆中的。(Java中都是值传递)
对象中会有方法,而这些方法会有一些局部变量。对于这些局部变量,仍然是存放于栈中的,而不是跟着对象一起存放于堆中。
对象中的成员变量,会随着对象一起存放在堆中。不论成员变量是基本类型还是对象引用都是在堆中。
静态变量则是随着class定义一起存放在堆中(与类信息在一起,而不是对象)。
在堆中的对象可以被所有持有引用的线程访问到。 当线程获得一个对象时,线程其实也就可以访问对象的成员变量了。 而如果两个线程同时调用同一个对象的同一个方法时,就意味着,两个线程可以同时访问对象的成员变量,而局部变量则是两个线程访问各自的拷贝。如图:
两个线程都有着各自的局部变量。但是
Local Variable 2
指向的是一个在堆中的共享对象(Object 3
)。 两个线程拿着指向同一个对象的两个引用。虽然引用是局部变量,是存放在各自的线程栈中。但是,这两个引用却指向的是堆中同一个对象。这样就可能导致并发问题了。
注意,共享对象(
Object 3
)还有两个成员变量(Object 2
,Object 4
)。通过成员变量引用,两个线程其实还可以同时访问Object 2
,Object 4
。
图中,还展示了局部变量指向堆中两个不同的对象(
Object 1
,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
)以及一个引用型的局部变量(ref:localVariable2
)。
每一个执行
methodOne()
的线程都会创建一个localVariable2
的拷贝。但是两份拷贝都是指向堆中的同一个对象。 都是指向静态变量MySharedObject.sharedInstance
。 两个线程都是对堆中的一个引用进行拷贝。 因此,localVariable2
的两份拷贝,都是指向静态变量对应的同一个MySharedObject
实例。 而MySharedObject
实例也是在堆中的,所以,这个实例就类似上图中的Object 3。
而
MySharedObject
类中,又包含两个成员变量。这些成员变量会随着对象一起存放于堆中。 这两个成员变量指向两个Integer
对象,这就类似上图中的Object 2 和 Object 4。
在
methodTwo()
方法,又会创建一个叫做localVariable1
的局部变量。这个局部变量是一个Integer
对象引用。 每一个调用methodTwo()
的线程都会持有一个属于自己的localVariable1
引用拷贝。 虽然,两个localVariable1
对象都是存放在堆中,但是每个线程都是各自创建的对象实例。这样localVariable1
就类似上图中的Object 1 和 Object 5。
MySharedObject
类中,还有两个long
类型的成员变量。虽然是基本类型,但是是成员变量,所以还是随着对象一起存放于堆中。 只有局部变量才会存放在线程栈中。
硬件中的内存模型
现代硬件中的内存模型会和Java内存模型有些不一样。 所以,除了需要了解Java内存模型,还需要了解硬件中的内存模型。这样才能更好的理解Java内存模型是如何工作的。 先来看看硬件内存模型的结构:
现在的计算机通常有着两个以上的CPU。而这些CPU里面还会有着多个内核。 关键的是,现代计算机上的多个CPU还支持同时运行多个线程。 这就意味着,如果Java应用是多线程的,就可以更好的利用每一个CPU上的多线程运行能力,从而获得更好的性能。
每一个CPU都包含一组寄存器(本质上也是CPU内部的内存)。 CPU对于寄存器的访问要比访问内存快很多。
每一个CPU还会有一个缓存层。事实上,大多数现代CPU会有着多层缓存。 CPU访问这些缓存要比内存快,但是比访问寄存器要慢一些。 因此,CPU缓存的访问速度,介于寄存器与内存之间。 有些CPU会有多层缓存(Level 1 以及 Level 2,甚至 Level 3),但是这些对于理解Java内存模型是如何工作的并不重要。 重要的是理解CPU内部有着多层缓存结构。
计算机中的主存区(RAM),也就是通俗意义上的内存。这些主存,是可以被所有的CPU访问的。 主存通常比CPU缓存要大很多。
比如说,当CPU需要访问主存,它会读取主存的某个小部分数据到CPU缓存中。 甚至可能,直接读取缓存中的数据到寄存器中,然后对这些数据进行操作。 当CPU需要将数据写回到主存时,它会将数据从寄存器中刷新到缓存,然后在某个时间点,写回主存。
当CPU需要存放新的数据到缓存中时,缓存中之前存放的数据就会写回到主存。 CPU缓存可能一次只从主存中读取一部分数据,也可能一次只写回一部分数据到主存中。 CPU并不会每次读写操作时,都对全部的缓存进行同步操作。 而是将缓存分块与内存块进行对应,从而完成缓存到内存的更新。这些缓存块,被称之为“缓存行(cache lines)”。 缓存行可以被读入到缓存中,然后这些缓存行在操作完成后,被写回主存。
Java内存模型与硬件内存模型的对应关系
正如上文中介绍的,Java内存模型与硬件内存模型是不一样的。所以,硬件内存模型不能区分线程栈和堆的。 对于硬件来说,线程栈和堆都是在主存中。在某些时刻,部分线程栈或者部分堆中的数据会进入到CPU缓存或者CPU寄存器中。
对象和变量可以被存放在不同的内存区域,这样就会有一些问题。其中最主要的两个是:
- 线程对共享变量进行写操作后的数据可见性
- 对共享变量进行读,检查,写等操作时的竞态条件
共享对象的可见性
如果多个线程持有同一个共享对象,而且还没使用
volatile
关键字,也没有使用同步块。那这种情况下,对共享对象的修改就可能对其他线程是不可见的。
想象一下,共享对象最初是存放在主存中的。一个线程在其中一个CPU上进行了读取,这个对象数据就会被读取到这个CPU的缓存中。然后这个线程对其进行了修改。这样,只要CPU缓存没有写回主存中,那这个修改对于其他线程,是看不见的。 这样就会出现,每个线程都拿着各自的副本,而这些副本还可能分布在不同的CPU缓存中。
下面这个图,展示了这类的问题:
一个线程在左边的CPU上运行,其中有一个共享对象的副本读取到了CPU缓存中,并且,将
count
修改成了2。 这个修改对于右边CPU上运行的线程是不可见的,因为,count
的修改还没有写回主存。
要解决这个问题,可以使用
volatile
关键字。volatile
可以保障变量直接从主存中读取,并且保障修改后可以直接写回主存。
竞态条件
如果多个线程修改了同一个共享变量,那就回触发竞态条件。
想象一下,如果线程A读取了变量
count
到CPU缓存中。 而线程B也读取了,但是是在另一个CPU的缓存中。 那线程A在count
上加一,线程B也对它的count
加一。 那么变量已经在不同的CPU缓存上执行了两次递增操作。
如果上述操作是串行执行的,那
count
会在原有数值上递增两次,相当与加二,然后写回主存。
但是,两次递增操作在没有同步机制的约束下,就可能是并发的。 不管线程A和B谁来更新
count
并写回主存,新值都可能是只加了一,而不是加二。
如下图所示:
要解决这个问题,就需要使用Java的
synchronized
块。同步块可以保障一次只有一个线程能够进入临界区。 并且还能保障同步块中所有从主存中读取的变量,在线程退出同步块的时候,都可以安全的写回主存。