一、前言
其实,本篇应该在《小白五:volatile》之前讲的,那么大家就更容易理解 volatile 了。
Java之所有流行,是因为 Java程序能够跨平台运行,而最核心的就是 JVM(Java Virtual Machine)了。
那么我想问一句:JVM的核心又是什么?
JVM 为程序提供了系统无关的统一的API,同时,也管理着每个程序的内存分配与回收,本篇,我们将深入学习JVM的内存模型:JMM。
JMM 即 Java内存模型,它屏蔽了不同操作系统和不同硬件厂商,提供了统一的内存管理。
JMM 在JDK1.5之前表现并不好,从 JDK1.5开始,JSR-133发布后,新的JMM模型一直用到了现在!
JMM 非常得要,只有熟悉了 JMM 后才能良好的、正确的开发出并行程序,它定义了不同线程对于一个共享变量的读写是如何可见的,以及需要时如何同步访问共享变量。
二、并发编程的关键问题
2.1、线程通信
这是一个老生常谈的话题了,线程通信一般有两种:
- 内存共享
- 消息传递
- 在共享内存的并发模型中,线程间通过读/写共享对象进行隐式通信;
- 在消息传递的并发模型中,线程间通过发送消息来显示的进行通信,例如:wait / notify;
2.2、线程间同步
同步指程序用于控制不同线程间操作发生的相对顺序的机制。
在共享内存的并发模型中,同步是显式的,必需显示指定某个方法或某段代码块需要在线程间互斥执行;
在消息传递的头型模型中,消息的发送必需要消息接收之前,因此同步是隐式的;
三、JMM内存划分
如下图所示,JMM规定了内存主要分为主内存(物理内存)和线程工作内存(缓存、寄存器、高速缓冲区、以及硬件和编译器优化后的一个数据存放位置)

JMM 规定了所有变量都存储在主内存中!每个线程还有自己的工作内存(即本地内存),工作内存中存储着共享变量的主内存副本。
主内存对应的是 Java 堆中的对象实例域、静态域和数组元素,因此,堆(Heap)内存在线程间共享。
局部变量、方法参数和异常处理器参数则不会在线程间共享,也不受JMM影响,因此是内存不可见的,我们也称为线程栈(方法调用会入栈,方法的变量也在栈上,方法退出时则弹栈)。
从中,我们也能够看出,JMM并不真实存在,它只定义了线程与主存之间的抽象关系。
3.1、从JVM角度来理解JMM(内部JMM)

我们从中看出,在JVM内,JMM分成了两部分:Thread Stack 和 Heap。这也印证了我们上面所说的,Heap在线程间共享,而栈则线程独有。为何叫 Thread Stack 我也在上一小节说了,调用方法即入栈。即便两个线程运行同样的代码,它们也分别各自创建自己的栈,入栈 -> 创建本地变量 -> 出栈。
所有的基本类型的本地变量都完全存储在线程栈中,因此对其它线程是完全不可见的。
线程可以将基本类型的变量副本传给另一个线程,但却无法共享基本类型变量给另一个线程!

上图是更为详细的 JMM 在 JVM 中。
本地变量:
- 如果是基本类型变量,百分百存储在线程栈上,且对其它不可见;
- 如果是引用对象变量,则该变量存储在线程栈上,但被引用的对象自身存储在堆上;
- 对象可能含有方法,这些方法又可能含有本地变量,这些变量同样存储在线程栈上,即使该对象自身存于堆中。
对象的成员变量存于堆上,即便成员变量是基本类型,或者是一个引用对象类型;
静态类变量也是存在堆上的;
所有线程都能访问堆上的对象,当一个线程访问一个对象,它也能访问对象的成员变量。如果两个线程同时调用同一个对象的同一个方法,它们会将同时访问对象的成员变量,但每个线程访问的实际上是对象的成员变量的副本(拷背)。

上图中,两个线程的『methodOne』的『Local variable 2』都引用堆中的同一个『Object 3』,同时注意,『Object 3』又将『Object 2』和『Objecet 4』作为其成员变量而引用,因此,两个线程都能通过『Object 3』来访问其成员变量『Object 2』和『Object 4』。
上图中,还显示了一点:『methodTwo』的『Local variable 1』在运行时,引用了不同的对象,分别是『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;
}
上述代码中,展示了同一个线程:
- 在执行『methodOne』时,共同引用了『MySharedObject』即『Object 3』,同时还引用到了『Object 2』和『Object 4』;
- 在执行『methodTwo』时,临时创建了一个对象(基本类型的包装对象),每次运行到这里都是新对象,在堆上分配内存,而实例则在栈上;
3.2、硬件内存架构(HMA:Hardware Memory Architecture)
现代HMA多少有些不同于JMM,但对于理解JMM也是非常重要的。

现在计算机有2个甚至更多个CPU,有些CPU还有多核。因此,每个线程都能同时运行在一个CPU或一个CPU的核上。如果我们的程序是多线程的,则很有可能被多个CPU或多个核同时运行(即 Concurently,并发)。
在上图中,我们还看到了:
- CPU自带寄存器,CPU在寄存器中操作变量比主存要快的多;
- CPU还自带有缓冲区,不同的CPU含有的缓冲区大小,个数都不一样,但同样比主存要快很多,但肯定比寄存器要慢;
学过计算机组成原理的都知道(非命中时会有下面1、2、3):
- CPU先访问寄存器,如果没有则;
- 访问缓冲区(L1、L2、L3....常说的二级、三级,多级缓冲区),如果没有则;
- 访问物理内存,即主存,如果没有则;
- 访问外设,如:磁盘、IO设备等,再没有就报错;
我们暂且不考虑第4条,当在主存时命中,则该数据会一级一级的拷背过去,直到CPU寄存器。
以上操作是读,如果是写操作,则CPU会将寄存器的数据一级一级的刷新过去,即寄存器 -> 缓存 -> 主存。
3.3、JMM 与 HMA 的关系
HMA 并不区分堆和栈,先来看看两者的关系:

当对象和变量能存储于不同的存储区时,可能会有两个主要的问题发生:
线程写共享对象时的可见性问题;
竞争问题:共享对象的读、写、检查同时发生时;
3.4、共享对象的可见性
如果多个线程共享一个对象,没有使用 volatile 或 synchronized,当一个线程更新共享对象时,另一个线程并不知道(不可见)。我们先不考虑前面说的线程工作内存,或者说,我们现在来阐明为何会存在线程工作内存。多核或多CPU中,两个线程分别运行在不同CPU上,其中一个线程读取了主存中的共享对象到了CPU的缓存或寄存器中,当这个线程修改了缓存或寄存器中的数据,且还未来的及同步/刷新主存中的共享对象时,另一个线程读取主存中该共享对象,此时就发生了数据不同步的问题。因此,所谓不可见,其实是因为多CPU或多核各自有自己的存储硬件(独立的存储区),使得CPU相互间无法访问,这才导致共享对象的不可见。

上图就很好的阐明了共享对象读/写不可见原理。为了解决这个问题,最简单的办法就是用 volatile (单操作是原子性的)。
3.5、条件竞争
同样,如果多个线程都同时要修改共享变量(工作内存修改后,回写到主存中),这时问题就产生了。试想一下:两个线程,需要对一个共享对象的基本类型成员变量分别 + 1,期望最终结果是 + 2,但结果很有可能只是 + 1。

为了解决要求顺序执行的操作,我们可以使用 synchronized 来加锁解决。
网友评论